diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx index fe6e3a5..17de203 100644 --- a/components/avatar/avatar-status-card.tsx +++ b/components/avatar/avatar-status-card.tsx @@ -29,10 +29,14 @@ export function AvatarStatusCard() { const [isReady, setIsReady] = useState(false); useEffect(() => { - const checkedIn = hasCompletedDailyQuizToday(); - setHasCheckedInToday(checkedIn); - setCondition(getAvatarConditionForToday() ?? defaultAvatarCondition); - setIsReady(true); + async function loadStatus() { + const checkedIn = await hasCompletedDailyQuizToday(); + setHasCheckedInToday(checkedIn); + setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition); + setIsReady(true); + } + + void loadStatus(); }, []); if (!isReady) { diff --git a/components/daily-quiz/daily-quiz-form.tsx b/components/daily-quiz/daily-quiz-form.tsx index eedb2b2..e5d48b6 100644 --- a/components/daily-quiz/daily-quiz-form.tsx +++ b/components/daily-quiz/daily-quiz-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { CheckCircle2 } from "lucide-react"; import { useEffect, useState } from "react"; import { PixelAvatar } from "@/components/avatar/pixel-avatar"; @@ -16,19 +17,18 @@ import { import { Label } from "@/components/ui/label"; import { defaultDailyQuizAnswers, + formatSleepHours, + SLEEP_HOURS_MAX, + SLEEP_HOURS_MIN, + SLEEP_HOURS_STEP, WELLNESS_SCALE_MAX, type DailyQuizAnswers, type DailyQuizSubmission, } from "@/lib/avatar-state"; import { - getDailyQuizSubmission, - getTodayDateKey, - saveDailyQuizSubmission, + getDailyEntryForToday, + saveDailyEntry, } from "@/lib/daily-quiz-storage"; -import { - getJournalEntryForToday, - saveJournalEntry, -} from "@/lib/journal-storage"; export function DailyQuizForm() { const [answers, setAnswers] = useState( @@ -44,19 +44,23 @@ export function DailyQuizForm() { useEffect(() => { async function loadDailyCheckIn() { - const existingSubmission = getDailyQuizSubmission(); - const existingJournal = await getJournalEntryForToday(); + try { + const existingEntry = await getDailyEntryForToday(); - if (existingSubmission?.date === getTodayDateKey()) { - setSubmission(existingSubmission); - setAnswers(existingSubmission.answers); + if (existingEntry) { + setSubmission(existingEntry); + setAnswers(existingEntry.answers); + setJournal(existingEntry.journal); + } + } catch (loadError) { + setError( + loadError instanceof Error + ? loadError.message + : "Could not load today's check-in.", + ); + } finally { + setIsReady(true); } - - if (existingJournal) { - setJournal(existingJournal.content); - } - - setIsReady(true); } void loadDailyCheckIn(); @@ -84,9 +88,8 @@ export function DailyQuizForm() { setError(null); try { - const savedSubmission = saveDailyQuizSubmission(answers); - await saveJournalEntry(journal); - setSubmission(savedSubmission); + const savedEntry = await saveDailyEntry(answers, journal); + setSubmission(savedEntry); } catch (submitError) { setError( submitError instanceof Error @@ -106,18 +109,77 @@ export function DailyQuizForm() { return (
-
- Today's check-in - - {isCompleted ? "Completed" : "Not completed"} - -
+ {isCompleted && submission ? ( + <> + + +
+
+
+

+ Today's quiz is complete +

+

+ Your wellness check-in and journal are saved for today. You + can review your answers below, and come back tomorrow for + your next quiz. +

+
+
+
- + + + Your pet today + + Based on the wellness check-in you already submitted. + + + + +
+
+

Mood

+

+ {submission.condition.mood} +

+
+
+

Energy

+

+ {submission.condition.energy}/{WELLNESS_SCALE_MAX} +

+
+
+

Health

+

+ {submission.condition.health}/{WELLNESS_SCALE_MAX} +

+
+
+
+
+ + ) : ( +
+ Today's check-in + Not completed +
+ )} + + - Wellness + + {isCompleted ? "Your answers today" : "Wellness"} + - Sliders from 1 to 5 for how you're doing today. + {isCompleted + ? "These fields are locked until tomorrow's quiz." + : "Rate your wellness from 1 to 5. Sleep length uses hours."} @@ -146,6 +208,10 @@ export function DailyQuizForm() { id="sleep-length" label="How long did you sleep?" value={answers.sleepLength} + min={SLEEP_HOURS_MIN} + max={SLEEP_HOURS_MAX} + step={SLEEP_HOURS_STEP} + formatValue={formatSleepHours} disabled={isCompleted} onChange={(value) => updateAnswer("sleepLength", value)} /> @@ -159,11 +225,13 @@ export function DailyQuizForm() { - + Daily journal - Write about your day. Saved separately from your quiz answers. + {isCompleted + ? "Your journal entry for today." + : "Write about your day."} @@ -183,37 +251,11 @@ export function DailyQuizForm() { {error ?

{error}

: null} - {isCompleted && submission ? ( - - - -
-
-

Mood

-

- {submission.condition.mood} -

-
-
-

Energy

-

- {submission.condition.energy}/{WELLNESS_SCALE_MAX} -

-
-
-

Health

-

- {submission.condition.health}/{WELLNESS_SCALE_MAX} -

-
-
-
-
- ) : ( + {!isCompleted ? ( - )} + ) : null}
); } diff --git a/components/daily-quiz/daily-quiz-link-button.tsx b/components/daily-quiz/daily-quiz-link-button.tsx index 0b63957..2dd6862 100644 --- a/components/daily-quiz/daily-quiz-link-button.tsx +++ b/components/daily-quiz/daily-quiz-link-button.tsx @@ -12,8 +12,12 @@ export function DailyQuizLinkButton() { const [isReady, setIsReady] = useState(false); useEffect(() => { - setIsCompleted(hasCompletedDailyQuizToday()); - setIsReady(true); + async function loadStatus() { + setIsCompleted(await hasCompletedDailyQuizToday()); + setIsReady(true); + } + + void loadStatus(); }, []); if (!isReady || isCompleted) { diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index 8bf4c93..3ffcd5c 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -1,5 +1,6 @@ "use client"; +import { Loader2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; @@ -42,9 +43,15 @@ const reasonLabels = { function DailyTaskList({ tasks, + getIsCompleted, + isPending, + getToggleError, onToggle, }: { tasks: DailyTask[]; + getIsCompleted: (habit: DailyTask) => boolean; + isPending: (habitId: string) => boolean; + getToggleError: (habitId: string) => string | null; onToggle: (habitId: string) => void; }) { if (tasks.length === 0) { @@ -57,8 +64,8 @@ function DailyTaskList({ } const sortedTasks = [...tasks].sort((a, b) => { - const aCompleted = isHabitCompletedToday(a); - const bCompleted = isHabitCompletedToday(b); + const aCompleted = getIsCompleted(a); + const bCompleted = getIsCompleted(b); if (aCompleted === bCompleted) { return 0; @@ -70,7 +77,9 @@ function DailyTaskList({ return ( <> {sortedTasks.map((habit) => { - const isCompleted = isHabitCompletedToday(habit); + const isCompleted = getIsCompleted(habit); + const pending = isPending(habit.id); + const toggleError = getToggleError(habit.id); return (
- onToggle(habit.id)} - className="mt-0.5" - /> +
+ onToggle(habit.id)} + /> + {pending ? ( + <> +
@@ -154,18 +177,62 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { const [newHabitLabel, setNewHabitLabel] = useState(""); const [focusTopics, setFocusTopics] = useState([]); const [quizCompletedToday, setQuizCompletedToday] = useState(false); + const [error, setError] = useState(null); const [isReady, setIsReady] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [optimisticCompletion, setOptimisticCompletion] = useState< + Record + >({}); + const [pendingHabitIds, setPendingHabitIds] = useState>( + () => new Set(), + ); + const [toggleErrors, setToggleErrors] = useState>({}); - const refresh = useCallback(() => { - setDailyTasks(getDailyTasks()); - setCustomHabits(getCustomHabits()); - setFocusTopics(getProfilePreferences().focusTopics); - setQuizCompletedToday(hasCompletedDailyQuizToday()); - setIsReady(true); + const getIsCompleted = useCallback( + (habit: DailyTask) => { + if (habit.id in optimisticCompletion) { + return optimisticCompletion[habit.id]; + } + + return isHabitCompletedToday(habit); + }, + [optimisticCompletion], + ); + + const isPending = useCallback( + (habitId: string) => pendingHabitIds.has(habitId), + [pendingHabitIds], + ); + + const getToggleError = useCallback( + (habitId: string) => toggleErrors[habitId] ?? null, + [toggleErrors], + ); + + const refresh = useCallback(async () => { + try { + setError(null); + const [tasks, customs] = await Promise.all([ + getDailyTasks(), + getCustomHabits(), + ]); + setDailyTasks(tasks); + setCustomHabits(customs); + setFocusTopics(getProfilePreferences().focusTopics); + setQuizCompletedToday(await hasCompletedDailyQuizToday()); + setIsReady(true); + } catch (refreshError) { + setError( + refreshError instanceof Error + ? refreshError.message + : "Could not load habits.", + ); + setIsReady(true); + } }, []); useEffect(() => { - refresh(); + void refresh(); window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); return () => { @@ -174,16 +241,92 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { }, [refresh]); const handleToggle = (habitId: string) => { - toggleHabitCompletion(habitId); + const habit = dailyTasks.find((task) => task.id === habitId); + + if (!habit || pendingHabitIds.has(habitId)) { + return; + } + + const nextCompleted = !getIsCompleted(habit); + + setOptimisticCompletion((current) => ({ + ...current, + [habitId]: nextCompleted, + })); + setPendingHabitIds((current) => new Set(current).add(habitId)); + setToggleErrors((current) => { + const next = { ...current }; + delete next[habitId]; + return next; + }); + + void (async () => { + try { + const updatedHabits = await toggleHabitCompletion(habitId); + + setDailyTasks((current) => + current.map((task) => { + const updated = updatedHabits.find((item) => item.id === task.id); + return updated ? { ...task, ...updated } : task; + }), + ); + setOptimisticCompletion((current) => { + const next = { ...current }; + delete next[habitId]; + return next; + }); + } catch (toggleError) { + setOptimisticCompletion((current) => { + const next = { ...current }; + delete next[habitId]; + return next; + }); + setToggleErrors((current) => ({ + ...current, + [habitId]: + toggleError instanceof Error + ? toggleError.message + : "Could not update habit.", + })); + } finally { + setPendingHabitIds((current) => { + const next = new Set(current); + next.delete(habitId); + return next; + }); + } + })(); }; - const handleAddHabit = () => { - addHabit(newHabitLabel); - setNewHabitLabel(""); + const handleAddHabit = async () => { + try { + setIsSaving(true); + setError(null); + await addHabit(newHabitLabel); + setNewHabitLabel(""); + } catch (addError) { + setError( + addError instanceof Error ? addError.message : "Could not add habit.", + ); + } finally { + setIsSaving(false); + } }; - const handleRemoveHabit = (habitId: string) => { - removeHabit(habitId); + const handleRemoveHabit = async (habitId: string) => { + try { + setIsSaving(true); + setError(null); + await removeHabit(habitId); + } catch (removeError) { + setError( + removeError instanceof Error + ? removeError.message + : "Could not remove habit.", + ); + } finally { + setIsSaving(false); + } }; if (!isReady) { @@ -199,6 +342,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { if (mode === "manage") { return (
+ {error ?

{error}

: null} + Custom habits @@ -213,15 +358,20 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { setNewHabitLabel(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); - handleAddHabit(); + void handleAddHabit(); } }} /> -
@@ -265,7 +415,14 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { - + {error ?

{error}

: null} +
); diff --git a/lib/avatar-state.ts b/lib/avatar-state.ts index 46f617f..b65d46c 100644 --- a/lib/avatar-state.ts +++ b/lib/avatar-state.ts @@ -17,26 +17,49 @@ export type AvatarCondition = { export type DailyQuizSubmission = { date: string; answers: DailyQuizAnswers; + journal: string; condition: AvatarCondition; }; export const WELLNESS_SCALE_MAX = 5; +export const SLEEP_HOURS_MIN = 0.5; +export const SLEEP_HOURS_MAX = 12; +export const SLEEP_HOURS_STEP = 0.5; +export const DEFAULT_SLEEP_HOURS = 7.5; + export const defaultDailyQuizAnswers: DailyQuizAnswers = { feeling: 3, stress: 3, energy: 3, - sleepLength: 3, + sleepLength: DEFAULT_SLEEP_HOURS, sleepQuality: 3, }; +export function clampSleepHours(hours: number) { + const stepped = Math.round(hours / SLEEP_HOURS_STEP) * SLEEP_HOURS_STEP; + return Math.min(SLEEP_HOURS_MAX, Math.max(SLEEP_HOURS_MIN, stepped)); +} + +export function formatSleepHours(hours: number) { + return hours === 1 ? "1 hour" : `${hours} hours`; +} + +function sleepHoursToWellnessScore(hours: number) { + const score = + 1 + ((clampSleepHours(hours) - SLEEP_HOURS_MIN) / (SLEEP_HOURS_MAX - SLEEP_HOURS_MIN)) * 4; + + return clampStat(Math.round(score)); +} + export function computeAvatarCondition( answers: DailyQuizAnswers, ): AvatarCondition { const { feeling, stress, energy, sleepLength, sleepQuality } = answers; const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3; - const sleepAverage = (sleepLength + sleepQuality) / 2; + const sleepScore = sleepHoursToWellnessScore(sleepLength); + const sleepAverage = (sleepScore + sleepQuality) / 2; const health = Math.round((wellnessAverage + sleepAverage) / 2); const energyLevel = Math.round((energy + sleepQuality) / 2); @@ -44,7 +67,7 @@ export function computeAvatarCondition( if (energyLevel >= 4 && feeling >= 4 && health >= 4) { avatarMood = "happy"; - } else if (energy <= 2 || sleepLength <= 2 || sleepQuality <= 2) { + } else if (energy <= 2 || sleepLength < 6 || sleepQuality <= 2) { avatarMood = "tired"; } @@ -82,11 +105,19 @@ export function normalizeDailyQuizAnswers( : value; }; + const normalizeSleepLength = (value: number | undefined) => { + if (value === undefined) { + return defaultDailyQuizAnswers.sleepLength; + } + + return clampSleepHours(value); + }; + return { feeling: scaleValue(answers.feeling ?? answers.mood, defaultDailyQuizAnswers.feeling), stress: scaleValue(answers.stress, defaultDailyQuizAnswers.stress), energy: scaleValue(answers.energy, defaultDailyQuizAnswers.energy), - sleepLength: scaleValue(answers.sleepLength, defaultDailyQuizAnswers.sleepLength), + sleepLength: normalizeSleepLength(answers.sleepLength), sleepQuality: scaleValue( answers.sleepQuality, defaultDailyQuizAnswers.sleepQuality, diff --git a/lib/daily-quiz-storage.ts b/lib/daily-quiz-storage.ts index 73c1101..fa30185 100644 --- a/lib/daily-quiz-storage.ts +++ b/lib/daily-quiz-storage.ts @@ -6,69 +6,142 @@ import { type DailyQuizSubmission, } from "@/lib/avatar-state"; import { notifyHabitPetDataUpdated } from "@/lib/app-events"; -const STORAGE_KEY = "habit-pet-daily-quiz"; +import { createClient } from "@/lib/supabase/client"; + +type DailyEntryRow = { + entry_date: string; + mood: number; + stress: number; + energy: number; + sleep_hours: number; + sleep_quality: number; + journal: string | null; +}; export function getTodayDateKey(date = new Date()) { return date.toISOString().slice(0, 10); } -function normalizeSubmission( - submission: DailyQuizSubmission, -): DailyQuizSubmission { - const answers = normalizeDailyQuizAnswers(submission.answers); +function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission { + const answers = normalizeDailyQuizAnswers({ + feeling: row.mood, + stress: row.stress, + energy: row.energy, + sleepLength: Number(row.sleep_hours), + sleepQuality: row.sleep_quality, + }); return { - ...submission, + date: row.entry_date, answers, + journal: row.journal ?? "", condition: computeAvatarCondition(answers), }; } -export function getDailyQuizSubmission(): DailyQuizSubmission | null { - if (typeof window === "undefined") { +async function getAuthenticatedUserId() { + const supabase = createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { return null; } - const raw = window.localStorage.getItem(STORAGE_KEY); - - if (!raw) { - return null; - } - - try { - return normalizeSubmission(JSON.parse(raw) as DailyQuizSubmission); - } catch { - return null; - } + return user.id; } -export function hasCompletedDailyQuizToday() { - const submission = getDailyQuizSubmission(); - return submission?.date === getTodayDateKey(); +export async function getDailyEntryForToday(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return null; + } + + const supabase = createClient(); + const entryDate = getTodayDateKey(); + const { data, error } = await supabase + .from("daily_entries") + .select( + "entry_date, mood, stress, energy, sleep_hours, sleep_quality, journal", + ) + .eq("user_id", userId) + .eq("entry_date", entryDate) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + if (!data) { + return null; + } + + return mapRowToSubmission(data as DailyEntryRow); } -export function saveDailyQuizSubmission( +export async function getDailyQuizSubmission() { + return getDailyEntryForToday(); +} + +export async function hasCompletedDailyQuizToday() { + const entry = await getDailyEntryForToday(); + return entry !== null; +} + +export async function saveDailyEntry( answers: DailyQuizAnswers, -): DailyQuizSubmission { - const submission: DailyQuizSubmission = { - date: getTodayDateKey(), - answers, - condition: computeAvatarCondition(answers), - }; + journal: string, +): Promise { + const userId = await getAuthenticatedUserId(); - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(submission)); + if (!userId) { + throw new Error("You must be signed in to save your daily check-in."); + } + const supabase = createClient(); + const entryDate = getTodayDateKey(); + const normalizedAnswers = normalizeDailyQuizAnswers(answers); + + const { data, error } = await supabase + .from("daily_entries") + .upsert( + { + user_id: userId, + entry_date: entryDate, + mood: normalizedAnswers.feeling, + stress: normalizedAnswers.stress, + energy: normalizedAnswers.energy, + sleep_hours: normalizedAnswers.sleepLength, + sleep_quality: normalizedAnswers.sleepQuality, + journal, + }, + { onConflict: "user_id,entry_date" }, + ) + .select( + "entry_date, mood, stress, energy, sleep_hours, sleep_quality, journal", + ) + .single(); + + if (error) { + throw new Error(error.message); + } + + const submission = mapRowToSubmission(data as DailyEntryRow); notifyHabitPetDataUpdated(); - return submission; } -export function getAvatarConditionForToday(): AvatarCondition | null { - const submission = getDailyQuizSubmission(); - - if (!submission || submission.date !== getTodayDateKey()) { - return null; - } - - return submission.condition; +export async function saveDailyQuizSubmission( + answers: DailyQuizAnswers, + journal = "", +) { + return saveDailyEntry(answers, journal); +} + +export async function getAvatarConditionForToday(): Promise { + const entry = await getDailyEntryForToday(); + return entry?.condition ?? null; } diff --git a/lib/daily-tasks.ts b/lib/daily-tasks.ts index 8cb190e..ef5ed6d 100644 --- a/lib/daily-tasks.ts +++ b/lib/daily-tasks.ts @@ -1,5 +1,5 @@ import type { DailyQuizAnswers } from "@/lib/avatar-state"; -import { getDailyQuizSubmission, getTodayDateKey } from "@/lib/daily-quiz-storage"; +import { getDailyEntryForToday } from "@/lib/daily-quiz-storage"; import { catalogHabitIds, getCatalogEntry, @@ -7,7 +7,11 @@ import { matchesFocusTopics, matchesQuizAnswers, } from "@/lib/habit-catalog"; -import { getHabits, saveHabits, type Habit } from "@/lib/habits-storage"; +import { + ensureCatalogHabit, + getHabits, + type Habit, +} from "@/lib/habits-storage"; import { getProfilePreferences, type ProfilePreferences, @@ -19,37 +23,9 @@ export type DailyTask = Habit & { reason: DailyTaskReason; }; -function getQuizAnswersForToday(): DailyQuizAnswers | null { - const submission = getDailyQuizSubmission(); - - if (!submission || submission.date !== getTodayDateKey()) { - return null; - } - - return submission.answers; -} - -function ensureCatalogHabit(entryId: string, habits: Habit[]) { - const entry = getCatalogEntry(entryId); - - if (!entry) { - return habits; - } - - if (habits.some((habit) => habit.id === entry.id)) { - return habits; - } - - return [ - ...habits, - { - id: entry.id, - label: entry.label, - streak: 0, - lastCompletedDate: null, - isCustom: false, - }, - ]; +async function getQuizAnswersForToday(): Promise { + const entry = await getDailyEntryForToday(); + return entry?.answers ?? null; } function getCatalogTaskReason( @@ -71,34 +47,33 @@ function getCatalogTaskReason( return null; } -export function getDailyTasks(): DailyTask[] { - let habits = getHabits(); +export async function getDailyTasks(): Promise { const preferences = getProfilePreferences(); - const quizAnswers = getQuizAnswersForToday(); - const initialHabitCount = habits.length; + const quizAnswers = await getQuizAnswersForToday(); const selectedCatalogIds = habitCatalog .map((entry) => { const reason = getCatalogTaskReason(entry, preferences, quizAnswers); - return reason ? { id: entry.id, reason } : null; + return reason ? { catalogId: entry.id, reason } : null; }) - .filter((item): item is { id: string; reason: DailyTaskReason } => item !== null); + .filter( + (item): item is { catalogId: string; reason: DailyTaskReason } => + item !== null, + ); - for (const { id } of selectedCatalogIds) { - habits = ensureCatalogHabit(id, habits); - } + await Promise.all( + selectedCatalogIds.map(({ catalogId }) => ensureCatalogHabit(catalogId)), + ); - if (habits.length !== initialHabitCount) { - saveHabits(habits, { notify: false }); - } + const habits = await getHabits(); const customTasks: DailyTask[] = habits .filter((habit) => habit.isCustom) .map((habit) => ({ ...habit, reason: "custom" as const })); const catalogTasks: DailyTask[] = selectedCatalogIds - .map(({ id, reason }) => { - const habit = habits.find((item) => item.id === id); + .map(({ catalogId, reason }) => { + const habit = habits.find((item) => item.catalogId === catalogId); return habit ? { ...habit, reason } : null; }) .filter((task): task is DailyTask => task !== null); @@ -106,6 +81,4 @@ export function getDailyTasks(): DailyTask[] { return [...customTasks, ...catalogTasks]; } -export function isCatalogHabitId(habitId: string) { - return catalogHabitIds.has(habitId); -} +export { isCatalogHabitId } from "@/lib/habits-storage"; diff --git a/lib/habit-catalog.ts b/lib/habit-catalog.ts index e084fba..ee4db1e 100644 --- a/lib/habit-catalog.ts +++ b/lib/habit-catalog.ts @@ -50,7 +50,7 @@ export const habitCatalog: HabitCatalogEntry[] = [ label: "Wind down before bed", focusTopics: ["Sleep"], suggestedByQuiz: (answers) => - answers.sleepLength <= 2 || answers.sleepQuality <= 2, + answers.sleepLength < 7 || answers.sleepQuality <= 2, }, ]; @@ -60,6 +60,14 @@ export function getCatalogEntry(habitId: string) { return habitCatalog.find((habit) => habit.id === habitId); } +export function getCatalogIdByLabel(label: string) { + return habitCatalog.find((habit) => habit.label === label)?.id ?? null; +} + +export function isCatalogLabel(label: string) { + return habitCatalog.some((habit) => habit.label === label); +} + export function matchesFocusTopics( entry: HabitCatalogEntry, preferences: ProfilePreferences, diff --git a/lib/habits-storage.ts b/lib/habits-storage.ts index 944d492..09e3211 100644 --- a/lib/habits-storage.ts +++ b/lib/habits-storage.ts @@ -1,135 +1,314 @@ -import { catalogHabitIds, habitCatalog } from "@/lib/habit-catalog"; +import { + catalogHabitIds, + getCatalogEntry, + getCatalogIdByLabel, + isCatalogLabel, +} from "@/lib/habit-catalog"; import { notifyHabitPetDataUpdated } from "@/lib/app-events"; +import { createClient } from "@/lib/supabase/client"; export type Habit = { id: string; label: string; + catalogId: string | null; streak: number; lastCompletedDate: string | null; isCustom: boolean; }; -const STORAGE_KEY = "habit-pet-habits"; +type HabitRow = { + id: string; + name: string; +}; -export const defaultHabits: Habit[] = habitCatalog.map((entry) => ({ - id: entry.id, - label: entry.label, - streak: 0, - lastCompletedDate: null, - isCustom: false, -})); +type HabitLogRow = { + habit_id: string; + completed_on: string; +}; export function getTodayDateKey(date = new Date()) { return date.toISOString().slice(0, 10); } -function getYesterdayDateKey(date = new Date()) { - const yesterday = new Date(date); - yesterday.setDate(yesterday.getDate() - 1); - return getTodayDateKey(yesterday); +function calculateStreak(completedDates: string[], today = getTodayDateKey()) { + if (completedDates.length === 0) { + return 0; + } + + const dates = new Set(completedDates); + let streak = 0; + const cursor = new Date(`${today}T00:00:00`); + + if (!dates.has(today)) { + cursor.setDate(cursor.getDate() - 1); + } + + while (dates.has(getTodayDateKey(cursor))) { + streak += 1; + cursor.setDate(cursor.getDate() - 1); + } + + return streak; } -function normalizeHabit(habit: Partial & { id: string; label: string }): Habit { +function getLastCompletedDate(completedDates: string[], today = getTodayDateKey()) { + if (completedDates.includes(today)) { + return today; + } + + const sortedDates = [...completedDates].sort().reverse(); + return sortedDates[0] ?? null; +} + +function mapHabitRow( + row: HabitRow, + logsByHabitId: Map, + today = getTodayDateKey(), +): Habit { + const completedDates = logsByHabitId.get(row.id) ?? []; + return { - id: habit.id, - label: habit.label, - streak: habit.streak ?? 0, - lastCompletedDate: habit.lastCompletedDate ?? null, - isCustom: habit.isCustom ?? !catalogHabitIds.has(habit.id), + id: row.id, + label: row.name, + catalogId: getCatalogIdByLabel(row.name), + streak: calculateStreak(completedDates, today), + lastCompletedDate: getLastCompletedDate(completedDates, today), + isCustom: !isCatalogLabel(row.name), }; } +async function getAuthenticatedUserId() { + const supabase = createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return null; + } + + return user.id; +} + +async function fetchHabitLogs(userId: string) { + const supabase = createClient(); + const { data, error } = await supabase + .from("habit_logs") + .select("habit_id, completed_on") + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + const logsByHabitId = new Map(); + + for (const log of (data ?? []) as HabitLogRow[]) { + const existing = logsByHabitId.get(log.habit_id) ?? []; + existing.push(log.completed_on); + logsByHabitId.set(log.habit_id, existing); + } + + return logsByHabitId; +} + export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) { return habit.lastCompletedDate === today; } -export function getHabits(): Habit[] { - if (typeof window === "undefined") { - return defaultHabits; +export async function getHabits(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return []; } - const raw = window.localStorage.getItem(STORAGE_KEY); + const supabase = createClient(); + const { data, error } = await supabase + .from("habits") + .select("id, name") + .eq("user_id", userId) + .eq("active", true) + .order("created_at", { ascending: true }); - if (!raw) { - return defaultHabits; + if (error) { + throw new Error(error.message); } - try { - const habits = (JSON.parse(raw) as Habit[]).map(normalizeHabit); - return habits.length > 0 ? habits : defaultHabits; - } catch { - return defaultHabits; - } + const logsByHabitId = await fetchHabitLogs(userId); + + return ((data ?? []) as HabitRow[]).map((row) => + mapHabitRow(row, logsByHabitId), + ); } -export function saveHabits(habits: Habit[], options?: { notify?: boolean }) { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(habits)); +export async function ensureCatalogHabit(catalogId: string): Promise { + const entry = getCatalogEntry(catalogId); - if (options?.notify !== false) { - notifyHabitPetDataUpdated(); + if (!entry) { + return null; } + + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return null; + } + + const supabase = createClient(); + const { data: existing, error: existingError } = await supabase + .from("habits") + .select("id, name") + .eq("user_id", userId) + .eq("name", entry.label) + .eq("active", true) + .maybeSingle(); + + if (existingError) { + throw new Error(existingError.message); + } + + let row = existing as HabitRow | null; + + if (!row) { + const { data: inserted, error: insertError } = await supabase + .from("habits") + .insert({ + user_id: userId, + name: entry.label, + active: true, + }) + .select("id, name") + .single(); + + if (insertError) { + throw new Error(insertError.message); + } + + row = inserted as HabitRow; + } + + const logsByHabitId = await fetchHabitLogs(userId); + return mapHabitRow(row, logsByHabitId); } -export function toggleHabitCompletion(habitId: string): Habit[] { +export async function toggleHabitCompletion(habitId: string): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return []; + } + + const supabase = createClient(); const today = getTodayDateKey(); - const yesterday = getYesterdayDateKey(); - const habits = getHabits().map((habit) => { - if (habit.id !== habitId) { - return habit; + const { data: existingLog, error: existingLogError } = await supabase + .from("habit_logs") + .select("id") + .eq("user_id", userId) + .eq("habit_id", habitId) + .eq("completed_on", today) + .maybeSingle(); + + if (existingLogError) { + throw new Error(existingLogError.message); + } + + if (existingLog) { + const { error: deleteError } = await supabase + .from("habit_logs") + .delete() + .eq("id", existingLog.id); + + if (deleteError) { + throw new Error(deleteError.message); } + } else { + const { error: insertError } = await supabase.from("habit_logs").insert({ + user_id: userId, + habit_id: habitId, + completed_on: today, + }); - if (isHabitCompletedToday(habit, today)) { - return { - ...habit, - lastCompletedDate: null, - streak: Math.max(0, habit.streak - 1), - }; + if (insertError) { + throw new Error(insertError.message); } + } - const nextStreak = - habit.lastCompletedDate === yesterday ? habit.streak + 1 : 1; - - return { - ...habit, - lastCompletedDate: today, - streak: nextStreak, - }; - }); - - saveHabits(habits); - return habits; + notifyHabitPetDataUpdated(); + return getHabits(); } -export function addHabit(label: string): Habit[] { +export async function addHabit(label: string): Promise { const trimmedLabel = label.trim(); if (!trimmedLabel) { return getHabits(); } - const habits = [ - ...getHabits(), - normalizeHabit({ - id: crypto.randomUUID(), - label: trimmedLabel, - streak: 0, - lastCompletedDate: null, - isCustom: true, - }), - ]; + const userId = await getAuthenticatedUserId(); - saveHabits(habits); - return habits; + if (!userId) { + return []; + } + + const supabase = createClient(); + const { error } = await supabase.from("habits").insert({ + user_id: userId, + name: trimmedLabel, + active: true, + }); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + return getHabits(); } -export function removeHabit(habitId: string): Habit[] { - const habits = getHabits().filter( - (habit) => habit.id !== habitId || !habit.isCustom, - ); - saveHabits(habits); - return habits; +export async function removeHabit(habitId: string): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return []; + } + + const supabase = createClient(); + const { data: habit, error: habitError } = await supabase + .from("habits") + .select("id, name") + .eq("id", habitId) + .eq("user_id", userId) + .maybeSingle(); + + if (habitError) { + throw new Error(habitError.message); + } + + if (!habit || isCatalogLabel(habit.name)) { + return getHabits(); + } + + const { error: deleteError } = await supabase + .from("habits") + .delete() + .eq("id", habitId) + .eq("user_id", userId); + + if (deleteError) { + throw new Error(deleteError.message); + } + + notifyHabitPetDataUpdated(); + return getHabits(); } -export function getCustomHabits() { - return getHabits().filter((habit) => habit.isCustom); +export async function getCustomHabits() { + const habits = await getHabits(); + return habits.filter((habit) => habit.isCustom); +} + +export function isCatalogHabitId(habitId: string) { + return catalogHabitIds.has(habitId); } diff --git a/lib/journal-storage.ts b/lib/journal-storage.ts deleted file mode 100644 index 2e1f757..0000000 --- a/lib/journal-storage.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { createClient } from "@/lib/supabase/client"; - -export type JournalEntry = { - entryDate: string; - content: string; -}; - -const LOCAL_STORAGE_KEY = "habit-pet-journal"; - -export function getTodayDateKey(date = new Date()) { - return date.toISOString().slice(0, 10); -} - -function getLocalJournalEntry(entryDate = getTodayDateKey()): JournalEntry | null { - if (typeof window === "undefined") { - return null; - } - - const raw = window.localStorage.getItem(LOCAL_STORAGE_KEY); - - if (!raw) { - return null; - } - - try { - const entry = JSON.parse(raw) as JournalEntry; - return entry.entryDate === entryDate ? entry : null; - } catch { - return null; - } -} - -function saveLocalJournalEntry(content: string, entryDate = getTodayDateKey()) { - const entry: JournalEntry = { entryDate, content }; - window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entry)); - return entry; -} - -export async function getJournalEntryForToday(): Promise { - const entryDate = getTodayDateKey(); - const supabase = createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return getLocalJournalEntry(entryDate); - } - - const { data, error } = await supabase - .from("journal_entries") - .select("entry_date, content") - .eq("user_id", user.id) - .eq("entry_date", entryDate) - .maybeSingle(); - - if (error) { - console.error("Failed to load journal entry:", error.message); - return getLocalJournalEntry(entryDate); - } - - if (!data) { - return null; - } - - return { - entryDate: data.entry_date, - content: data.content, - }; -} - -export async function saveJournalEntry(content: string): Promise { - const entryDate = getTodayDateKey(); - const supabase = createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return saveLocalJournalEntry(content, entryDate); - } - - const { data, error } = await supabase - .from("journal_entries") - .upsert( - { - user_id: user.id, - entry_date: entryDate, - content, - updated_at: new Date().toISOString(), - }, - { onConflict: "user_id,entry_date" }, - ) - .select("entry_date, content") - .single(); - - if (error) { - console.error("Failed to save journal entry:", error.message); - return saveLocalJournalEntry(content, entryDate); - } - - return { - entryDate: data.entry_date, - content: data.content, - }; -} - -export function hasJournalEntryToday(entry: JournalEntry | null) { - return entry?.entryDate === getTodayDateKey() && entry.content.trim().length > 0; -} diff --git a/supabase/migrations/20260523120000_journal_entries.sql b/supabase/migrations/20260523120000_journal_entries.sql deleted file mode 100644 index fbf645a..0000000 --- a/supabase/migrations/20260523120000_journal_entries.sql +++ /dev/null @@ -1,35 +0,0 @@ -create table if not exists public.journal_entries ( - id uuid primary key default gen_random_uuid(), - user_id uuid not null references auth.users (id) on delete cascade, - entry_date date not null, - content text not null default '', - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (user_id, entry_date) -); - -create index if not exists journal_entries_user_id_entry_date_idx - on public.journal_entries (user_id, entry_date desc); - -alter table public.journal_entries enable row level security; - -create policy "Users can read own journal entries" - on public.journal_entries - for select - using (auth.uid() = user_id); - -create policy "Users can insert own journal entries" - on public.journal_entries - for insert - with check (auth.uid() = user_id); - -create policy "Users can update own journal entries" - on public.journal_entries - for update - using (auth.uid() = user_id) - with check (auth.uid() = user_id); - -create policy "Users can delete own journal entries" - on public.journal_entries - for delete - using (auth.uid() = user_id);