Skip to content

Commit d5e80ec

Browse files
committed
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 /.
1 parent 5dda46c commit d5e80ec

13 files changed

Lines changed: 333 additions & 56 deletions

app/(app)/avatar/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export default function AvatarPage() {
88
<AppShell
99
title="Your Habit Pet"
1010
description="Watch your pet roam, breathe, and react to how you care for them."
11+
fillViewport
12+
centered
1113
>
1214
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading...</p>}>
1315
<AvatarPageContent />

app/globals.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,21 @@
116116
max-width: calc(32rem - 2.5rem - 2rem);
117117
}
118118

119+
.pet-viewport-stage {
120+
@apply flex min-h-0 w-full flex-1 flex-col items-center justify-center;
121+
}
122+
123+
.tamagotchi-shell-fill {
124+
@apply flex min-h-0 w-full max-w-4xl flex-1 flex-col;
125+
}
126+
127+
.tamagotchi-lcd-pet-fill {
128+
@apply relative min-h-0 w-full flex-1;
129+
min-height: 16rem;
130+
max-width: none;
131+
aspect-ratio: auto;
132+
}
133+
119134
.customize-body {
120135
min-height: 26rem;
121136
}

components/avatar/avatar-page-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ export function AvatarPageContent() {
4545
);
4646
}
4747

48-
return <PetHabitat customization={customization} />;
48+
return <PetHabitat customization={customization} fillViewport />;
4949
}

components/daily-quiz/daily-quiz-form.tsx

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,58 +26,86 @@ import {
2626
type DailyQuizSubmission,
2727
} from "@/lib/avatar-state";
2828
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
29+
import {
30+
getCachedQuizTabData,
31+
prefetchQuizTabData,
32+
} from "@/lib/app-tab-data-cache";
2933
import {
3034
getAvatarConditionForToday,
31-
getDailyEntryForToday,
3235
saveDailyEntry,
3336
} from "@/lib/daily-quiz-storage";
3437
import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety";
3538

3639
export function DailyQuizForm() {
40+
const cachedQuiz = getCachedQuizTabData();
3741
const [answers, setAnswers] = useState<DailyQuizAnswers>(
38-
defaultDailyQuizAnswers,
42+
cachedQuiz?.submission?.answers ?? defaultDailyQuizAnswers,
3943
);
40-
const [journal, setJournal] = useState("");
44+
const [journal, setJournal] = useState(cachedQuiz?.submission?.journal ?? "");
4145
const [submission, setSubmission] = useState<DailyQuizSubmission | null>(
42-
null,
46+
cachedQuiz?.submission ?? null,
4347
);
4448
const [isSubmitting, setIsSubmitting] = useState(false);
4549
const [error, setError] = useState<string | null>(null);
46-
const [isReady, setIsReady] = useState(false);
50+
const [isReady, setIsReady] = useState(cachedQuiz !== null);
4751
const [liveCondition, setLiveCondition] = useState(
48-
submission?.condition ?? null,
52+
cachedQuiz?.liveCondition ?? cachedQuiz?.submission?.condition ?? null,
53+
);
54+
55+
const applyQuizData = useCallback(
56+
(data: NonNullable<ReturnType<typeof getCachedQuizTabData>>) => {
57+
if (data.submission) {
58+
setSubmission(data.submission);
59+
setAnswers(data.submission.answers);
60+
setJournal(data.submission.journal);
61+
setLiveCondition(data.submission.condition);
62+
} else {
63+
setSubmission(null);
64+
setLiveCondition(data.liveCondition);
65+
}
66+
},
67+
[],
4968
);
5069

5170
const refreshLiveCondition = useCallback(async () => {
5271
setLiveCondition(await getAvatarConditionForToday());
5372
}, []);
5473

5574
useEffect(() => {
75+
let cancelled = false;
76+
5677
async function loadDailyCheckIn() {
5778
try {
58-
const existingEntry = await getDailyEntryForToday();
79+
const data = await prefetchQuizTabData();
5980

60-
if (existingEntry) {
61-
setSubmission(existingEntry);
62-
setAnswers(existingEntry.answers);
63-
setJournal(existingEntry.journal);
64-
setLiveCondition(existingEntry.condition);
65-
} else {
66-
await refreshLiveCondition();
81+
if (cancelled) {
82+
return;
6783
}
84+
85+
applyQuizData(data);
6886
} catch (loadError) {
87+
if (cancelled) {
88+
return;
89+
}
90+
6991
setError(
7092
loadError instanceof Error
7193
? loadError.message
7294
: "Could not load today's check-in.",
7395
);
7496
} finally {
75-
setIsReady(true);
97+
if (!cancelled) {
98+
setIsReady(true);
99+
}
76100
}
77101
}
78102

79103
void loadDailyCheckIn();
80-
}, [refreshLiveCondition]);
104+
105+
return () => {
106+
cancelled = true;
107+
};
108+
}, [applyQuizData]);
81109

