diff --git a/app/(app)/daily/check-in/page.tsx b/app/(app)/daily/check-in/page.tsx new file mode 100644 index 0000000..873a14e --- /dev/null +++ b/app/(app)/daily/check-in/page.tsx @@ -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`); +} diff --git a/app/(app)/daily/quiz/page.tsx b/app/(app)/daily/quiz/page.tsx index 8e33fed..4efcb70 100644 --- a/app/(app)/daily/quiz/page.tsx +++ b/app/(app)/daily/quiz/page.tsx @@ -3,5 +3,5 @@ import { redirect } from "next/navigation"; import { routes } from "@/lib/routes"; export default function DailyQuizPage() { - redirect(`${routes.avatar}#bit-daily-quiz`); + redirect(`${routes.avatar}#bit-daily-check-in`); } diff --git a/app/globals.css b/app/globals.css index ea8a992..c663222 100644 --- a/app/globals.css +++ b/app/globals.css @@ -262,6 +262,19 @@ @apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground; 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 { diff --git a/app/page.tsx b/app/page.tsx index bc89ee8..3ed5f4d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -21,7 +21,7 @@ export default function Home() {

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.

@@ -35,7 +35,7 @@ export default function Home() {

Your daily loop

- Habits, wellness, journal, quiz, shop. + Habits, wellness, journal, check-in, shop.

diff --git a/components/avatar/avatar-page-content.tsx b/components/avatar/avatar-page-content.tsx index 64c24d7..1c9f891 100644 --- a/components/avatar/avatar-page-content.tsx +++ b/components/avatar/avatar-page-content.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { DailyQuizForm } from "@/components/daily-quiz/daily-quiz-form"; import { HabitTracker } from "@/components/habits/habit-tracker"; +import { JournalCalendar } from "@/components/journal/journal-calendar"; import { PetHabitat } from "@/components/pet/pet-habitat"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { @@ -37,6 +38,14 @@ export function AvatarPageContent() { }; }, [loadCustomization]); + useEffect(() => { + const hash = window.location.hash.replace("#", ""); + + if (hash === "bit-daily-quiz") { + window.location.hash = "bit-daily-check-in"; + } + }, []); + if (loadError) { return

{loadError}

; } @@ -66,10 +75,14 @@ export function AvatarPageContent() {
-
+
+ +
+ +
); } diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx index 7732704..71160a4 100644 --- a/components/avatar/avatar-status-card.tsx +++ b/components/avatar/avatar-status-card.tsx @@ -94,21 +94,21 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) { Health {condition.health}/{WELLNESS_SCALE_MAX} - {hasCheckedInToday ? "Checked in today" : "Quiz pending"} + {hasCheckedInToday ? "Checked in today" : "Check-in pending"}

{hasCheckedInToday ? 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." : completedTaskCount > 0 - ? "Complete today's quiz 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 for a full wellness read, then keep completing habits to boost your bit." + : "Complete today's check-in to update your bit's mood, energy, and health."}

{!hasCheckedInToday ? ( ) : null} diff --git a/components/daily-quiz/daily-quiz-form.tsx b/components/daily-quiz/daily-quiz-form.tsx index b09adca..b5012d3 100644 --- a/components/daily-quiz/daily-quiz-form.tsx +++ b/components/daily-quiz/daily-quiz-form.tsx @@ -138,7 +138,7 @@ export function DailyQuizForm() { if (!isReady) { return ( -

Loading today's quiz...

+

Loading today's check-in...

); } @@ -155,7 +155,7 @@ export function DailyQuizForm() {

- Today's quiz is complete + Today's check-in is complete

Your answers are saved below. Come back tomorrow for your next @@ -264,7 +264,7 @@ export function DailyQuizForm() { {!isCompleted ? ( ) : null}

diff --git a/components/daily-quiz/daily-quiz-link-button.tsx b/components/daily-quiz/daily-quiz-link-button.tsx index ab09384..a815a13 100644 --- a/components/daily-quiz/daily-quiz-link-button.tsx +++ b/components/daily-quiz/daily-quiz-link-button.tsx @@ -23,14 +23,14 @@ export function DailyQuizLinkButton() { if (!isReady || isCompleted) { return ( ); } return ( ); } diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index a74ef98..4f12d89 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -81,7 +81,7 @@ function DailyTaskList({ if (tasks.length === 0) { return (

- 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.

); @@ -450,7 +450,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { @@ -508,17 +508,17 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { How daily tasks are picked Your tasks combine focus preferences, custom habits, and today's - quiz. + check-in.

Focus topics: {focusTopic ?? "None selected"}

- Daily quiz today:{" "} + Daily check-in today:{" "} {quizCompletedToday ? "Completed" : "Not completed yet"}

- 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.

@@ -571,7 +571,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { How daily tasks are picked Your tasks combine custom habits and AI suggestions based on your - profile, onboarding, daily quiz, and journal. + profile, onboarding, daily check-in, and journal. @@ -580,7 +580,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { {focusTopic ?? "None selected"}

- Daily quiz today:{" "} + Daily check-in today:{" "} {quizCompletedToday ? "Completed" : "Not completed yet"}

@@ -596,7 +596,7 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { return ( diff --git a/components/journal/journal-calendar.tsx b/components/journal/journal-calendar.tsx new file mode 100644 index 0000000..52d1fb1 --- /dev/null +++ b/components/journal/journal-calendar.tsx @@ -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([]); + const [selectedDateKey, setSelectedDateKey] = useState(null); + const [selectedEntry, setSelectedEntry] = useState( + null, + ); + const [isExpanded, setIsExpanded] = useState(false); + const [isLoadingMonth, setIsLoadingMonth] = useState(false); + const [isLoadingEntry, setIsLoadingEntry] = useState(false); + const [error, setError] = useState(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 ( + <> + + +

+ + +
+ + {isExpanded ? ( + +
+

+ {formatMonthLabel(visibleYear, visibleMonth)} +

+
+ + +
+
+
+ {WEEKDAY_LABELS.map((label) => ( +
+ {label} +
+ ))} +
+ +
+ {monthGrid.map((cell) => { + const summary = summaryByDate.get(cell.dateKey); + const hasEntry = Boolean(summary); + const isToday = cell.dateKey === todayKey; + const isSelected = cell.dateKey === selectedDateKey; + + return ( + + ); + })} +
+ +
+ + + Journal written + + + + Check-in only + +
+ + {isLoadingMonth ? ( +

Loading calendar...

+ ) : null} + {error ?

{error}

: null} +
+ ) : null} +
+ + + + ); +} diff --git a/components/journal/journal-entry-modal.tsx b/components/journal/journal-entry-modal.tsx new file mode 100644 index 0000000..27226b6 --- /dev/null +++ b/components/journal/journal-entry-modal.tsx @@ -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 ( +
+
event.stopPropagation()} + > +
+
+

+ Journal entry +

+

+ {formatEntryDateLabel(entry.date)} +

+
+ +
+ +
+
+

Daily journal

+

+ {journalText || "No journal written."} +

+
+ +
+

Wellness

+ undefined} + /> + undefined} + /> + undefined} + /> + undefined} + /> + undefined} + /> +
+
+
+
+ ); +} diff --git a/components/profile/profile-preferences-form.tsx b/components/profile/profile-preferences-form.tsx index f1b8b81..3fba700 100644 --- a/components/profile/profile-preferences-form.tsx +++ b/components/profile/profile-preferences-form.tsx @@ -247,13 +247,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { try { await resetTodaysDailyQuiz(); 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) { setError( resetError instanceof Error ? resetError.message - : "Could not reset today's quiz.", + : "Could not reset today's check-in.", ); } finally { setIsResettingQuiz(false); @@ -337,7 +337,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { Notification preferences - Control reminders for your daily quiz and habits. + Control reminders for your daily check-in and habits. @@ -425,7 +425,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {

- Reset clears today's wellness quiz entry so you can complete it + Reset clears today's wellness check-in entry so you can complete it again immediately. Demo points are for testing the shop only.

{quizResetMessage ? (

{quizResetMessage}

diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index ba143c2..e304949 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -22,6 +22,7 @@ import { } from "@/lib/shop-storage"; import { getLayerLabel, + getShopItemDisplayName, type ShopItemRecord, type ShopLayerId, } from "@/lib/shop-catalog"; @@ -103,6 +104,8 @@ function ShopItemCard({ ? null : getShopItemThumbnailColors(item, characterColors); + const displayName = getShopItemDisplayName(item); + const actionLabel = (() => { if (isPending) { return owned ? "Updating..." : "Processing..."; @@ -123,27 +126,33 @@ function ShopItemCard({
-
- {room ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : thumbnailColors ? ( - +
+ {thumbnailColors || room ? ( +
) : null} +
+ {room ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : thumbnailColors ? ( + + ) : null} +

- {item.name} + {displayName}

Preview on your bit

-

{item.name}

+

{getShopItemDisplayName(item)}