Finish interrupted features: tab prefetch, pet viewport, and home logo.

Add shared quiz/habits tab cache with nav hover prefetch and AppDataPrefetcher,
make the pet screen fill and center the viewport, and point the HaBit logo to /.
This commit is contained in:
2026-05-24 04:57:13 -06:00
parent 5dda46cb87
commit d5e80ec380
13 changed files with 333 additions and 56 deletions

View File

@@ -8,6 +8,8 @@ export default function AvatarPage() {
<AppShell
title="Your Habit Pet"
description="Watch your pet roam, breathe, and react to how you care for them."
fillViewport
centered
>
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading...</p>}>
<AvatarPageContent />

View File

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

View File

@@ -45,5 +45,5 @@ export function AvatarPageContent() {
);
}
return <PetHabitat customization={customization} />;
return <PetHabitat customization={customization} fillViewport />;
}

View File

@@ -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<DailyQuizAnswers>(
defaultDailyQuizAnswers,
cachedQuiz?.submission?.answers ?? defaultDailyQuizAnswers,
);
const [journal, setJournal] = useState("");
const [journal, setJournal] = useState(cachedQuiz?.submission?.journal ?? "");
const [submission, setSubmission] = useState<DailyQuizSubmission | null>(
null,
cachedQuiz?.submission ?? null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<ReturnType<typeof getCachedQuizTabData>>) => {
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 = () => {

View File

@@ -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<DailyTask[]>([]);
const [customHabits, setCustomHabits] = useState<Habit[]>([]);
const cachedHabits = getCachedHabitsTabData();
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>(
cachedHabits?.dailyTasks ?? [],
);
const [customHabits, setCustomHabits] = useState<Habit[]>(
cachedHabits?.customHabits ?? [],
);
const [newHabitLabel, setNewHabitLabel] = useState("");
const [focusTopic, setFocusTopic] = useState<string | null>(null);
const [quizCompletedToday, setQuizCompletedToday] = useState(false);
const [focusTopic, setFocusTopic] = useState<string | null>(
cachedHabits?.focusTopic ?? null,
);
const [quizCompletedToday, setQuizCompletedToday] = useState(
cachedHabits?.quizCompletedToday ?? false,
);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false);
const [isReady, setIsReady] = useState(cachedHabits !== null);
const [isSaving, setIsSaving] = useState(false);
const [optimisticCompletion, setOptimisticCompletion] = useState<
Record<string, boolean>
@@ -223,17 +230,21 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
[toggleErrors],
);
const applyHabitsData = useCallback(
(data: NonNullable<ReturnType<typeof getCachedHabitsTabData>>) => {
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);

View File

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

View File

@@ -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<Record<string, () => void>> = {
[routes.habits]: () => {
void prefetchHabitsTabData();
},
[routes.dailyQuiz]: () => {
void prefetchQuizTabData();
},
};
type AppNavProps = {
items?: readonly NavItem[];
};
@@ -76,6 +89,9 @@ export const AppNav = forwardRef<HTMLElement, AppNavProps>(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

View File

@@ -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 (
<div className="relative min-h-dvh bg-background bg-[radial-gradient(circle_at_20%_0%,hsl(var(--primary)/0.12)_0%,transparent_45%),radial-gradient(circle_at_80%_100%,hsl(var(--secondary)/0.1)_0%,transparent_40%)] pt-topbar">
<div className="relative flex min-h-dvh flex-col bg-background bg-[radial-gradient(circle_at_20%_0%,hsl(var(--primary)/0.12)_0%,transparent_45%),radial-gradient(circle_at_80%_100%,hsl(var(--secondary)/0.1)_0%,transparent_40%)] pt-topbar">
<AppTopBar />
<main
className={cn(
"mx-auto px-4 py-6 lg:px-5",
"mx-auto flex w-full flex-1 flex-col px-4 py-6 lg:px-5",
fillViewport
? "min-h-[calc(100dvh-var(--app-topbar-height)-var(--app-nav-offset)-env(safe-area-inset-top,0px))]"
: null,
centered ? "items-center" : null,
wide ? contentWidthClass.wide : contentWidthClass.default,
fillViewport && "max-w-none lg:max-w-none",
)}
>
{title ? (
<AppPageHeader title={title} description={description} />
) : null}
{children}
<div
className={cn(
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
)}
>
{children}
</div>
</main>
</div>
);

View File

@@ -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 (
<header

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { AppNav } from "@/components/layout/app-nav";
import { AppDataPrefetcher } from "@/components/layout/app-data-prefetcher";
import {
NavOffsetProvider,
useNavOffsetRef,
@@ -55,6 +56,7 @@ export function GlobalBottomNav() {
return (
<NavOffsetProvider enabled={showNav}>
<AppDataPrefetcher enabled={showNav} />
{showNav ? <MeasuredAppNav /> : null}
</NavOffsetProvider>
);

View File

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

View File

@@ -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<AvatarCondition>(
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 (
<div className={cn("tamagotchi-shell p-4", className)}>
<div className={shellClassName}>
<p className="text-center text-[10px] text-muted-foreground">
Waking up pet...
</p>
@@ -56,15 +72,15 @@ export function PetHabitat({ customization, className }: PetHabitatProps) {
}
return (
<div className={cn("tamagotchi-shell overflow-hidden p-3 sm:p-4", className)}>
<div className="mb-3 border-b-2 border-border/60 pb-3">
<div className={shellClassName}>
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
<p className="text-sm text-foreground">{customization.name}</p>
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
{MOOD_LABELS[condition.mood]}
</p>
</div>
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
<div className={lcdClassName}>
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
<AnimatedPetSprite customization={customization} />
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">

122
lib/app-tab-data-cache.ts Normal file
View File

@@ -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<QuizTabData> | null = null;
let habitsInflight: Promise<HabitsTabData> | null = null;
export function getCachedQuizTabData() {
return quizCache;
}
export function getCachedHabitsTabData() {
return habitsCache;
}
export function invalidateAppTabDataCache() {
quizCache = null;
habitsCache = null;
}
async function loadQuizTabData(): Promise<QuizTabData> {
const submission = await getDailyEntryForToday();
if (submission) {
return {
submission,
liveCondition: submission.condition,
};
}
return {
submission: null,
liveCondition: await getAvatarConditionForToday(),
};
}
async function loadHabitsTabData(): Promise<HabitsTabData> {
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),
]);
}