diff --git a/components/daily-quiz/daily-quiz-countdown.tsx b/components/daily-quiz/daily-quiz-countdown.tsx new file mode 100644 index 0000000..a79c437 --- /dev/null +++ b/components/daily-quiz/daily-quiz-countdown.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { + formatDailyQuizCountdown, + getMillisecondsUntilNextDailyQuiz, + getNextDailyQuizAvailableAt, +} from "@/lib/daily-quiz-storage"; + +type DailyQuizCountdownProps = { + onAvailable?: () => void; +}; + +export function DailyQuizCountdown({ onAvailable }: DailyQuizCountdownProps) { + const [remainingMs, setRemainingMs] = useState(() => + getMillisecondsUntilNextDailyQuiz(), + ); + const hasNotifiedRef = useRef(false); + + useEffect(() => { + const tick = () => { + const nextRemainingMs = getMillisecondsUntilNextDailyQuiz(); + setRemainingMs(nextRemainingMs); + + if (nextRemainingMs === 0) { + if (!hasNotifiedRef.current) { + hasNotifiedRef.current = true; + onAvailable?.(); + } + return; + } + + hasNotifiedRef.current = false; + }; + + tick(); + const intervalId = window.setInterval(tick, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, [onAvailable]); + + const nextAvailableAt = getNextDailyQuizAvailableAt(); + const nextAvailableLabel = nextAvailableAt.toLocaleString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + + return ( +
+

+ Next check-in available in{" "} + + {formatDailyQuizCountdown(remainingMs)} + +

+

+ Opens at midnight ({nextAvailableLabel}) +

+
+ ); +} diff --git a/components/daily-quiz/daily-quiz-form.tsx b/components/daily-quiz/daily-quiz-form.tsx index e75f1a2..315e13d 100644 --- a/components/daily-quiz/daily-quiz-form.tsx +++ b/components/daily-quiz/daily-quiz-form.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { WellnessSlider } from "@/components/daily-quiz/wellness-slider"; +import { DailyQuizCountdown } from "@/components/daily-quiz/daily-quiz-countdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -28,6 +29,7 @@ import { getCachedQuizTabData, prefetchQuizTabData, } from "@/lib/app-tab-data-cache"; +import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { saveDailyEntry } from "@/lib/daily-quiz-storage"; import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety"; import { routes } from "@/lib/routes"; @@ -63,7 +65,7 @@ export function DailyQuizForm() { async function loadDailyCheckIn() { try { - const data = await prefetchQuizTabData(); + const data = await prefetchQuizTabData({ force: true }); if (cancelled) { return; @@ -89,8 +91,15 @@ export function DailyQuizForm() { void loadDailyCheckIn(); + const handleDataUpdated = () => { + void loadDailyCheckIn(); + }; + + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated); + return () => { cancelled = true; + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated); }; }, [applyQuizData]); @@ -161,6 +170,11 @@ export function DailyQuizForm() { {" "} whenever you want to see how they are doing.

+ { + void prefetchQuizTabData({ force: true }).then(applyQuizData); + }} + /> diff --git a/components/profile/profile-preferences-form.tsx b/components/profile/profile-preferences-form.tsx index 4e175a1..656234e 100644 --- a/components/profile/profile-preferences-form.tsx +++ b/components/profile/profile-preferences-form.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/daily-reminders"; import { getDailyReminderStatus } from "@/lib/daily-reminder-status"; import { getTodayDateKey } from "@/lib/habits-storage"; +import { resetTodaysDailyQuiz } from "@/lib/daily-quiz-storage"; import { reminderDeliveryOptions, usesSystemReminders, @@ -56,6 +57,8 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { const [notificationMessage, setNotificationMessage] = useState( null, ); + const [quizResetMessage, setQuizResetMessage] = useState(null); + const [isResettingQuiz, setIsResettingQuiz] = useState(false); useEffect(() => { setNotificationPermission(getNotificationPermissionState()); @@ -209,6 +212,27 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { ); }; + const handleResetDailyQuiz = async () => { + setQuizResetMessage(null); + setError(null); + setIsResettingQuiz(true); + + try { + await resetTodaysDailyQuiz(); + setQuizResetMessage( + "Today's quiz was reset. Open the Quiz tab to take it again.", + ); + } catch (resetError) { + setError( + resetError instanceof Error + ? resetError.message + : "Could not reset today's quiz.", + ); + } finally { + setIsResettingQuiz(false); + } + }; + if (!isReady) { return (

Loading profile...

@@ -365,6 +389,33 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { + + + Demo tools + + Shortcuts for testing flows without waiting for the daily reset. + + + +

+ Reset clears today's wellness quiz entry so you can complete it + again immediately. +

+ + {quizResetMessage ? ( +

{quizResetMessage}

+ ) : null} +
+
+ Account settings diff --git a/lib/daily-quiz-storage.ts b/lib/daily-quiz-storage.ts index 417eb54..f4eac1c 100644 --- a/lib/daily-quiz-storage.ts +++ b/lib/daily-quiz-storage.ts @@ -5,6 +5,11 @@ import { type DailyQuizSubmission, } from "@/lib/avatar-state"; import { notifyHabitPetDataUpdated } from "@/lib/app-events"; +import { + getMillisecondsUntilLocalMidnight, + getNextLocalMidnight, + getTodayDateKey, +} from "@/lib/date-keys"; import { computeAvatarConditionWithHabitBoosts } from "@/lib/habit-wellness-effects"; import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage"; import { validateJournalEntry } from "@/lib/journal-safety"; @@ -20,8 +25,45 @@ type DailyEntryRow = { journal: string | null; }; -export function getTodayDateKey(date = new Date()) { - return date.toISOString().slice(0, 10); +export { getTodayDateKey }; + +export function getNextDailyQuizAvailableAt(from = new Date()) { + return getNextLocalMidnight(from); +} + +export function getMillisecondsUntilNextDailyQuiz(from = new Date()) { + return getMillisecondsUntilLocalMidnight(from); +} + +export function formatDailyQuizCountdown(milliseconds: number) { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +export async function resetTodaysDailyQuiz() { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to reset today's quiz."); + } + + const supabase = createClient(); + const entryDate = getTodayDateKey(); + const { error } = await supabase + .from("daily_entries") + .delete() + .eq("user_id", userId) + .eq("entry_date", entryDate); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); } function mapRowToSubmission( diff --git a/lib/date-keys.ts b/lib/date-keys.ts new file mode 100644 index 0000000..5593d29 --- /dev/null +++ b/lib/date-keys.ts @@ -0,0 +1,19 @@ +export function getTodayDateKey(date = new Date()) { + return date.toLocaleDateString("en-CA"); +} + +export function getNextLocalMidnight(from = new Date()) { + return new Date( + from.getFullYear(), + from.getMonth(), + from.getDate() + 1, + 0, + 0, + 0, + 0, + ); +} + +export function getMillisecondsUntilLocalMidnight(from = new Date()) { + return Math.max(0, getNextLocalMidnight(from).getTime() - from.getTime()); +} diff --git a/lib/habits-storage.ts b/lib/habits-storage.ts index ec13018..e6495f9 100644 --- a/lib/habits-storage.ts +++ b/lib/habits-storage.ts @@ -16,6 +16,9 @@ import { getStreakMilestoneBonus, } from "@/lib/coins"; import { createClient } from "@/lib/supabase/client"; +import { getTodayDateKey } from "@/lib/date-keys"; + +export { getTodayDateKey }; export type Habit = { id: string; @@ -43,10 +46,6 @@ type HabitRow = { const habitSelectFields = "id, name, streak, last_completed_on, claimed_streak_milestones"; -export function getTodayDateKey(date = new Date()) { - return date.toISOString().slice(0, 10); -} - function getDayDiff(fromDate: string, toDate: string) { const from = new Date(`${fromDate}T00:00:00`); const to = new Date(`${toDate}T00:00:00`);