Merge branch staging into mason

Resolve habit-tracker conflict by keeping the daily tasks countdown header and adopting check-in terminology from staging.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masongga
2026-05-24 10:24:27 -06:00
22 changed files with 732 additions and 110 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

@@ -262,6 +262,19 @@
@apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground; @apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground;
box-shadow: var(--retro-shadow-sm); box-shadow: var(--retro-shadow-sm);
} }
.shop-item-thumbnail {
background: hsl(var(--muted) / 0.22);
}
.shop-item-thumbnail-glow {
background: radial-gradient(
circle at 50% 58%,
hsl(52 55% 90% / 0.55) 0%,
hsl(48 30% 88% / 0.18) 45%,
transparent 72%
);
}
} }
@keyframes emotion-pop { @keyframes emotion-pop {

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

@@ -81,7 +81,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>
); );
@@ -450,7 +450,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
<Card> <Card>
<DailyTasksCardHeader <DailyTasksCardHeader
description={`Personalized from your focus topics${ description={`Personalized from your focus topics${
quizCompletedToday ? ", today's quiz," : "" quizCompletedToday ? ", today's check-in," : ""
} and custom habits.`} } and custom habits.`}
onDayChange={refresh} onDayChange={refresh}
/> />
@@ -508,17 +508,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>
@@ -571,7 +571,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">
@@ -580,7 +580,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>
@@ -596,7 +596,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
return ( return (
<Card> <Card>
<DailyTasksCardHeader <DailyTasksCardHeader
description="Personalized from your profile, daily quiz, journal, and custom habits." description="Personalized from your profile, daily check-in, journal, and custom habits."
onDayChange={refresh} onDayChange={refresh}
/> />
<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

@@ -22,6 +22,7 @@ import {
} from "@/lib/shop-storage"; } from "@/lib/shop-storage";
import { import {
getLayerLabel, getLayerLabel,
getShopItemDisplayName,
type ShopItemRecord, type ShopItemRecord,
type ShopLayerId, type ShopLayerId,
} from "@/lib/shop-catalog"; } from "@/lib/shop-catalog";
@@ -103,6 +104,8 @@ function ShopItemCard({
? null ? null
: getShopItemThumbnailColors(item, characterColors); : getShopItemThumbnailColors(item, characterColors);
const displayName = getShopItemDisplayName(item);
const actionLabel = (() => { const actionLabel = (() => {
if (isPending) { if (isPending) {
return owned ? "Updating..." : "Processing..."; return owned ? "Updating..." : "Processing...";
@@ -123,27 +126,33 @@ function ShopItemCard({
<Card className="flex h-full flex-col overflow-hidden"> <Card className="flex h-full flex-col overflow-hidden">
<CardContent className="flex h-full flex-col gap-3 p-3"> <CardContent className="flex h-full flex-col gap-3 p-3">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border bg-zinc-950/80 p-1"> <div className="shop-item-thumbnail relative flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border p-1">
{room ? ( {thumbnailColors || room ? (
// eslint-disable-next-line @next/next/no-img-element <div aria-hidden className="shop-item-thumbnail-glow pointer-events-none absolute inset-0" />
<img
src={item.image_path || room.sceneImage}
alt=""
className="size-full object-cover image-pixelated"
/>
) : thumbnailColors ? (
<TintedSpriteIcon
src={item.image_path}
color={thumbnailColors.color}
skinColor={thumbnailColors.skinColor}
size={32}
/>
) : null} ) : null}
<div className="relative z-10 flex size-full items-center justify-center">
{room ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.image_path || room.sceneImage}
alt=""
className="size-full object-cover image-pixelated"
/>
) : thumbnailColors ? (
<TintedSpriteIcon
src={item.image_path}
color={thumbnailColors.color}
skinColor={thumbnailColors.skinColor}
size={32}
className="drop-shadow-[0_0_3px_hsl(52_55%_90%_/_0.85)]"
/>
) : null}
</div>
</div> </div>
<div className="flex min-w-0 flex-1 items-start justify-between gap-2"> <div className="flex min-w-0 flex-1 items-start justify-between gap-2">
<p className="min-w-0 pt-1 text-sm font-medium leading-snug"> <p className="min-w-0 pt-1 text-sm font-medium leading-snug">
{item.name} {displayName}
</p> </p>
<ShopItemPriceBadge <ShopItemPriceBadge
price={item.price} price={item.price}
@@ -249,7 +258,7 @@ export function ShopContent() {
try { try {
const item = await purchaseShopItem(itemId); const item = await purchaseShopItem(itemId);
await equipShopItem(itemId); await equipShopItem(itemId);
toast(`Purchased and equipped ${item.name}.`, "success"); toast(`Purchased and equipped ${getShopItemDisplayName(item)}.`, "success");
await refreshShop(); await refreshShop();
} catch (purchaseError) { } catch (purchaseError) {
toast( toast(
@@ -274,10 +283,10 @@ export function ShopContent() {
if (item.type !== "room" && isEquipped) { if (item.type !== "room" && isEquipped) {
await unequipShopItem(item.id); await unequipShopItem(item.id);
toast(`Unequipped ${item.name}.`, "default"); toast(`Unequipped ${getShopItemDisplayName(item)}.`, "default");
} else if (!isEquipped) { } else if (!isEquipped) {
await equipShopItem(item.id); await equipShopItem(item.id);
toast(`Equipped ${item.name}.`, "success"); toast(`Equipped ${getShopItemDisplayName(item)}.`, "success");
} }
await refreshShop(); await refreshShop();

View File

@@ -12,7 +12,7 @@ import {
buildShopItemPreviewCustomization, buildShopItemPreviewCustomization,
getShopPreviewRoomId, getShopPreviewRoomId,
} from "@/lib/shop-preview"; } from "@/lib/shop-preview";
import type { ShopItemRecord } from "@/lib/shop-catalog"; import { getShopItemDisplayName, type ShopItemRecord } from "@/lib/shop-catalog";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
type ShopItemPreviewModalProps = { type ShopItemPreviewModalProps = {
@@ -89,7 +89,7 @@ export function ShopItemPreviewModal({
> >
Preview on your bit Preview on your bit
</p> </p>
<p className="mt-1 truncate text-sm">{item.name}</p> <p className="mt-1 truncate text-sm">{getShopItemDisplayName(item)}</p>
</div> </div>
<Button <Button
type="button" type="button"

View File

@@ -61,25 +61,25 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset {
export const VARIANT_DISPLAY_NAMES: Record<string, string> = { export const VARIANT_DISPLAY_NAMES: Record<string, string> = {
"skin-1": "Default Form", "skin-1": "Default Form",
"pants-1": "Slim Fit Jeans", "pants-1": "Shorts",
"pants-2": "Rolled Cuff Shorts", "pants-2": "Pants",
"pants-3": "Pleated Skirt", "pants-3": "Skirt",
"shoes-1": "Canvas Sneakers", "shoes-1": "Boots",
"shoes-2": "Chunky High-Tops", "shoes-2": "Shoes",
"shoes-3": "Neon Glow Runners", "shoes-3": "Loafers",
"torso-1": "Crew Neck Tee", "torso-1": "T-Shirt",
"torso-2": "Zip-Up Hoodie", "torso-2": "Button-up",
"torso-3": "Button-Up Shirt", "torso-3": "Vest",
"torso-4": "Crimson Blouse", "torso-4": "Robe",
"eyes-1": "Round Gentle Eyes", "eyes-1": "Eyes",
"eyes-2": "Starry Spark Eyes", "eyes-2": "Eyelashes",
"head-1": "Spiked Mohawk", "head-1": "Short",
"head-2": "Cozy Knit Beanie", "head-2": "Short-medium",
"head-3": "Retro Snapback Cap", "head-3": "Mohawk",
"head-4": "Long Hair", "head-4": "Long",
"head-5": "Shoulder-Length Hair", "head-5": "Pixie",
"head-6": "Flowing Side Locks", "head-6": "Middle-part",
"head-7": "Messy Shag Cut", "head-7": "Bun",
}; };
export function getVariantDisplayName(variantId: string, fallbackLabel = variantId) { export function getVariantDisplayName(variantId: string, fallbackLabel = variantId) {

View File

@@ -44,7 +44,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();
@@ -193,6 +193,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

@@ -19,25 +19,25 @@ export const ROOM_SCENE_IMAGE_PATH = (id: RoomBackgroundId) =>
export const ROOM_BACKGROUNDS: RoomBackground[] = [ export const ROOM_BACKGROUNDS: RoomBackground[] = [
{ {
id: "room-1", id: "room-1",
name: "Sunny Field", name: "Plains",
free: true, free: true,
sceneImage: ROOM_SCENE_IMAGE_PATH("room-1"), sceneImage: ROOM_SCENE_IMAGE_PATH("room-1"),
}, },
{ {
id: "room-2", id: "room-2",
name: "Golden Dusk", name: "Sunset",
free: true, free: true,
sceneImage: ROOM_SCENE_IMAGE_PATH("room-2"), sceneImage: ROOM_SCENE_IMAGE_PATH("room-2"),
}, },
{ {
id: "room-3", id: "room-3",
name: "Starlit Night", name: "Forest",
free: false, free: false,
sceneImage: ROOM_SCENE_IMAGE_PATH("room-3"), sceneImage: ROOM_SCENE_IMAGE_PATH("room-3"),
}, },
{ {
id: "room-4", id: "room-4",
name: "Mystic Forest", name: "Night",
free: false, free: false,
sceneImage: ROOM_SCENE_IMAGE_PATH("room-4"), sceneImage: ROOM_SCENE_IMAGE_PATH("room-4"),
}, },

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",

View File

@@ -1,7 +1,11 @@
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access"; import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
import type { CharacterLayerId } from "@/lib/character/presets"; import type { CharacterLayerId } from "@/lib/character/presets";
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets"; import {
import { getShopRoomBackgrounds } from "@/lib/room-backgrounds"; CHARACTER_LAYERS,
getVariantDisplayName,
NONE_VARIANT_ID,
} from "@/lib/character/presets";
import { getRoomBackground, getShopRoomBackgrounds } from "@/lib/room-backgrounds";
export type ShopLayerId = Exclude<CharacterLayerId, "skin">; export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
@@ -95,6 +99,14 @@ export function getLayerLabel(layerId: ShopLayerId) {
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId; return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId;
} }
export function getShopItemDisplayName(item: ShopItemRecord): string {
if (item.type === "room") {
return getRoomBackground(item.id).name;
}
return getVariantDisplayName(item.id, item.name);
}
export function findShopItemById( export function findShopItemById(
items: readonly ShopItemRecord[], items: readonly ShopItemRecord[],
itemId: string, itemId: string,

View File

@@ -0,0 +1,30 @@
-- Align shop item display names with sprite-based labels.
update public.shop_items set name = 'Plains' where id = 'room-1';
update public.shop_items set name = 'Sunset' where id = 'room-2';
update public.shop_items set name = 'Forest' where id = 'room-3';
update public.shop_items set name = 'Night' where id = 'room-4';
update public.shop_items set name = 'Eyes' where id = 'eyes-1';
update public.shop_items set name = 'Eyelashes' where id = 'eyes-2';
update public.shop_items set name = 'Short' where id = 'head-1';
update public.shop_items set name = 'Short-medium' where id = 'head-2';
update public.shop_items set name = 'Mohawk' where id = 'head-3';
update public.shop_items set name = 'Long' where id = 'head-4';
update public.shop_items set name = 'Pixie' where id = 'head-5';
update public.shop_items set name = 'Middle-part' where id = 'head-6';
update public.shop_items set name = 'Bun' where id = 'head-7';
update public.shop_items set name = 'T-Shirt' where id = 'torso-1';
update public.shop_items set name = 'Button-up' where id = 'torso-2';
update public.shop_items set name = 'Vest' where id = 'torso-3';
update public.shop_items set name = 'Robe' where id = 'torso-4';
update public.shop_items set name = 'Shorts' where id = 'pants-1';
update public.shop_items set name = 'Pants' where id = 'pants-2';
update public.shop_items set name = 'Skirt' where id = 'pants-3';
update public.shop_items set name = 'Boots' where id = 'shoes-1';
update public.shop_items set name = 'Shoes' where id = 'shoes-2';
update public.shop_items set name = 'Loafers' where id = 'shoes-3';

View File

@@ -1,40 +1,18 @@
-- Character layer styles sold in the shop (id = variant id, type = layer id). -- Shop-only catalog items (variant ids 3+ and paid rooms).
delete from public.user_items
where item_id in ('hat_blue', 'glasses_round', 'room_sunset', 'room-space', 'room-neon');
delete from public.shop_items
where id in (
'hat_blue',
'glasses_round',
'room_sunset',
'room-space',
'room-neon',
'pants-1',
'pants-2',
'shoes-1',
'shoes-2',
'torso-1',
'torso-2',
'eyes-1',
'eyes-2',
'head-1',
'head-2'
);
insert into public.shop_items (id, name, type, price, image_path) insert into public.shop_items (id, name, type, price, image_path)
values values
('pants-3', 'Pleated Skirt', 'pants', 30, '/character/pants/pants-3.png'), ('head-3', 'Mohawk', 'head', 40, '/character/head/head-3.png'),
('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'), ('head-4', 'Long', 'head', 45, '/character/head/head-4.png'),
('torso-3', 'Button-Up Shirt', 'torso', 35, '/character/torso/torso-3.png'), ('head-5', 'Pixie', 'head', 50, '/character/head/head-5.png'),
('torso-4', 'Crimson Blouse', 'torso', 40, '/character/torso/torso-4.png'), ('head-6', 'Middle-part', 'head', 55, '/character/head/head-6.png'),
('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'), ('head-7', 'Bun', 'head', 60, '/character/head/head-7.png'),
('head-4', 'Long Hair', 'head', 45, '/character/head/head-4.png'), ('torso-3', 'Vest', 'torso', 35, '/character/torso/torso-3.png'),
('head-5', 'Shoulder-Length Hair', 'head', 50, '/character/head/head-5.png'), ('torso-4', 'Robe', 'torso', 40, '/character/torso/torso-4.png'),
('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'), ('pants-3', 'Skirt', 'pants', 30, '/character/pants/pants-3.png'),
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'), ('shoes-3', 'Loafers', 'shoes', 30, '/character/shoes/shoes-3.png'),
('room-3', 'Starlit Night', 'room', 40, '/room/room-3.png'), ('room-3', 'Forest', 'room', 40, '/room/room-3.png'),
('room-4', 'Mystic Forest', 'room', 50, '/room/room-4.png') ('room-4', 'Night', 'room', 50, '/room/room-4.png')
on conflict (id) do update on conflict (id) do update
set set
name = excluded.name, name = excluded.name,