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

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