Merge pull request #2 from RecentRunner/mason

Mason
This commit is contained in:
Mason
2026-05-23 20:21:44 -06:00
committed by GitHub
11 changed files with 735 additions and 409 deletions

View File

@@ -29,10 +29,14 @@ export function AvatarStatusCard() {
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
useEffect(() => { useEffect(() => {
const checkedIn = hasCompletedDailyQuizToday(); async function loadStatus() {
const checkedIn = await hasCompletedDailyQuizToday();
setHasCheckedInToday(checkedIn); setHasCheckedInToday(checkedIn);
setCondition(getAvatarConditionForToday() ?? defaultAvatarCondition); setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true); setIsReady(true);
}
void loadStatus();
}, []); }, []);
if (!isReady) { if (!isReady) {

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { CheckCircle2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar"; import { PixelAvatar } from "@/components/avatar/pixel-avatar";
@@ -16,19 +17,18 @@ import {
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
defaultDailyQuizAnswers, defaultDailyQuizAnswers,
formatSleepHours,
SLEEP_HOURS_MAX,
SLEEP_HOURS_MIN,
SLEEP_HOURS_STEP,
WELLNESS_SCALE_MAX, WELLNESS_SCALE_MAX,
type DailyQuizAnswers, type DailyQuizAnswers,
type DailyQuizSubmission, type DailyQuizSubmission,
} from "@/lib/avatar-state"; } from "@/lib/avatar-state";
import { import {
getDailyQuizSubmission, getDailyEntryForToday,
getTodayDateKey, saveDailyEntry,
saveDailyQuizSubmission,
} from "@/lib/daily-quiz-storage"; } from "@/lib/daily-quiz-storage";
import {
getJournalEntryForToday,
saveJournalEntry,
} from "@/lib/journal-storage";
export function DailyQuizForm() { export function DailyQuizForm() {
const [answers, setAnswers] = useState<DailyQuizAnswers>( const [answers, setAnswers] = useState<DailyQuizAnswers>(
@@ -44,20 +44,24 @@ export function DailyQuizForm() {
useEffect(() => { useEffect(() => {
async function loadDailyCheckIn() { async function loadDailyCheckIn() {
const existingSubmission = getDailyQuizSubmission(); try {
const existingJournal = await getJournalEntryForToday(); const existingEntry = await getDailyEntryForToday();
if (existingSubmission?.date === getTodayDateKey()) { if (existingEntry) {
setSubmission(existingSubmission); setSubmission(existingEntry);
setAnswers(existingSubmission.answers); setAnswers(existingEntry.answers);
setJournal(existingEntry.journal);
} }
} catch (loadError) {
if (existingJournal) { setError(
setJournal(existingJournal.content); loadError instanceof Error
} ? loadError.message
: "Could not load today's check-in.",
);
} finally {
setIsReady(true); setIsReady(true);
} }
}
void loadDailyCheckIn(); void loadDailyCheckIn();
}, []); }, []);
@@ -84,9 +88,8 @@ export function DailyQuizForm() {
setError(null); setError(null);
try { try {
const savedSubmission = saveDailyQuizSubmission(answers); const savedEntry = await saveDailyEntry(answers, journal);
await saveJournalEntry(journal); setSubmission(savedEntry);
setSubmission(savedSubmission);
} catch (submitError) { } catch (submitError) {
setError( setError(
submitError instanceof Error submitError instanceof Error
@@ -106,18 +109,77 @@ export function DailyQuizForm() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> {isCompleted && submission ? (
<Badge>Today&apos;s check-in</Badge> <>
<Badge variant={isCompleted ? "default" : "outline"}> <Card className="border-primary/40 bg-primary/10">
{isCompleted ? "Completed" : "Not completed"} <CardContent className="flex items-start gap-4 pt-6">
</Badge> <div className="rounded-full bg-primary/15 p-2.5">
<CheckCircle2
aria-hidden="true"
className="h-7 w-7 text-primary"
/>
</div> </div>
<div className="space-y-1">
<p className="text-lg font-semibold tracking-tight">
Today&apos;s quiz is complete
</p>
<p className="text-sm text-muted-foreground">
Your wellness check-in and journal are saved for today. You
can review your answers below, and come back tomorrow for
your next quiz.
</p>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Wellness</CardTitle> <CardTitle className="text-base">Your pet today</CardTitle>
<CardDescription> <CardDescription>
Sliders from 1 to 5 for how you&apos;re doing today. Based on the wellness check-in you already submitted.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<PixelAvatar mood={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}
</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}
</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}
</p>
</div>
</div>
</CardContent>
</Card>
</>
) : (
<div className="flex items-center justify-between">
<Badge>Today&apos;s check-in</Badge>
<Badge variant="outline">Not completed</Badge>
</div>
)}
<Card className={isCompleted ? "opacity-90" : undefined}>
<CardHeader>
<CardTitle className="text-base">
{isCompleted ? "Your answers today" : "Wellness"}
</CardTitle>
<CardDescription>
{isCompleted
? "These fields are locked until tomorrow's quiz."
: "Rate your wellness from 1 to 5. Sleep length uses hours."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@@ -146,6 +208,10 @@ export function DailyQuizForm() {
id="sleep-length" id="sleep-length"
label="How long did you sleep?" label="How long did you sleep?"
value={answers.sleepLength} value={answers.sleepLength}
min={SLEEP_HOURS_MIN}
max={SLEEP_HOURS_MAX}
step={SLEEP_HOURS_STEP}
formatValue={formatSleepHours}
disabled={isCompleted} disabled={isCompleted}
onChange={(value) => updateAnswer("sleepLength", value)} onChange={(value) => updateAnswer("sleepLength", value)}
/> />
@@ -159,11 +225,13 @@ export function DailyQuizForm() {
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className={isCompleted ? "opacity-90" : undefined}>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Daily journal</CardTitle> <CardTitle className="text-base">Daily journal</CardTitle>
<CardDescription> <CardDescription>
Write about your day. Saved separately from your quiz answers. {isCompleted
? "Your journal entry for today."
: "Write about your day."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -183,37 +251,11 @@ export function DailyQuizForm() {
{error ? <p className="text-sm text-red-500">{error}</p> : null} {error ? <p className="text-sm text-red-500">{error}</p> : null}
{isCompleted && submission ? ( {!isCompleted ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<PixelAvatar mood={submission.condition.mood} size="md" />
<div className="grid w-full grid-cols-3 gap-2 text-center text-sm">
<div className="rounded-lg border px-3 py-2">
<p className="text-muted-foreground">Mood</p>
<p className="font-medium capitalize">
{submission.condition.mood}
</p>
</div>
<div className="rounded-lg border px-3 py-2">
<p className="text-muted-foreground">Energy</p>
<p className="font-medium">
{submission.condition.energy}/{WELLNESS_SCALE_MAX}
</p>
</div>
<div className="rounded-lg border px-3 py-2">
<p className="text-muted-foreground">Health</p>
<p className="font-medium">
{submission.condition.health}/{WELLNESS_SCALE_MAX}
</p>
</div>
</div>
</CardContent>
</Card>
) : (
<Button className="w-full" disabled={!canSubmit} onClick={handleSubmit}> <Button className="w-full" disabled={!canSubmit} onClick={handleSubmit}>
{isSubmitting ? "Saving..." : "Complete daily quiz"} {isSubmitting ? "Saving..." : "Complete daily quiz"}
</Button> </Button>
)} ) : null}
</div> </div>
); );
} }

View File

@@ -12,8 +12,12 @@ export function DailyQuizLinkButton() {
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
useEffect(() => { useEffect(() => {
setIsCompleted(hasCompletedDailyQuizToday()); async function loadStatus() {
setIsCompleted(await hasCompletedDailyQuizToday());
setIsReady(true); setIsReady(true);
}
void loadStatus();
}, []); }, []);
if (!isReady || isCompleted) { if (!isReady || isCompleted) {

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -42,9 +43,15 @@ const reasonLabels = {
function DailyTaskList({ function DailyTaskList({
tasks, tasks,
getIsCompleted,
isPending,
getToggleError,
onToggle, onToggle,
}: { }: {
tasks: DailyTask[]; tasks: DailyTask[];
getIsCompleted: (habit: DailyTask) => boolean;
isPending: (habitId: string) => boolean;
getToggleError: (habitId: string) => string | null;
onToggle: (habitId: string) => void; onToggle: (habitId: string) => void;
}) { }) {
if (tasks.length === 0) { if (tasks.length === 0) {
@@ -57,8 +64,8 @@ function DailyTaskList({
} }
const sortedTasks = [...tasks].sort((a, b) => { const sortedTasks = [...tasks].sort((a, b) => {
const aCompleted = isHabitCompletedToday(a); const aCompleted = getIsCompleted(a);
const bCompleted = isHabitCompletedToday(b); const bCompleted = getIsCompleted(b);
if (aCompleted === bCompleted) { if (aCompleted === bCompleted) {
return 0; return 0;
@@ -70,7 +77,9 @@ function DailyTaskList({
return ( return (
<> <>
{sortedTasks.map((habit) => { {sortedTasks.map((habit) => {
const isCompleted = isHabitCompletedToday(habit); const isCompleted = getIsCompleted(habit);
const pending = isPending(habit.id);
const toggleError = getToggleError(habit.id);
return ( return (
<div <div
@@ -80,12 +89,23 @@ function DailyTaskList({
}`} }`}
> >
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex items-center gap-2">
<Checkbox <Checkbox
id={habit.id} id={habit.id}
checked={isCompleted} checked={isCompleted}
disabled={pending}
onCheckedChange={() => onToggle(habit.id)} onCheckedChange={() => onToggle(habit.id)}
className="mt-0.5"
/> />
{pending ? (
<>
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin text-muted-foreground"
/>
<span className="sr-only">Saving habit completion</span>
</>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<Label htmlFor={habit.id} className="leading-snug"> <Label htmlFor={habit.id} className="leading-snug">
{habit.label} {habit.label}
@@ -96,6 +116,9 @@ function DailyTaskList({
> >
{reasonLabels[habit.reason]} {reasonLabels[habit.reason]}
</Badge> </Badge>
{toggleError ? (
<p className="text-xs text-red-500">{toggleError}</p>
) : null}
</div> </div>
</div> </div>
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="shrink-0">
@@ -154,18 +177,62 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
const [newHabitLabel, setNewHabitLabel] = useState(""); const [newHabitLabel, setNewHabitLabel] = useState("");
const [focusTopics, setFocusTopics] = useState<string[]>([]); const [focusTopics, setFocusTopics] = useState<string[]>([]);
const [quizCompletedToday, setQuizCompletedToday] = useState(false); const [quizCompletedToday, setQuizCompletedToday] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [optimisticCompletion, setOptimisticCompletion] = useState<
Record<string, boolean>
>({});
const [pendingHabitIds, setPendingHabitIds] = useState<Set<string>>(
() => new Set(),
);
const [toggleErrors, setToggleErrors] = useState<Record<string, string>>({});
const refresh = useCallback(() => { const getIsCompleted = useCallback(
setDailyTasks(getDailyTasks()); (habit: DailyTask) => {
setCustomHabits(getCustomHabits()); 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); setFocusTopics(getProfilePreferences().focusTopics);
setQuizCompletedToday(hasCompletedDailyQuizToday()); setQuizCompletedToday(await hasCompletedDailyQuizToday());
setIsReady(true); setIsReady(true);
} catch (refreshError) {
setError(
refreshError instanceof Error
? refreshError.message
: "Could not load habits.",
);
setIsReady(true);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
refresh(); void refresh();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
return () => { return () => {
@@ -174,16 +241,92 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
}, [refresh]); }, [refresh]);
const handleToggle = (habitId: string) => { 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 = () => { const handleAddHabit = async () => {
addHabit(newHabitLabel); try {
setIsSaving(true);
setError(null);
await addHabit(newHabitLabel);
setNewHabitLabel(""); setNewHabitLabel("");
} catch (addError) {
setError(
addError instanceof Error ? addError.message : "Could not add habit.",
);
} finally {
setIsSaving(false);
}
}; };
const handleRemoveHabit = (habitId: string) => { const handleRemoveHabit = async (habitId: string) => {
removeHabit(habitId); 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) { if (!isReady) {
@@ -199,6 +342,8 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
if (mode === "manage") { if (mode === "manage") {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Custom habits</CardTitle> <CardTitle className="text-base">Custom habits</CardTitle>
@@ -213,15 +358,20 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<Input <Input
value={newHabitLabel} value={newHabitLabel}
placeholder="Add a custom habit" placeholder="Add a custom habit"
disabled={isSaving}
onChange={(event) => setNewHabitLabel(event.target.value)} onChange={(event) => setNewHabitLabel(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
handleAddHabit(); void handleAddHabit();
} }
}} }}
/> />
<Button type="button" onClick={handleAddHabit}> <Button
type="button"
disabled={isSaving || newHabitLabel.trim().length === 0}
onClick={() => void handleAddHabit()}
>
Add Add
</Button> </Button>
</div> </div>
@@ -265,7 +415,14 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<DailyTaskList tasks={dailyTasks} onToggle={handleToggle} /> {error ? <p className="text-sm text-red-500">{error}</p> : null}
<DailyTaskList
tasks={dailyTasks}
getIsCompleted={getIsCompleted}
isPending={isPending}
getToggleError={getToggleError}
onToggle={handleToggle}
/>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -17,26 +17,49 @@ export type AvatarCondition = {
export type DailyQuizSubmission = { export type DailyQuizSubmission = {
date: string; date: string;
answers: DailyQuizAnswers; answers: DailyQuizAnswers;
journal: string;
condition: AvatarCondition; condition: AvatarCondition;
}; };
export const WELLNESS_SCALE_MAX = 5; 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 = { export const defaultDailyQuizAnswers: DailyQuizAnswers = {
feeling: 3, feeling: 3,
stress: 3, stress: 3,
energy: 3, energy: 3,
sleepLength: 3, sleepLength: DEFAULT_SLEEP_HOURS,
sleepQuality: 3, 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( export function computeAvatarCondition(
answers: DailyQuizAnswers, answers: DailyQuizAnswers,
): AvatarCondition { ): AvatarCondition {
const { feeling, stress, energy, sleepLength, sleepQuality } = answers; const { feeling, stress, energy, sleepLength, sleepQuality } = answers;
const wellnessAverage = (feeling + energy + (WELLNESS_SCALE_MAX + 1 - stress)) / 3; 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 health = Math.round((wellnessAverage + sleepAverage) / 2);
const energyLevel = Math.round((energy + sleepQuality) / 2); const energyLevel = Math.round((energy + sleepQuality) / 2);
@@ -44,7 +67,7 @@ export function computeAvatarCondition(
if (energyLevel >= 4 && feeling >= 4 && health >= 4) { if (energyLevel >= 4 && feeling >= 4 && health >= 4) {
avatarMood = "happy"; avatarMood = "happy";
} else if (energy <= 2 || sleepLength <= 2 || sleepQuality <= 2) { } else if (energy <= 2 || sleepLength < 6 || sleepQuality <= 2) {
avatarMood = "tired"; avatarMood = "tired";
} }
@@ -82,11 +105,19 @@ export function normalizeDailyQuizAnswers(
: value; : value;
}; };
const normalizeSleepLength = (value: number | undefined) => {
if (value === undefined) {
return defaultDailyQuizAnswers.sleepLength;
}
return clampSleepHours(value);
};
return { return {
feeling: scaleValue(answers.feeling ?? answers.mood, defaultDailyQuizAnswers.feeling), feeling: scaleValue(answers.feeling ?? answers.mood, defaultDailyQuizAnswers.feeling),
stress: scaleValue(answers.stress, defaultDailyQuizAnswers.stress), stress: scaleValue(answers.stress, defaultDailyQuizAnswers.stress),
energy: scaleValue(answers.energy, defaultDailyQuizAnswers.energy), energy: scaleValue(answers.energy, defaultDailyQuizAnswers.energy),
sleepLength: scaleValue(answers.sleepLength, defaultDailyQuizAnswers.sleepLength), sleepLength: normalizeSleepLength(answers.sleepLength),
sleepQuality: scaleValue( sleepQuality: scaleValue(
answers.sleepQuality, answers.sleepQuality,
defaultDailyQuizAnswers.sleepQuality, defaultDailyQuizAnswers.sleepQuality,

View File

@@ -6,69 +6,142 @@ import {
type DailyQuizSubmission, type DailyQuizSubmission,
} from "@/lib/avatar-state"; } from "@/lib/avatar-state";
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; 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()) { export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function normalizeSubmission( function mapRowToSubmission(row: DailyEntryRow): DailyQuizSubmission {
submission: DailyQuizSubmission, const answers = normalizeDailyQuizAnswers({
): DailyQuizSubmission { feeling: row.mood,
const answers = normalizeDailyQuizAnswers(submission.answers); stress: row.stress,
energy: row.energy,
sleepLength: Number(row.sleep_hours),
sleepQuality: row.sleep_quality,
});
return { return {
...submission, date: row.entry_date,
answers, answers,
journal: row.journal ?? "",
condition: computeAvatarCondition(answers), condition: computeAvatarCondition(answers),
}; };
} }
export function getDailyQuizSubmission(): DailyQuizSubmission | null { async function getAuthenticatedUserId() {
if (typeof window === "undefined") { const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null; return null;
} }
const raw = window.localStorage.getItem(STORAGE_KEY); return user.id;
}
if (!raw) { export async function getDailyEntryForToday(): Promise<DailyQuizSubmission | null> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return null; return null;
} }
try { const supabase = createClient();
return normalizeSubmission(JSON.parse(raw) as DailyQuizSubmission); const entryDate = getTodayDateKey();
} catch { 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 null;
} }
return mapRowToSubmission(data as DailyEntryRow);
} }
export function hasCompletedDailyQuizToday() { export async function getDailyQuizSubmission() {
const submission = getDailyQuizSubmission(); return getDailyEntryForToday();
return submission?.date === getTodayDateKey();
} }
export function saveDailyQuizSubmission( export async function hasCompletedDailyQuizToday() {
const entry = await getDailyEntryForToday();
return entry !== null;
}
export async function saveDailyEntry(
answers: DailyQuizAnswers, answers: DailyQuizAnswers,
): DailyQuizSubmission { journal: string,
const submission: DailyQuizSubmission = { ): Promise<DailyQuizSubmission> {
date: getTodayDateKey(), const userId = await getAuthenticatedUserId();
answers,
condition: computeAvatarCondition(answers),
};
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(); notifyHabitPetDataUpdated();
return submission; return submission;
} }
export function getAvatarConditionForToday(): AvatarCondition | null { export async function saveDailyQuizSubmission(
const submission = getDailyQuizSubmission(); answers: DailyQuizAnswers,
journal = "",
if (!submission || submission.date !== getTodayDateKey()) { ) {
return null; return saveDailyEntry(answers, journal);
} }
return submission.condition; export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
const entry = await getDailyEntryForToday();
return entry?.condition ?? null;
} }

View File

@@ -1,5 +1,5 @@
import type { DailyQuizAnswers } from "@/lib/avatar-state"; import type { DailyQuizAnswers } from "@/lib/avatar-state";
import { getDailyQuizSubmission, getTodayDateKey } from "@/lib/daily-quiz-storage"; import { getDailyEntryForToday } from "@/lib/daily-quiz-storage";
import { import {
catalogHabitIds, catalogHabitIds,
getCatalogEntry, getCatalogEntry,
@@ -7,7 +7,11 @@ import {
matchesFocusTopics, matchesFocusTopics,
matchesQuizAnswers, matchesQuizAnswers,
} from "@/lib/habit-catalog"; } from "@/lib/habit-catalog";
import { getHabits, saveHabits, type Habit } from "@/lib/habits-storage"; import {
ensureCatalogHabit,
getHabits,
type Habit,
} from "@/lib/habits-storage";
import { import {
getProfilePreferences, getProfilePreferences,
type ProfilePreferences, type ProfilePreferences,
@@ -19,37 +23,9 @@ export type DailyTask = Habit & {
reason: DailyTaskReason; reason: DailyTaskReason;
}; };
function getQuizAnswersForToday(): DailyQuizAnswers | null { async function getQuizAnswersForToday(): Promise<DailyQuizAnswers | null> {
const submission = getDailyQuizSubmission(); const entry = await getDailyEntryForToday();
return entry?.answers ?? null;
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,
},
];
} }
function getCatalogTaskReason( function getCatalogTaskReason(
@@ -71,34 +47,33 @@ function getCatalogTaskReason(
return null; return null;
} }
export function getDailyTasks(): DailyTask[] { export async function getDailyTasks(): Promise<DailyTask[]> {
let habits = getHabits();
const preferences = getProfilePreferences(); const preferences = getProfilePreferences();
const quizAnswers = getQuizAnswersForToday(); const quizAnswers = await getQuizAnswersForToday();
const initialHabitCount = habits.length;
const selectedCatalogIds = habitCatalog const selectedCatalogIds = habitCatalog
.map((entry) => { .map((entry) => {
const reason = getCatalogTaskReason(entry, preferences, quizAnswers); 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) { await Promise.all(
habits = ensureCatalogHabit(id, habits); selectedCatalogIds.map(({ catalogId }) => ensureCatalogHabit(catalogId)),
} );
if (habits.length !== initialHabitCount) { const habits = await getHabits();
saveHabits(habits, { notify: false });
}
const customTasks: DailyTask[] = habits const customTasks: DailyTask[] = habits
.filter((habit) => habit.isCustom) .filter((habit) => habit.isCustom)
.map((habit) => ({ ...habit, reason: "custom" as const })); .map((habit) => ({ ...habit, reason: "custom" as const }));
const catalogTasks: DailyTask[] = selectedCatalogIds const catalogTasks: DailyTask[] = selectedCatalogIds
.map(({ id, reason }) => { .map(({ catalogId, reason }) => {
const habit = habits.find((item) => item.id === id); const habit = habits.find((item) => item.catalogId === catalogId);
return habit ? { ...habit, reason } : null; return habit ? { ...habit, reason } : null;
}) })
.filter((task): task is DailyTask => task !== null); .filter((task): task is DailyTask => task !== null);
@@ -106,6 +81,4 @@ export function getDailyTasks(): DailyTask[] {
return [...customTasks, ...catalogTasks]; return [...customTasks, ...catalogTasks];
} }
export function isCatalogHabitId(habitId: string) { export { isCatalogHabitId } from "@/lib/habits-storage";
return catalogHabitIds.has(habitId);
}

View File

@@ -50,7 +50,7 @@ export const habitCatalog: HabitCatalogEntry[] = [
label: "Wind down before bed", label: "Wind down before bed",
focusTopics: ["Sleep"], focusTopics: ["Sleep"],
suggestedByQuiz: (answers) => 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); 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( export function matchesFocusTopics(
entry: HabitCatalogEntry, entry: HabitCatalogEntry,
preferences: ProfilePreferences, preferences: ProfilePreferences,

View File

@@ -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 { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { createClient } from "@/lib/supabase/client";
export type Habit = { export type Habit = {
id: string; id: string;
label: string; label: string;
catalogId: string | null;
streak: number; streak: number;
lastCompletedDate: string | null; lastCompletedDate: string | null;
isCustom: boolean; isCustom: boolean;
}; };
const STORAGE_KEY = "habit-pet-habits"; type HabitRow = {
id: string;
name: string;
};
export const defaultHabits: Habit[] = habitCatalog.map((entry) => ({ type HabitLogRow = {
id: entry.id, habit_id: string;
label: entry.label, completed_on: string;
streak: 0, };
lastCompletedDate: null,
isCustom: false,
}));
export function getTodayDateKey(date = new Date()) { export function getTodayDateKey(date = new Date()) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function getYesterdayDateKey(date = new Date()) { function calculateStreak(completedDates: string[], today = getTodayDateKey()) {
const yesterday = new Date(date); if (completedDates.length === 0) {
yesterday.setDate(yesterday.getDate() - 1); return 0;
return getTodayDateKey(yesterday);
} }
function normalizeHabit(habit: Partial<Habit> & { id: string; label: string }): Habit { 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 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<string, string[]>,
today = getTodayDateKey(),
): Habit {
const completedDates = logsByHabitId.get(row.id) ?? [];
return { return {
id: habit.id, id: row.id,
label: habit.label, label: row.name,
streak: habit.streak ?? 0, catalogId: getCatalogIdByLabel(row.name),
lastCompletedDate: habit.lastCompletedDate ?? null, streak: calculateStreak(completedDates, today),
isCustom: habit.isCustom ?? !catalogHabitIds.has(habit.id), 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<string, string[]>();
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()) { export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
return habit.lastCompletedDate === today; return habit.lastCompletedDate === today;
} }
export function getHabits(): Habit[] { export async function getHabits(): Promise<Habit[]> {
if (typeof window === "undefined") { const userId = await getAuthenticatedUserId();
return defaultHabits;
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) { if (error) {
return defaultHabits; throw new Error(error.message);
} }
try { const logsByHabitId = await fetchHabitLogs(userId);
const habits = (JSON.parse(raw) as Habit[]).map(normalizeHabit);
return habits.length > 0 ? habits : defaultHabits; return ((data ?? []) as HabitRow[]).map((row) =>
} catch { mapHabitRow(row, logsByHabitId),
return defaultHabits; );
}
} }
export function saveHabits(habits: Habit[], options?: { notify?: boolean }) { export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(habits)); const entry = getCatalogEntry(catalogId);
if (options?.notify !== false) { if (!entry) {
notifyHabitPetDataUpdated(); return null;
}
} }
export function toggleHabitCompletion(habitId: string): Habit[] { 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 async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const supabase = createClient();
const today = getTodayDateKey(); const today = getTodayDateKey();
const yesterday = getYesterdayDateKey(); const { data: existingLog, error: existingLogError } = await supabase
const habits = getHabits().map((habit) => { .from("habit_logs")
if (habit.id !== habitId) { .select("id")
return habit; .eq("user_id", userId)
.eq("habit_id", habitId)
.eq("completed_on", today)
.maybeSingle();
if (existingLogError) {
throw new Error(existingLogError.message);
} }
if (isHabitCompletedToday(habit, today)) { if (existingLog) {
return { const { error: deleteError } = await supabase
...habit, .from("habit_logs")
lastCompletedDate: null, .delete()
streak: Math.max(0, habit.streak - 1), .eq("id", existingLog.id);
};
if (deleteError) {
throw new Error(deleteError.message);
} }
} else {
const nextStreak = const { error: insertError } = await supabase.from("habit_logs").insert({
habit.lastCompletedDate === yesterday ? habit.streak + 1 : 1; user_id: userId,
habit_id: habitId,
return { completed_on: today,
...habit,
lastCompletedDate: today,
streak: nextStreak,
};
}); });
saveHabits(habits); if (insertError) {
return habits; throw new Error(insertError.message);
}
} }
export function addHabit(label: string): Habit[] { notifyHabitPetDataUpdated();
return getHabits();
}
export async function addHabit(label: string): Promise<Habit[]> {
const trimmedLabel = label.trim(); const trimmedLabel = label.trim();
if (!trimmedLabel) { if (!trimmedLabel) {
return getHabits(); return getHabits();
} }
const habits = [ const userId = await getAuthenticatedUserId();
...getHabits(),
normalizeHabit({
id: crypto.randomUUID(),
label: trimmedLabel,
streak: 0,
lastCompletedDate: null,
isCustom: true,
}),
];
saveHabits(habits); if (!userId) {
return habits; return [];
} }
export function removeHabit(habitId: string): Habit[] { const supabase = createClient();
const habits = getHabits().filter( const { error } = await supabase.from("habits").insert({
(habit) => habit.id !== habitId || !habit.isCustom, user_id: userId,
); name: trimmedLabel,
saveHabits(habits); active: true,
return habits; });
if (error) {
throw new Error(error.message);
} }
export function getCustomHabits() { notifyHabitPetDataUpdated();
return getHabits().filter((habit) => habit.isCustom); return getHabits();
}
export async function removeHabit(habitId: string): Promise<Habit[]> {
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 async function getCustomHabits() {
const habits = await getHabits();
return habits.filter((habit) => habit.isCustom);
}
export function isCatalogHabitId(habitId: string) {
return catalogHabitIds.has(habitId);
} }

View File

@@ -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<JournalEntry | null> {
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<JournalEntry> {
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;
}

View File

@@ -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);