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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 +83,12 @@ 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."
|
||||
: "Complete today's quiz to update your pet's mood, energy, and health."}
|
||||
? 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 ? (
|
||||
<Button asChild className="w-full">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
|
||||
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(
|
||||
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