added windows notifications and have avatar health/energy update with task completion

This commit is contained in:
masongga
2026-05-24 00:11:23 -06:00
parent 25fb5499c1
commit dfafba0c94
12 changed files with 842 additions and 33 deletions

View File

@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { AppAuthGate } from "@/components/layout/app-auth-gate";
import { DailyReminderProvider } from "@/components/reminders/daily-reminder-provider";
export default function AppLayout({
children,
@@ -9,7 +10,10 @@ export default function AppLayout({
}) {
return (
<Suspense fallback={<div className="min-h-svh bg-background" />}>
<AppAuthGate>{children}</AppAuthGate>
<AppAuthGate>
<DailyReminderProvider />
{children}
</AppAuthGate>
</Suspense>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { Badge } from "@/components/ui/badge";
@@ -15,10 +15,12 @@ import {
WELLNESS_SCALE_MAX,
type AvatarCondition,
} from "@/lib/avatar-state";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
getAvatarConditionForToday,
hasCompletedDailyQuizToday,
} from "@/lib/daily-quiz-storage";
import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage";
import { routes } from "@/lib/routes";
export function AvatarStatusCard() {
@@ -26,19 +28,32 @@ export function AvatarStatusCard() {
defaultAvatarCondition,
);
const [hasCheckedInToday, setHasCheckedInToday] = useState(false);
const [completedTaskCount, setCompletedTaskCount] = useState(0);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function loadStatus() {
const checkedIn = await hasCompletedDailyQuizToday();
setHasCheckedInToday(checkedIn);
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true);
}
const loadStatus = useCallback(async () => {
const [checkedIn, nextCondition, completedLabels] = await Promise.all([
hasCompletedDailyQuizToday(),
getAvatarConditionForToday(),
getCompletedHabitLabelsForToday(),
]);
void loadStatus();
setHasCheckedInToday(checkedIn);
setCondition(nextCondition ?? defaultAvatarCondition);
setCompletedTaskCount(completedLabels.length);
setIsReady(true);
}, []);
useEffect(() => {
void loadStatus();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, loadStatus);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, loadStatus);
};
}, [loadStatus]);
if (!isReady) {
return (
<Card>
@@ -68,7 +83,11 @@ export function AvatarStatusCard() {
</div>
<p className="text-center text-sm text-muted-foreground">
{hasCheckedInToday
? "Your pet is reacting to today's wellness check-in and journal."
? completedTaskCount > 0
? `Your pet reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.`
: "Your pet is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health."
: completedTaskCount > 0
? "Complete today's quiz for a full wellness read, then keep completing habits to boost your pet."
: "Complete today's quiz to update your pet's mood, energy, and health."}
</p>
{!hasCheckedInToday ? (

View File

@@ -1,7 +1,7 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
@@ -25,7 +25,9 @@ import {
type DailyQuizAnswers,
type DailyQuizSubmission,
} from "@/lib/avatar-state";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
getAvatarConditionForToday,
getDailyEntryForToday,
saveDailyEntry,
} from "@/lib/daily-quiz-storage";
@@ -42,6 +44,13 @@ export function DailyQuizForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false);
const [liveCondition, setLiveCondition] = useState(
submission?.condition ?? null,
);
const refreshLiveCondition = useCallback(async () => {
setLiveCondition(await getAvatarConditionForToday());
}, []);
useEffect(() => {
async function loadDailyCheckIn() {
@@ -52,6 +61,9 @@ export function DailyQuizForm() {
setSubmission(existingEntry);
setAnswers(existingEntry.answers);
setJournal(existingEntry.journal);
setLiveCondition(existingEntry.condition);
} else {
await refreshLiveCondition();
}
} catch (loadError) {
setError(
@@ -65,7 +77,19 @@ export function DailyQuizForm() {
}
void loadDailyCheckIn();
}, []);
}, [refreshLiveCondition]);
useEffect(() => {
const handleDataUpdated = () => {
void refreshLiveCondition();
};
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
};
}, [refreshLiveCondition]);
const isCompleted = submission !== null;
const canSubmit = !isCompleted && !isSubmitting;
@@ -91,6 +115,7 @@ export function DailyQuizForm() {
try {
const savedEntry = await saveDailyEntry(answers, journal);
setSubmission(savedEntry);
setLiveCondition(savedEntry.condition);
} catch (submitError) {
setError(
submitError instanceof Error
@@ -137,28 +162,33 @@ export function DailyQuizForm() {
<CardHeader>
<CardTitle className="text-base">Your pet today</CardTitle>
<CardDescription>
Based on the wellness check-in you already submitted.
Based on your wellness check-in and completed habits today.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<PixelAvatar mood={submission.condition.mood} size="md" />
<PixelAvatar
mood={(liveCondition ?? submission.condition).mood}
size="md"
/>
<div className="grid w-full grid-cols-3 gap-2 text-center text-sm">
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Mood</p>
<p className="font-medium capitalize">
{submission.condition.mood}
{(liveCondition ?? submission.condition).mood}
</p>
</div>
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Energy</p>
<p className="font-medium">
{submission.condition.energy}/{WELLNESS_SCALE_MAX}
{(liveCondition ?? submission.condition).energy}/
{WELLNESS_SCALE_MAX}
</p>
</div>
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Health</p>
<p className="font-medium">
{submission.condition.health}/{WELLNESS_SCALE_MAX}
{(liveCondition ?? submission.condition).health}/
{WELLNESS_SCALE_MAX}
</p>
</div>
</div>

View File

@@ -30,6 +30,7 @@ import {
type Habit,
} from "@/lib/habits-storage";
import { formatCoinsDelta } from "@/lib/coins";
import { describeHabitWellnessBoost } from "@/lib/habit-wellness-effects";
import { getProfilePreferences } from "@/lib/profile-preferences-storage";
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
@@ -334,7 +335,14 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
delete next[habitId];
return next;
});
setCoinMessage(formatCoinsDelta(coinsDelta));
setCoinMessage(
[
formatCoinsDelta(coinsDelta),
nextCompleted ? describeHabitWellnessBoost(habit.label) : null,
]
.filter(Boolean)
.join(" · "),
);
} catch (toggleError) {
setOptimisticCompletion((current) => {
const next = { ...current };

View File

@@ -15,6 +15,16 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
clearDailyReminderSentToday,
getNotificationPermissionLabel,
getNotificationPermissionState,
isNotificationSupported,
requestNotificationPermission,
showTestDailyReminderNotification,
} from "@/lib/daily-reminders";
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
import { getTodayDateKey } from "@/lib/habits-storage";
import {
avatarVibeOptions,
defaultProfilePreferences,
@@ -35,6 +45,16 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [notificationPermission, setNotificationPermission] = useState(
getNotificationPermissionState(),
);
const [notificationMessage, setNotificationMessage] = useState<string | null>(
null,
);
useEffect(() => {
setNotificationPermission(getNotificationPermissionState());
}, []);
useEffect(() => {
async function loadPreferences() {
@@ -63,6 +83,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
try {
const savedPreferences = await saveProfilePreferences(nextPreferences);
setPreferences(savedPreferences);
if (
"dailyReminderEnabled" in updates ||
"dailyReminderTime" in updates
) {
clearDailyReminderSentToday(getTodayDateKey());
}
} catch (saveError) {
setPreferences(preferences);
setError(
@@ -75,6 +102,64 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
}
};
const handleDailyReminderToggle = async (checked: boolean) => {
if (checked) {
if (!isNotificationSupported()) {
setError("This browser does not support notifications.");
return;
}
const permission = await requestNotificationPermission();
setNotificationPermission(permission);
if (permission !== "granted") {
setError(
permission === "denied"
? "Notifications are blocked. Enable them in your browser settings to use daily reminders."
: "Notification permission was not granted.",
);
return;
}
}
setError(null);
await updatePreferences({ dailyReminderEnabled: checked });
};
const handleTestNotification = async () => {
setNotificationMessage(null);
setError(null);
if (!isNotificationSupported()) {
setError("This browser does not support notifications.");
return;
}
const permission = await requestNotificationPermission();
setNotificationPermission(permission);
if (permission !== "granted") {
setError(
permission === "denied"
? "Notifications are blocked. Enable them in your browser settings."
: "Notification permission was not granted.",
);
return;
}
const status = await getDailyReminderStatus();
const shown = showTestDailyReminderNotification(status);
if (!shown) {
setError("Could not show a test notification.");
return;
}
setNotificationMessage(
"Test notification sent. Check the corner of your screen or Windows notification center.",
);
};
if (!isReady) {
return (
<p className="text-sm text-muted-foreground">Loading profile...</p>
@@ -154,13 +239,19 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<Label htmlFor="daily-reminder">Daily reminder</Label>
<Checkbox
id="daily-reminder"
disabled={isSaving}
disabled={isSaving || !isNotificationSupported()}
checked={preferences.dailyReminderEnabled}
onCheckedChange={(checked) =>
void updatePreferences({ dailyReminderEnabled: checked === true })
void handleDailyReminderToggle(checked === true)
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<span className="text-sm">Browser permission</span>
<Badge variant="secondary">
{getNotificationPermissionLabel(notificationPermission)}
</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-time">Daily reminder time</Label>
<Input
@@ -173,6 +264,26 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
}
/>
</div>
<p className="text-xs text-muted-foreground">
Reminders use your browser&apos;s local time and notify you once
per day when the quiz or habits are still incomplete. Keep this app
open in a tab for reminders to fire. On Windows, check Focus Assist
if notifications are hidden.
</p>
<Button
type="button"
variant="outline"
className="w-full"
disabled={
isSaving || notificationPermission !== "granted"
}
onClick={() => void handleTestNotification()}
>
Send test notification
</Button>
{notificationMessage ? (
<p className="text-xs text-emerald-600">{notificationMessage}</p>
) : null}
</CardContent>
</Card>

View File

@@ -0,0 +1,133 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
import {
getNotificationPermissionState,
getReminderCheckIntervalMs,
hasReachedReminderTime,
markDailyReminderSentToday,
showDailyReminderNotification,
wasDailyReminderSentToday,
} from "@/lib/daily-reminders";
import {
getProfilePreferences,
type ProfilePreferences,
} from "@/lib/profile-preferences-storage";
export function DailyReminderProvider() {
const [preferences, setPreferences] = useState<ProfilePreferences | null>(
null,
);
const isCheckingRef = useRef(false);
const loadPreferences = useCallback(async () => {
try {
const nextPreferences = await getProfilePreferences();
setPreferences(nextPreferences);
return nextPreferences;
} catch {
setPreferences(null);
return null;
}
}, []);
const runReminderCheck = useCallback(
async (activePreferences = preferences) => {
if (isCheckingRef.current) {
return;
}
isCheckingRef.current = true;
try {
if (!activePreferences?.dailyReminderEnabled) {
return;
}
if (getNotificationPermissionState() !== "granted") {
return;
}
const status = await getDailyReminderStatus();
if (wasDailyReminderSentToday(status.dateKey)) {
return;
}
if (!hasReachedReminderTime(activePreferences.dailyReminderTime)) {
return;
}
if (status.needsReminder) {
showDailyReminderNotification(status);
}
markDailyReminderSentToday(status.dateKey);
} finally {
isCheckingRef.current = false;
}
},
[preferences],
);
useEffect(() => {
let cancelled = false;
async function initialize() {
const loadedPreferences = await loadPreferences();
if (!cancelled && loadedPreferences) {
await runReminderCheck(loadedPreferences);
}
}
void initialize();
const handleDataUpdated = () => {
void (async () => {
const loadedPreferences = await loadPreferences();
if (loadedPreferences) {
await runReminderCheck(loadedPreferences);
}
})();
};
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
return () => {
cancelled = true;
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
};
}, [loadPreferences, runReminderCheck]);
useEffect(() => {
if (!preferences) {
return;
}
const intervalId = window.setInterval(() => {
void runReminderCheck(preferences);
}, getReminderCheckIntervalMs());
const handleVisibilityOrFocus = () => {
if (document.visibilityState === "visible") {
void runReminderCheck(preferences);
}
};
window.addEventListener("focus", handleVisibilityOrFocus);
document.addEventListener("visibilitychange", handleVisibilityOrFocus);
return () => {
window.clearInterval(intervalId);
window.removeEventListener("focus", handleVisibilityOrFocus);
document.removeEventListener("visibilitychange", handleVisibilityOrFocus);
};
}, [preferences, runReminderCheck]);
return null;
}

View File

@@ -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,
};
}

View File

@@ -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,
);
}

View 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
View 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";
}
}

View 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(", ")}`;
}

View File

@@ -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);