added windows notifications and have avatar health/energy update with task completion
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { AppAuthGate } from "@/components/layout/app-auth-gate";
|
import { AppAuthGate } from "@/components/layout/app-auth-gate";
|
||||||
|
import { DailyReminderProvider } from "@/components/reminders/daily-reminder-provider";
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -9,7 +10,10 @@ export default function AppLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-svh bg-background" />}>
|
<Suspense fallback={<div className="min-h-svh bg-background" />}>
|
||||||
<AppAuthGate>{children}</AppAuthGate>
|
<AppAuthGate>
|
||||||
|
<DailyReminderProvider />
|
||||||
|
{children}
|
||||||
|
</AppAuthGate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
|
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
WELLNESS_SCALE_MAX,
|
WELLNESS_SCALE_MAX,
|
||||||
type AvatarCondition,
|
type AvatarCondition,
|
||||||
} from "@/lib/avatar-state";
|
} from "@/lib/avatar-state";
|
||||||
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
import {
|
import {
|
||||||
getAvatarConditionForToday,
|
getAvatarConditionForToday,
|
||||||
hasCompletedDailyQuizToday,
|
hasCompletedDailyQuizToday,
|
||||||
} from "@/lib/daily-quiz-storage";
|
} from "@/lib/daily-quiz-storage";
|
||||||
|
import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
export function AvatarStatusCard() {
|
export function AvatarStatusCard() {
|
||||||
@@ -26,19 +28,32 @@ export function AvatarStatusCard() {
|
|||||||
defaultAvatarCondition,
|
defaultAvatarCondition,
|
||||||
);
|
);
|
||||||
const [hasCheckedInToday, setHasCheckedInToday] = useState(false);
|
const [hasCheckedInToday, setHasCheckedInToday] = useState(false);
|
||||||
|
const [completedTaskCount, setCompletedTaskCount] = useState(0);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadStatus = useCallback(async () => {
|
||||||
async function loadStatus() {
|
const [checkedIn, nextCondition, completedLabels] = await Promise.all([
|
||||||
const checkedIn = await hasCompletedDailyQuizToday();
|
hasCompletedDailyQuizToday(),
|
||||||
setHasCheckedInToday(checkedIn);
|
getAvatarConditionForToday(),
|
||||||
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
|
getCompletedHabitLabelsForToday(),
|
||||||
setIsReady(true);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -68,7 +83,11 @@ export function AvatarStatusCard() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{hasCheckedInToday
|
{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."}
|
: "Complete today's quiz to update your pet's mood, energy, and health."}
|
||||||
</p>
|
</p>
|
||||||
{!hasCheckedInToday ? (
|
{!hasCheckedInToday ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
|
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
|
||||||
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
|
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
|
||||||
@@ -25,7 +25,9 @@ import {
|
|||||||
type DailyQuizAnswers,
|
type DailyQuizAnswers,
|
||||||
type DailyQuizSubmission,
|
type DailyQuizSubmission,
|
||||||
} from "@/lib/avatar-state";
|
} from "@/lib/avatar-state";
|
||||||
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
import {
|
import {
|
||||||
|
getAvatarConditionForToday,
|
||||||
getDailyEntryForToday,
|
getDailyEntryForToday,
|
||||||
saveDailyEntry,
|
saveDailyEntry,
|
||||||
} from "@/lib/daily-quiz-storage";
|
} from "@/lib/daily-quiz-storage";
|
||||||
@@ -42,6 +44,13 @@ export function DailyQuizForm() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [liveCondition, setLiveCondition] = useState(
|
||||||
|
submission?.condition ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshLiveCondition = useCallback(async () => {
|
||||||
|
setLiveCondition(await getAvatarConditionForToday());
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadDailyCheckIn() {
|
async function loadDailyCheckIn() {
|
||||||
@@ -52,6 +61,9 @@ export function DailyQuizForm() {
|
|||||||
setSubmission(existingEntry);
|
setSubmission(existingEntry);
|
||||||
setAnswers(existingEntry.answers);
|
setAnswers(existingEntry.answers);
|
||||||
setJournal(existingEntry.journal);
|
setJournal(existingEntry.journal);
|
||||||
|
setLiveCondition(existingEntry.condition);
|
||||||
|
} else {
|
||||||
|
await refreshLiveCondition();
|
||||||
}
|
}
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(
|
setError(
|
||||||
@@ -65,7 +77,19 @@ export function DailyQuizForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadDailyCheckIn();
|
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 isCompleted = submission !== null;
|
||||||
const canSubmit = !isCompleted && !isSubmitting;
|
const canSubmit = !isCompleted && !isSubmitting;
|
||||||
@@ -91,6 +115,7 @@ export function DailyQuizForm() {
|
|||||||
try {
|
try {
|
||||||
const savedEntry = await saveDailyEntry(answers, journal);
|
const savedEntry = await saveDailyEntry(answers, journal);
|
||||||
setSubmission(savedEntry);
|
setSubmission(savedEntry);
|
||||||
|
setLiveCondition(savedEntry.condition);
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(
|
setError(
|
||||||
submitError instanceof Error
|
submitError instanceof Error
|
||||||
@@ -137,28 +162,33 @@ export function DailyQuizForm() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Your pet today</CardTitle>
|
<CardTitle className="text-base">Your pet today</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Based on the wellness check-in you already submitted.
|
Based on your wellness check-in and completed habits today.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col items-center gap-4">
|
<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="grid w-full grid-cols-3 gap-2 text-center text-sm">
|
||||||
<div className="rounded-lg border bg-background px-3 py-2">
|
<div className="rounded-lg border bg-background px-3 py-2">
|
||||||
<p className="text-muted-foreground">Mood</p>
|
<p className="text-muted-foreground">Mood</p>
|
||||||
<p className="font-medium capitalize">
|
<p className="font-medium capitalize">
|
||||||
{submission.condition.mood}
|
{(liveCondition ?? submission.condition).mood}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-background px-3 py-2">
|
<div className="rounded-lg border bg-background px-3 py-2">
|
||||||
<p className="text-muted-foreground">Energy</p>
|
<p className="text-muted-foreground">Energy</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{submission.condition.energy}/{WELLNESS_SCALE_MAX}
|
{(liveCondition ?? submission.condition).energy}/
|
||||||
|
{WELLNESS_SCALE_MAX}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-background px-3 py-2">
|
<div className="rounded-lg border bg-background px-3 py-2">
|
||||||
<p className="text-muted-foreground">Health</p>
|
<p className="text-muted-foreground">Health</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{submission.condition.health}/{WELLNESS_SCALE_MAX}
|
{(liveCondition ?? submission.condition).health}/
|
||||||
|
{WELLNESS_SCALE_MAX}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type Habit,
|
type Habit,
|
||||||
} from "@/lib/habits-storage";
|
} from "@/lib/habits-storage";
|
||||||
import { formatCoinsDelta } from "@/lib/coins";
|
import { formatCoinsDelta } from "@/lib/coins";
|
||||||
|
import { describeHabitWellnessBoost } from "@/lib/habit-wellness-effects";
|
||||||
import { getProfilePreferences } from "@/lib/profile-preferences-storage";
|
import { getProfilePreferences } from "@/lib/profile-preferences-storage";
|
||||||
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
|
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
|
||||||
|
|
||||||
@@ -334,7 +335,14 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
delete next[habitId];
|
delete next[habitId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setCoinMessage(formatCoinsDelta(coinsDelta));
|
setCoinMessage(
|
||||||
|
[
|
||||||
|
formatCoinsDelta(coinsDelta),
|
||||||
|
nextCompleted ? describeHabitWellnessBoost(habit.label) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · "),
|
||||||
|
);
|
||||||
} catch (toggleError) {
|
} catch (toggleError) {
|
||||||
setOptimisticCompletion((current) => {
|
setOptimisticCompletion((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import {
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 {
|
import {
|
||||||
avatarVibeOptions,
|
avatarVibeOptions,
|
||||||
defaultProfilePreferences,
|
defaultProfilePreferences,
|
||||||
@@ -35,6 +45,16 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [notificationPermission, setNotificationPermission] = useState(
|
||||||
|
getNotificationPermissionState(),
|
||||||
|
);
|
||||||
|
const [notificationMessage, setNotificationMessage] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotificationPermission(getNotificationPermissionState());
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPreferences() {
|
async function loadPreferences() {
|
||||||
@@ -63,6 +83,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
try {
|
try {
|
||||||
const savedPreferences = await saveProfilePreferences(nextPreferences);
|
const savedPreferences = await saveProfilePreferences(nextPreferences);
|
||||||
setPreferences(savedPreferences);
|
setPreferences(savedPreferences);
|
||||||
|
|
||||||
|
if (
|
||||||
|
"dailyReminderEnabled" in updates ||
|
||||||
|
"dailyReminderTime" in updates
|
||||||
|
) {
|
||||||
|
clearDailyReminderSentToday(getTodayDateKey());
|
||||||
|
}
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setPreferences(preferences);
|
setPreferences(preferences);
|
||||||
setError(
|
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) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">Loading profile...</p>
|
<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>
|
<Label htmlFor="daily-reminder">Daily reminder</Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="daily-reminder"
|
id="daily-reminder"
|
||||||
disabled={isSaving}
|
disabled={isSaving || !isNotificationSupported()}
|
||||||
checked={preferences.dailyReminderEnabled}
|
checked={preferences.dailyReminderEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
void updatePreferences({ dailyReminderEnabled: checked === true })
|
void handleDailyReminderToggle(checked === true)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="reminder-time">Daily reminder time</Label>
|
<Label htmlFor="reminder-time">Daily reminder time</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -173,6 +264,26 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Reminders use your browser'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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
133
components/reminders/daily-reminder-provider.tsx
Normal file
133
components/reminders/daily-reminder-provider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -54,14 +54,19 @@ function sleepHoursToWellnessScore(hours: number) {
|
|||||||
|
|
||||||
export function computeAvatarCondition(
|
export function computeAvatarCondition(
|
||||||
answers: DailyQuizAnswers,
|
answers: DailyQuizAnswers,
|
||||||
|
taskBonuses: { health?: number; energy?: number } = {},
|
||||||
): AvatarCondition {
|
): AvatarCondition {
|
||||||
const { feeling, stress, energy, sleepLength, sleepQuality } = answers;
|
const { feeling, stress, energy, sleepLength, sleepQuality } = answers;
|
||||||
|
|
||||||
const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3;
|
const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3;
|
||||||
const sleepScore = sleepHoursToWellnessScore(sleepLength);
|
const sleepScore = sleepHoursToWellnessScore(sleepLength);
|
||||||
const sleepAverage = (sleepScore + sleepQuality) / 2;
|
const sleepAverage = (sleepScore + sleepQuality) / 2;
|
||||||
const health = Math.round((wellnessAverage + sleepAverage) / 2);
|
const health = clampStat(
|
||||||
const energyLevel = Math.round((energy + sleepQuality) / 2);
|
Math.round((wellnessAverage + sleepAverage) / 2) + (taskBonuses.health ?? 0),
|
||||||
|
);
|
||||||
|
const energyLevel = clampStat(
|
||||||
|
Math.round((energy + sleepQuality) / 2) + (taskBonuses.energy ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
let avatarMood: AvatarMood = "neutral";
|
let avatarMood: AvatarMood = "neutral";
|
||||||
|
|
||||||
@@ -73,8 +78,8 @@ export function computeAvatarCondition(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mood: avatarMood,
|
mood: avatarMood,
|
||||||
energy: clampStat(energyLevel),
|
energy: energyLevel,
|
||||||
health: clampStat(health),
|
health,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
computeAvatarCondition,
|
|
||||||
normalizeDailyQuizAnswers,
|
normalizeDailyQuizAnswers,
|
||||||
type AvatarCondition,
|
type AvatarCondition,
|
||||||
type DailyQuizAnswers,
|
type DailyQuizAnswers,
|
||||||
type DailyQuizSubmission,
|
type DailyQuizSubmission,
|
||||||
} from "@/lib/avatar-state";
|
} from "@/lib/avatar-state";
|
||||||
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
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 { validateJournalEntry } from "@/lib/journal-safety";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
@@ -23,7 +24,10 @@ export function getTodayDateKey(date = new Date()) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
|
function mapRowToSubmission(
|
||||||
|
row: DailyEntryRow,
|
||||||
|
completedHabitLabels: string[] = [],
|
||||||
|
): DailyQuizSubmission {
|
||||||
const answers = normalizeDailyQuizAnswers({
|
const answers = normalizeDailyQuizAnswers({
|
||||||
feeling: row.mood,
|
feeling: row.mood,
|
||||||
stress: row.stress,
|
stress: row.stress,
|
||||||
@@ -36,7 +40,7 @@ function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
|
|||||||
date: row.entry_date,
|
date: row.entry_date,
|
||||||
answers,
|
answers,
|
||||||
journal: row.journal ?? "",
|
journal: row.journal ?? "",
|
||||||
condition: computeAvatarCondition(answers),
|
condition: computeAvatarConditionWithHabitBoosts(answers, completedHabitLabels),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +87,9 @@ export async function getDailyEntryForToday(): Promise<DailyQuizSubmission | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapRowToSubmission(row as DailyEntryRow);
|
const completedHabitLabels = await getCompletedHabitLabelsForToday();
|
||||||
|
|
||||||
|
return mapRowToSubmission(row as DailyEntryRow, completedHabitLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDailyQuizSubmission() {
|
export async function getDailyQuizSubmission() {
|
||||||
@@ -134,7 +140,10 @@ export async function saveDailyEntry(
|
|||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const submission = mapRowToSubmission(data as DailyEntryRow);
|
const submission = mapRowToSubmission(
|
||||||
|
data as DailyEntryRow,
|
||||||
|
await getCompletedHabitLabelsForToday(),
|
||||||
|
);
|
||||||
notifyHabitPetDataUpdated();
|
notifyHabitPetDataUpdated();
|
||||||
return submission;
|
return submission;
|
||||||
}
|
}
|
||||||
@@ -147,6 +156,17 @@ export async function saveDailyQuizSubmission(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
|
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
|
||||||
const entry = await getDailyEntryForToday();
|
const [entry, completedHabitLabels] = await Promise.all([
|
||||||
return entry?.condition ?? null;
|
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);
|
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> {
|
export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
|
||||||
const entry = getCatalogEntry(catalogId);
|
const entry = getCatalogEntry(catalogId);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user