From 1ce4d0d68f4e74d95d7d65e14b423fbca44ccd05 Mon Sep 17 00:00:00 2001 From: masongga Date: Sat, 23 May 2026 19:46:33 -0600 Subject: [PATCH 1/4] added habits to work in db --- components/habits/habit-tracker.tsx | 90 ++++++-- lib/daily-tasks.ts | 58 ++--- lib/habit-catalog.ts | 8 + lib/habits-storage.ts | 337 +++++++++++++++++++++------- 4 files changed, 358 insertions(+), 135 deletions(-) diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index 8bf4c93..093c701 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -154,18 +154,34 @@ 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 refresh = useCallback(() => { - setDailyTasks(getDailyTasks()); - setCustomHabits(getCustomHabits()); - setFocusTopics(getProfilePreferences().focusTopics); - setQuizCompletedToday(hasCompletedDailyQuizToday()); - setIsReady(true); + const refresh = useCallback(async () => { + try { + setError(null); + const [tasks, customs] = await Promise.all([ + getDailyTasks(), + getCustomHabits(), + ]); + setDailyTasks(tasks); + setCustomHabits(customs); + setFocusTopics(getProfilePreferences().focusTopics); + setQuizCompletedToday(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 () => { @@ -173,17 +189,51 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { }; }, [refresh]); - const handleToggle = (habitId: string) => { - toggleHabitCompletion(habitId); + const handleToggle = async (habitId: string) => { + try { + setIsSaving(true); + setError(null); + await toggleHabitCompletion(habitId); + } catch (toggleError) { + setError( + toggleError instanceof Error + ? toggleError.message + : "Could not update habit.", + ); + } finally { + setIsSaving(false); + } }; - 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 +249,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { if (mode === "manage") { return (
+ {error ?

{error}

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

{error}

: null}
diff --git a/lib/daily-tasks.ts b/lib/daily-tasks.ts index 8cb190e..9000aed 100644 --- a/lib/daily-tasks.ts +++ b/lib/daily-tasks.ts @@ -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, @@ -29,29 +33,6 @@ function getQuizAnswersForToday(): DailyQuizAnswers | 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, - }, - ]; -} - function getCatalogTaskReason( entry: NonNullable>, preferences: ProfilePreferences, @@ -71,34 +52,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 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 +86,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..021235e 100644 --- a/lib/habit-catalog.ts +++ b/lib/habit-catalog.ts @@ -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); } From 8d5ffc6fed1dab677365ab02738b7439d6f35be2 Mon Sep 17 00:00:00 2001 From: masongga Date: Sat, 23 May 2026 19:53:18 -0600 Subject: [PATCH 2/4] daily quiz entries store in db correctly --- components/avatar/avatar-status-card.tsx | 12 +- components/daily-quiz/daily-quiz-form.tsx | 54 ++++--- .../daily-quiz/daily-quiz-link-button.tsx | 8 +- components/habits/habit-tracker.tsx | 2 +- lib/avatar-state.ts | 39 ++++- lib/daily-quiz-storage.ts | 151 +++++++++++++----- lib/daily-tasks.ts | 15 +- lib/habit-catalog.ts | 2 +- lib/journal-storage.ts | 110 ------------- .../20260523120000_journal_entries.sql | 35 ---- 10 files changed, 197 insertions(+), 231 deletions(-) delete mode 100644 lib/journal-storage.ts delete mode 100644 supabase/migrations/20260523120000_journal_entries.sql 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..00fee4b 100644 --- a/components/daily-quiz/daily-quiz-form.tsx +++ b/components/daily-quiz/daily-quiz-form.tsx @@ -16,19 +16,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 +43,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 +87,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 @@ -117,7 +119,7 @@ export function DailyQuizForm() { Wellness - Sliders from 1 to 5 for how you're doing today. + Rate your wellness from 1 to 5. Sleep length uses hours. @@ -146,6 +148,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)} /> @@ -162,9 +168,7 @@ export function DailyQuizForm() { Daily journal - - Write about your day. Saved separately from your quiz answers. - + Write about your day.