diff --git a/docs/MOBILE_DOCTRINE.md b/docs/MOBILE_DOCTRINE.md new file mode 100644 index 000000000..74d259a4b --- /dev/null +++ b/docs/MOBILE_DOCTRINE.md @@ -0,0 +1,68 @@ +# Mobile Doctrine + +Repo-wide rules for phone-class surfaces (golf, baseball, Bridge). Distilled +from the 2026-07-09 nine-reviewer mobile audit and the owner's brief: "Need +more native and centered — not like a copy-and-paste slim-down"; "too full and +crammed, lotta scrolling, not segmented"; "PREMIUM ARCHITECTURE, EVERY PAGE +HAND-CRAFTED AND THOUGHT OUT — that goes for desktop too." + +Execution waves and per-surface grades live in the mobile overhaul plan +(see PR #797 and successors). This file is the standing law; cite the rule +number in reviews. + +## The rules + +1. **Home = triage, never an aggregator.** A home surface shows the next + action, live counts, and links into detail tabs. It must never re-render + the full body of a destination that owns its own tab. +2. **Above-fold budget.** The primary action is reachable in the first + viewport at 390px. Editorial mastheads (eyebrow + long title + paragraph) + are desktop cover treatments — on phone they condense to one line. +3. **Cap the scroll at ~3 screen-heights.** Beyond that: segmented control, + tabs, collapsed-by-default sections, or push to a route. Empty sections + never render — they roll into one "all caught up" card. +4. **Bottom sheets, not centered modals**, for every input/create flow under + `md`. The vaul Sheet primitive already ships in-repo. +5. **Thumb-zone commits.** Sticky bottom action bar for the primary CTA on + any decision/entry screen; never scroll-to-save. +6. **More-sheet, not left drawer.** The 5th bottom-nav slot opens a + thumb-reachable bottom sheet listing overflow destinations. + Hamburger-top-left dies on phone; the desktop rail is untouched. +7. **No desktop chrome on phones.** ⌘K pills, breadcrumb trails, hover-only + affordances: desktop-only. One condensing chrome band on hub routes, not + three stacked bands. +8. **Tables become cards below `md`.** Any `min-w-[###px]` table on a + phone-primary surface renders as full-width rows (identity + 2–3 key + stats + tap-through). +9. **Tab switches are instant.** No cross-fade between bottom-tab roots; + motion is reserved for forward/detail pushes. Reduced-motion disables all. +10. **Bottom nav = the role's actual daily loop** (4 destinations + More), + declared in the nav registry — a daily destination must never be + overflow-only. +11. **No full-screen monolith cards — every page is composed.** A single + card/panel stretched to the viewport as the screen's whole composition is + banned (same vibe-coded tell-class as the retired accent-stripe cards). + Compose: inset grouped sections, mixed row/card rhythm, deliberate + hierarchy per surface. + +## The craft bar (mobile AND desktop) + +- Every UI change ships from a **per-surface design brief** — what the screen + is for, its above-fold statement, its rhythm — never "make it responsive." +- Waves get **taste verification** (does it look hand-crafted and native?) in + addition to diff-correctness verification. +- Desktop is judged to the same bar: density, alignment, composition. + +## Performance floor (phone-class GPUs) + +- No `backdrop-blur` on scrolling chrome below `md` (top bars, bottom navs). +- One route fade, one owner (`template.tsx`); never a second pathname-keyed + fade in the shell. +- framer-motion only via `LazyMotion` + the async loader at + `src/lib/motion/load-features.ts` (domAnimation only — no layout + animations). +- `React.memo` on shell chrome must not be defeated: element props passed + into `AppShell` (`brand`, `sidebarFooter`, `topBarActions`, …) are + memoized at the call site. +- Realtime subscriptions on chrome (badges, unread counts) are filtered to + the current user/team — never org-wide. diff --git a/memory/projects/golfhelm.md b/memory/projects/golfhelm.md index 0e01f71b0..615b29271 100644 --- a/memory/projects/golfhelm.md +++ b/memory/projects/golfhelm.md @@ -264,7 +264,7 @@ src/lib/coachhelm/ -**224 routes** (source: `src/app/**/page.tsx`). +**221 routes** (source: `src/app/**/page.tsx`).
Full alphabetical route list @@ -369,15 +369,12 @@ src/lib/coachhelm/ - `/baseball/dashboard/stats/games` - `/baseball/dashboard/stats/games/[gameId]` - `/baseball/dashboard/stats/games/create` -- `/baseball/dashboard/stats/season` - `/baseball/dashboard/stats/upload` - `/baseball/dashboard/tasks` - `/baseball/dashboard/team` - `/baseball/dashboard/teams` - `/baseball/dashboard/travel` - `/baseball/dashboard/videos` -- `/baseball/dashboard/videos/[id]` -- `/baseball/dashboard/videos/[id]/edit` - `/baseball/dashboard/watchlist` - `/baseball/demo` - `/baseball/forgot-password` @@ -505,7 +502,7 @@ src/lib/coachhelm/ -**182 server-action files** (source: `src/app/**/actions/**/*.ts`). +**183 server-action files** (source: `src/app/**/actions/**/*.ts`).
Full alphabetical action file list @@ -636,6 +633,7 @@ src/lib/coachhelm/ - `src/app/golf/actions/player-feedback.ts` - `src/app/golf/actions/player-fingerprint-types.ts` - `src/app/golf/actions/player-fingerprint.ts` +- `src/app/golf/actions/player-hub-data.ts` - `src/app/golf/actions/player-notifications.ts` - `src/app/golf/actions/player-profile-stats.ts` - `src/app/golf/actions/push-notifications.ts` diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 73040dfb9..204cc347e 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -2,7 +2,8 @@ import Link from 'next/link'; import Image from 'next/image'; -import { m, LazyMotion, domAnimation, useReducedMotion } from 'framer-motion'; +import { m, LazyMotion, useReducedMotion } from 'framer-motion'; +import { loadFeatures } from '@/lib/motion/load-features'; import { Navigation } from '@/components/landing/Navigation'; import { Footer } from '@/components/landing/Footer'; import { Button } from '@/components/ui/button'; @@ -61,7 +62,7 @@ const staggerContainer = { export default function AboutPage() { const prefersReducedMotion = useReducedMotion(); return ( - +
Skip to main content diff --git a/src/app/admin/_components/AdminShell.tsx b/src/app/admin/_components/AdminShell.tsx index c74c058ac..7f984f15f 100644 --- a/src/app/admin/_components/AdminShell.tsx +++ b/src/app/admin/_components/AdminShell.tsx @@ -1,23 +1,30 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { LayoutDashboard, Activity, AlertTriangle, KeyRound, Flag, CircleDot, Users, Timer, Rocket, HeartPulse, ExternalLink, MessageSquarePlus, Gauge, SearchCheck, ScrollText, + RefreshCw, } from 'lucide-react'; import { AppShell, Button, + IconButton, CommandMenu, type Breadcrumb, type NavSection, type CommandGroup, type CommandItem, } from '@/components/fairway'; +import { FairwayBottomNav } from '@/components/fairway/app-shell/FairwayBottomNav'; +import { selectOverflow, summarizeMoreTab } from '@/components/fairway/app-shell/more-nav'; +import type { NavItem } from '@/components/fairway/app-shell/types'; import { SessionActivityProvider } from '@/components/providers/SessionActivityProvider'; -import { ADMIN_NAV, hrefForShortcut } from './admin-nav'; +import { cn } from '@/lib/utils'; +import { ADMIN_NAV, hrefForShortcut, BRIDGE_BOTTOM_NAV_HREFS, BRIDGE_BOTTOM_NAV_LABELS } from './admin-nav'; +import { RelativeTime } from './RelativeTime'; /** Sub-route leaf labels the Breadcrumb trail can't derive from ADMIN_NAV * (dynamic `[id]`/`[fingerprint]` segments render as "Detail" instead of @@ -66,22 +73,99 @@ const NAV_ICON_BY_HREF = { '/admin/health': HeartPulse, } as const; +/** + * The `RefreshCw` glyph's ONE-SHOT rotation (docs/MOBILE_DOCTRINE.md's + * motion spec — "rotates 360° once, ~0.6s linear, while refresh is pending"). + * Tailwind's built-in `spin` keyframe run for a single iteration, not the + * continuous `animate-spin` loop. `motion-safe:` so `prefers-reduced-motion` + * gets no spin at all (just the button's own `disabled` dimming). + */ +const REFRESH_SPIN_CLASS = 'motion-safe:animate-[spin_0.6s_linear_1]'; + +/** + * M1 (bridge-chrome): the More sheet's `header` slot — the prod pill, the + * live "updated Xs ago" freshness clock, and a full-width Refresh button. + * Presentational only (side effects live in `AdminShell`); a plain function + * component so the caller can memoize the element on its own dependencies + * (Decision 7 — every element prop entering AppShell is stable at the call + * site). + */ +function BridgeMoreSheetHeader({ + lastSyncedAt, + refreshing, + onRefresh, +}: { + lastSyncedAt: number; + refreshing: boolean; + onRefresh: () => void; +}) { + return ( +
+
+ + prod + + · + +
+ +
+ ); +} + /** * Helm Bridge chrome: Fairway AppShell (warm-black rail + cream canvas) as * the neutral ops shell. Sport inks appear ONLY inside sport-scoped panes. * Keyboard: 1-9 then 0 jump the 10 tabs (see admin-nav.ts), R refreshes, * ⌘K opens the command menu — preserving the old admin's muscle memory. + * + * M1 (bridge-chrome, docs/MOBILE_DOCTRINE.md rule 6/10): on phone, the rail + * is replaced by a persistent bottom-tab bar (Overview/Errors/Health/Users — + * Synthesis Decision 6) + the shared `MoreNavSheet` for the long tail — the + * SAME overflow surface golf/baseball use (`selectOverflow`/`summarizeMoreTab` + * from `more-nav.ts`), not a bespoke Bridge sheet. */ export function AdminShell({ email, + errorCount, children, }: { email: string; + /** Bridge bottom-nav Errors badge — 0 renders no badge (honest-only). */ + errorCount: number; children: React.ReactNode; }) { const pathname = usePathname(); const router = useRouter(); const [commandOpen, setCommandOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + + // M1 (bridge-chrome): the More sheet header's "updated Xs ago" freshness + // clock. Reset on every navigation (the force-dynamic layout re-fetches + // per route already) and on every manual refresh — never on a background + // timer (Overview's own AutoRefresh is page-scoped; a shell-level poll is + // out of scope for M1, see the brief's edge-case note on badge staleness). + const [lastSyncedAt, setLastSyncedAt] = useState(() => Date.now()); + useEffect(() => { + setLastSyncedAt(Date.now()); + }, [pathname]); + + const [refreshing, startRefresh] = useTransition(); + const doRefresh = useCallback(() => { + startRefresh(() => { + router.refresh(); + setLastSyncedAt(Date.now()); + }); + }, [router]); const sections: readonly NavSection[] = useMemo( () => { @@ -129,7 +213,10 @@ export function AdminShell({ if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === 'r' || e.key === 'R') { e.preventDefault(); - router.refresh(); + // Routed through the SAME `doRefresh` the Refresh button/icon use — + // one refresh path, so the "R" shortcut also updates the freshness + // clock and the icon spin instead of silently going stale. + doRefresh(); return; } const href = hrefForShortcut(e.key); @@ -140,7 +227,7 @@ export function AdminShell({ } window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [router]); + }, [router, doRefresh]); const commandGroups: CommandGroup[] = useMemo( () => @@ -205,43 +292,125 @@ export function AdminShell({ [], ); + // Stable element/object identities (perf packet [shell-render-hygiene]): + // inline literals here are fresh objects every render (e.g. each ⌘K + // open/close), defeating the React.memo on FairwaySidebar/FairwayTopBar. + const brand = useMemo( + () => ( + + + HB + + + Helm Bridge + + + ), + [], + ); + const shellUser = useMemo(() => ({ name: 'Super admin', teamName: email }), [email]); + + // M1 (bridge-chrome): the daily-loop four (Synthesis Decision 6). Memoized + // on `errorCount` alone — `FairwayBottomNav` is `React.memo`'d on this + // ARRAY's reference identity, so a fresh literal every render (e.g. every + // unrelated pathname-driven re-render) would defeat it. Only the Errors + // tab carries a badge, and only when > 0 (honest-only). + const bottomNavItems: NavItem[] = useMemo( + () => + BRIDGE_BOTTOM_NAV_HREFS.map((href) => ({ + label: BRIDGE_BOTTOM_NAV_LABELS[href], + href, + icon: NAV_ICON_BY_HREF[href], + activeMatch: (p: string) => (href === '/admin' ? p === '/admin' : p.startsWith(href)), + badge: href === '/admin/errors' && errorCount > 0 ? errorCount : undefined, + })), + [errorCount], + ); + + // M1 (more-sheet-nav, docs/MOBILE_DOCTRINE.md Rule 6/10): the More sheet's + // content is the FULL rail `sections` minus the 4 bottom-nav hrefs + // (`selectOverflow`), and the bottom bar's 5th "More" column derives its + // active/badge state from that same overflow (`summarizeMoreTab`) — never + // a second hand-maintained destination list. + const overflow = useMemo(() => selectOverflow(sections, BRIDGE_BOTTOM_NAV_HREFS), [sections]); + const more = useMemo(() => summarizeMoreTab(overflow, pathname), [overflow, pathname]); + const openMoreSheet = useCallback(() => setMoreOpen(true), []); + + const bottomNav = useMemo( + () => ( + + ), + [bottomNavItems, pathname, openMoreSheet, more.active, more.badge, moreOpen], + ); + + // M1 (bridge-chrome): the prod pill stays desktop-adjacent (`hidden + // sm:inline-flex`); the Refresh affordance is now icon-only and shown at + // EVERY breakpoint (was `lg:`-only text) — phone had no manual refresh at + // all before this. Spins once on tap while `doRefresh`'s transition is + // pending (see REFRESH_SPIN_CLASS's doc comment). + const topBarActions = useMemo( + () => ( +
+ + prod + + + + +
+ ), + [doRefresh, refreshing], + ); + + const moreSheetHeader = useMemo( + () => , + [lastSyncedAt, refreshing, doRefresh], + ); + const moreSheetFooter = useMemo( + () => ( +

+ Signed in as {email} +

+ ), + [email], + ); + return (
- - HB - - - Helm Bridge - - - } - user={{ name: 'Super admin', teamName: email }} + brand={brand} + user={shellUser} pathname={pathname} linkComponent={Link} breadcrumbs={breadcrumbs} onSearchOpen={() => setCommandOpen(true)} searchPlaceholder="Jump to command, incident, user…" - topBarActions={ -
- - prod - - -
- } + topBarActions={topBarActions} + // M1 (bridge-chrome): Bridge's own `template.tsx` already owns the + // route-reveal fade (useRouteRevealMotion, keyed on pathname) — + // disabling AppShell's OWN RouteTransition here prevents a redundant + // second pathname-keyed fade (mirrors golf/baseball's shells, which + // disable it for the same reason). + disableRouteTransition + mobileOpen={moreOpen} + onMobileOpenChange={setMoreOpen} + // The More sheet's identity row has no real Bridge "Settings" route + // to link to — Overview is the closest sane destination. + settingsHref="/admin" + bottomNavHrefs={BRIDGE_BOTTOM_NAV_HREFS} + bottomNav={bottomNav} + moreSheetHeader={moreSheetHeader} + moreSheetFooter={moreSheetFooter} > {children}
diff --git a/src/app/admin/_components/AdminStatusBanner.tsx b/src/app/admin/_components/AdminStatusBanner.tsx index a41b01432..300da9b09 100644 --- a/src/app/admin/_components/AdminStatusBanner.tsx +++ b/src/app/admin/_components/AdminStatusBanner.tsx @@ -7,7 +7,11 @@ const STATES: Record 'All systems nominal' }, attention: { icon: AlertTriangle, dot: 'bg-fw-warning', label: (n) => `${n} item${n === 1 ? '' : 's'} need attention` }, critical: { icon: AlertOctagon, dot: 'bg-fw-danger', label: (n) => `${n} critical item${n === 1 ? '' : 's'} — immediate attention needed` }, - stale: { icon: CloudOff, dot: 'bg-fw-warning', label: () => 'Status feed stale — showing last known state' }, + // Honesty pattern from PanelStale (src/app/admin/_components/PanelStates.tsx): + // this state renders when the fetch FAILED and nothing is cached below it — + // "showing last known state" was never true and was flagged in the same + // 2026-07-03 Mission Control sweep that fixed the identical false claim there. + stale: { icon: CloudOff, dot: 'bg-fw-warning', label: () => 'Status feed unavailable — unable to confirm current state' }, }; /** Severity is icon + label + dot — never color alone. */ diff --git a/src/app/admin/_components/FeatureHealthRollup.tsx b/src/app/admin/_components/FeatureHealthRollup.tsx index 51d7a21ed..3f238bca8 100644 --- a/src/app/admin/_components/FeatureHealthRollup.tsx +++ b/src/app/admin/_components/FeatureHealthRollup.tsx @@ -31,9 +31,11 @@ export function FeatureHealthRollup({ summary }: { summary: FeatureHealthSummary Features: {summary.green} green · {summary.amber} amber · {summary.red} red · {summary.neutral} neutral {chips.map((c) => ( - - {c.label} - + + + {c.label} + + ))} {overflow > 0 ? ( diff --git a/src/app/admin/_components/RelativeTime.tsx b/src/app/admin/_components/RelativeTime.tsx new file mode 100644 index 000000000..53239d593 --- /dev/null +++ b/src/app/admin/_components/RelativeTime.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useVisibilityAwareInterval } from '@/hooks/useVisibilityAwareInterval'; + +/** + * ============================================================================ + * Helm Bridge · RelativeTime (M1, bridge-chrome) + * ---------------------------------------------------------------------------- + * "updated Ns ago" / "updated Nm ago" from an epoch-ms timestamp, ticking on + * a visibility-aware 10s interval (the same `useVisibilityAwareInterval` hook + * `AutoRefresh` uses — paused while the tab isn't visible, no point re- + * rendering chrome nobody's looking at). + * + * Same fix as `LocalTime.tsx` (React #418 — confirmed root cause of the + * 6:47:57 PM Overview incident, 2026-07-02): `Date.now()` resolves + * differently on the server vs. the client's first render if read directly + * during render, which is a guaranteed text mismatch for a 'use client' + * component. Render a deterministic placeholder on both the server AND the + * client's first pass, then swap to the real relative label from a + * post-hydration effect (a normal update, not a hydration disagreement). + * ========================================================================== */ + +function formatSince(sinceMs: number, nowMs: number): string { + const seconds = Math.max(0, Math.round((nowMs - sinceMs) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + return `${minutes}m ago`; +} + +export function RelativeTime({ + sinceMs, + intervalMs = 10_000, + className, +}: { + /** Epoch ms the freshness clock counts up from. */ + sinceMs: number; + intervalMs?: number; + className?: string; +}) { + const [label, setLabel] = useState(null); + + useEffect(() => { + setLabel(formatSince(sinceMs, Date.now())); + }, [sinceMs]); + + useVisibilityAwareInterval(() => { + setLabel(formatSince(sinceMs, Date.now())); + }, intervalMs); + + return ( + + updated {label ?? '—'} + + ); +} diff --git a/src/app/admin/_components/SessionsPanel.tsx b/src/app/admin/_components/SessionsPanel.tsx index f8c0ae948..401af4627 100644 --- a/src/app/admin/_components/SessionsPanel.tsx +++ b/src/app/admin/_components/SessionsPanel.tsx @@ -21,8 +21,8 @@ export function SessionsPanel({ sessions }: { sessions: SessionRow[] }) {