Rename daily quiz to check-in and add collapsible journal calendar.

Replace user-facing quiz wording with daily check-in, add journal history on the Bit page with month browsing and read-only entry modals, and keep legacy quiz routes redirecting to the new anchor.
This commit is contained in:
2026-05-24 10:20:03 -06:00
parent e41fc10c77
commit 93af01dec1
14 changed files with 610 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { routes } from "@/lib/routes";
export default function DailyCheckInPage() {
redirect(`${routes.avatar}#bit-daily-check-in`);
}

View File

@@ -3,5 +3,5 @@ import { redirect } from "next/navigation";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
export default function DailyQuizPage() { export default function DailyQuizPage() {
redirect(`${routes.avatar}#bit-daily-quiz`); redirect(`${routes.avatar}#bit-daily-check-in`);
} }

View File

@@ -21,7 +21,7 @@ export default function Home() {
</h1> </h1>
<p className="max-w-xl text-base text-muted-foreground sm:text-lg"> <p className="max-w-xl text-base text-muted-foreground sm:text-lg">
HaBit turns daily check-ins, wellness sliders, and journaling into HaBit turns daily check-ins, wellness sliders, and journaling into
a simple game. Keep streaks alive, take your once-per-day quiz, a simple game. Keep streaks alive, take your once-per-day check-in,
and watch your bit change with you. and watch your bit change with you.
</p> </p>
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -35,7 +35,7 @@ export default function Home() {
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<p className="text-sm font-medium">Your daily loop</p> <p className="text-sm font-medium">Your daily loop</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Habits, wellness, journal, quiz, shop. Habits, wellness, journal, check-in, shop.
</p> </p>
</div> </div>
</CardContent> </CardContent>

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { DailyQuizForm } from "@/components/daily-quiz/daily-quiz-form"; import { DailyQuizForm } from "@/components/daily-quiz/daily-quiz-form";
import { HabitTracker } from "@/components/habits/habit-tracker"; import { HabitTracker } from "@/components/habits/habit-tracker";
import { JournalCalendar } from "@/components/journal/journal-calendar";
import { PetHabitat } from "@/components/pet/pet-habitat"; import { PetHabitat } from "@/components/pet/pet-habitat";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { import {
@@ -37,6 +38,14 @@ export function AvatarPageContent() {
}; };
}, [loadCustomization]); }, [loadCustomization]);
useEffect(() => {
const hash = window.location.hash.replace("#", "");
if (hash === "bit-daily-quiz") {
window.location.hash = "bit-daily-check-in";
}
}, []);
if (loadError) { if (loadError) {
return <p className="text-xs text-red-500">{loadError}</p>; return <p className="text-xs text-red-500">{loadError}</p>;
} }
@@ -66,10 +75,14 @@ export function AvatarPageContent() {
<div id="bit-daily-tasks"> <div id="bit-daily-tasks">
<HabitTracker mode="daily" /> <HabitTracker mode="daily" />
</div> </div>
<div id="bit-daily-quiz"> <div id="bit-daily-check-in">
<DailyQuizForm /> <DailyQuizForm />
</div> </div>
</section> </section>
<section className="w-full pt-8">
<JournalCalendar />
</section>
</div> </div>
); );
} }

View File

