added windows notifications and have avatar health/energy update with task completion
This commit is contained in:
@@ -54,14 +54,19 @@ function sleepHoursToWellnessScore(hours: number) {
|
||||
|
||||
export function computeAvatarCondition(
|
||||
answers: DailyQuizAnswers,
|
||||
taskBonuses: { health?: number; energy?: number } = {},
|
||||
): AvatarCondition {
|
||||
const { feeling, stress, energy, sleepLength, sleepQuality } = answers;
|
||||
|
||||
const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3;
|
||||
const sleepScore = sleepHoursToWellnessScore(sleepLength);
|
||||
const sleepAverage = (sleepScore + sleepQuality) / 2;
|
||||
const health = Math.round((wellnessAverage + sleepAverage) / 2);
|
||||
const energyLevel = Math.round((energy + sleepQuality) / 2);
|
||||
const health = clampStat(
|
||||
Math.round((wellnessAverage + sleepAverage) / 2) + (taskBonuses.health ?? 0),
|
||||
);
|
||||
const energyLevel = clampStat(
|
||||
Math.round((energy + sleepQuality) / 2) + (taskBonuses.energy ?? 0),
|
||||
);
|
||||
|
||||
let avatarMood: AvatarMood = "neutral";
|
||||
|
||||
@@ -73,8 +78,8 @@ export function computeAvatarCondition(
|
||||
|
||||
return {
|
||||
mood: avatarMood,
|
||||
energy: clampStat(energyLevel),
|
||||
health: clampStat(health),
|
||||
energy: energyLevel,
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
computeAvatarCondition,
|
||||
normalizeDailyQuizAnswers,
|
||||
type AvatarCondition,
|
||||
type DailyQuizAnswers,
|
||||
type DailyQuizSubmission,
|
||||
} from "@/lib/avatar-state";
|
||||
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
||||
import { computeAvatarConditionWithHabitBoosts } from "@/lib/habit-wellness-effects";
|
||||
import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage";
|
||||
import { validateJournalEntry } from "@/lib/journal-safety";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
@@ -23,7 +24,10 @@ export function getTodayDateKey(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
|
||||
function mapRowToSubmission(
|
||||
row: DailyEntryRow,
|
||||
completedHabitLabels: string[] = [],
|
||||
): DailyQuizSubmission {
|
||||
const answers = normalizeDailyQuizAnswers({
|
||||
feeling: row.mood,
|
||||
stress: row.stress,
|
||||
@@ -36,7 +40,7 @@ function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
|
||||
date: row.entry_date,
|
||||
answers,
|
||||
journal: row.journal ?? "",
|
||||
condition: computeAvatarCondition(answers),
|
||||
condition: computeAvatarConditionWithHabitBoosts(answers, completedHabitLabels),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +87,9 @@ export async function getDailyEntryForToday(): Promise<DailyQuizSubmission | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapRowToSubmission(row as DailyEntryRow);
|
||||
const completedHabitLabels = await getCompletedHabitLabelsForToday();
|
||||
|
||||
return mapRowToSubmission(row as DailyEntryRow, completedHabitLabels);
|
||||
}
|
||||
|
||||
export async function getDailyQuizSubmission() {
|
||||
@@ -134,7 +140,10 @@ export async function saveDailyEntry(
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const submission = mapRowToSubmission(data as DailyEntryRow);
|
||||
const submission = mapRowToSubmission(
|
||||
data as DailyEntryRow,
|
||||
await getCompletedHabitLabelsForToday(),
|
||||
);
|
||||
notifyHabitPetDataUpdated();
|
||||
return submission;
|
||||
}
|
||||
@@ -147,6 +156,17 @@ export async function saveDailyQuizSubmission(
|
||||
}
|
||||
|
||||
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
|
||||
const entry = await getDailyEntryForToday();
|
||||
return entry?.condition ?? null;
|
||||
const [entry, completedHabitLabels] = await Promise.all([
|
||||
getDailyEntryForToday(),
|
||||
getCompletedHabitLabelsForToday(),
|
||||
]);
|
||||
|
||||
if (!entry && completedHabitLabels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return computeAvatarConditionWithHabitBoosts(
|
||||
entry?.answers,
|
||||
completedHabitLabels,
|
||||
);
|
||||
}
|
||||
|
||||
31
lib/daily-reminder-status.ts
Normal file
31
lib/daily-reminder-status.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
|
||||
import { getDailyTasks } from "@/lib/daily-tasks";
|
||||
import { getTodayDateKey, isHabitCompletedToday } from "@/lib/habits-storage";
|
||||
|
||||
export type DailyReminderStatus = {
|
||||
dateKey: string;
|
||||
quizCompleted: boolean;
|
||||
totalTasks: number;
|
||||
incompleteTaskCount: number;
|
||||
needsReminder: boolean;
|
||||
};
|
||||
|
||||
export async function getDailyReminderStatus(): Promise<DailyReminderStatus> {
|
||||
const dateKey = getTodayDateKey();
|
||||
const [quizCompleted, tasks] = await Promise.all([
|
||||
hasCompletedDailyQuizToday(),
|
||||
getDailyTasks(),
|
||||
]);
|
||||
|
||||
const incompleteTaskCount = tasks.filter(
|
||||
(task) => !isHabitCompletedToday(task, dateKey),
|
||||
).length;
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
quizCompleted,
|
||||
totalTasks: tasks.length,
|
||||
incompleteTaskCount,
|
||||
needsReminder: !quizCompleted || incompleteTaskCount > 0,
|
||||
};
|
||||
}
|
||||
205
lib/daily-reminders.ts
Normal file
205
lib/daily-reminders.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { DailyReminderStatus } from "@/lib/daily-reminder-status";
|
||||
import { routes } from "@/lib/routes";
|
||||
|
||||
const REMINDER_SENT_STORAGE_PREFIX = "habit-pet-daily-reminder-sent";
|
||||
const REMINDER_CHECK_INTERVAL_MS = 15_000;
|
||||
|
||||
export type NotificationPermissionState =
|
||||
| "default"
|
||||
| "granted"
|
||||
| "denied"
|
||||
| "unsupported";
|
||||
|
||||
export function isNotificationSupported() {
|
||||
return typeof window !== "undefined" && "Notification" in window;
|
||||
}
|
||||
|
||||
export function getNotificationPermissionState(): NotificationPermissionState {
|
||||
if (!isNotificationSupported()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermissionState> {
|
||||
if (!isNotificationSupported()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
return "granted";
|
||||
}
|
||||
|
||||
if (Notification.permission === "denied") {
|
||||
return "denied";
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
return permission === "default" ? "default" : permission;
|
||||
}
|
||||
|
||||
export function parseReminderTime(reminderTime: string) {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(reminderTime.trim());
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
|
||||
if (
|
||||
!Number.isInteger(hours) ||
|
||||
!Number.isInteger(minutes) ||
|
||||
hours < 0 ||
|
||||
hours > 23 ||
|
||||
minutes < 0 ||
|
||||
minutes > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function hasReachedReminderTime(
|
||||
reminderTime: string,
|
||||
now = new Date(),
|
||||
) {
|
||||
const reminderMinutes = parseReminderTime(reminderTime);
|
||||
|
||||
if (reminderMinutes === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
return nowMinutes >= reminderMinutes;
|
||||
}
|
||||
|
||||
function getReminderSentStorageKey(dateKey: string) {
|
||||
return `${REMINDER_SENT_STORAGE_PREFIX}:${dateKey}`;
|
||||
}
|
||||
|
||||
export function wasDailyReminderSentToday(dateKey: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem(getReminderSentStorageKey(dateKey)) === "1"
|
||||
);
|
||||
}
|
||||
|
||||
export function markDailyReminderSentToday(dateKey: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(getReminderSentStorageKey(dateKey), "1");
|
||||
}
|
||||
|
||||
export function clearDailyReminderSentToday(dateKey: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(getReminderSentStorageKey(dateKey));
|
||||
}
|
||||
|
||||
export function buildDailyReminderNotification(status: DailyReminderStatus) {
|
||||
const targetUrl = routes.avatar;
|
||||
|
||||
if (!status.quizCompleted && status.incompleteTaskCount > 0) {
|
||||
return {
|
||||
title: "Time for your daily check-in",
|
||||
body: `Complete today's wellness quiz and ${status.incompleteTaskCount} remaining habit${status.incompleteTaskCount === 1 ? "" : "s"}.`,
|
||||
targetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (!status.quizCompleted) {
|
||||
return {
|
||||
title: "Time for your daily check-in",
|
||||
body: "Complete today's wellness quiz to keep your pet happy.",
|
||||
targetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Habit reminder",
|
||||
body: `You have ${status.incompleteTaskCount} habit${status.incompleteTaskCount === 1 ? "" : "s"} left for today.`,
|
||||
targetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function showDailyReminderNotification(
|
||||
status: DailyReminderStatus,
|
||||
) {
|
||||
if (!isNotificationSupported() || Notification.permission !== "granted") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { title, body, targetUrl } = buildDailyReminderNotification(status);
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
tag: `habit-pet-daily-reminder-${status.dateKey}`,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
|
||||
if (window.location.pathname !== targetUrl) {
|
||||
window.location.assign(targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function showTestDailyReminderNotification(
|
||||
status: DailyReminderStatus,
|
||||
) {
|
||||
if (!isNotificationSupported() || Notification.permission !== "granted") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { body, targetUrl } = buildDailyReminderNotification(status);
|
||||
const notification = new Notification("Test: Habit Pet reminder", {
|
||||
body: `This is a test notification. ${body}`,
|
||||
tag: `habit-pet-daily-reminder-test-${Date.now()}`,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
|
||||
if (window.location.pathname !== targetUrl) {
|
||||
window.location.assign(targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getReminderCheckIntervalMs() {
|
||||
return REMINDER_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function getNotificationPermissionLabel(
|
||||
permission: NotificationPermissionState,
|
||||
) {
|
||||
switch (permission) {
|
||||
case "granted":
|
||||
return "Allowed";
|
||||
case "denied":
|
||||
return "Blocked";
|
||||
case "default":
|
||||
return "Not requested";
|
||||
default:
|
||||
return "Unsupported";
|
||||
}
|
||||
}
|
||||
202
lib/habit-wellness-effects.ts
Normal file
202
lib/habit-wellness-effects.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
computeAvatarCondition,
|
||||
defaultDailyQuizAnswers,
|
||||
WELLNESS_SCALE_MAX,
|
||||
type AvatarCondition,
|
||||
type DailyQuizAnswers,
|
||||
} from "@/lib/avatar-state";
|
||||
|
||||
export type HabitWellnessCategory =
|
||||
| "hydration"
|
||||
| "nutrition"
|
||||
| "sleep"
|
||||
| "relaxation"
|
||||
| "fun"
|
||||
| "movement"
|
||||
| "mindfulness"
|
||||
| "screen_balance";
|
||||
|
||||
type WellnessDelta = {
|
||||
feeling: number;
|
||||
stress: number;
|
||||
energy: number;
|
||||
sleepQuality: number;
|
||||
health: number;
|
||||
};
|
||||
|
||||
const CATEGORY_DELTAS: Record<HabitWellnessCategory, WellnessDelta> = {
|
||||
hydration: { feeling: 0, stress: 0, energy: 1, sleepQuality: 0, health: 1 },
|
||||
nutrition: { feeling: 1, stress: 0, energy: 1, sleepQuality: 0, health: 1 },
|
||||
sleep: { feeling: 1, stress: -1, energy: 1, sleepQuality: 1, health: 1 },
|
||||
relaxation: { feeling: 1, stress: -1, energy: 1, sleepQuality: 0, health: 0 },
|
||||
fun: { feeling: 1, stress: -1, energy: 0, sleepQuality: 0, health: 0 },
|
||||
movement: { feeling: 1, stress: -1, energy: 1, sleepQuality: 0, health: 0 },
|
||||
mindfulness: { feeling: 1, stress: -1, energy: 0, sleepQuality: 0, health: 0 },
|
||||
screen_balance: { feeling: 0, stress: -1, energy: 1, sleepQuality: 0, health: 0 },
|
||||
};
|
||||
|
||||
const LABEL_CATEGORY_PATTERNS: Array<{
|
||||
category: HabitWellnessCategory;
|
||||
pattern: RegExp;
|
||||
}> = [
|
||||
{
|
||||
category: "hydration",
|
||||
pattern:
|
||||
/\b(water|hydrat|drink a (?:full )?glass|refill.*bottle|glass of water)\b/i,
|
||||
},
|
||||
{
|
||||
category: "nutrition",
|
||||
pattern:
|
||||
/\b(meal|cook|eat|ate|healthy food|proper meal|nutrit|breakfast|lunch|dinner)\b/i,
|
||||
},
|
||||
{
|
||||
category: "sleep",
|
||||
pattern:
|
||||
/\b(sleep|wind down|bedtime|before bed|avoid screens|screen.*before sleep)\b/i,
|
||||
},
|
||||
{
|
||||
category: "relaxation",
|
||||
pattern:
|
||||
/\b(meditat|breathe|breath|relax|calm|rest|nap|unwind|soothe)\b/i,
|
||||
},
|
||||
{
|
||||
category: "fun",
|
||||
pattern:
|
||||
/\b(swim|play|game|hobby|fun|music|dance|social|friend|call|laugh|enjoy)\b/i,
|
||||
},
|
||||
{
|
||||
category: "movement",
|
||||
pattern:
|
||||
/\b(walk|run|jog|workout|exercise|yoga|stretch|bike|hike|move|gym|cycle)\b/i,
|
||||
},
|
||||
{
|
||||
category: "mindfulness",
|
||||
pattern: /\b(mindful|journal|gratitud|reflect|presence|grounding)\b/i,
|
||||
},
|
||||
{
|
||||
category: "screen_balance",
|
||||
pattern:
|
||||
/\b(screen break|limit social|social media|scroll|doomscroll|phone break|digital detox)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_ANSWER_BOOST_PER_STAT = 2;
|
||||
const MAX_DIRECT_HEALTH_BOOST = 3;
|
||||
|
||||
function clampStat(value: number) {
|
||||
return Math.min(WELLNESS_SCALE_MAX, Math.max(1, value));
|
||||
}
|
||||
|
||||
export function classifyHabitWellnessCategories(
|
||||
label: string,
|
||||
): HabitWellnessCategory[] {
|
||||
const categories = new Set<HabitWellnessCategory>();
|
||||
|
||||
for (const { category, pattern } of LABEL_CATEGORY_PATTERNS) {
|
||||
if (pattern.test(label)) {
|
||||
categories.add(category);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(categories);
|
||||
}
|
||||
|
||||
function aggregateWellnessTotals(completedLabels: string[]) {
|
||||
const answerTotals: WellnessDelta = {
|
||||
feeling: 0,
|
||||
stress: 0,
|
||||
energy: 0,
|
||||
sleepQuality: 0,
|
||||
health: 0,
|
||||
};
|
||||
|
||||
for (const label of completedLabels) {
|
||||
for (const category of classifyHabitWellnessCategories(label)) {
|
||||
const delta = CATEGORY_DELTAS[category];
|
||||
|
||||
answerTotals.feeling += delta.feeling;
|
||||
answerTotals.stress += delta.stress;
|
||||
answerTotals.energy += delta.energy;
|
||||
answerTotals.sleepQuality += delta.sleepQuality;
|
||||
answerTotals.health += delta.health;
|
||||
}
|
||||
}
|
||||
|
||||
return answerTotals;
|
||||
}
|
||||
|
||||
export function aggregateHabitWellnessDeltas(completedLabels: string[]) {
|
||||
const totals = aggregateWellnessTotals(completedLabels);
|
||||
|
||||
return {
|
||||
feeling: Math.min(MAX_ANSWER_BOOST_PER_STAT, totals.feeling),
|
||||
stress: Math.max(-MAX_ANSWER_BOOST_PER_STAT, totals.stress),
|
||||
energy: Math.min(MAX_ANSWER_BOOST_PER_STAT, totals.energy),
|
||||
sleepQuality: Math.min(MAX_ANSWER_BOOST_PER_STAT, totals.sleepQuality),
|
||||
health: Math.min(MAX_DIRECT_HEALTH_BOOST, totals.health),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyHabitBoostsToAnswers(
|
||||
answers: DailyQuizAnswers,
|
||||
completedLabels: string[],
|
||||
): DailyQuizAnswers {
|
||||
const delta = aggregateHabitWellnessDeltas(completedLabels);
|
||||
|
||||
return {
|
||||
feeling: clampStat(answers.feeling + delta.feeling),
|
||||
stress: clampStat(answers.stress + delta.stress),
|
||||
energy: clampStat(answers.energy + delta.energy),
|
||||
sleepLength: answers.sleepLength,
|
||||
sleepQuality: clampStat(answers.sleepQuality + delta.sleepQuality),
|
||||
};
|
||||
}
|
||||
|
||||
export function computeAvatarConditionWithHabitBoosts(
|
||||
answers: DailyQuizAnswers | null | undefined,
|
||||
completedLabels: string[],
|
||||
): AvatarCondition {
|
||||
const baseAnswers = answers ?? defaultDailyQuizAnswers;
|
||||
const boostedAnswers = applyHabitBoostsToAnswers(baseAnswers, completedLabels);
|
||||
const delta = aggregateHabitWellnessDeltas(completedLabels);
|
||||
|
||||
return computeAvatarCondition(boostedAnswers, {
|
||||
health: delta.health,
|
||||
});
|
||||
}
|
||||
|
||||
export function describeHabitWellnessBoost(label: string) {
|
||||
const categories = classifyHabitWellnessCategories(label);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = new Set<string>();
|
||||
|
||||
for (const category of categories) {
|
||||
const delta = CATEGORY_DELTAS[category];
|
||||
|
||||
if (delta.feeling > 0) {
|
||||
messages.add("mood");
|
||||
}
|
||||
|
||||
if (delta.stress < 0) {
|
||||
messages.add("stress relief");
|
||||
}
|
||||
|
||||
if (delta.energy > 0) {
|
||||
messages.add("energy");
|
||||
}
|
||||
|
||||
if (delta.health > 0) {
|
||||
messages.add("health");
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `Boosts ${Array.from(messages).join(", ")}`;
|
||||
}
|
||||
@@ -215,6 +215,47 @@ export async function getHabits(): Promise<Habit[]> {
|
||||
return rows.map(mapHabitRow);
|
||||
}
|
||||
|
||||
export async function getCompletedHabitLabelsForToday(): Promise<string[]> {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const today = getTodayDateKey();
|
||||
|
||||
const { data: logs, error: logsError } = await supabase
|
||||
.from("habit_logs")
|
||||
.select("habit_id")
|
||||
.eq("user_id", userId)
|
||||
.eq("completed_on", today);
|
||||
|
||||
if (logsError) {
|
||||
throw new Error(logsError.message);
|
||||
}
|
||||
|
||||
const habitIds = (logs ?? []).map((log) => log.habit_id as string);
|
||||
|
||||
if (habitIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { data: habits, error: habitsError } = await supabase
|
||||
.from("habits")
|
||||
.select("name")
|
||||
.eq("user_id", userId)
|
||||
.in("id", habitIds);
|
||||
|
||||
if (habitsError) {
|
||||
throw new Error(habitsError.message);
|
||||
}
|
||||
|
||||
return (habits ?? []).map((habit) =>
|
||||
displayHabitName((habit as { name: string }).name),
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
|
||||
const entry = getCatalogEntry(catalogId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user