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:
@@ -8,6 +8,8 @@ export default function AvatarPage() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
title="Your Habit Pet"
|
title="Your Habit Pet"
|
||||||
description="Watch your pet roam, breathe, and react to how you care for them."
|
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>}>
|
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading...</p>}>
|
||||||
<AvatarPageContent />
|
<AvatarPageContent />
|
||||||
|
|||||||
@@ -116,6 +116,21 @@
|
|||||||
max-width: calc(32rem - 2.5rem - 2rem);
|
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 {
|
.customize-body {
|
||||||
min-height: 26rem;
|
min-height: 26rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,5 +45,5 @@ export function AvatarPageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PetHabitat customization={customization} />;
|
return <PetHabitat customization={customization} fillViewport />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,26 +26,45 @@ import {
|
|||||||
type DailyQuizSubmission,
|
type DailyQuizSubmission,
|
||||||
} from "@/lib/avatar-state";
|
} from "@/lib/avatar-state";
|
||||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
|
import {
|
||||||
|
getCachedQuizTabData,
|
||||||
|
prefetchQuizTabData,
|
||||||
|
} from "@/lib/app-tab-data-cache";
|
||||||
import {
|
import {
|
||||||
getAvatarConditionForToday,
|
getAvatarConditionForToday,
|
||||||
getDailyEntryForToday,
|
|
||||||
saveDailyEntry,
|
saveDailyEntry,
|
||||||
} from "@/lib/daily-quiz-storage";
|
} from "@/lib/daily-quiz-storage";
|
||||||
import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety";
|
import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety";
|
||||||
|
|
||||||
export function DailyQuizForm() {
|
export function DailyQuizForm() {
|
||||||
|
const cachedQuiz = getCachedQuizTabData();
|
||||||
const [answers, setAnswers] = useState<DailyQuizAnswers>(
|
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>(
|
const [submission, setSubmission] = useState<DailyQuizSubmission | null>(
|
||||||
null,
|
cachedQuiz?.submission ?? null,
|
||||||
);
|
);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(cachedQuiz !== null);
|
||||||
const [liveCondition, setLiveCondition] = useState(
|
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 () => {
|
const refreshLiveCondition = useCallback(async () => {
|
||||||
@@ -53,31 +72,40 @@ export function DailyQuizForm() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
async function loadDailyCheckIn() {
|
async function loadDailyCheckIn() {
|
||||||
try {
|
try {
|
||||||
const existingEntry = await getDailyEntryForToday();
|
const data = await prefetchQuizTabData();
|
||||||
|
|
||||||
if (existingEntry) {
|
if (cancelled) {
|
||||||
setSubmission(existingEntry);
|
return;
|
||||||
setAnswers(existingEntry.answers);
|
|
||||||
setJournal(existingEntry.journal);
|
|
||||||
setLiveCondition(existingEntry.condition);
|
|
||||||
} else {
|
|
||||||
await refreshLiveCondition();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyQuizData(data);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(
|
setError(
|
||||||
loadError instanceof Error
|
loadError instanceof Error
|
||||||
? loadError.message
|
? loadError.message
|
||||||
: "Could not load today's check-in.",
|
: "Could not load today's check-in.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsReady(true);
|
if (!cancelled) {
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadDailyCheckIn();
|
void loadDailyCheckIn();
|
||||||
}, [refreshLiveCondition]);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applyQuizData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDataUpdated = () => {
|
const handleDataUpdated = () => {
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
import {
|
import {
|
||||||
getDailyTasks,
|
getCachedHabitsTabData,
|
||||||
type DailyTask,
|
prefetchHabitsTabData,
|
||||||
} from "@/lib/daily-tasks";
|
} from "@/lib/app-tab-data-cache";
|
||||||
|
import type { DailyTask } from "@/lib/daily-tasks";
|
||||||
import {
|
import {
|
||||||
addHabit,
|
addHabit,
|
||||||
getCustomHabits,
|
|
||||||
getTodayDateKey,
|
getTodayDateKey,
|
||||||
isHabitCompletedToday,
|
isHabitCompletedToday,
|
||||||
removeHabit,
|
removeHabit,
|
||||||
@@ -31,8 +31,6 @@ import {
|
|||||||
} from "@/lib/habits-storage";
|
} from "@/lib/habits-storage";
|
||||||
import { formatCoinsDelta } from "@/lib/coins";
|
import { formatCoinsDelta } from "@/lib/coins";
|
||||||
import { describeHabitWellnessBoost } from "@/lib/habit-wellness-effects";
|
import { describeHabitWellnessBoost } from "@/lib/habit-wellness-effects";
|
||||||
import { getProfilePreferences } from "@/lib/profile-preferences-storage";
|
|
||||||
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
|
|
||||||
|
|
||||||
type HabitTrackerProps = {
|
type HabitTrackerProps = {
|
||||||
mode?: "daily" | "manage" | "all";
|
mode?: "daily" | "manage" | "all";
|
||||||
@@ -184,13 +182,22 @@ function CustomHabitList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
||||||
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>([]);
|
const cachedHabits = getCachedHabitsTabData();
|
||||||
const [customHabits, setCustomHabits] = useState<Habit[]>([]);
|
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>(
|
||||||
|
cachedHabits?.dailyTasks ?? [],
|
||||||
|
);
|
||||||
|
const [customHabits, setCustomHabits] = useState<Habit[]>(
|
||||||
|
cachedHabits?.customHabits ?? [],
|
||||||
|
);
|
||||||
const [newHabitLabel, setNewHabitLabel] = useState("");
|
const [newHabitLabel, setNewHabitLabel] = useState("");
|
||||||
const [focusTopic, setFocusTopic] = useState<string | null>(null);
|
const [focusTopic, setFocusTopic] = useState<string | null>(
|
||||||
const [quizCompletedToday, setQuizCompletedToday] = useState(false);
|
cachedHabits?.focusTopic ?? null,
|
||||||
|
);
|
||||||
|
const [quizCompletedToday, setQuizCompletedToday] = useState(
|
||||||
|
cachedHabits?.quizCompletedToday ?? false,
|
||||||
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(cachedHabits !== null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [optimisticCompletion, setOptimisticCompletion] = useState<
|
const [optimisticCompletion, setOptimisticCompletion] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
@@ -223,17 +230,21 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
[toggleErrors],
|
[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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const [tasks, customs] = await Promise.all([
|
const data = await prefetchHabitsTabData({ force: true });
|
||||||
getDailyTasks(),
|
applyHabitsData(data);
|
||||||
getCustomHabits(),
|
|
||||||
]);
|
|
||||||
setDailyTasks(tasks);
|
|
||||||
setCustomHabits(customs);
|
|
||||||
setFocusTopic((await getProfilePreferences()).focusTopic);
|
|
||||||
setQuizCompletedToday(await hasCompletedDailyQuizToday());
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
setError(
|
setError(
|
||||||
@@ -243,7 +254,27 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
);
|
);
|
||||||
setIsReady(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!coinMessage) {
|
if (!coinMessage) {
|
||||||
@@ -290,15 +321,6 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
};
|
};
|
||||||
}, [refresh, todayKey]);
|
}, [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 handleToggle = (habitId: string) => {
|
||||||
const habit = dailyTasks.find((task) => task.id === habitId);
|
const habit = dailyTasks.find((task) => task.id === habitId);
|
||||||
|
|
||||||
|
|||||||
36
components/layout/app-data-prefetcher.tsx
Normal file
36
components/layout/app-data-prefetcher.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appNavItems, routes, type NavItem } from "@/lib/routes";
|
import { appNavItems, routes, type NavItem } from "@/lib/routes";
|
||||||
|
import {
|
||||||
|
prefetchHabitsTabData,
|
||||||
|
prefetchQuizTabData,
|
||||||
|
} from "@/lib/app-tab-data-cache";
|
||||||
|
|
||||||
const gridColsClass = {
|
const gridColsClass = {
|
||||||
1: "grid-cols-1",
|
1: "grid-cols-1",
|
||||||
@@ -31,6 +35,15 @@ const navIcons = {
|
|||||||
[routes.profile]: UserRound,
|
[routes.profile]: UserRound,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const prefetchByRoute: Partial<Record<string, () => void>> = {
|
||||||
|
[routes.habits]: () => {
|
||||||
|
void prefetchHabitsTabData();
|
||||||
|
},
|
||||||
|
[routes.dailyQuiz]: () => {
|
||||||
|
void prefetchQuizTabData();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type AppNavProps = {
|
type AppNavProps = {
|
||||||
items?: readonly NavItem[];
|
items?: readonly NavItem[];
|
||||||
};
|
};
|
||||||
@@ -76,6 +89,9 @@ export const AppNav = forwardRef<HTMLElement, AppNavProps>(function AppNav(
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
|
prefetch
|
||||||
|
onPointerEnter={() => prefetchByRoute[item.href]?.()}
|
||||||
|
onFocus={() => prefetchByRoute[item.href]?.()}
|
||||||
className={cn(
|
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",
|
"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
|
isActive
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ type AppShellProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
wide?: boolean;
|
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 = {
|
const contentWidthClass = {
|
||||||
@@ -19,20 +23,33 @@ export function AppShell({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
wide = false,
|
wide = false,
|
||||||
|
fillViewport = false,
|
||||||
|
centered = false,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
return (
|
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 />
|
<AppTopBar />
|
||||||
<main
|
<main
|
||||||
className={cn(
|
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,
|
wide ? contentWidthClass.wide : contentWidthClass.default,
|
||||||
|
fillViewport && "max-w-none lg:max-w-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title ? (
|
{title ? (
|
||||||
<AppPageHeader title={title} description={description} />
|
<AppPageHeader title={title} description={description} />
|
||||||
) : null}
|
) : null}
|
||||||
{children}
|
<div
|
||||||
|
className={cn(
|
||||||
|
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ type AppTopBarProps = {
|
|||||||
|
|
||||||
export function AppTopBar({ className }: AppTopBarProps) {
|
export function AppTopBar({ className }: AppTopBarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isHome = pathname === routes.avatar;
|
const isHome =
|
||||||
|
pathname === routes.home || pathname === routes.avatar;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { AppNav } from "@/components/layout/app-nav";
|
import { AppNav } from "@/components/layout/app-nav";
|
||||||
|
import { AppDataPrefetcher } from "@/components/layout/app-data-prefetcher";
|
||||||
import {
|
import {
|
||||||
NavOffsetProvider,
|
NavOffsetProvider,
|
||||||
useNavOffsetRef,
|
useNavOffsetRef,
|
||||||
@@ -55,6 +56,7 @@ export function GlobalBottomNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NavOffsetProvider enabled={showNav}>
|
<NavOffsetProvider enabled={showNav}>
|
||||||
|
<AppDataPrefetcher enabled={showNav} />
|
||||||
{showNav ? <MeasuredAppNav /> : null}
|
{showNav ? <MeasuredAppNav /> : null}
|
||||||
</NavOffsetProvider>
|
</NavOffsetProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type HabitBrandProps = {
|
|||||||
|
|
||||||
export function HabitBrand({
|
export function HabitBrand({
|
||||||
className,
|
className,
|
||||||
href = routes.avatar,
|
href = routes.home,
|
||||||
"aria-current": ariaCurrent,
|
"aria-current": ariaCurrent,
|
||||||
}: HabitBrandProps) {
|
}: HabitBrandProps) {
|
||||||
const [useText, setUseText] = useState(false);
|
const [useText, setUseText] = useState(false);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
|
|||||||
type PetHabitatProps = {
|
type PetHabitatProps = {
|
||||||
customization: AvatarCustomization;
|
customization: AvatarCustomization;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
fillViewport?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOOD_LABELS = {
|
const MOOD_LABELS = {
|
||||||
@@ -29,7 +30,11 @@ const MOOD_LABELS = {
|
|||||||
tired: "SLEEPY",
|
tired: "SLEEPY",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function PetHabitat({ customization, className }: PetHabitatProps) {
|
export function PetHabitat({
|
||||||
|
customization,
|
||||||
|
className,
|
||||||
|
fillViewport = false,
|
||||||
|
}: PetHabitatProps) {
|
||||||
const [condition, setCondition] = useState<AvatarCondition>(
|
const [condition, setCondition] = useState<AvatarCondition>(
|
||||||
defaultAvatarCondition,
|
defaultAvatarCondition,
|
||||||
);
|
);
|
||||||
@@ -45,9 +50,20 @@ export function PetHabitat({ customization, className }: PetHabitatProps) {
|
|||||||
void loadStatus();
|
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) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("tamagotchi-shell p-4", className)}>
|
<div className={shellClassName}>
|
||||||
<p className="text-center text-[10px] text-muted-foreground">
|
<p className="text-center text-[10px] text-muted-foreground">
|
||||||
Waking up pet...
|
Waking up pet...
|
||||||
</p>
|
</p>
|
||||||
@@ -56,15 +72,15 @@ export function PetHabitat({ customization, className }: PetHabitatProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("tamagotchi-shell overflow-hidden p-3 sm:p-4", className)}>
|
<div className={shellClassName}>
|
||||||
<div className="mb-3 border-b-2 border-border/60 pb-3">
|
<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="text-sm text-foreground">{customization.name}</p>
|
||||||
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
|
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
|
||||||
{MOOD_LABELS[condition.mood]}
|
{MOOD_LABELS[condition.mood]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
<div className={lcdClassName}>
|
||||||
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
|
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
|
||||||
<AnimatedPetSprite customization={customization} />
|
<AnimatedPetSprite customization={customization} />
|
||||||
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">
|
<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
122
lib/app-tab-data-cache.ts
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user