@@ -94,21 +94,21 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
Health {condition.health}/{WELLNESS_SCALE_MAX} Health {condition.health}/{WELLNESS_SCALE_MAX}
</Badge> </Badge>
<Badge variant={hasCheckedInToday ? "default" : "outline"}> <Badge variant={hasCheckedInToday ? "default" : "outline"}>
{hasCheckedInToday ? "Checked in today" : "Quiz pending"} {hasCheckedInToday ? "Checked in today" : "Check-in pending"}
</Badge> </Badge>
</div> </div>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
{hasCheckedInToday {hasCheckedInToday
? completedTaskCount > 0 ? completedTaskCount > 0
? `Your bit reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.` ? `Your bit reacts to today's check-in and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.`
: "Your bit is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health." : "Your bit is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health."
: completedTaskCount > 0 : completedTaskCount > 0
? "Complete today's quiz for a full wellness read, then keep completing habits to boost your bit." ? "Complete today's check-in for a full wellness read, then keep completing habits to boost your bit."
: "Complete today's quiz to update your bit's mood, energy, and health."} : "Complete today's check-in to update your bit's mood, energy, and health."}
</p> </p>
{!hasCheckedInToday ? ( {!hasCheckedInToday ? (
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link href={`${routes.avatar}#bit-daily-quiz`}>Take today&apos;s quiz</Link> <Link href={`${routes.avatar}#bit-daily-check-in`}>Start daily check-in</Link>
</Button> </Button>
) : null} ) : null}
</CardContent> </CardContent>

View File

