From dfafba0c94e12511da0701a0ceac87b7909fb2a0 Mon Sep 17 00:00:00 2001 From: masongga Date: Sun, 24 May 2026 00:11:23 -0600 Subject: [PATCH] added windows notifications and have avatar health/energy update with task completion --- app/(app)/layout.tsx | 6 +- components/avatar/avatar-status-card.tsx | 41 +++- components/daily-quiz/daily-quiz-form.tsx | 44 +++- components/habits/habit-tracker.tsx | 10 +- .../profile/profile-preferences-form.tsx | 115 +++++++++- .../reminders/daily-reminder-provider.tsx | 133 ++++++++++++ lib/avatar-state.ts | 13 +- lib/daily-quiz-storage.ts | 34 ++- lib/daily-reminder-status.ts | 31 +++ lib/daily-reminders.ts | 205 ++++++++++++++++++ lib/habit-wellness-effects.ts | 202 +++++++++++++++++ lib/habits-storage.ts | 41 ++++ 12 files changed, 842 insertions(+), 33 deletions(-) create mode 100644 components/reminders/daily-reminder-provider.tsx create mode 100644 lib/daily-reminder-status.ts create mode 100644 lib/daily-reminders.ts create mode 100644 lib/habit-wellness-effects.ts diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index ee5f790..a4665af 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 ( }> - {children} + + + {children} + ); } diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx index 17de203..1a1d87a 100644 --- a/components/avatar/avatar-status-card.tsx +++ b/components/avatar/avatar-status-card.tsx @@ -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 ( @@ -68,8 +83,12 @@ export function AvatarStatusCard() {

{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."}

{!hasCheckedInToday ? ( + {notificationMessage ? ( +

{notificationMessage}

+ ) : null}
diff --git a/components/reminders/daily-reminder-provider.tsx b/components/reminders/daily-reminder-provider.tsx new file mode 100644 index 0000000..e2d3a3d --- /dev/null +++ b/components/reminders/daily-reminder-provider.tsx @@ -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( + 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; +} diff --git a/lib/avatar-state.ts b/lib/avatar-state.ts index b65d46c..98246a1 100644 --- a/lib/avatar-state.ts +++ b/lib/avatar-state.ts @@ -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, }; } diff --git a/lib/daily-quiz-storage.ts b/lib/daily-quiz-storage.ts index 84753f1..417eb54 100644 --- a/lib/daily-quiz-storage.ts +++ b/lib/daily-quiz-storage.ts @@ -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 { - 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, + ); } diff --git a/lib/daily-reminder-status.ts b/lib/daily-reminder-status.ts new file mode 100644 index 0000000..13c1a8d --- /dev/null +++ b/lib/daily-reminder-status.ts @@ -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 { + 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, + }; +} diff --git a/lib/daily-reminders.ts b/lib/daily-reminders.ts new file mode 100644 index 0000000..bf2226d --- /dev/null +++ b/lib/daily-reminders.ts @@ -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 { + 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"; + } +} diff --git a/lib/habit-wellness-effects.ts b/lib/habit-wellness-effects.ts new file mode 100644 index 0000000..92b517c --- /dev/null +++ b/lib/habit-wellness-effects.ts @@ -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 = { + 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(); + + 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(); + + 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(", ")}`; +} diff --git a/lib/habits-storage.ts b/lib/habits-storage.ts index 1bd308b..ec13018 100644 --- a/lib/habits-storage.ts +++ b/lib/habits-storage.ts @@ -215,6 +215,47 @@ export async function getHabits(): Promise { return rows.map(mapHabitRow); } +export async function getCompletedHabitLabelsForToday(): Promise { + 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 { const entry = getCatalogEntry(catalogId);