added windows notifications and have avatar health/energy update with task completion
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user