@@ -138,7 +138,7 @@ export function DailyQuizForm() {
if (!isReady) { if (!isReady) {
return ( return (
<p className="text-sm text-muted-foreground">Loading today&apos;s quiz...</p> <p className="text-sm text-muted-foreground">Loading today&apos;s check-in...</p>
); );
} }
@@ -155,7 +155,7 @@ export function DailyQuizForm() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-lg font-semibold tracking-tight"> <p className="text-lg font-semibold tracking-tight">
Today&apos;s quiz is complete Today&apos;s check-in is complete
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your answers are saved below. Come back tomorrow for your next Your answers are saved below. Come back tomorrow for your next
@@ -264,7 +264,7 @@ export function DailyQuizForm() {
{!isCompleted ? ( {!isCompleted ? (
<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 check-in"}
</Button> </Button>
) : null} ) : null}
</div> </div>

View File

@@ -23,14 +23,14 @@ export function DailyQuizLinkButton() {
if (!isReady || isCompleted) { if (!isReady || isCompleted) {
return ( return (
<Button className="w-full" variant="outline" disabled> <Button className="w-full" variant="outline" disabled>
Open daily quiz Open daily check-in
</Button> </Button>
); );
} }
return ( return (
<Button asChild className="w-full" variant="outline"> <Button asChild className="w-full" variant="outline">
<Link href={`${routes.avatar}#bit-daily-quiz`}>Open daily quiz</Link> <Link href={`${routes.avatar}#bit-daily-check-in`}>Open daily check-in</Link>
</Button> </Button>
); );
} }

View File

@@ -57,7 +57,7 @@ function DailyTaskList({
if (tasks.length === 0) { if (tasks.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No daily tasks yet. Complete your daily quiz or update your profile so No daily tasks yet. Complete your daily check-in or update your profile so
AI can suggest habits for you. AI can suggest habits for you.
</p> </p>
); );
@@ -428,7 +428,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<CardTitle className="text-base">Today&apos;s tasks</CardTitle> <CardTitle className="text-base">Today&apos;s tasks</CardTitle>
<CardDescription> <CardDescription>
Personalized from your focus topics Personalized from your focus topics
{quizCompletedToday ? ", today's quiz," : ""} and custom habits. {quizCompletedToday ? ", today's check-in," : ""} and custom habits.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -485,17 +485,17 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<CardTitle className="text-base">How daily tasks are picked</CardTitle> <CardTitle className="text-base">How daily tasks are picked</CardTitle>
<CardDescription> <CardDescription>
Your tasks combine focus preferences, custom habits, and today&apos;s Your tasks combine focus preferences, custom habits, and today&apos;s
quiz. check-in.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground"> <CardContent className="space-y-3 text-sm text-muted-foreground">
<p>Focus topics: {focusTopic ?? "None selected"}</p> <p>Focus topics: {focusTopic ?? "None selected"}</p>
<p> <p>
Daily quiz today:{" "} Daily check-in today:{" "}
{quizCompletedToday ? "Completed" : "Not completed yet"} {quizCompletedToday ? "Completed" : "Not completed yet"}
</p> </p>
<p> <p>
Suggested habits can appear when your quiz shows low energy, high Suggested habits can appear when your check-in shows low energy, high
stress, or poor sleep. stress, or poor sleep.
</p> </p>
</CardContent> </CardContent>
@@ -548,7 +548,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<CardTitle className="text-base">How daily tasks are picked</CardTitle> <CardTitle className="text-base">How daily tasks are picked</CardTitle>
<CardDescription> <CardDescription>
Your tasks combine custom habits and AI suggestions based on your Your tasks combine custom habits and AI suggestions based on your
profile, onboarding, daily quiz, and journal. profile, onboarding, daily check-in, and journal.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground"> <CardContent className="space-y-3 text-sm text-muted-foreground">
@@ -557,7 +557,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
{focusTopic ?? "None selected"} {focusTopic ?? "None selected"}
</p> </p>
<p> <p>
Daily quiz today:{" "} Daily check-in today:{" "}
{quizCompletedToday ? "Completed" : "Not completed yet"} {quizCompletedToday ? "Completed" : "Not completed yet"}
</p> </p>
<p> <p>
@@ -575,7 +575,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Today&apos;s tasks</CardTitle> <CardTitle className="text-base">Today&apos;s tasks</CardTitle>
<CardDescription> <CardDescription>
Personalized from your profile, daily quiz, journal, and custom habits. Personalized from your profile, daily check-in, journal, and custom habits.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@@ -0,0 +1,329 @@
"use client";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { JournalEntryModal } from "@/components/journal/journal-entry-modal";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import type { DailyQuizSubmission } from "@/lib/avatar-state";
import {
getDailyEntryByDate,
getDailyEntrySummariesForMonth,
getTodayDateKey,
type DailyEntrySummary,
} from "@/lib/daily-quiz-storage";
import { cn } from "@/lib/utils";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
type CalendarCell = {
dateKey: string;
day: number;
inMonth: boolean;
};
function buildMonthGrid(year: number, month: number): CalendarCell[] {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const grid: CalendarCell[] = [];
for (let index = 0; index < firstDay.getDay(); index += 1) {
const date = new Date(year, month, index - firstDay.getDay() + 1);
grid.push({
dateKey: date.toLocaleDateString("en-CA"),
day: date.getDate(),
inMonth: false,
});
}
for (let day = 1; day <= lastDay.getDate(); day += 1) {
const date = new Date(year, month, day);
grid.push({
dateKey: date.toLocaleDateString("en-CA"),
day,
inMonth: true,
});
}
let trailingDay = 1;
while (grid.length % 7 !== 0) {
const date = new Date(year, month + 1, trailingDay);
grid.push({
dateKey: date.toLocaleDateString("en-CA"),
day: date.getDate(),
inMonth: false,
});
trailingDay += 1;
}
return grid;
}
function formatMonthLabel(year: number, month: number) {
return new Date(year, month, 1).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
}
export function JournalCalendar() {
const todayKey = getTodayDateKey();
const todayDate = new Date();
const [visibleYear, setVisibleYear] = useState(todayDate.getFullYear());
const [visibleMonth, setVisibleMonth] = useState(todayDate.getMonth());
const [summaries, setSummaries] = useState<DailyEntrySummary[]>([]);
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
const [selectedEntry, setSelectedEntry] = useState<DailyQuizSubmission | null>(
null,
);
const [isExpanded, setIsExpanded] = useState(false);
const [isLoadingMonth, setIsLoadingMonth] = useState(false);
const [isLoadingEntry, setIsLoadingEntry] = useState(false);
const [error, setError] = useState<string | null>(null);
const summaryByDate = useMemo(
() => new Map(summaries.map((summary) => [summary.date, summary])),
[summaries],
);
const monthGrid = useMemo(
() => buildMonthGrid(visibleYear, visibleMonth),
[visibleYear, visibleMonth],
);
const loadMonth = useCallback(async () => {
setIsLoadingMonth(true);
setError(null);
try {
setSummaries(
await getDailyEntrySummariesForMonth(visibleYear, visibleMonth),
);
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: "Could not load journal history.",
);
} finally {
setIsLoadingMonth(false);
}
}, [visibleMonth, visibleYear]);
useEffect(() => {
if (!isExpanded) {
return;
}
void loadMonth();
}, [isExpanded, loadMonth]);
useEffect(() => {
if (!isExpanded) {
return;
}
const handleDataUpdated = () => {
void loadMonth();
};
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
};
}, [isExpanded, loadMonth]);
const goToPreviousMonth = () => {
if (visibleMonth === 0) {
setVisibleYear((current) => current - 1);
setVisibleMonth(11);
return;
}
setVisibleMonth((current) => current - 1);
};
const goToNextMonth = () => {
if (visibleMonth === 11) {
setVisibleYear((current) => current + 1);
setVisibleMonth(0);
return;
}
setVisibleMonth((current) => current + 1);
};
const handleDaySelect = async (dateKey: string) => {
const summary = summaryByDate.get(dateKey);
if (!summary) {
return;
}
setSelectedDateKey(dateKey);
setIsLoadingEntry(true);
setError(null);
try {
setSelectedEntry(await getDailyEntryByDate(dateKey));
} catch (loadError) {
setSelectedDateKey(null);
setError(
loadError instanceof Error
? loadError.message
: "Could not load that journal entry.",
);
} finally {
setIsLoadingEntry(false);
}
};
const closeEntryModal = () => {
setSelectedEntry(null);
setSelectedDateKey(null);
};
return (
<>
<Card>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-3">
<button
type="button"
className="min-w-0 flex-1 text-left"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((current) => !current)}
>
<CardTitle className="text-base">Journal history</CardTitle>
<CardDescription>
Browse past check-ins and journal entries.
</CardDescription>
</button>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
aria-label={isExpanded ? "Collapse journal history" : "Expand journal history"}
onClick={() => setIsExpanded((current) => !current)}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
{isExpanded ? (
<CardContent className="space-y-4 border-t pt-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{formatMonthLabel(visibleYear, visibleMonth)}
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
aria-label="Previous month"
onClick={goToPreviousMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Next month"
onClick={goToNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs text-muted-foreground">
{WEEKDAY_LABELS.map((label) => (
<div key={label} className="py-1 font-medium">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{monthGrid.map((cell) => {
const summary = summaryByDate.get(cell.dateKey);
const hasEntry = Boolean(summary);
const isToday = cell.dateKey === todayKey;
const isSelected = cell.dateKey === selectedDateKey;
return (
<button
key={cell.dateKey}
type="button"
disabled={!hasEntry || isLoadingEntry}
aria-label={
hasEntry
? `View journal entry for ${cell.dateKey}`
: undefined
}
onClick={() => void handleDaySelect(cell.dateKey)}
className={cn(
"relative flex aspect-square flex-col items-center justify-center rounded-md text-sm transition-colors",
cell.inMonth
? "text-foreground"
: "text-muted-foreground/45",
hasEntry
? "cursor-pointer hover:bg-muted/60"
: "cursor-default",
isToday && "ring-1 ring-primary/50",
isSelected && "bg-primary/10 ring-1 ring-primary",
!hasEntry && "opacity-70",
)}
>
<span>{cell.day}</span>
{hasEntry ? (
<span
aria-hidden="true"
className={cn(
"absolute bottom-1 h-1.5 w-1.5 rounded-full",
summary?.hasJournal ? "bg-primary" : "bg-muted-foreground/50",
)}
/>
) : null}
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
Journal written
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
Check-in only
</span>
</div>
{isLoadingMonth ? (
<p className="text-sm text-muted-foreground">Loading calendar...</p>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
</CardContent>
) : null}
</Card>
<JournalEntryModal entry={selectedEntry} onClose={closeEntryModal} />
</>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { X } from "lucide-react";
import { useEffect } from "react";
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
import { Button } from "@/components/ui/button";
import {
formatSleepHours,
SLEEP_HOURS_MAX,
SLEEP_HOURS_MIN,
SLEEP_HOURS_STEP,
type DailyQuizSubmission,
} from "@/lib/avatar-state";
type JournalEntryModalProps = {
entry: DailyQuizSubmission | null;
onClose: () => void;
};
function formatEntryDateLabel(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
const date = new Date(year, month - 1, day);
return date.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
}
export function JournalEntryModal({ entry, onClose }: JournalEntryModalProps) {
useEffect(() => {
if (!entry) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [entry, onClose]);
if (!entry) {
return null;
}
const journalText = entry.journal.trim();
return (
<div
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/70 p-4 sm:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="journal-entry-title"
onClick={onClose}
>
<div
className="max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-5 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Journal entry
</p>
<h2
id="journal-entry-title"
className="truncate text-lg font-semibold tracking-tight"
>
{formatEntryDateLabel(entry.date)}
</h2>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Close journal entry"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-5 space-y-5">
<div className="space-y-2">
<p className="text-sm font-medium">Daily journal</p>
<p className="whitespace-pre-wrap rounded-lg border bg-muted/20 px-3 py-3 text-sm text-foreground">
{journalText || "No journal written."}
</p>
</div>
<div className="space-y-4 rounded-lg border px-3 py-4">
<p className="text-sm font-medium">Wellness</p>
<WellnessSlider
id="journal-feeling"
label="How were you feeling?"
value={entry.answers.feeling}
disabled
onChange={() => undefined}
/>
<WellnessSlider
id="journal-stress"
label="Stress level"
value={entry.answers.stress}
disabled
onChange={() => undefined}
/>
<WellnessSlider
id="journal-energy"
label="Energy level"
value={entry.answers.energy}
disabled
onChange={() => undefined}
/>
<WellnessSlider
id="journal-sleep-length"
label="Sleep length"
value={entry.answers.sleepLength}
min={SLEEP_HOURS_MIN}
max={SLEEP_HOURS_MAX}
step={SLEEP_HOURS_STEP}
formatValue={formatSleepHours}
disabled
onChange={() => undefined}
/>
<WellnessSlider
id="journal-sleep-quality"
label="Sleep quality"
value={entry.answers.sleepQuality}
disabled
onChange={() => undefined}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -247,13 +247,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
try { try {
await resetTodaysDailyQuiz(); await resetTodaysDailyQuiz();
setQuizResetMessage( setQuizResetMessage(
"Today's quiz was reset. Open the Quiz tab to take it again.", "Today's check-in was reset. Open the Bit page to complete it again.",
); );
} catch (resetError) { } catch (resetError) {
setError( setError(
resetError instanceof Error resetError instanceof Error
? resetError.message ? resetError.message
: "Could not reset today's quiz.", : "Could not reset today's check-in.",
); );
} finally { } finally {
setIsResettingQuiz(false); setIsResettingQuiz(false);
@@ -337,7 +337,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Notification preferences</CardTitle> <CardTitle className="text-base">Notification preferences</CardTitle>
<CardDescription> <CardDescription>
Control reminders for your daily quiz and habits. Control reminders for your daily check-in and habits.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -425,7 +425,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Reset clears today&apos;s wellness quiz entry so you can complete it Reset clears today&apos;s wellness check-in entry so you can complete it
again immediately. Demo points are for testing the shop only. again immediately. Demo points are for testing the shop only.
</p> </p>
<Button <Button
@@ -449,7 +449,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
disabled={isSaving || isResettingQuiz} disabled={isSaving || isResettingQuiz}
onClick={() => void handleResetDailyQuiz()} onClick={() => void handleResetDailyQuiz()}
> >
{isResettingQuiz ? "Resetting..." : "Reset today's quiz"} {isResettingQuiz ? "Resetting..." : "Reset today's check-in"}
</Button> </Button>
{quizResetMessage ? ( {quizResetMessage ? (
<p className="text-xs text-emerald-600">{quizResetMessage}</p> <p className="text-xs text-emerald-600">{quizResetMessage}</p>

View File

@@ -48,7 +48,7 @@ export async function resetTodaysDailyQuiz() {
const userId = await getAuthenticatedUserId(); const userId = await getAuthenticatedUserId();
if (!userId) { if (!userId) {
throw new Error("You must be signed in to reset today's quiz."); throw new Error("You must be signed in to reset today's check-in.");
} }
const supabase = createClient(); const supabase = createClient();
@@ -197,6 +197,89 @@ export async function saveDailyQuizSubmission(
return saveDailyEntry(answers, journal); return saveDailyEntry(answers, journal);
} }
export type DailyEntrySummary = {
date: string;
hasJournal: boolean;
};
const DAILY_ENTRY_SELECT =
"entry_date, mood, stress, energy, sleep_hours, sleep_quality, journal";
function formatDateKey(date: Date) {
return date.toLocaleDateString("en-CA");
}
function getMonthDateBounds(year: number, month: number) {
const monthStart = new Date(year, month, 1);
const monthEnd = new Date(year, month + 1, 0);
return {
startKey: formatDateKey(monthStart),
endKey: formatDateKey(monthEnd),
};
}
export async function getDailyEntrySummariesForMonth(
year: number,
month: number,
): Promise<DailyEntrySummary[]> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const { startKey, endKey } = getMonthDateBounds(year, month);
const supabase = createClient();
const { data, error } = await supabase
.from("daily_entries")
.select("entry_date, journal")
.eq("user_id", userId)
.gte("entry_date", startKey)
.lte("entry_date", endKey)
.order("entry_date", { ascending: true });
if (error) {
throw new Error(error.message);
}
return (data ?? []).map((row) => ({
date: row.entry_date as string,
hasJournal: Boolean((row.journal as string | null)?.trim()),
}));
}
export async function getDailyEntryByDate(
dateKey: string,
): Promise<DailyQuizSubmission | null> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return null;
}
const supabase = createClient();
const { data, error } = await supabase
.from("daily_entries")
.select(DAILY_ENTRY_SELECT)
.eq("user_id", userId)
.eq("entry_date", dateKey)
.order("created_at", { ascending: false })
.limit(1);
if (error) {
throw new Error(error.message);
}
const row = data?.[0];
if (!row) {
return null;
}
return mapRowToSubmission(row as DailyEntryRow);
}
export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> { export async function getAvatarConditionForToday(): Promise<AvatarCondition | null> {
const [entry, completedHabitLabels] = await Promise.all([ const [entry, completedHabitLabels] = await Promise.all([
getDailyEntryForToday(), getDailyEntryForToday(),

View File

@@ -121,7 +121,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
if (!status.quizCompleted && status.incompleteTaskCount > 0) { if (!status.quizCompleted && status.incompleteTaskCount > 0) {
return { return {
title: "Time for your daily check-in", title: "Time for your daily check-in",
body: `Complete today's wellness quiz and ${status.incompleteTaskCount} remaining habit${status.incompleteTaskCount === 1 ? "" : "s"}.`, body: `Complete today's wellness check-in and ${status.incompleteTaskCount} remaining habit${status.incompleteTaskCount === 1 ? "" : "s"}.`,
targetUrl, targetUrl,
}; };
} }
@@ -129,7 +129,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
if (!status.quizCompleted) { if (!status.quizCompleted) {
return { return {
title: "Time for your daily check-in", title: "Time for your daily check-in",
body: "Complete today's wellness quiz to keep your bit in good spirits.", body: "Complete today's wellness check-in to keep your bit in good spirits.",
targetUrl, targetUrl,
}; };
} }

View File

@@ -8,6 +8,7 @@ export const routes = {
avatar: "/avatar", avatar: "/avatar",
customize: "/customize", customize: "/customize",
habits: "/habits", habits: "/habits",
dailyCheckIn: "/daily/check-in",
dailyQuiz: "/daily/quiz", dailyQuiz: "/daily/quiz",
shop: "/shop", shop: "/shop",
profile: "/profile", profile: "/profile",