From 79b0b9f4699b9a3254e8620b4a4d8018b6f37c08 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 11:51:16 -0400 Subject: [PATCH] Rework command center for race weekends --- README.md | 5 +- .../refactor/21-mvp-completion-checklist.md | 9 +- .../25-phase-18-fan-command-center.md | 97 +++ documentations/refactor/README.md | 3 + frontend/src/components/Nav.tsx | 12 +- frontend/src/lib/gpIdentity.ts | 66 ++ frontend/src/pages/CommandCenterPage.tsx | 580 ++++++++-------- frontend/src/pages/DataLibraryPage.tsx | 27 +- frontend/src/router.tsx | 15 +- frontend/src/styles/app.css | 643 +++++++++++++----- frontend/src/test/CommandCenterPage.test.tsx | 7 +- tests/command-center.spec.ts | 8 +- tests/data-library.spec.ts | 24 +- tests/production-smoke.spec.ts | 16 +- .../__snapshots__/desktop/command-center.png | Bin 52746 -> 36383 bytes .../__snapshots__/desktop/data-library.png | Bin 106936 -> 113310 bytes .../__snapshots__/mobile/command-center.png | Bin 50149 -> 29391 bytes .../__snapshots__/mobile/data-library.png | Bin 91520 -> 97552 bytes .../__snapshots__/tablet/command-center.png | Bin 51792 -> 35272 bytes .../__snapshots__/tablet/data-library.png | Bin 98721 -> 104827 bytes tests/visual/helpers.ts | 2 +- 21 files changed, 1030 insertions(+), 484 deletions(-) create mode 100644 documentations/refactor/25-phase-18-fan-command-center.md create mode 100644 frontend/src/lib/gpIdentity.ts diff --git a/README.md b/README.md index 0032be1..3f0ca31 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,10 @@ BOXBOX_API_PORT=18080 npm run dev --prefix frontend | Route | Purpose | | --- | --- | -| `/` | **Command Center** — local season/weekend coverage, focus weekend, quick links to Live / Race Hub / Data Library | +| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links | | `/race-hub?session_key=` | **Race Hub** — classification, grid, strategy, positions, laps, race control, weather (from local DB) | -| `/data-library` | **Data Library** — ingestion coverage and suggested CLI commands | +| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands | +| `/data-library` | Legacy alias for Admin / Data Health | | `/live` | **Live Timing** — timing tower and race control via SSE when a session is live | Example after seeding: `http://localhost:5173/race-hub?session_key=9472` diff --git a/documentations/refactor/21-mvp-completion-checklist.md b/documentations/refactor/21-mvp-completion-checklist.md index e203e5c..b774768 100644 --- a/documentations/refactor/21-mvp-completion-checklist.md +++ b/documentations/refactor/21-mvp-completion-checklist.md @@ -22,7 +22,8 @@ checkpoint for what has been completed and what remains. - React + TypeScript frontend added with TanStack Query and Router. - Race Hub route added for classification, grid, strategy, positions, laps, race control, weather, and dataset status. -- Data Library route added for local season/weekend coverage and CLI guidance. +- Admin / Data Health route added for local season/weekend coverage and CLI + guidance; `/data-library` remains a legacy alias. - Live Timing route added for current backend live snapshot/SSE state. - Go web mode serves the built React app from `frontend/dist` and falls back to embedded legacy assets when no build is present. @@ -54,7 +55,8 @@ BOXBOX_API_PORT=18080 npm run dev --prefix frontend -- --host 127.0.0.1 --port 1 Then open: - `http://127.0.0.1:15173/race-hub?session_key=9472` -- `http://127.0.0.1:15173/data-library` +- `http://127.0.0.1:15173/admin` +- `http://127.0.0.1:15173/data-library` (legacy alias) - `http://127.0.0.1:15173/live` ### Production web smoke (Go serves built React) @@ -70,7 +72,8 @@ BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port Then open: - `http://127.0.0.1:18080/race-hub?session_key=9472` -- `http://127.0.0.1:18080/data-library` +- `http://127.0.0.1:18080/admin` +- `http://127.0.0.1:18080/data-library` (legacy alias) - `http://127.0.0.1:18080/live` Automated production-serving coverage: diff --git a/documentations/refactor/25-phase-18-fan-command-center.md b/documentations/refactor/25-phase-18-fan-command-center.md new file mode 100644 index 0000000..c5e9383 --- /dev/null +++ b/documentations/refactor/25-phase-18-fan-command-center.md @@ -0,0 +1,97 @@ +# Phase 18: Fan Command Center and Admin Split + +## Goal + +Shift the Web UI from a local-data console toward an F1 fan race-weekend +command center, while keeping the dark, dense, ops-room aesthetic. Move +local-data and ingestion concerns into a thin Admin / Data Health area so they +no longer dominate the fan-facing first screen. + +## Completed Scope + +- Reworked `/` (`CommandCenterPage`) around the focus race weekend: + - GP identity band with country decal, GP name, location, circuit, date + range, status eyebrow (Live now / Current / Next / Recent), and a + country-derived left-edge accent strip. + - Countdown block — LIVE state, current-session label, next-session + `Xd HHh MMm SSs` countdown, or "Weekend finished" — anchored to the band. + - Primary actions row: Watch Live, Open Analysis (preselects race → qual → + first local session), Schedule jump. + - Session schedule as responsive cards instead of a table, with per-session + coverage dot, status pill, and direct Race Hub link (keeps the existing + `cc-session-{key}` testid). + - Recent local weekends rendered as a chip strip with country decals. + - Empty state reframed: short eyebrow, single-paragraph instruction, and + links to Live + Admin (no inline CLI on the fan-facing surface). +- Added `frontend/src/lib/gpIdentity.ts` for country accent, 3-letter decal, + and short date-range formatting. +- Reframed `DataLibraryPage` as **Admin · Data Health**: page header, + utility-style stats banner (seasons / full / partial / missing), and a + back-link to Command Center in the footer. +- Added `/admin` route rendering the same Data Health page. `/data-library` + remains as a legacy alias so existing links keep working. +- Updated `Nav` so primary fan destinations (Command / Live / Race Hub) sit + next to the logo, and a small monospace **Admin** chip is anchored to the + far right as a utility link. + +## What Moved Into Admin / Data Health + +- Five-stat coverage strip (Seasons / Full / Partial / Missing / Sessions + Local) — now lives in the admin banner. +- CLI ingest guidance — only shown under Admin and inside meeting detail. +- Per-meeting weekend table, per-session dataset status panel, and ingest + command blocks — unchanged content, but no longer reachable from the fan + nav directly. + +## What Did Not Change + +- Race Hub session-key table flow is unchanged (deeper rework is deferred). +- No new backend endpoints. Command Center still calls the existing local-first + APIs (`/api/v1/seasons`, `/api/v1/meetings`, `/api/v1/weekend`, + `/api/v1/live/state`). +- Live SignalR bridge, Live Timing page, and TUI live mode are untouched. + +## Tests and Visual Coverage + +Updated: + +- `frontend/src/test/CommandCenterPage.test.tsx` — covers new band, decal, + empty-state copy, and analysis action label. +- `tests/command-center.spec.ts` — exercises new actions container and the + reframed admin route alongside existing routes. +- `tests/data-library.spec.ts` — renamed describe block, exercises both + `/admin` and the `/data-library` alias, uses the new Admin nav link. +- `tests/production-smoke.spec.ts` — covers `/admin`, the legacy + `/data-library` route, and the updated nav labels. +- `tests/visual/__snapshots__/{desktop,tablet,mobile}/{command-center,data-library}.png` + regenerated against the new layout. + +## Verification + +```bash +npm --prefix frontend test -- --run +npm --prefix frontend run build +npm run test:e2e +npm run test:e2e:prod +npm run test:visual +npm run test:visual:prod +``` + +All commands above were run in this branch and pass: 94 unit tests, 15 E2E, +6 prod smoke, 12 visual baseline, 12 prod visual baseline. + +## Limitations and Follow-ups + +- The Schedule action only renders when a next session exists. During a + live session the row shows two actions instead of three; intentional. +- Country accent palette in `gpIdentity.ts` is a hand-tuned subset of country + codes; unknown codes fall back to a neutral gray. +- Race Hub remains the next target — its session-selection flow is still the + most dated part of the fan path, especially on tablet. + +## Related + +- [21 MVP Completion Checklist](21-mvp-completion-checklist.md) +- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md) +- [23 Phase 15 Command Center](23-phase-15-command-center.md) +- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md) diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index dd4acee..bc1626a 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -89,6 +89,9 @@ not implementation tickets yet. entry screen for local coverage, weekend focus, live status, and next actions. - [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser React live timing layout, status treatment, and race-control polish. +- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks + `/` around race-weekend identity and splits ingestion/admin concerns into + the new `/admin` (Data Health) route. ## External References diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 8d37bf4..6966786 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -10,14 +10,16 @@ export function Nav() { Command - - Race Hub - Live - - Data Library + + Race Hub + + +
+ + Admin
diff --git a/frontend/src/lib/gpIdentity.ts b/frontend/src/lib/gpIdentity.ts new file mode 100644 index 0000000..0683d01 --- /dev/null +++ b/frontend/src/lib/gpIdentity.ts @@ -0,0 +1,66 @@ +import type { Meeting } from '../types' + +const COUNTRY_ACCENTS: Record = { + ITA: '#1f7a4d', + GBR: '#3a6cf5', + USA: '#d62828', + MON: '#d61a3e', + ESP: '#e7a90b', + FRA: '#2353d6', + GER: '#cdc7c2', + DEU: '#cdc7c2', + BEL: '#e0a01b', + NLD: '#ef7c1a', + HUN: '#3f8a4b', + AUT: '#c61f1f', + AZE: '#1f7eaf', + CAN: '#c8202c', + AUS: '#1b6b86', + JPN: '#c8202c', + SGP: '#c8202c', + BRA: '#2fa84f', + BHR: '#a8132b', + SAU: '#0a7a3b', + ARE: '#4d6b2a', + QAT: '#7a1e3e', + CHN: '#c8202c', + MEX: '#1f7a4d', +} + +const DEFAULT_ACCENT = '#9aa0a6' + +export function countryAccent(meeting: Meeting | undefined | null): string { + if (!meeting) return DEFAULT_ACCENT + const code = meeting.country_code?.toUpperCase() + if (code && COUNTRY_ACCENTS[code]) return COUNTRY_ACCENTS[code] + return DEFAULT_ACCENT +} + +export function countryDecal(meeting: Meeting | undefined | null): string { + const code = meeting?.country_code?.toUpperCase() + if (code && code.length >= 2) return code.slice(0, 3) + const name = meeting?.country_name ?? '' + return name.slice(0, 3).toUpperCase() || '—' +} + +export function formatGpDateRange(meeting: Meeting | undefined | null): string { + if (!meeting) return '' + const start = meeting.date_start?.slice(0, 10) + const end = meeting.date_end?.slice(0, 10) + if (!start && !end) return '' + const fmt = (iso: string): string => { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + } + if (start && end && start !== end) { + const left = fmt(start) + const right = fmt(end) + const year = end.slice(0, 4) + return `${left} – ${right} ${year}` + } + if (start) { + return `${fmt(start)} ${start.slice(0, 4)}` + } + return end ? `${fmt(end)} ${end.slice(0, 4)}` : '' +} diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx index 1ff52d7..e789127 100644 --- a/frontend/src/pages/CommandCenterPage.tsx +++ b/frontend/src/pages/CommandCenterPage.tsx @@ -6,61 +6,56 @@ import { countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib import { currentAndNextSession, focusMeetingKind, - focusMeetingLabel, formatCountdown, formatSessionScheduleTime, meetingHasStarted, pickFocusMeeting, + sessionEndTime, sessionStartTime, sortSessionsByStart, } from '../lib/schedule' -import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge' -import { CliCommands } from '../components/CliCommands' -import type { Meeting, Weekend, WeekendSession } from '../types' +import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' +import type { Meeting, Session, Weekend, WeekendSession } from '../types' -function formatMeetingDates(meeting: Meeting): string { - const start = meeting.date_start?.slice(0, 10) - const end = meeting.date_end?.slice(0, 10) - if (start && end && start !== end) return `${start} – ${end}` - return start || end || '—' +type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback' + +function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' { + const start = sessionStartTime(session) + const end = sessionEndTime(session) + if (start && end && now >= start && now < end) return 'live' + if (start && now >= start) return 'done' + return 'upcoming' } -function countSessionStats(weekends: (Weekend | undefined)[]) { - let local = 0 - let partial = 0 - let total = 0 - +function collectRecentWeekends(weekends: (Weekend | undefined)[], focusKey: number | null, limit = 6) { + const rows: Weekend[] = [] for (const weekend of weekends) { if (!weekend) continue - for (const entry of weekend.sessions) { - total++ - if (entry.source === 'local') local++ - else if (entry.source === 'partial') partial++ - } + if (focusKey != null && weekend.meeting_key === focusKey) continue + if (weekend.source === 'none') continue + rows.push(weekend) } - - return { local, partial, total } -} - -function collectRecentSessions(weekends: (Weekend | undefined)[], limit = 8) { - const rows: Array = [] - - for (const weekend of weekends) { - if (!weekend) continue - for (const entry of weekend.sessions) { - rows.push({ ...entry, meeting: weekend.meeting }) - } - } - return rows .sort((a, b) => { - const left = Date.parse(a.session.date_start) - const right = Date.parse(b.session.date_start) + const left = Date.parse(a.meeting.date_start ?? '') + const right = Date.parse(b.meeting.date_start ?? '') return right - left }) .slice(0, limit) } +function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined { + if (!weekend) return undefined + const local = weekend.sessions.filter((s) => s.source === 'local') + const partial = weekend.sessions.filter((s) => s.source === 'partial') + const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions + const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race')) + if (race) return race + const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying')) + if (qual) return qual + return pool[0] +} + export function CommandCenterPage() { const [now, setNow] = useState(() => Date.now()) @@ -112,14 +107,21 @@ export function CommandCenterPage() { const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries]) const meetingStats = countWeekendStats(weekendList) - const sessionStats = countSessionStats(weekendList) const focusMeeting = pickFocusMeeting(meetings, nowDate) const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null - const focusSessions = focusWeekend ? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session)) : [] + const focusSessions: Session[] = focusWeekend + ? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session)) + : [] const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate) - const defaultSessionKey = focusWeekend?.default_session_key ?? focusWeekend?.sessions[0]?.session.session_key - const recentSessions = collectRecentSessions(weekendList) + + const recentWeekends = collectRecentWeekends(weekendList, focusMeeting?.meeting_key ?? null) + const analysisSession = pickAnalysisSession(focusWeekend) + const analysisSessionKey = + analysisSession?.session.session_key ?? + focusWeekend?.default_session_key ?? + focusWeekend?.sessions[0]?.session.session_key + const weekendsLoading = weekendQueries.some((q) => q.isLoading) if (seasonsQuery.isLoading) { @@ -138,276 +140,284 @@ export function CommandCenterPage() { if (seasons.length === 0) { return ( -
-
-

Command Center

- Local-first F1 operations +
+
+ box-box · command center +

No local data yet

+

+ Ingest a race weekend from the CLI to populate this screen with live status, next-session + countdowns, and analysis links. +

-
-
No ingested seasons yet
-
- Ingest a season or session from the CLI, then return here for coverage and navigation. -
-
-
-
- Get Started -
- ' }, - ]} - /> +
+ + Live Timing + Standby + + + Admin · Data Health + Ingestion guidance +
) } const liveActive = liveQuery.data?.is_live === true + const statusKind: WeekendStatusKind = liveActive + ? 'live' + : focusKind === 'current' + ? 'current' + : focusKind === 'next' + ? 'next' + : focusKind === 'recent' + ? 'recent' + : 'fallback' + + const accent = countryAccent(focusMeeting ?? null) + const decal = countryDecal(focusMeeting ?? null) + const accentStyle = { '--gp-accent': accent } as React.CSSProperties return ( -
-
-
-

Command Center

- - {latestSeason} season · {seasons.length} season{seasons.length === 1 ? '' : 's'} local - -
-
+
+
+ box-box · command center + + {latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full + + {liveActive ? 'Live session active' : 'No live session'} -
+
-
-
- Seasons - {seasons.length} + {!focusMeeting && ( +
+ No meetings ingested for {latestSeason}. Run{' '} + box-box --ingest-year {latestSeason}
-
- Weekends Full - {meetingStats.full} -
-
- Partial - {meetingStats.partial} -
-
- Missing - {meetingStats.missing} -
-
- Sessions Local - - {sessionStats.local}/{sessionStats.total || '—'} - -
-
+ )} -
-
-
- {focusKind ? focusMeetingLabel(focusKind) : 'Weekend'} - {focusWeekend && ( - - )} -
- - {meetingsQuery.isLoading &&
loading meetings…
} - - {meetingsQuery.isError && ( -
- {meetingsQuery.error instanceof Error - ? meetingsQuery.error.message - : 'Failed to load meetings'} -
- )} - - {!meetingsQuery.isLoading && !meetingsQuery.isError && focusMeeting && ( - <> -
-
-
{focusMeeting.meeting_name}
-
- {focusMeeting.location} - {focusMeeting.country_code ? ` · ${focusMeeting.country_code}` : ''} - {' · '} - {formatMeetingDates(focusMeeting)} -
+ {focusMeeting && ( +
+
+ )} -
- {liveActive && ( -
- LIVE - SignalR feed connected — open Live Timing -
- )} - {currentSession && ( -
- ON TRACK - {currentSession.session_name} -
- )} - {!currentSession && nextSession && sessionStartTime(nextSession) && ( -
- Next session - {nextSession.session_name} - - {formatCountdown(sessionStartTime(nextSession)!, nowDate)} + {focusMeeting && ( +
+ + Watch Live + {liveActive ? 'Feed active' : 'Standby'} + + + Open Analysis + + {analysisSession + ? `${analysisSession.session.session_name} · session ${analysisSession.session.session_key}` + : 'Pick a session'} + + + {nextSession && sessionStartTime(nextSession) && ( + { + e.preventDefault() + document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' }) + }} + > + Schedule + + next: {nextSession.session_name} + + + )} +
+ )} + + {focusWeekend && focusWeekend.sessions.length > 0 && ( +
+
+ Weekend Schedule + {focusWeekend.sessions.length} sessions +
+
+ {focusWeekend.sessions.map(({ session, source, datasets }) => { + const status = classifySessionStatus(session, nowDate) + const isNext = nextSession?.session_key === session.session_key + const isCurrent = currentSession?.session_key === session.session_key + return ( + +
+ + {sessionTypeAbbrev(session.session_type, session.session_name)} + + + {isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
- )} - {!currentSession && !nextSession && focusMeeting && meetingHasStarted(focusMeeting, nowDate) && ( -
Weekend finished
- )} - {!currentSession && !nextSession && focusKind === 'recent' && ( -
Historical weekend — local data available
- )} -
+
{session.session_name}
+
{formatSessionScheduleTime(session.date_start)}
+
+
+ + ) + })} +
+
+ )} - {weekendsLoading && !focusWeekend && ( -
loading weekend schedule…
- )} - - {focusWeekend && focusWeekend.sessions.length > 0 && ( -
- - - - - - - - - - - {focusWeekend.sessions.map(({ session, source, datasets }) => { - const isCurrent = currentSession?.session_key === session.session_key - const isNext = nextSession?.session_key === session.session_key - return ( - - - - - - - ) - })} - -
SessionStartCoverageOpen
- - {sessionTypeAbbrev(session.session_type, session.session_name)} - - {session.session_name} - {session.session_key === focusWeekend.default_session_key && ( - default - )} - - {formatSessionScheduleTime(session.date_start)} - - - {formatCoverageHint(datasets)} - - - - - Race Hub - -
-
- )} - - )} - - {!meetingsQuery.isLoading && meetings.length === 0 && ( -
- No meetings ingested for {latestSeason}. Run{' '} - box-box --ingest-year {latestSeason} + {(weekendsLoading || recentWeekends.length > 0) && ( +
+
+ Recent Local Weekends + {recentWeekends.length} +
+ {weekendsLoading && recentWeekends.length === 0 ? ( +
loading weekends…
+ ) : recentWeekends.length === 0 ? ( +
No additional local weekends.
+ ) : ( +
+ {recentWeekends.map((weekend) => { + const finished = meetingHasStarted(weekend.meeting, nowDate) + const target = pickAnalysisSession(weekend) + const target_key = target?.session.session_key ?? weekend.default_session_key + return ( + +
+ {countryDecal(weekend.meeting)} +
+
{weekend.meeting.meeting_name}
+
+ {formatGpDateRange(weekend.meeting)} + {' · '} + {finished ? 'Past' : 'Upcoming'} +
+ + ) + })}
)}
- - -
+ )}
) } + +function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) { + switch (kind) { + case 'live': + return ● Live now + case 'current': + return Current weekend + case 'next': + return Next weekend + case 'recent': + return Recent weekend + default: + return Weekend + } +} + +interface CountdownBlockProps { + liveActive: boolean + currentSession: Session | null + nextSession: Session | null + meeting: Meeting + now: Date +} + +function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) { + if (liveActive) { + return ( +
+
SignalR
+
LIVE
+
{currentSession?.session_name ?? 'Feed connected'}
+
+ ) + } + if (currentSession) { + return ( +
+
On Track
+
{currentSession.session_name}
+
In session
+
+ ) + } + if (nextSession && sessionStartTime(nextSession)) { + return ( +
+
Next · {nextSession.session_name}
+
{formatCountdown(sessionStartTime(nextSession)!, now)}
+
{formatSessionScheduleTime(nextSession.date_start)}
+
+ ) + } + if (meetingHasStarted(meeting, now)) { + return ( +
+
Status
+
Complete
+
Weekend finished
+
+ ) + } + return null +} diff --git a/frontend/src/pages/DataLibraryPage.tsx b/frontend/src/pages/DataLibraryPage.tsx index f93861a..7ee1f12 100644 --- a/frontend/src/pages/DataLibraryPage.tsx +++ b/frontend/src/pages/DataLibraryPage.tsx @@ -95,7 +95,9 @@ export function DataLibraryPage() { return (
-

Data Library

+ box-box · admin +

Data Health

+ Local SQLite domain store · ingestion guidance
No ingested seasons yet
@@ -120,6 +122,26 @@ export function DataLibraryPage() { return (
+
+
+ box-box · admin +

Data Health

+
+
+ + {seasons.length} season{seasons.length === 1 ? '' : 's'} + + + {stats.full} full + + + {stats.partial} partial + + + {stats.missing} missing + +
+