82110
useEffect(() => {
83111
const handleDataUpdated = () => {

components/habits/habit-tracker.tsx

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ import { Input } from "@/components/ui/input";
1717
import { Label } from "@/components/ui/label";
1818
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
1919
import {
20-
getDailyTasks,
21-
type DailyTask,
22-
} from "@/lib/daily-tasks";
20+
getCachedHabitsTabData,
21+
prefetchHabitsTabData,
22+
} from "@/lib/app-tab-data-cache";
23+
import type { DailyTask } from "@/lib/daily-tasks";
2324
import {
2425
addHabit,
25-
getCustomHabits,
2626
getTodayDateKey,
2727
isHabitCompletedToday,
2828
removeHabit,
@@ -31,8 +31,6 @@ import {
3131
} from "@/lib/habits-storage";
3232
import { formatCoinsDelta } from "@/lib/coins";
3333
import { describeHabitWellnessBoost } from "@/lib/habit-wellness-effects";
34-
import { getProfilePreferences } from "@/lib/profile-preferences-storage";
35-
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
3634

3735
type HabitTrackerProps = {
3836
mode?: "daily" | "manage" | "all";
@@ -184,13 +182,22 @@ function CustomHabitList({
184182
}
185183

186184
export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
187-
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>([]);
188-
const [customHabits, setCustomHabits] = useState<Habit[]>([]);
185+
const cachedHabits = getCachedHabitsTabData();
186+
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>(
187+
cachedHabits?.dailyTasks ?? [],
188+
);
189+
const [customHabits, setCustomHabits] = useState<Habit[]>(
190+
cachedHabits?.customHabits ?? [],
191+
);
189192
const [newHabitLabel, setNewHabitLabel] = useState("");
190-
const [focusTopic, setFocusTopic] = useState<string | null>(null);
191-
const [quizCompletedToday, setQuizCompletedToday] = useState(false);
193+
const [focusTopic, setFocusTopic] = useState<string | null>(
194+
cachedHabits?.focusTopic ?? null,
195+
);
196+
const [quizCompletedToday, setQuizCompletedToday] = useState(
197+
cachedHabits?.quizCompletedToday ?? false,
198+
);
192199
const [error, setError] = useState<string | null>(null);
193-
const [isReady, setIsReady] = useState(false);
200+
const [isReady, setIsReady] = useState(cachedHabits !== null);
194201
const [isSaving, setIsSaving] = useState(false);
195202
const [optimisticCompletion, setOptimisticCompletion] = useState<
196203
Record<string, boolean>
@@ -223,17 +230,21 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
223230
[toggleErrors],
224231
);
225232

233+
const applyHabitsData = useCallback(
234+
(data: NonNullable<ReturnType<typeof getCachedHabitsTabData>>) => {
235+
setDailyTasks(data.dailyTasks);
236+
setCustomHabits(data.customHabits);
237+
setFocusTopic(data.focusTopic);
238+
setQuizCompletedToday(data.quizCompletedToday);
239+
},
240+
[],
241+
);
242+
226243
const refresh = useCallback(async () => {
227244
try {
228245
setError(null);
229-
const [tasks, customs] = await Promise.all([
230-
getDailyTasks(),
231-
getCustomHabits(),
232-
]);
233-
setDailyTasks(tasks);
234-
setCustomHabits(customs);
235-
setFocusTopic((await getProfilePreferences()).focusTopic);
236-
setQuizCompletedToday(await hasCompletedDailyQuizToday());
246+
const data = await prefetchHabitsTabData({ force: true });
247+
applyHabitsData(data);
237248
setIsReady(true);
238249
} catch (refreshError) {
239250
setError(
@@ -243,7 +254,27 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
243254
);
244255
setIsReady(true);
245256
}
246-
}, []);
257+
}, [applyHabitsData]);
258+
259+
useEffect(() => {
260+
void prefetchHabitsTabData()
261+
.then(applyHabitsData)
262+
.catch((refreshError) => {
263+
setError(
264+
refreshError instanceof Error
265+
? refreshError.message
266+
: "Could not load habits.",
267+
);
268+
})
269+
.finally(() => {
270+
setIsReady(true);
271+
});
272+
273+
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
274+
return () => {
275+
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
276+
};
277+
}, [applyHabitsData, refresh]);
247278

248279
useEffect(() => {
249280
if (!coinMessage) {
@@ -290,15 +321,6 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
290321
};
291322
}, [refresh, todayKey]);
292323

293-
useEffect(() => {
294-
void refresh();
295-
296-
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
297-
return () => {
298-
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
299-
};
300-
}, [refresh]);
301-
302324
const handleToggle = (habitId: string) => {
303325
const habit = dailyTasks.find((task) => task.id === habitId);
304326

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
5+
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
6+
import {
7+
invalidateAppTabDataCache,
8+
prefetchAppTabData,
9+
} from "@/lib/app-tab-data-cache";
10+
11+
type AppDataPrefetcherProps = {
12+
enabled: boolean;
13+
};
14+
15+
export function AppDataPrefetcher({ enabled }: AppDataPrefetcherProps) {
16+
useEffect(() => {
17+
if (!enabled) {
18+
return;
19+
}
20+
21+
void prefetchAppTabData();
22+
23+
const handleDataUpdated = () => {
24+
invalidateAppTabDataCache();
25+
void prefetchAppTabData({ force: true });
26+
};
27+
28+
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
29+
30+
return () => {
31+
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
32+
};
33+
}, [enabled]);
34+
35+
return null;
36+
}

components/layout/app-nav.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313

1414
import { cn } from "@/lib/utils";
1515
import { appNavItems, routes, type NavItem } from "@/lib/routes";
16+
import {
17+
prefetchHabitsTabData,
18+
prefetchQuizTabData,
19+
} from "@/lib/app-tab-data-cache";
1620

1721
const gridColsClass = {
1822
1: "grid-cols-1",
@@ -31,6 +35,15 @@ const navIcons = {
3135
[routes.profile]: UserRound,
3236
} as const;
3337

38+
const prefetchByRoute: Partial<Record<string, () => void>> = {
39+
[routes.habits]: () => {
40+
void prefetchHabitsTabData();
41+
},
42+
[routes.dailyQuiz]: () => {
43+
void prefetchQuizTabData();
44+
},
45+
};
46+
3447
type AppNavProps = {
3548
items?: readonly NavItem[];
3649
};
@@ -76,6 +89,9 @@ export const AppNav = forwardRef<HTMLElement, AppNavProps>(function AppNav(
7689
href={item.href}
7790
aria-label={item.label}
7891
title={item.label}
92+
prefetch
93+
onPointerEnter={() => prefetchByRoute[item.href]?.()}
94+
onFocus={() => prefetchByRoute[item.href]?.()}
7995
className={cn(
8096
"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",
8197
isActive

components/layout/app-shell.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ type AppShellProps = {
77
title?: string;
88
description?: string;
99
wide?: boolean;
10+
/** Use remaining viewport height between top bar and bottom nav. */
11+
fillViewport?: boolean;
12+
/** Center page content horizontally (pairs well with fillViewport). */
13+
centered?: boolean;
1014
};
1115

1216
const contentWidthClass = {
@@ -19,20 +23,33 @@ export function AppShell({
1923
title,
2024
description,
2125
wide = false,
26+
fillViewport = false,
27+
centered = false,
2228
}: AppShellProps) {
2329
return (
24-
<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">
30+
<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">
2531
<AppTopBar />
2632
<main
2733
className={cn(
28-
"mx-auto px-4 py-6 lg:px-5",
34+
"mx-auto flex w-full flex-1 flex-col px-4 py-6 lg:px-5",
35+
fillViewport
36+
? "min-h-[calc(100dvh-var(--app-topbar-height)-var(--app-nav-offset)-env(safe-area-inset-top,0px))]"
37+
: null,
38+
centered ? "items-center" : null,
2939
wide ? contentWidthClass.wide : contentWidthClass.default,
40+
fillViewport && "max-w-none lg:max-w-none",
3041
)}
3142
>
3243
{title ? (
3344
<AppPageHeader title={title} description={description} />
3445
) : null}
35-
{children}
46+
<div
47+
className={cn(
48+
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
49+
)}
50+
>
51+
{children}
52+
</div>
3653
</main>
3754
</div>
3855
);

0 commit comments

Comments
 (0)