diff --git a/app/(app)/avatar/page.tsx b/app/(app)/avatar/page.tsx index 1fb05b6..57ca086 100644 --- a/app/(app)/avatar/page.tsx +++ b/app/(app)/avatar/page.tsx @@ -8,6 +8,8 @@ export default function AvatarPage() { Loading...

}> diff --git a/app/globals.css b/app/globals.css index b85a3b5..9b9551f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -116,6 +116,21 @@ max-width: calc(32rem - 2.5rem - 2rem); } + .pet-viewport-stage { + @apply flex min-h-0 w-full flex-1 flex-col items-center justify-center; + } + + .tamagotchi-shell-fill { + @apply flex min-h-0 w-full max-w-4xl flex-1 flex-col; + } + + .tamagotchi-lcd-pet-fill { + @apply relative min-h-0 w-full flex-1; + min-height: 16rem; + max-width: none; + aspect-ratio: auto; + } + .customize-body { min-height: 26rem; } diff --git a/components/avatar/avatar-page-content.tsx b/components/avatar/avatar-page-content.tsx index 162ba44..be9bf63 100644 --- a/components/avatar/avatar-page-content.tsx +++ b/components/avatar/avatar-page-content.tsx @@ -45,5 +45,5 @@ export function AvatarPageContent() { ); } - return ; + return ; } diff --git a/components/daily-quiz/daily-quiz-form.tsx b/components/daily-quiz/daily-quiz-form.tsx index f30b7df..50fea34 100644 --- a/components/daily-quiz/daily-quiz-form.tsx +++ b/components/daily-quiz/daily-quiz-form.tsx @@ -26,26 +26,45 @@ import { type DailyQuizSubmission, } from "@/lib/avatar-state"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; +import { + getCachedQuizTabData, + prefetchQuizTabData, +} from "@/lib/app-tab-data-cache"; import { getAvatarConditionForToday, - getDailyEntryForToday, saveDailyEntry, } from "@/lib/daily-quiz-storage"; import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety"; export function DailyQuizForm() { + const cachedQuiz = getCachedQuizTabData(); const [answers, setAnswers] = useState( - defaultDailyQuizAnswers, + cachedQuiz?.submission?.answers ?? defaultDailyQuizAnswers, ); - const [journal, setJournal] = useState(""); + const [journal, setJournal] = useState(cachedQuiz?.submission?.journal ?? ""); const [submission, setSubmission] = useState( - null, + cachedQuiz?.submission ?? null, ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - const [isReady, setIsReady] = useState(false); + const [isReady, setIsReady] = useState(cachedQuiz !== null); const [liveCondition, setLiveCondition] = useState( - submission?.condition ?? null, + cachedQuiz?.liveCondition ?? cachedQuiz?.submission?.condition ?? null, + ); + + const applyQuizData = useCallback( + (data: NonNullable>) => { + if (data.submission) { + setSubmission(data.submission); + setAnswers(data.submission.answers); + setJournal(data.submission.journal); + setLiveCondition(data.submission.condition); + } else { + setSubmission(null); + setLiveCondition(data.liveCondition); + } + }, + [], ); const refreshLiveCondition = useCallback(async () => { @@ -53,31 +72,40 @@ export function DailyQuizForm() { }, []); useEffect(() => { + let cancelled = false; + async function loadDailyCheckIn() { try { - const existingEntry = await getDailyEntryForToday(); + const data = await prefetchQuizTabData(); - if (existingEntry) { - setSubmission(existingEntry); - setAnswers(existingEntry.answers); - setJournal(existingEntry.journal); - setLiveCondition(existingEntry.condition); - } else { - await refreshLiveCondition(); + if (cancelled) { + return; } + + applyQuizData(data); } catch (loadError) { + if (cancelled) { + return; + } + setError( loadError instanceof Error ? loadError.message : "Could not load today's check-in.", ); } finally { - setIsReady(true); + if (!cancelled) { + setIsReady(true); + } } } void loadDailyCheckIn(); - }, [refreshLiveCondition]); + + return () => { + cancelled = true; + }; + }, [applyQuizData]); useEffect(() => { const handleDataUpdated = () => { diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index 4553fa0..ce452bf 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -17,12 +17,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { - getDailyTasks, - type DailyTask, -} from "@/lib/daily-tasks"; + getCachedHabitsTabData, + prefetchHabitsTabData, +} from "@/lib/app-tab-data-cache"; +import type { DailyTask } from "@/lib/daily-tasks"; import { addHabit, - getCustomHabits, getTodayDateKey, isHabitCompletedToday, removeHabit, @@ -31,8 +31,6 @@ import { } from "@/lib/habits-storage"; import { formatCoinsDelta } from "@/lib/coins"; import { describeHabitWellnessBoost } from "@/lib/habit-wellness-effects"; -import { getProfilePreferences } from "@/lib/profile-preferences-storage"; -import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage"; type HabitTrackerProps = { mode?: "daily" | "manage" | "all"; @@ -184,13 +182,22 @@ function CustomHabitList({ } export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { - const [dailyTasks, setDailyTasks] = useState([]); - const [customHabits, setCustomHabits] = useState([]); + const cachedHabits = getCachedHabitsTabData(); + const [dailyTasks, setDailyTasks] = useState( + cachedHabits?.dailyTasks ?? [], + ); + const [customHabits, setCustomHabits] = useState( + cachedHabits?.customHabits ?? [], + ); const [newHabitLabel, setNewHabitLabel] = useState(""); - const [focusTopic, setFocusTopic] = useState(null); - const [quizCompletedToday, setQuizCompletedToday] = useState(false); + const [focusTopic, setFocusTopic] = useState( + cachedHabits?.focusTopic ?? null, + ); + const [quizCompletedToday, setQuizCompletedToday] = useState( + cachedHabits?.quizCompletedToday ?? false, + ); const [error, setError] = useState(null); - const [isReady, setIsReady] = useState(false); + const [isReady, setIsReady] = useState(cachedHabits !== null); const [isSaving, setIsSaving] = useState(false); const [optimisticCompletion, setOptimisticCompletion] = useState< Record @@ -223,17 +230,21 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { [toggleErrors], ); + const applyHabitsData = useCallback( + (data: NonNullable>) => { + setDailyTasks(data.dailyTasks); + setCustomHabits(data.customHabits); + setFocusTopic(data.focusTopic); + setQuizCompletedToday(data.quizCompletedToday); + }, + [], + ); + const refresh = useCallback(async () => { try { setError(null); - const [tasks, customs] = await Promise.all([ - getDailyTasks(), - getCustomHabits(), - ]); - setDailyTasks(tasks); - setCustomHabits(customs); - setFocusTopic((await getProfilePreferences()).focusTopic); - setQuizCompletedToday(await hasCompletedDailyQuizToday()); + const data = await prefetchHabitsTabData({ force: true }); + applyHabitsData(data); setIsReady(true); } catch (refreshError) { setError( @@ -243,7 +254,27 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { ); setIsReady(true); } - }, []); + }, [applyHabitsData]); + + useEffect(() => { + void prefetchHabitsTabData() + .then(applyHabitsData) + .catch((refreshError) => { + setError( + refreshError instanceof Error + ? refreshError.message + : "Could not load habits.", + ); + }) + .finally(() => { + setIsReady(true); + }); + + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); + return () => { + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); + }; + }, [applyHabitsData, refresh]); useEffect(() => { if (!coinMessage) { @@ -290,15 +321,6 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) { }; }, [refresh, todayKey]); - useEffect(() => { - void refresh(); - - window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); - return () => { - window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh); - }; - }, [refresh]); - const handleToggle = (habitId: string) => { const habit = dailyTasks.find((task) => task.id === habitId); diff --git a/components/layout/app-data-prefetcher.tsx b/components/layout/app-data-prefetcher.tsx new file mode 100644 index 0000000..bf972ec --- /dev/null +++ b/components/layout/app-data-prefetcher.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; + +import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; +import { + invalidateAppTabDataCache, + prefetchAppTabData, +} from "@/lib/app-tab-data-cache"; + +type AppDataPrefetcherProps = { + enabled: boolean; +}; + +export function AppDataPrefetcher({ enabled }: AppDataPrefetcherProps) { + useEffect(() => { + if (!enabled) { + return; + } + + void prefetchAppTabData(); + + const handleDataUpdated = () => { + invalidateAppTabDataCache(); + void prefetchAppTabData({ force: true }); + }; + + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated); + + return () => { + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated); + }; + }, [enabled]); + + return null; +} diff --git a/components/layout/app-nav.tsx b/components/layout/app-nav.tsx index 68e211d..53dc716 100644 --- a/components/layout/app-nav.tsx +++ b/components/layout/app-nav.tsx @@ -13,6 +13,10 @@ import { import { cn } from "@/lib/utils"; import { appNavItems, routes, type NavItem } from "@/lib/routes"; +import { + prefetchHabitsTabData, + prefetchQuizTabData, +} from "@/lib/app-tab-data-cache"; const gridColsClass = { 1: "grid-cols-1", @@ -31,6 +35,15 @@ const navIcons = { [routes.profile]: UserRound, } as const; +const prefetchByRoute: Partial void>> = { + [routes.habits]: () => { + void prefetchHabitsTabData(); + }, + [routes.dailyQuiz]: () => { + void prefetchQuizTabData(); + }, +}; + type AppNavProps = { items?: readonly NavItem[]; }; @@ -76,6 +89,9 @@ export const AppNav = forwardRef(function AppNav( href={item.href} aria-label={item.label} title={item.label} + prefetch + onPointerEnter={() => prefetchByRoute[item.href]?.()} + onFocus={() => prefetchByRoute[item.href]?.()} className={cn( "bottom-nav-item flex flex-col items-center justify-center gap-1.5 overflow-hidden border-2 px-1 py-2 text-[9px] leading-none transition-colors duration-75", isActive diff --git a/components/layout/app-shell.tsx b/components/layout/app-shell.tsx index 67fc956..5046105 100644 --- a/components/layout/app-shell.tsx +++ b/components/layout/app-shell.tsx @@ -7,6 +7,10 @@ type AppShellProps = { title?: string; description?: string; wide?: boolean; + /** Use remaining viewport height between top bar and bottom nav. */ + fillViewport?: boolean; + /** Center page content horizontally (pairs well with fillViewport). */ + centered?: boolean; }; const contentWidthClass = { @@ -19,20 +23,33 @@ export function AppShell({ title, description, wide = false, + fillViewport = false, + centered = false, }: AppShellProps) { return ( -
+
{title ? ( ) : null} - {children} +
+ {children} +
); diff --git a/components/layout/app-top-bar.tsx b/components/layout/app-top-bar.tsx index 46dac9e..ca76d96 100644 --- a/components/layout/app-top-bar.tsx +++ b/components/layout/app-top-bar.tsx @@ -12,7 +12,8 @@ type AppTopBarProps = { export function AppTopBar({ className }: AppTopBarProps) { const pathname = usePathname(); - const isHome = pathname === routes.avatar; + const isHome = + pathname === routes.home || pathname === routes.avatar; return (
+ {showNav ? : null} ); diff --git a/components/layout/habit-brand.tsx b/components/layout/habit-brand.tsx index 2e16300..8d6c942 100644 --- a/components/layout/habit-brand.tsx +++ b/components/layout/habit-brand.tsx @@ -16,7 +16,7 @@ type HabitBrandProps = { export function HabitBrand({ className, - href = routes.avatar, + href = routes.home, "aria-current": ariaCurrent, }: HabitBrandProps) { const [useText, setUseText] = useState(false); diff --git a/components/pet/pet-habitat.tsx b/components/pet/pet-habitat.tsx index 64ff7d6..971aed5 100644 --- a/components/pet/pet-habitat.tsx +++ b/components/pet/pet-habitat.tsx @@ -21,6 +21,7 @@ import { cn } from "@/lib/utils"; type PetHabitatProps = { customization: AvatarCustomization; className?: string; + fillViewport?: boolean; }; const MOOD_LABELS = { @@ -29,7 +30,11 @@ const MOOD_LABELS = { tired: "SLEEPY", } as const; -export function PetHabitat({ customization, className }: PetHabitatProps) { +export function PetHabitat({ + customization, + className, + fillViewport = false, +}: PetHabitatProps) { const [condition, setCondition] = useState( defaultAvatarCondition, ); @@ -45,9 +50,20 @@ export function PetHabitat({ customization, className }: PetHabitatProps) { void loadStatus(); }, []); + const shellClassName = cn( + "tamagotchi-shell overflow-hidden p-3 sm:p-4", + fillViewport && "tamagotchi-shell-fill", + className, + ); + + const lcdClassName = cn( + "tamagotchi-lcd relative", + fillViewport ? "tamagotchi-lcd-pet-fill" : "tamagotchi-lcd-pet-match", + ); + if (!isReady) { return ( -
+

Waking up pet...

@@ -56,15 +72,15 @@ export function PetHabitat({ customization, className }: PetHabitatProps) { } return ( -
-
+
+

{customization.name}

{MOOD_LABELS[condition.mood]}

-
+
diff --git a/lib/app-tab-data-cache.ts b/lib/app-tab-data-cache.ts new file mode 100644 index 0000000..136b664 --- /dev/null +++ b/lib/app-tab-data-cache.ts @@ -0,0 +1,122 @@ +import type { AvatarCondition, DailyQuizSubmission } from "@/lib/avatar-state"; +import type { DailyTask } from "@/lib/daily-tasks"; +import { getDailyTasks } from "@/lib/daily-tasks"; +import { + getAvatarConditionForToday, + getDailyEntryForToday, + hasCompletedDailyQuizToday, +} from "@/lib/daily-quiz-storage"; +import { getCustomHabits, type Habit } from "@/lib/habits-storage"; +import { getProfilePreferences } from "@/lib/profile-preferences-storage"; + +export type QuizTabData = { + submission: DailyQuizSubmission | null; + liveCondition: AvatarCondition | null; +}; + +export type HabitsTabData = { + dailyTasks: DailyTask[]; + customHabits: Habit[]; + focusTopic: string | null; + quizCompletedToday: boolean; +}; + +let quizCache: QuizTabData | null = null; +let habitsCache: HabitsTabData | null = null; +let quizInflight: Promise | null = null; +let habitsInflight: Promise | null = null; + +export function getCachedQuizTabData() { + return quizCache; +} + +export function getCachedHabitsTabData() { + return habitsCache; +} + +export function invalidateAppTabDataCache() { + quizCache = null; + habitsCache = null; +} + +async function loadQuizTabData(): Promise { + const submission = await getDailyEntryForToday(); + + if (submission) { + return { + submission, + liveCondition: submission.condition, + }; + } + + return { + submission: null, + liveCondition: await getAvatarConditionForToday(), + }; +} + +async function loadHabitsTabData(): Promise { + const [dailyTasks, customHabits, preferences, quizCompletedToday] = + await Promise.all([ + getDailyTasks(), + getCustomHabits(), + getProfilePreferences(), + hasCompletedDailyQuizToday(), + ]); + + return { + dailyTasks, + customHabits, + focusTopic: preferences.focusTopic, + quizCompletedToday, + }; +} + +export async function prefetchQuizTabData(options?: { force?: boolean }) { + if (!options?.force && quizCache) { + return quizCache; + } + + if (quizInflight) { + return quizInflight; + } + + quizInflight = loadQuizTabData() + .then((data) => { + quizCache = data; + return data; + }) + .finally(() => { + quizInflight = null; + }); + + return quizInflight; +} + +export async function prefetchHabitsTabData(options?: { force?: boolean }) { + if (!options?.force && habitsCache) { + return habitsCache; + } + + if (habitsInflight) { + return habitsInflight; + } + + habitsInflight = loadHabitsTabData() + .then((data) => { + habitsCache = data; + return data; + }) + .finally(() => { + habitsInflight = null; + }); + + return habitsInflight; +} + +export async function prefetchAppTabData(options?: { force?: boolean }) { + await Promise.all([ + prefetchQuizTabData(options), + prefetchHabitsTabData(options), + ]); +}