Compare commits
29 Commits
feat/issue
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a377d0bc4 | ||
|
|
3228e4adbd | ||
|
|
44a32bfd73 | ||
|
|
deda3bc4ad | ||
|
|
48030a6d7c | ||
|
|
74b33d1853 | ||
|
|
cefdac7006 | ||
|
|
7434621024 | ||
|
|
7be0b808fe | ||
|
|
26cd1857f6 | ||
|
|
8e0d0253c2 | ||
|
|
de614e6339 | ||
|
|
9ef8c7e9a3 | ||
|
|
dff187da92 | ||
|
|
9c0d37904c | ||
|
|
54ddc13f57 | ||
|
|
a1d8f85fd3 | ||
|
|
3b9b58ff7b | ||
|
|
480e6ca860 | ||
|
|
81deed4c75 | ||
|
|
d6d0558c72 | ||
|
|
b884ba8885 | ||
|
|
56ea860101 | ||
|
|
7c7886e6c6 | ||
|
|
71f81924ee | ||
|
|
7b86698b22 | ||
|
|
e88e0885ec | ||
|
|
255b296ecc | ||
|
|
888378e210 |
1
.gitignore
vendored
@@ -30,6 +30,7 @@ node_modules/
|
|||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
/release-fidelity/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/playwright/.auth/
|
||||||
|
|
||||||
|
|||||||
32
docs/product/v0.4.0/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# v0.4.0 — Current Weekend & Product Truth
|
||||||
|
|
||||||
|
Product research references for the v0.4.0 feature sprint.
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
The sprint replaces route-first navigation with a state-aware Weekend experience:
|
||||||
|
|
||||||
|
- Weekend is the adaptive home for what happened, what is live, and what is next.
|
||||||
|
- Preview content folds into Weekend before a session.
|
||||||
|
- Live remains a stable deep link and becomes Weekend's active-session state.
|
||||||
|
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
|
||||||
|
- Championship and Briefing remain dedicated destinations.
|
||||||
|
- Explore owns secondary discovery; Admin moves to operator utility.
|
||||||
|
|
||||||
|
## Mockups
|
||||||
|
|
||||||
|
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
|
||||||
|
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
|
||||||
|
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
|
||||||
|
|
||||||
|
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- No OpenF1 REST dependency during active sessions.
|
||||||
|
- Public live GPS is not assumed to be available.
|
||||||
|
- Championship round numbers exclude tests and cancelled meetings.
|
||||||
|
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
|
||||||
|
- Future sessions must not render empty post-session analysis.
|
||||||
|
|
||||||
|
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.
|
||||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -57,3 +57,58 @@ npm run test:visual:prod:update
|
|||||||
```
|
```
|
||||||
|
|
||||||
Snapshots live under `tests/visual/__snapshots__/`.
|
Snapshots live under `tests/visual/__snapshots__/`.
|
||||||
|
|
||||||
|
## Release Fidelity and Owner Review
|
||||||
|
|
||||||
|
Visual regression and mockup fidelity are deliberately separate gates. `npm run test:visual:prod` verifies the candidate against committed regression snapshots; it does not establish that the current UI matches the approved product design.
|
||||||
|
|
||||||
|
After the approved references are available at `docs/product/<version>/mockups/`, make an offline review packet. For v0.4.0 the packet pairs `weekend-between-races` and `weekend-live` at the 1280×800 desktop reference viewport, plus `weekend-between-sessions-mobile` at the 390×844 mobile reference viewport. The capture configuration uses the same seeded SQLite data and unreachable OpenF1 endpoint as the visual suite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RELEASE_FIDELITY_VERSION=v0.4.0
|
||||||
|
npm run release:fidelity:capture
|
||||||
|
npm run release:fidelity:packet
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `release-fidelity/v0.4.0/index.html` and review every approved-mockup/candidate pair. The owner, not an automated tool, records a decision in the committed file `docs/release/owner-reviews/v0.4.0.md`:
|
||||||
|
|
||||||
|
```md
|
||||||
|
# Owner Fidelity Sign-off: v0.4.0
|
||||||
|
|
||||||
|
- Version: v0.4.0
|
||||||
|
- Candidate commit: <full commit SHA>
|
||||||
|
- Reviewed by: <owner name>
|
||||||
|
- Reviewed on: YYYY-MM-DD
|
||||||
|
- Decision: approved
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not create the file or use `approved` until the owner has reviewed the packet. Once it is committed, the release gate can verify the evidence and decision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:fidelity:verify
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/<version>.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval.
|
||||||
|
|
||||||
|
For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off.
|
||||||
|
|
||||||
|
## Deployment and Rollback
|
||||||
|
|
||||||
|
Build and preserve a SHA-256 record with the deployable binary. Verify the staged binary before replacing the running one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p dist
|
||||||
|
go build -trimpath -o dist/box-box ./cmd/main.go
|
||||||
|
sha256sum dist/box-box | tee dist/box-box.sha256
|
||||||
|
sha256sum -c dist/box-box.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the current production binary and its SHA before deployment. If owner review rejects the release or deployment fails, restore that saved binary, then verify the restored SHA is byte-identical to the pre-deployment record:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum /srv/box-box/box-box
|
||||||
|
install -m 0755 /srv/box-box/backups/box-box.previous /srv/box-box/box-box
|
||||||
|
sha256sum -c /srv/box-box/backups/box-box.previous.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart and health-check the service using the deployment environment's normal procedure. Keep the candidate SHA, prior SHA, fidelity packet path, and owner sign-off path with the release record.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
Session,
|
Session,
|
||||||
TrackOutline,
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
|
WeekendContext,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
@@ -114,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
||||||
|
const res = await fetch('/api/v1/weekend-context')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||||
const params = new URLSearchParams({ source: 'auto' })
|
const params = new URLSearchParams({ source: 'auto' })
|
||||||
if (year) params.set('year', year.toString())
|
if (year) params.set('year', year.toString())
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-switcher" data-testid="rh-switcher">
|
<div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
|
||||||
<div className="rh-switcher-head">
|
<div className="rh-switcher-head">
|
||||||
<span className="sec-title">Switch Weekend</span>
|
<span className="sec-title">Switch Weekend</span>
|
||||||
<div className="rh-switcher-years">
|
<div className="rh-switcher-years">
|
||||||
|
|||||||
@@ -53,9 +53,8 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
|||||||
|
|
||||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
const isRace = isRaceSession(sessionType)
|
const isRace = isRaceSession(sessionType)
|
||||||
// In practice/qualifying deg trends are secondary — collapse by default so
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
// the Timing Tower stays above the fold. Races keep it open.
|
const [readerChose, setReaderChose] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useState(!isRace)
|
|
||||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||||
@@ -75,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
[rows, pinned],
|
[rows, pinned],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One linear fit per driver per snapshot, shared by the readiness check and
|
||||||
|
// the rows below. degradationModel is O(laps) and this runs at feed rate.
|
||||||
|
const models = useMemo(() => {
|
||||||
|
const out: Record<string, ReturnType<typeof degradationModel>> = {}
|
||||||
|
for (const row of visible) {
|
||||||
|
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [visible, stints])
|
||||||
|
|
||||||
|
// Before any stint has enough clean laps to fit, every row reads "warming
|
||||||
|
// up" — a full-height panel of placeholders that pushed the Timing Tower off
|
||||||
|
// the fold for the first third of a race. Stay collapsed until there is
|
||||||
|
// something to say, then open. A reader who has toggled it keeps their choice.
|
||||||
|
const hasSignal = useMemo(
|
||||||
|
() => visible.some((row) => models[row.RacingNumber] != null),
|
||||||
|
[visible, models],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerChose) return
|
||||||
|
setCollapsed(!(isRace && hasSignal))
|
||||||
|
}, [isRace, hasSignal, readerChose])
|
||||||
|
|
||||||
if (visible.length === 0) return null
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sec-header tyredeg-toggle"
|
className="sec-header tyredeg-toggle"
|
||||||
onClick={() => setCollapsed((prev) => !prev)}
|
onClick={() => {
|
||||||
|
setReaderChose(true)
|
||||||
|
setCollapsed((prev) => !prev)
|
||||||
|
}}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
>
|
>
|
||||||
<span className="sec-title">Tyre Deg & Pit Window</span>
|
<span className="sec-title">Tyre Deg & Pit Window</span>
|
||||||
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
{!hasSignal ? (
|
||||||
|
<span className="sec-meta">collecting clean laps</span>
|
||||||
|
) : (
|
||||||
|
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
|
||||||
|
)}
|
||||||
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="tyredeg-rows">
|
<div className="tyredeg-rows">
|
||||||
{visible.map((row) => {
|
{visible.map((row) => {
|
||||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
const model = models[row.RacingNumber]
|
||||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -145,6 +145,15 @@ export function formatSessionScheduleTime(value: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MAX_BROWSER_TIMEOUT = 2_147_483_647
|
||||||
|
|
||||||
|
export function refreshDeadlineDelay(refreshAt: string | undefined, now = Date.now()): number | null {
|
||||||
|
if (!refreshAt) return null
|
||||||
|
const deadline = Date.parse(refreshAt)
|
||||||
|
if (Number.isNaN(deadline)) return null
|
||||||
|
return Math.min(Math.max(0, deadline - now), MAX_BROWSER_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
|
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
|
||||||
|
|
||||||
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {
|
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
currentAndNextSession,
|
currentAndNextSession,
|
||||||
focusMeetingKind,
|
focusMeetingKind,
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
|
meetingEndTime,
|
||||||
meetingHasStarted,
|
meetingHasStarted,
|
||||||
mostRecentPastMeeting,
|
mostRecentPastMeeting,
|
||||||
nextUpcomingMeeting,
|
nextUpcomingMeeting,
|
||||||
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
|
|||||||
) as WeekendSession['datasets']
|
) as WeekendSession['datasets']
|
||||||
|
|
||||||
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
||||||
|
const end = meetingEndTime(meeting)
|
||||||
|
if (end && now >= end) return 'past'
|
||||||
if (meeting.meeting_key === focusKey) return 'focus'
|
if (meeting.meeting_key === focusKey) return 'focus'
|
||||||
if (meetingHasStarted(meeting, now)) return 'past'
|
if (meetingHasStarted(meeting, now)) return 'past'
|
||||||
return 'future'
|
return 'future'
|
||||||
@@ -163,6 +166,12 @@ export function CommandCenterPage() {
|
|||||||
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
||||||
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
||||||
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
||||||
|
const railSessions =
|
||||||
|
focusWeekendSessions.length > 0
|
||||||
|
? focusWeekendSessions
|
||||||
|
: heroStateKind === 'between'
|
||||||
|
? lastPastWeekend?.sessions ?? []
|
||||||
|
: []
|
||||||
|
|
||||||
const lastRaceHubQuery = useQuery({
|
const lastRaceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
||||||
@@ -178,6 +187,16 @@ export function CommandCenterPage() {
|
|||||||
|
|
||||||
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
||||||
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
||||||
|
const focusNarrative =
|
||||||
|
heroStateKind === 'between'
|
||||||
|
? lastRaceName
|
||||||
|
? `${lastRaceName} is in the archive. The next chapter begins at ${focusMeeting?.circuit_short_name || focusMeeting?.meeting_name || 'the next round'}.`
|
||||||
|
: 'The season is between race weekends. Use the timeline to revisit a completed round or look ahead.'
|
||||||
|
: currentSession
|
||||||
|
? `${currentSession.session_name} is the active chapter of this weekend. Session detail remains available as it lands locally.`
|
||||||
|
: nextSession
|
||||||
|
? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.`
|
||||||
|
: 'The weekend timetable is ready to explore.'
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading command center…</div>
|
return <div className="page loading-state">loading command center…</div>
|
||||||
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
|
|||||||
<div className="cc-topbar">
|
<div className="cc-topbar">
|
||||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||||
<span className="cc-topbar-meta mono">
|
<span className="cc-topbar-meta mono">
|
||||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
{latestSeason} season · weekend desk
|
||||||
</span>
|
</span>
|
||||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||||
@@ -262,6 +281,26 @@ export function CommandCenterPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{focusMeeting && (
|
||||||
|
<section className="cc-context" data-testid="cc-circuit-context">
|
||||||
|
<div className="cc-context-decal mono" aria-hidden="true">
|
||||||
|
{countryDecal(focusMeeting)}
|
||||||
|
</div>
|
||||||
|
<div className="cc-context-copy">
|
||||||
|
<span className="cc-context-kicker mono">Circuit context</span>
|
||||||
|
<h2>{focusMeeting.circuit_short_name || focusMeeting.meeting_name}</h2>
|
||||||
|
<p>{focusNarrative}</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-context-meta mono">
|
||||||
|
<span>{focusMeeting.location || focusMeeting.country_name}</span>
|
||||||
|
<span>{formatGpDateRange(focusMeeting)}</span>
|
||||||
|
<Link to="/preview" className="cc-context-preview">
|
||||||
|
Weekend preview →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="cc-dashboard-grid">
|
<div className="cc-dashboard-grid">
|
||||||
<div className="cc-dashboard-main">
|
<div className="cc-dashboard-main">
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
|
|||||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
<div className="cc-calendar-accent" aria-hidden="true" />
|
||||||
<div className="cc-calendar-top mono">
|
<div className="cc-calendar-top mono">
|
||||||
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
||||||
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
|
<span className={`cc-calendar-status cc-calendar-status-${status}`}>
|
||||||
|
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cc-calendar-id">
|
<div className="cc-calendar-id">
|
||||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
||||||
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{focusWeekendSessions.length > 0 && (
|
{railSessions.length > 0 && (
|
||||||
<section className="cc-schedule" data-testid="cc-schedule">
|
<section className="cc-schedule" data-testid="cc-schedule">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Weekend Schedule</span>
|
<span className="sec-title">Weekend Schedule</span>
|
||||||
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
|
<span className="sec-meta mono">{railSessions.length} sessions</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cc-session-strip" role="list">
|
<div className="cc-session-strip" role="list">
|
||||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
{railSessions.map(({ session, source, datasets }) => {
|
||||||
const status = classifySessionStatus(session, nowDate)
|
const status = classifySessionStatus(session, nowDate)
|
||||||
const isNext = nextSession?.session_key === session.session_key
|
const isNext = nextSession?.session_key === session.session_key
|
||||||
const isCurrent = currentSession?.session_key === session.session_key
|
const isCurrent = currentSession?.session_key === session.session_key
|
||||||
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="cc-session-name">{session.session_name}</div>
|
<div className="cc-session-name">{session.session_name}</div>
|
||||||
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
||||||
<div className="cc-session-cov mono">
|
<div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
|
||||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||||
{formatCoverageHint(datasets)}
|
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PaddockBriefing />
|
<PaddockBriefing />
|
||||||
|
<div className="cc-data-note mono" data-testid="cc-data-note">
|
||||||
|
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
|
||||||
|
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,14 +289,16 @@ export function LiveTimingPage() {
|
|||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Rail runs most-synthesized to most-raw: a reader arriving
|
||||||
|
mid-session wants "what did I miss" before the regulatory log. */}
|
||||||
<div className="live-rc-col">
|
<div className="live-rc-col">
|
||||||
|
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||||
<TeamRadioTicker
|
<TeamRadioTicker
|
||||||
captures={snapshot.TeamRadio ?? []}
|
captures={snapshot.TeamRadio ?? []}
|
||||||
driverInfo={snapshot.DriverInfo}
|
driverInfo={snapshot.DriverInfo}
|
||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
||||||
fetchLocalMeetings,
|
|
||||||
fetchRaceHub,
|
|
||||||
fetchSeasons,
|
|
||||||
fetchWeekend,
|
|
||||||
} from '../api'
|
|
||||||
import { DatasetStrip } from '../components/DatasetStrip'
|
import { DatasetStrip } from '../components/DatasetStrip'
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
import { TabBar, type Tab } from '../components/TabBar'
|
import { TabBar, type Tab } from '../components/TabBar'
|
||||||
@@ -22,76 +17,72 @@ import { SourceBadge } from '../components/SourceBadge'
|
|||||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||||
import {
|
import {
|
||||||
|
MAX_BROWSER_TIMEOUT,
|
||||||
|
formatCountdown,
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
pickFocusMeeting,
|
refreshDeadlineDelay,
|
||||||
sortSessionsByStart,
|
sortSessionsByStart,
|
||||||
} from '../lib/schedule'
|
} from '../lib/schedule'
|
||||||
import type { Weekend, WeekendSession } from '../types'
|
import type { ContextSession, Weekend } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
}
|
}
|
||||||
|
|
||||||
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 RaceHubPage({ sessionKey }: Props) {
|
export function RaceHubPage({ sessionKey }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const [refreshGeneration, setRefreshGeneration] = useState(0)
|
||||||
|
|
||||||
// ─── Auto-redirect when no session_key is supplied ───
|
// The server owns bare Race Hub selection so every open tab crosses the
|
||||||
const seasonsQuery = useQuery({
|
// one-hour handoff at the same instant.
|
||||||
queryKey: ['seasons'],
|
const contextQuery = useQuery({
|
||||||
queryFn: fetchSeasons,
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: fetchWeekendContext,
|
||||||
enabled: sessionKey === 0,
|
enabled: sessionKey === 0,
|
||||||
})
|
})
|
||||||
|
const { refetch: refetchContext } = contextQuery
|
||||||
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const context = contextQuery.data
|
||||||
|
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
|
||||||
const meetingsQuery = useQuery({
|
const preSessionRef = context?.race_hub_default_session
|
||||||
queryKey: ['meetings', latestSeason],
|
const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
|
||||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
const preSessionWeekendQuery = useQuery({
|
||||||
enabled: sessionKey === 0 && latestSeason != null,
|
queryKey: ['weekend', preSessionMeetingKey],
|
||||||
})
|
queryFn: () => fetchWeekend(preSessionMeetingKey!),
|
||||||
|
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
|
||||||
const focusMeeting = useMemo(() => {
|
|
||||||
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
|
||||||
return pickFocusMeeting(meetingsQuery.data, new Date())
|
|
||||||
}, [sessionKey, meetingsQuery.data])
|
|
||||||
|
|
||||||
const fallbackWeekendQuery = useQuery({
|
|
||||||
queryKey: ['weekend', focusMeeting?.meeting_key],
|
|
||||||
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
|
|
||||||
enabled: sessionKey === 0 && focusMeeting != null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionKey !== 0) return
|
if (sessionKey !== 0) return
|
||||||
const weekend = fallbackWeekendQuery.data
|
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
|
||||||
if (!weekend) return
|
if (delay == null) return
|
||||||
const target = pickAnalysisSession(weekend)?.session.session_key
|
const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
|
||||||
?? weekend.default_session_key
|
const timer = window.setTimeout(() => {
|
||||||
?? weekend.sessions[0]?.session.session_key
|
void refetchContext().finally(() => {
|
||||||
if (target) {
|
if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
|
||||||
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
})
|
||||||
}
|
}, delay)
|
||||||
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preSession) return
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [preSession])
|
||||||
|
|
||||||
|
// A bare route retains canonical context ownership while rendering its
|
||||||
|
// completed analysis selection. Explicit URLs remain user-owned.
|
||||||
|
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
|
||||||
|
|
||||||
// ─── Active session payload ───
|
// ─── Active session payload ───
|
||||||
const raceHubQuery = useQuery({
|
const raceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', sessionKey],
|
queryKey: ['race-hub', selectedSessionKey],
|
||||||
queryFn: () => fetchRaceHub(sessionKey),
|
queryFn: () => fetchRaceHub(selectedSessionKey),
|
||||||
enabled: sessionKey > 0,
|
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,25 +99,36 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const accent = countryAccent(data?.meeting ?? null)
|
const accent = countryAccent(data?.meeting ?? null)
|
||||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
// ─── No session_key: resolve exclusively through canonical Weekend Context ───
|
||||||
if (sessionKey === 0) {
|
if (sessionKey === 0) {
|
||||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
<div className="loading-state">resolving latest local weekend…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const seasons = seasonsQuery.data ?? []
|
if (preSession && preSessionRef) {
|
||||||
if (seasons.length === 0) {
|
return (
|
||||||
|
<RaceHubPreSession
|
||||||
|
session={preSessionRef}
|
||||||
|
weekend={preSessionWeekendQuery.data}
|
||||||
|
now={now}
|
||||||
|
switcherOpen={switcherOpen}
|
||||||
|
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
|
||||||
|
onCloseSwitcher={() => setSwitcherOpen(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!selectedSessionKey) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
||||||
<div className="rh-empty-band">
|
<div className="rh-empty-band">
|
||||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
<h1 className="rh-empty-title">No local sessions yet</h1>
|
<h1 className="rh-empty-title">No completed local analysis yet</h1>
|
||||||
<p className="rh-empty-sub">
|
<p className="rh-empty-sub">
|
||||||
The Race Hub reads from local ingest only. Once a weekend is ingested
|
Race Hub opens completed local analysis between weekends. Check Data Health
|
||||||
it will open here automatically.
|
to ingest a completed session.
|
||||||
</p>
|
</p>
|
||||||
<div className="rh-empty-actions">
|
<div className="rh-empty-actions">
|
||||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
||||||
@@ -136,18 +138,13 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<div className="rh-page" style={accentStyle}>
|
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Loading / error for the requested session_key ───
|
// ─── Loading / error for the selected session ───
|
||||||
if (raceHubQuery.isLoading) {
|
if (raceHubQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">loading session {sessionKey}…</div>
|
<div className="loading-state">loading session {selectedSessionKey}…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,7 +154,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<div className="error-box">
|
<div className="error-box">
|
||||||
{raceHubQuery.error instanceof Error
|
{raceHubQuery.error instanceof Error
|
||||||
? raceHubQuery.error.message
|
? raceHubQuery.error.message
|
||||||
: `Failed to load session ${sessionKey}.`}
|
: `Failed to load session ${selectedSessionKey}.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -168,7 +165,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const sessionMeta = weekend
|
const sessionMeta = weekend
|
||||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||||
: {}
|
: {}
|
||||||
const activeSessionMeta = sessionMeta[sessionKey]
|
const activeSessionMeta = sessionMeta[selectedSessionKey]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
@@ -194,7 +191,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
{switcherOpen && (
|
{switcherOpen && (
|
||||||
<WeekendSwitcher
|
<WeekendSwitcher
|
||||||
currentMeetingKey={meetingKey}
|
currentMeetingKey={meetingKey}
|
||||||
currentSessionKey={sessionKey}
|
currentSessionKey={selectedSessionKey}
|
||||||
onClose={() => setSwitcherOpen(false)}
|
onClose={() => setSwitcherOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -225,7 +222,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const meta = sessionMeta[session.session_key]
|
const meta = sessionMeta[session.session_key]
|
||||||
const active = session.session_key === sessionKey
|
const active = session.session_key === selectedSessionKey
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -278,7 +275,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
<span className="rh-active-key mono">key {selectedSessionKey}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -314,7 +311,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="sec-title">Driver Compare</span>
|
<span className="sec-title">Driver Compare</span>
|
||||||
</div>
|
</div>
|
||||||
<CompareView
|
<CompareView
|
||||||
sessionKey={sessionKey}
|
sessionKey={selectedSessionKey}
|
||||||
results={data.results}
|
results={data.results}
|
||||||
drivers={data.drivers}
|
drivers={data.drivers}
|
||||||
/>
|
/>
|
||||||
@@ -368,3 +365,72 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RaceHubPreSession({
|
||||||
|
session,
|
||||||
|
weekend,
|
||||||
|
now,
|
||||||
|
switcherOpen,
|
||||||
|
onToggleSwitcher,
|
||||||
|
onCloseSwitcher,
|
||||||
|
}: {
|
||||||
|
session: ContextSession
|
||||||
|
weekend?: Weekend
|
||||||
|
now: number
|
||||||
|
switcherOpen: boolean
|
||||||
|
onToggleSwitcher: () => void
|
||||||
|
onCloseSwitcher: () => void
|
||||||
|
}) {
|
||||||
|
const meeting = session.meeting
|
||||||
|
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
|
||||||
|
const target = new Date(session.session.date_start)
|
||||||
|
const accent = countryAccent(meeting ?? null)
|
||||||
|
const pendingLiveEvidence = target.getTime() <= now
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
|
||||||
|
<div className="rh-topbar">
|
||||||
|
<span className="rh-topbar-label mono">box-box · race hub</span>
|
||||||
|
<span className="rh-topbar-spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
|
||||||
|
onClick={onToggleSwitcher}
|
||||||
|
aria-expanded={switcherOpen}
|
||||||
|
aria-controls="rh-weekend-switcher"
|
||||||
|
data-testid="rh-switch-weekend"
|
||||||
|
>
|
||||||
|
{switcherOpen ? 'Close' : 'Switch Weekend'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{switcherOpen && (
|
||||||
|
<WeekendSwitcher
|
||||||
|
currentMeetingKey={meeting?.meeting_key}
|
||||||
|
currentSessionKey={session.session.session_key}
|
||||||
|
onClose={onCloseSwitcher}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="rh-empty-band">
|
||||||
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
|
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
|
||||||
|
<p className="rh-empty-sub">
|
||||||
|
{pendingLiveEvidence
|
||||||
|
? `${session.session.session_name} is scheduled; awaiting live timing.`
|
||||||
|
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
|
||||||
|
</p>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
|
||||||
|
{sessions.map((scheduled) => (
|
||||||
|
<div key={scheduled.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{scheduled.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.app-nav::-webkit-scrollbar { display: none; }
|
.app-nav::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
|
||||||
|
where the links stop fitting, fade the trailing edge so the cut-off item
|
||||||
|
reads as "scroll for more" instead of as a clipping bug. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.app-nav {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -4754,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: #f82f34;
|
background: #f82f34;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weekend recut: put the active circuit and season journey ahead of data plumbing. */
|
||||||
|
.cc-context {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(84px, 0.3fr) minmax(0, 1fr) auto;
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: center;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: var(--s5) var(--s6);
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--gp-accent) 9%, transparent), transparent 46%),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-decal {
|
||||||
|
color: color-mix(in srgb, var(--gp-accent) 58%, var(--text));
|
||||||
|
font-size: clamp(38px, 6vw, 72px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
line-height: 0.75;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-copy h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-kicker {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-copy p {
|
||||||
|
max-width: 64ch;
|
||||||
|
margin-top: var(--s2);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--s2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-preview {
|
||||||
|
margin-top: var(--s2);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cc-context-preview:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.cc-calendar-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: var(--s3) 0 var(--s4);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-calendar-card {
|
||||||
|
min-width: 112px;
|
||||||
|
min-height: 138px;
|
||||||
|
flex: 1 0 112px;
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border-radius: 0;
|
||||||
|
border-width: 1px 1px 1px 0;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.cc-calendar-card:first-child { border-left-width: 1px; }
|
||||||
|
.cc-calendar-card:hover { transform: none; box-shadow: inset 0 -2px 0 var(--gp-card-accent); }
|
||||||
|
.cc-calendar-card::before { display: none; }
|
||||||
|
.cc-calendar-accent { top: auto; bottom: 0; height: 2px; opacity: 0.65; }
|
||||||
|
.cc-calendar-past { filter: none; opacity: 0.62; }
|
||||||
|
.cc-calendar-past:hover { filter: none; opacity: 1; }
|
||||||
|
.cc-calendar-focus { background: color-mix(in srgb, var(--gp-card-accent) 10%, transparent); }
|
||||||
|
.cc-calendar-decal { font-size: 60px; bottom: -5px; }
|
||||||
|
.cc-calendar-status {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.cc-calendar-status-focus { color: var(--gp-card-accent); }
|
||||||
|
.cc-calendar-status-future { color: var(--text-2); }
|
||||||
|
|
||||||
|
.cc-data-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding-top: var(--s4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.cc-dashboard-grid { gap: var(--s5); }
|
||||||
|
.cc-dashboard-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); }
|
||||||
|
.cc-briefing, .cc-data-note { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cc-context { grid-template-columns: 1fr auto; gap: var(--s4); padding: var(--s4); }
|
||||||
|
.cc-context-decal { display: none; }
|
||||||
|
.cc-context-meta { align-items: flex-start; grid-column: 1 / -1; text-align: left; }
|
||||||
|
.cc-context-copy h2 { font-size: 17px; }
|
||||||
|
.cc-context-copy p { font-size: 11px; }
|
||||||
|
.cc-dashboard-sidebar { display: flex; }
|
||||||
|
.cc-calendar-grid { margin: 0 calc(var(--s4) * -1); padding-left: var(--s4); padding-right: var(--s4); }
|
||||||
|
.cc-calendar-card { min-width: 104px; flex-basis: 104px; }
|
||||||
|
.cc-calendar-circuit { display: none; }
|
||||||
|
.cc-champ-snapshot { order: 2; }
|
||||||
|
.cc-schedule { order: 1; }
|
||||||
|
.cc-briefing { order: 3; margin-top: var(--s4); }
|
||||||
|
.cc-data-note { order: 4; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ describe('CommandCenterPage', () => {
|
|||||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
||||||
|
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Circuit context')
|
||||||
|
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Weekend preview')
|
||||||
|
expect(screen.getByTestId('cc-data-note')).toHaveTextContent('availability is shown per session')
|
||||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
||||||
})
|
})
|
||||||
@@ -238,8 +241,40 @@ describe('CommandCenterPage', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
||||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
||||||
|
expect(screen.getByTestId('cc-calendar-1301')).toHaveTextContent('Archive')
|
||||||
|
expect(screen.getByTestId('cc-calendar-1302')).toHaveTextContent('Now')
|
||||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||||
|
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the completed local session rail available when the next weekend has no sessions', async () => {
|
||||||
|
const nextMeeting = {
|
||||||
|
...meeting,
|
||||||
|
meeting_key: 1303,
|
||||||
|
meeting_name: 'Canada',
|
||||||
|
country_name: 'Canada',
|
||||||
|
country_code: 'CAN',
|
||||||
|
circuit_short_name: 'Montreal',
|
||||||
|
date_start: '2026-06-12T00:00:00+00:00',
|
||||||
|
date_end: '2026-06-14T23:59:59+00:00',
|
||||||
|
year: 2026,
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
|
||||||
|
mockFetchSeasons.mockResolvedValue([2026])
|
||||||
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
|
mockFetchSeasonMeetings.mockResolvedValue([meeting, nextMeeting])
|
||||||
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
|
mockFetchSessions.mockResolvedValue([])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('cc-schedule')).toHaveTextContent('1 sessions')
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -9,21 +9,24 @@ import {
|
|||||||
createRoute,
|
createRoute,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import { RaceHubPage } from '../pages/RaceHubPage'
|
import { RaceHubPage } from '../pages/RaceHubPage'
|
||||||
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
|
import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
|
||||||
|
import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchRaceHub: vi.fn(),
|
fetchRaceHub: vi.fn(),
|
||||||
fetchSeasons: vi.fn(),
|
fetchSeasons: vi.fn(),
|
||||||
fetchLocalMeetings: vi.fn(),
|
fetchLocalMeetings: vi.fn(),
|
||||||
fetchWeekend: vi.fn(),
|
fetchWeekend: vi.fn(),
|
||||||
|
fetchWeekendContext: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
|
import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
|
||||||
|
|
||||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||||
|
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||||
|
|
||||||
const meeting: Meeting = {
|
const meeting: Meeting = {
|
||||||
meeting_key: 1229,
|
meeting_key: 1229,
|
||||||
@@ -169,6 +172,26 @@ const weekend: Weekend = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availability: ContextAvailability = {
|
||||||
|
source: 'local',
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'local',
|
||||||
|
limitations: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysisContext: WeekendContext = {
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
default_analysis_session: { session: raceSession, meeting, availability },
|
||||||
|
race_hub_default_session: { session: raceSession, meeting, availability },
|
||||||
|
race_hub_pre_session: false,
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
}
|
||||||
|
|
||||||
function renderRaceHub(sessionKey: number) {
|
function renderRaceHub(sessionKey: number) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
@@ -193,23 +216,28 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
return <RaceHubPage sessionKey={session_key ?? 0} />
|
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree: rootRoute.addChildren([raceHubRoute]),
|
routeTree: rootRoute.addChildren([raceHubRoute]),
|
||||||
history: undefined,
|
history: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigate to the URL before mounting
|
return { queryClient, ...render(<RouterProvider router={router} />) }
|
||||||
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
|
||||||
return render(<RouterProvider router={router} />)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('RaceHubPage', () => {
|
describe('RaceHubPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
mockFetchWeekend.mockResolvedValue(weekend)
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
mockFetchWeekendContext.mockResolvedValue(analysisContext)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
||||||
@@ -256,4 +284,122 @@ describe('RaceHubPage', () => {
|
|||||||
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||||
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
|
||||||
|
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the intentional pre-session state without analysis cards', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
|
||||||
|
meeting,
|
||||||
|
availability,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: '2099-05-23T13:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
|
||||||
|
meeting,
|
||||||
|
availability,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: '2099-05-23T13:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
const switchWeekend = await screen.findByTestId('rh-switch-weekend')
|
||||||
|
expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
fireEvent.click(switchWeekend)
|
||||||
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
|
expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByTestId('rh-switcher-session-9471'))
|
||||||
|
await waitFor(() => expect(window.location.search).toBe('?session_key=9471'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows recovery instead of selecting an empty future session', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: undefined,
|
||||||
|
race_hub_pre_session: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
const handoff = new Date(Date.now() + 10_000).toISOString()
|
||||||
|
const pendingContext: WeekendContext = {
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: handoff },
|
||||||
|
meeting,
|
||||||
|
availability,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
|
||||||
|
}
|
||||||
|
mockFetchWeekendContext
|
||||||
|
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
|
||||||
|
.mockResolvedValueOnce(pendingContext)
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
await screen.findByTestId('race-hub')
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
|
||||||
|
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-arms a bare route refresh after a capped browser timer', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
await screen.findByTestId('race-hub')
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||||
|
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
|
||||||
|
|
||||||
|
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
|
||||||
|
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TyreDegPanel', () => {
|
describe('TyreDegPanel', () => {
|
||||||
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
it('stays collapsed while every stint is still warming up', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
|
||||||
|
// A panel of "warming up" placeholders carries no information and used to
|
||||||
|
// push the Timing Tower off the fold for the first third of a race.
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
expect(panel).toHaveTextContent('collecting clean laps')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a warming-up placeholder on each row once expanded', () => {
|
||||||
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
expect(panel).toHaveTextContent('VER')
|
expect(panel).toHaveTextContent('VER')
|
||||||
expect(panel).toHaveTextContent('M +5')
|
expect(panel).toHaveTextContent('M +5')
|
||||||
expect(panel).toHaveTextContent('fresh')
|
expect(panel).toHaveTextContent('fresh')
|
||||||
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||||
]
|
]
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,16 +104,44 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).not.toHaveTextContent('~P')
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts expanded during a race', () => {
|
it('opens itself during a race as soon as a stint has signal', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the reader\'s own collapse choice when signal arrives', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
// Reader opens it early, then closes it again — that decision must stick
|
||||||
|
// even once the panel would otherwise auto-open.
|
||||||
|
const toggle = screen.getByRole('button', { name: /tyre deg/i })
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('limits rows to the top ten plus pinned drivers', () => {
|
it('limits rows to the top ten plus pinned drivers', () => {
|
||||||
const rows = Array.from({ length: 15 }, (_, index) =>
|
const rows = Array.from({ length: 15 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
)
|
)
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
focusMeetingKind,
|
focusMeetingKind,
|
||||||
focusMeetingLabel,
|
focusMeetingLabel,
|
||||||
formatCountdown,
|
formatCountdown,
|
||||||
|
MAX_BROWSER_TIMEOUT,
|
||||||
nextUpcomingMeeting,
|
nextUpcomingMeeting,
|
||||||
pickFocusMeeting,
|
pickFocusMeeting,
|
||||||
|
refreshDeadlineDelay,
|
||||||
} from '../lib/schedule'
|
} from '../lib/schedule'
|
||||||
import type { Meeting, Session } from '../types'
|
import type { Meeting, Session } from '../types'
|
||||||
|
|
||||||
@@ -78,4 +80,15 @@ describe('schedule helpers', () => {
|
|||||||
const target = new Date('2025-05-25T13:00:00+00:00')
|
const target = new Date('2025-05-25T13:00:00+00:00')
|
||||||
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
|
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the server refresh deadline without local timezone conversion', () => {
|
||||||
|
expect(refreshDeadlineDelay('2025-05-25T13:00:00Z', Date.parse('2025-05-25T12:59:30Z'))).toBe(30_000)
|
||||||
|
expect(refreshDeadlineDelay(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps a refresh deadline beyond the browser timer maximum', () => {
|
||||||
|
const now = Date.parse('2025-05-25T12:00:00Z')
|
||||||
|
const deadline = new Date(now + MAX_BROWSER_TIMEOUT + 1_000).toISOString()
|
||||||
|
expect(refreshDeadlineDelay(deadline, now)).toBe(MAX_BROWSER_TIMEOUT)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -217,6 +217,41 @@ export interface Weekend {
|
|||||||
default_session_key?: number
|
default_session_key?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextAvailability {
|
||||||
|
source: string
|
||||||
|
schedule: string
|
||||||
|
live_transport: string
|
||||||
|
live_session: string
|
||||||
|
archive: string
|
||||||
|
local_analysis: string
|
||||||
|
freshness: string
|
||||||
|
observed_at?: string
|
||||||
|
limitations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextSession {
|
||||||
|
session: Session
|
||||||
|
meeting?: Meeting
|
||||||
|
availability: ContextAvailability
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekendContext {
|
||||||
|
season?: number
|
||||||
|
temporal_state: string
|
||||||
|
previous_meeting?: Meeting
|
||||||
|
focus_meeting?: Meeting
|
||||||
|
next_meeting?: Meeting
|
||||||
|
previous_completed_session?: ContextSession
|
||||||
|
active_session?: ContextSession
|
||||||
|
next_session?: ContextSession
|
||||||
|
default_analysis_session?: ContextSession
|
||||||
|
race_hub_default_session?: ContextSession
|
||||||
|
race_hub_pre_session: boolean
|
||||||
|
race_hub_refresh_at?: string
|
||||||
|
championship_round: number
|
||||||
|
total_championship_rounds: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveStateResponse {
|
export interface LiveStateResponse {
|
||||||
is_live: boolean
|
is_live: boolean
|
||||||
data: LiveStreamData | null
|
data: LiveStreamData | null
|
||||||
|
|||||||
@@ -472,6 +472,62 @@ func TestProcessTopicTimingAppData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The feed sends stints as sparse deltas keyed by stint index. Replacing the
|
||||||
|
// slice on each delta collapsed pit history to one entry and pinned tyre age
|
||||||
|
// near zero — observed live at lap 49 of a 70-lap race, where every driver
|
||||||
|
// reported a single stint of age 0 despite having pitted.
|
||||||
|
func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) {
|
||||||
|
state := live.NewState()
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}}
|
||||||
|
}`))
|
||||||
|
// Stint 0 runs to 18 laps, then the driver pits onto a new hard.
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}}
|
||||||
|
}`))
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}}
|
||||||
|
}`))
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}}
|
||||||
|
}`))
|
||||||
|
|
||||||
|
snap := state.Snapshot()
|
||||||
|
stints := snap.Stints["4"]
|
||||||
|
if len(stints) != 2 {
|
||||||
|
t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints)
|
||||||
|
}
|
||||||
|
if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 {
|
||||||
|
t.Errorf("first stint lost across deltas: %+v", stints[0])
|
||||||
|
}
|
||||||
|
if stints[1].Compound != "HARD" || stints[1].Laps != 12 {
|
||||||
|
t.Errorf("second stint = %+v", stints[1])
|
||||||
|
}
|
||||||
|
if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 {
|
||||||
|
t.Errorf("current tyre should track the latest stint, got %+v", tyre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) {
|
||||||
|
state := live.NewState()
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}}
|
||||||
|
}`))
|
||||||
|
// "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0
|
||||||
|
// would overwrite the real first stint.
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}}
|
||||||
|
}`))
|
||||||
|
|
||||||
|
stints := state.Snapshot().Stints["4"]
|
||||||
|
if len(stints) != 1 {
|
||||||
|
t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints)
|
||||||
|
}
|
||||||
|
if stints[0].Compound != "SOFT" || stints[0].Laps != 9 {
|
||||||
|
t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessTopicTimingStats(t *testing.T) {
|
func TestProcessTopicTimingStats(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
|||||||
Stints json.RawMessage `json:"Stints"`
|
Stints json.RawMessage `json:"Stints"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
||||||
var driverStints []LiveStintData
|
// The feed sends stints as sparse deltas keyed by stint index:
|
||||||
|
// a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge
|
||||||
|
// each entry into the stint it addresses. Replacing the slice
|
||||||
|
// wholesale discarded every earlier stint, so pit history
|
||||||
|
// collapsed to one entry and tyre age stuck near zero for the
|
||||||
|
// whole race.
|
||||||
|
driverStints := append([]LiveStintData(nil), s.Stints[num]...)
|
||||||
|
changed := false
|
||||||
for _, sRaw := range indexedRawValues(line.Stints) {
|
for _, sRaw := range indexedRawValues(line.Stints) {
|
||||||
var st struct {
|
var st struct {
|
||||||
Compound string `json:"Compound"`
|
Compound *string `json:"Compound"`
|
||||||
New string `json:"New"`
|
New *string `json:"New"`
|
||||||
TotalLaps int `json:"TotalLaps"`
|
TotalLaps *int `json:"TotalLaps"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
|
if json.Unmarshal(sRaw.Raw, &st) != nil {
|
||||||
driverStints = append(driverStints, LiveStintData{
|
continue
|
||||||
Compound: st.Compound,
|
|
||||||
New: st.New == "true" || st.New == "True",
|
|
||||||
Laps: st.TotalLaps,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if st.Compound == nil && st.New == nil && st.TotalLaps == nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if len(driverStints) > 0 {
|
for len(driverStints) <= sRaw.Index {
|
||||||
|
driverStints = append(driverStints, LiveStintData{})
|
||||||
|
}
|
||||||
|
entry := &driverStints[sRaw.Index]
|
||||||
|
if st.Compound != nil && *st.Compound != "" {
|
||||||
|
entry.Compound = *st.Compound
|
||||||
|
}
|
||||||
|
if st.New != nil {
|
||||||
|
entry.New = *st.New == "true" || *st.New == "True"
|
||||||
|
}
|
||||||
|
if st.TotalLaps != nil {
|
||||||
|
entry.Laps = *st.TotalLaps
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
s.Stints[num] = driverStints
|
s.Stints[num] = driverStints
|
||||||
lastStint := driverStints[len(driverStints)-1]
|
lastStint := driverStints[len(driverStints)-1]
|
||||||
t := s.Tyres[num]
|
t := s.Tyres[num]
|
||||||
@@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
|||||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||||
values := make([]indexedRaw, 0, len(obj))
|
values := make([]indexedRaw, 0, len(obj))
|
||||||
for k, v := range obj {
|
for k, v := range obj {
|
||||||
i := 0
|
// Keys are array indices in the feed's delta form. Non-numeric keys
|
||||||
fmt.Sscanf(k, "%d", &i)
|
// are feed metadata — "_kf" (key frame) is the common one — and must
|
||||||
|
// not be folded in as index 0, which would clobber the first entry.
|
||||||
|
i, err := strconv.Atoi(k)
|
||||||
|
if err != nil || i < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
values = append(values, indexedRaw{Index: i, Raw: v})
|
values = append(values, indexedRaw{Index: i, Raw: v})
|
||||||
}
|
}
|
||||||
sort.Slice(values, func(i, j int) bool {
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
|
|
||||||
preSessionWindow = 48 * time.Hour
|
preSessionWindow = 48 * time.Hour
|
||||||
postWeekendWindow = 48 * time.Hour
|
postWeekendWindow = 48 * time.Hour
|
||||||
|
raceHubPendingPollInterval = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
||||||
@@ -69,6 +70,9 @@ type WeekendContext struct {
|
|||||||
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
||||||
NextSession *ContextSession `json:"next_session,omitempty"`
|
NextSession *ContextSession `json:"next_session,omitempty"`
|
||||||
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
||||||
|
RaceHubDefaultSession *ContextSession `json:"race_hub_default_session,omitempty"`
|
||||||
|
RaceHubPreSession bool `json:"race_hub_pre_session"`
|
||||||
|
RaceHubRefreshAt string `json:"race_hub_refresh_at,omitempty"`
|
||||||
ChampionshipRound int `json:"championship_round"`
|
ChampionshipRound int `json:"championship_round"`
|
||||||
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
||||||
}
|
}
|
||||||
@@ -150,7 +154,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var previous, next, defaultAnalysis *contextCandidate
|
var previous, next, defaultAnalysis, pending *contextCandidate
|
||||||
for i := range candidates {
|
for i := range candidates {
|
||||||
c := &candidates[i]
|
c := &candidates[i]
|
||||||
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
||||||
@@ -164,6 +168,10 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
|
|||||||
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
||||||
next = c
|
next = c
|
||||||
}
|
}
|
||||||
|
if !isActive && !c.complete && !c.start.IsZero() && !c.start.After(now) &&
|
||||||
|
(c.end.IsZero() || now.Before(c.end)) && (pending == nil || c.start.After(pending.start)) {
|
||||||
|
pending = c
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if previous != nil {
|
if previous != nil {
|
||||||
@@ -188,9 +196,40 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
|
|||||||
if out.FocusMeeting != nil {
|
if out.FocusMeeting != nil {
|
||||||
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
||||||
}
|
}
|
||||||
|
applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyRaceHubDefault is deliberately distinct from TemporalPreSession. Other
|
||||||
|
// weekend surfaces begin preparation 48 hours ahead; Race Hub remains an
|
||||||
|
// analysis destination until the one-hour handoff before the next session.
|
||||||
|
func applyRaceHubDefault(out *WeekendContext, active, analysis, next, pending *contextCandidate, now time.Time) {
|
||||||
|
if active != nil {
|
||||||
|
out.RaceHubDefaultSession = out.ActiveSession
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
handoff := next.start.Add(-time.Hour)
|
||||||
|
if now.Before(handoff) {
|
||||||
|
out.RaceHubRefreshAt = handoff.Format(time.RFC3339)
|
||||||
|
} else if now.Before(next.start) {
|
||||||
|
out.RaceHubDefaultSession = out.NextSession
|
||||||
|
out.RaceHubPreSession = true
|
||||||
|
out.RaceHubRefreshAt = next.start.Format(time.RFC3339)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending != nil {
|
||||||
|
out.RaceHubDefaultSession = sessionRef(*pending, LiveEvidence{}, now)
|
||||||
|
out.RaceHubPreSession = true
|
||||||
|
out.RaceHubRefreshAt = now.Add(raceHubPendingPollInterval).Format(time.RFC3339)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if analysis != nil {
|
||||||
|
out.RaceHubDefaultSession = out.DefaultAnalysisSession
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func currentLocalSeason(years []int, current int) int {
|
func currentLocalSeason(years []int, current int) int {
|
||||||
for _, year := range years {
|
for _, year := range years {
|
||||||
if year == current {
|
if year == current {
|
||||||
|
|||||||
@@ -399,3 +399,88 @@ func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testi
|
|||||||
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
|
||||||
|
seed := func(t *testing.T, svc *Service) {
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("keeps completed analysis before handoff", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T07:59:59Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 11 || got.RaceHubPreSession {
|
||||||
|
t.Fatalf("race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
if got.RaceHubRefreshAt != "2026-07-17T08:00:00Z" {
|
||||||
|
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hands off exactly one hour before first session", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
|
||||||
|
t.Fatalf("race hub handoff = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
if got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" {
|
||||||
|
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps the scheduled session pending after its start without live evidence", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-17T09:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
|
||||||
|
t.Fatalf("scheduled race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
if got.RaceHubRefreshAt != "2026-07-17T09:00:15Z" {
|
||||||
|
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("active live session wins", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:30:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || got.RaceHubPreSession {
|
||||||
|
t.Fatalf("live default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not select an empty future session before handoff", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession != nil || got.RaceHubPreSession {
|
||||||
|
t.Fatalf("unexpected empty future default: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,3 +167,30 @@ func TestTerminalSessionStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerSerializesRaceHubRefreshDeadline(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
if err := st.UpsertSessionResult(store.SessionResult{SessionKey: 11, MeetingKey: 1, DriverNumber: 1, Position: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-19T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-17T10:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time {
|
||||||
|
return time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||||
|
})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.RaceHubPreSession || got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" || got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 {
|
||||||
|
t.Fatalf("race hub context = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
"test:visual": "playwright test --config playwright.visual.config.ts",
|
"test:visual": "playwright test --config playwright.visual.config.ts",
|
||||||
"test:visual:prod": "playwright test --config playwright.visual.prod.config.ts",
|
"test:visual:prod": "playwright test --config playwright.visual.prod.config.ts",
|
||||||
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
|
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
|
||||||
"test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots"
|
"test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots",
|
||||||
|
"release:fidelity:capture": "playwright test --config playwright.fidelity.config.ts",
|
||||||
|
"release:fidelity:packet": "node scripts/release-fidelity/generate.mjs",
|
||||||
|
"release:fidelity:verify": "node scripts/release-fidelity/verify.mjs",
|
||||||
|
"release:fidelity:gate": "npm run test:visual:prod && npm run release:fidelity:capture && npm run release:fidelity:packet && npm run release:fidelity:verify",
|
||||||
|
"test:release-fidelity": "node --test scripts/release-fidelity/*.test.mjs"
|
||||||
},
|
},
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
37
playwright.fidelity.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
import { VIEWPORTS } from './tests/visual/helpers'
|
||||||
|
|
||||||
|
const E2E_DB = '.playwright/boxbox-fidelity.db'
|
||||||
|
const API_PORT = process.env.BOXBOX_API_PORT ?? '18080'
|
||||||
|
const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
|
||||||
|
|
||||||
|
// Candidate captures are evidence for owner review, not regression baselines.
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/release-fidelity',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: `http://localhost:${WEB_PORT}`,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'desktop', use: { browserName: 'chromium', viewport: VIEWPORTS.desktop } },
|
||||||
|
{ name: 'mobile', use: { browserName: 'chromium', viewport: VIEWPORTS.mobile } },
|
||||||
|
],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 BOXBOX_OPENF1_BASE_URL=http://127.0.0.1:9 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`,
|
||||||
|
url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`,
|
||||||
|
url: `http://localhost:${WEB_PORT}`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -7,10 +7,11 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/visual',
|
testDir: './tests/visual',
|
||||||
fullyParallel: true,
|
// All projects share one seeded SQLite database behind the same Go server.
|
||||||
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
reporter: process.env.CI ? 'github' : 'html',
|
reporter: process.env.CI ? 'github' : 'html',
|
||||||
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
|
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
|
||||||
expect: {
|
expect: {
|
||||||
|
|||||||
86
scripts/release-fidelity/generate.mjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { join, relative, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const references = [
|
||||||
|
{ viewport: 'desktop', name: 'weekend-between-races' },
|
||||||
|
{ viewport: 'desktop', name: 'weekend-live' },
|
||||||
|
{ viewport: 'mobile', name: 'weekend-between-sessions-mobile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function option(name, fallback) {
|
||||||
|
const index = process.argv.indexOf(`--${name}`)
|
||||||
|
return index === -1 ? fallback : process.argv[index + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesBelow(directory) {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
|
const files = await Promise.all(entries.map(async (entry) => {
|
||||||
|
const path = join(directory, entry.name)
|
||||||
|
return entry.isDirectory() ? filesBelow(path) : [path]
|
||||||
|
}))
|
||||||
|
return files.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function locateMockup(root, name) {
|
||||||
|
const preferred = join(root, `${name}.png`)
|
||||||
|
try {
|
||||||
|
await stat(preferred)
|
||||||
|
return preferred
|
||||||
|
} catch {
|
||||||
|
const matches = (await filesBelow(root)).filter((path) => path.endsWith(`${name}.png`))
|
||||||
|
return matches.length === 1 ? matches[0] : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageData(path) {
|
||||||
|
return readFile(path).then((data) => `data:image/png;base64,${data.toString('base64')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePacket({ version, evidence, mockups }) {
|
||||||
|
const candidateRoot = join(evidence, 'candidate')
|
||||||
|
const pairs = []
|
||||||
|
const missing = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(mockups)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Approved mockups directory is missing: ${mockups}. It is supplied by the product reference integration.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { viewport, name } of references) {
|
||||||
|
const candidate = join(candidateRoot, viewport, `${name}.png`)
|
||||||
|
const reference = await locateMockup(mockups, name)
|
||||||
|
try {
|
||||||
|
await stat(candidate)
|
||||||
|
} catch {
|
||||||
|
missing.push(`candidate: ${candidate}`)
|
||||||
|
}
|
||||||
|
if (!reference) missing.push(`mockup: ${join(mockups, `${name}.png`)}`)
|
||||||
|
if (reference) pairs.push({ viewport, name, candidate, reference })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`Release-fidelity packet is incomplete:\n${missing.join('\n')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await Promise.all(pairs.map(async ({ viewport, name, candidate, reference }) => `
|
||||||
|
<section><h2>${viewport}: ${name}</h2><div class="pair">
|
||||||
|
<figure><figcaption>Approved mockup</figcaption><img src="${await imageData(reference)}"></figure>
|
||||||
|
<figure><figcaption>Candidate</figcaption><img src="${await imageData(candidate)}"></figure>
|
||||||
|
</div><p>Reference: <code>${relative(process.cwd(), reference)}</code></p></section>`))
|
||||||
|
const summary = `# Release Fidelity Evidence: ${version}\n\n- Candidate screenshots: candidate/\n- Approved mockups: ${relative(process.cwd(), mockups)}\n- Review: human owner decision required; this packet does not approve the release.\n\nOpen index.html for side-by-side evidence.\n`
|
||||||
|
const html = `<!doctype html><title>${version} release-fidelity review</title><style>body{background:#111;color:#eee;font:16px system-ui;margin:2rem}section{margin:3rem 0}.pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}figure{margin:0}img{max-width:100%;border:1px solid #555}figcaption{font-weight:700;margin-bottom:.5rem}code{color:#9fd}@media(max-width:700px){.pair{grid-template-columns:1fr}}</style><h1>${version} Release-Fidelity Review</h1><p>Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.</p>${cards.join('')}</html>`
|
||||||
|
await writeFile(join(evidence, 'summary.md'), summary)
|
||||||
|
await writeFile(join(evidence, 'index.html'), html)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
|
||||||
|
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
|
||||||
|
const evidence = resolve(option('evidence', join(output, version)))
|
||||||
|
const mockups = resolve(option('mockups', join('docs/product', version, 'mockups')))
|
||||||
|
generatePacket({ version, evidence, mockups }).then(
|
||||||
|
() => console.log(`Release-fidelity packet: ${join(evidence, 'index.html')}`),
|
||||||
|
(error) => { console.error(error.message); process.exitCode = 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
27
scripts/release-fidelity/generate.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { generatePacket } from './generate.mjs'
|
||||||
|
|
||||||
|
test('generates a self-contained side-by-side packet for desktop and mobile', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-'))
|
||||||
|
const evidence = join(root, 'evidence')
|
||||||
|
const mockups = join(root, 'mockups')
|
||||||
|
const references = [
|
||||||
|
['desktop', 'weekend-between-races'],
|
||||||
|
['desktop', 'weekend-live'],
|
||||||
|
['mobile', 'weekend-between-sessions-mobile'],
|
||||||
|
]
|
||||||
|
for (const [viewport, name] of references) {
|
||||||
|
await mkdir(join(evidence, 'candidate', viewport), { recursive: true })
|
||||||
|
await mkdir(mockups, { recursive: true })
|
||||||
|
await writeFile(join(evidence, 'candidate', viewport, `${name}.png`), 'candidate')
|
||||||
|
await writeFile(join(mockups, `${name}.png`), 'mockup')
|
||||||
|
}
|
||||||
|
|
||||||
|
await generatePacket({ version: 'v-test', evidence, mockups })
|
||||||
|
assert.match(await readFile(join(evidence, 'index.html'), 'utf8'), /Approved mockup/)
|
||||||
|
assert.match(await readFile(join(evidence, 'summary.md'), 'utf8'), /human owner decision required/)
|
||||||
|
})
|
||||||
50
scripts/release-fidelity/verify.mjs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
function option(name, fallback) {
|
||||||
|
const index = process.argv.indexOf(`--${name}`)
|
||||||
|
return index === -1 ? fallback : process.argv[index + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(...args) {
|
||||||
|
return execFileSync('git', args, { encoding: 'utf8' }).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
|
||||||
|
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
|
||||||
|
const evidence = resolve(option('evidence', join(output, version)))
|
||||||
|
const signoff = option('signoff', join('docs/release/owner-reviews', `${version}.md`))
|
||||||
|
const required = ['index.html', 'summary.md']
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of required) await readFile(join(evidence, file))
|
||||||
|
git('cat-file', '-e', `HEAD:${signoff}`)
|
||||||
|
const text = git('show', `HEAD:${signoff}`)
|
||||||
|
const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1]
|
||||||
|
if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`)
|
||||||
|
git('cat-file', '-e', `${candidate}^{commit}`)
|
||||||
|
const parent = git('rev-parse', 'HEAD^')
|
||||||
|
const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean)
|
||||||
|
if (changed.length !== 1 || changed[0] !== signoff) {
|
||||||
|
throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`)
|
||||||
|
}
|
||||||
|
if (candidate.toLowerCase() !== parent.toLowerCase()) {
|
||||||
|
throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`)
|
||||||
|
}
|
||||||
|
const fields = [
|
||||||
|
['Version', version],
|
||||||
|
['Reviewed by', '.+'],
|
||||||
|
['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'],
|
||||||
|
['Decision', 'approved'],
|
||||||
|
]
|
||||||
|
for (const [name, value] of fields) {
|
||||||
|
if (!new RegExp(`^- ${name}: ${value}$`, 'm').test(text)) {
|
||||||
|
throw new Error(`${signoff} must contain "- ${name}: ${value}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Release-fidelity evidence and committed owner approval verified for ${version} at ${candidate}.`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Release-fidelity gate blocked: ${error.message}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
80
scripts/release-fidelity/verify.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const verifier = resolve('scripts/release-fidelity/verify.mjs')
|
||||||
|
|
||||||
|
function git(directory, ...args) {
|
||||||
|
return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify(root, evidence) {
|
||||||
|
return execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approvedCandidate({ changeCodeWithSignoff = false, decision = 'approved' } = {}) {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-'))
|
||||||
|
git(root, 'init')
|
||||||
|
git(root, 'config', 'user.email', 'test@example.com')
|
||||||
|
git(root, 'config', 'user.name', 'Test')
|
||||||
|
await writeFile(join(root, 'candidate.txt'), 'candidate')
|
||||||
|
git(root, 'add', 'candidate.txt')
|
||||||
|
git(root, 'commit', '-m', 'candidate')
|
||||||
|
const candidate = git(root, 'rev-parse', 'HEAD')
|
||||||
|
|
||||||
|
await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true })
|
||||||
|
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: ${decision}\n`)
|
||||||
|
if (changeCodeWithSignoff) await writeFile(join(root, 'candidate.txt'), 'changed with approval')
|
||||||
|
git(root, 'add', 'docs/release/owner-reviews/v-test.md')
|
||||||
|
if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt')
|
||||||
|
git(root, 'commit', '-m', 'owner sign-off')
|
||||||
|
|
||||||
|
const evidence = join(root, 'evidence')
|
||||||
|
await mkdir(evidence)
|
||||||
|
await writeFile(join(evidence, 'index.html'), '')
|
||||||
|
await writeFile(join(evidence, 'summary.md'), '')
|
||||||
|
return { candidate, evidence, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBlocked(root, evidence) {
|
||||||
|
assert.throws(() => verify(root, evidence), (error) => {
|
||||||
|
assert.match(String(error.stderr), /HEAD must contain only the sign-off file change/)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('accepts a sign-off-only HEAD for its direct parent candidate', async () => {
|
||||||
|
const { candidate, evidence, root } = await approvedCandidate()
|
||||||
|
const output = verify(root, evidence)
|
||||||
|
assert.match(output, new RegExp(candidate))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects code committed after approval', async () => {
|
||||||
|
const { evidence, root } = await approvedCandidate()
|
||||||
|
await writeFile(join(root, 'candidate.txt'), 'changed after review')
|
||||||
|
git(root, 'add', 'candidate.txt')
|
||||||
|
git(root, 'commit', '-m', 'code after approval')
|
||||||
|
|
||||||
|
assertBlocked(root, evidence)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a sign-off commit that also changes code', async () => {
|
||||||
|
const { evidence, root } = await approvedCandidate({ changeCodeWithSignoff: true })
|
||||||
|
assertBlocked(root, evidence)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a dirty working-tree edit that spoofs approval', async () => {
|
||||||
|
const { candidate, evidence, root } = await approvedCandidate({ decision: 'rejected' })
|
||||||
|
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`)
|
||||||
|
|
||||||
|
assert.throws(() => verify(root, evidence), (error) => {
|
||||||
|
assert.match(String(error.stderr), /Decision: approved/)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,30 @@ test.describe('Command Center', () => {
|
|||||||
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
|
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('falls back to the local calendar when season metadata fails', async ({ page }) => {
|
||||||
|
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
|
||||||
|
await route.continue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: 'season calendar unavailable' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText('Using local meetings because the full calendar could not load.'),
|
||||||
|
).toBeVisible({ timeout: 15_000 })
|
||||||
|
})
|
||||||
|
|
||||||
test('nav link reaches command center from race hub', async ({ page }) => {
|
test('nav link reaches command center from race hub', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await page.getByRole('link', { name: 'Command' }).click()
|
await page.getByRole('link', { name: 'Command' }).click()
|
||||||
|
|||||||
@@ -2,6 +2,42 @@ import { test, expect } from '@playwright/test'
|
|||||||
|
|
||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
const CORE_ONLY_SESSION = 9000
|
const CORE_ONLY_SESSION = 9000
|
||||||
|
const CONTEXT_MEETING = {
|
||||||
|
meeting_key: 1229,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
}
|
||||||
|
const CONTEXT_AVAILABILITY = {
|
||||||
|
source: 'local', schedule: 'available', live_transport: 'unknown', live_session: 'inactive',
|
||||||
|
archive: 'unavailable', local_analysis: 'complete', freshness: 'local', limitations: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedContext(refreshAt?: string) {
|
||||||
|
return {
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { session_key: FULL_SESSION }, meeting: CONTEXT_MEETING, availability: CONTEXT_AVAILABILITY,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: false,
|
||||||
|
race_hub_refresh_at: refreshAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingContext(refreshAt: string) {
|
||||||
|
return {
|
||||||
|
temporal_state: 'pre_session',
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 9473, meeting_key: 1229, session_name: 'Practice 1', session_type: 'Practice',
|
||||||
|
date_start: '2030-01-01T00:00:01Z', date_end: '2030-01-01T01:00:01Z', gmt_offset: '00:00:00',
|
||||||
|
},
|
||||||
|
meeting: CONTEXT_MEETING,
|
||||||
|
availability: { ...CONTEXT_AVAILABILITY, local_analysis: 'not_applicable' },
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: refreshAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Race Hub Weekend Workspace', () => {
|
test.describe('Race Hub Weekend Workspace', () => {
|
||||||
test('lands on the Overview tab with workspace identity', async ({ page }) => {
|
test('lands on the Overview tab with workspace identity', async ({ page }) => {
|
||||||
@@ -102,9 +138,87 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('bare /race-hub redirects to the focus session', async ({ page }) => {
|
test('bare /race-hub shows server-selected completed analysis without changing the URL', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(completedContext()) }),
|
||||||
|
)
|
||||||
await page.goto('/race-hub')
|
await page.goto('/race-hub')
|
||||||
await expect(page).toHaveURL(/session_key=\d+/)
|
await expect(page).toHaveURL(/\/race-hub$/)
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('bare /race-hub hands off to pending pre-session state at the refresh deadline', async ({ page }) => {
|
||||||
|
await page.clock.install({ time: new Date('2030-01-01T00:00:00Z') })
|
||||||
|
let requests = 0
|
||||||
|
const raceHubRequests: number[] = []
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const url = new URL(request.url())
|
||||||
|
if (url.pathname === '/api/v1/race-hub') {
|
||||||
|
raceHubRequests.push(Number(url.searchParams.get('session_key')))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) => {
|
||||||
|
requests += 1
|
||||||
|
const body = requests === 1
|
||||||
|
? completedContext('2030-01-01T00:00:01Z')
|
||||||
|
: pendingContext('2030-01-01T00:00:16Z')
|
||||||
|
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/race-hub')
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await page.clock.fastForward(1_000)
|
||||||
|
|
||||||
|
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/race-hub$/)
|
||||||
|
expect(raceHubRequests).toContain(FULL_SESSION)
|
||||||
|
expect(raceHubRequests).not.toContain(9473)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pre-session state opens the weekend switcher and navigates to an explicit session', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(pendingContext('2030-01-01T00:00:16Z')),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/race-hub')
|
||||||
|
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
|
||||||
|
|
||||||
|
const switchWeekend = page.getByTestId('rh-switch-weekend')
|
||||||
|
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
await switchWeekend.click()
|
||||||
|
await expect(page.getByTestId('rh-switcher')).toBeVisible()
|
||||||
|
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
|
||||||
|
await page.getByTestId(`rh-switcher-session-${FULL_SESSION}`).click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bare /race-hub recovers when no completed local analysis exists', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ temporal_state: 'between_weekends', race_hub_pre_session: false }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/race-hub')
|
||||||
|
await expect(page.getByTestId('race-hub-empty')).toContainText('No completed local analysis yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an explicit session URL remains stable when canonical context would refresh', async ({ page }) => {
|
||||||
|
let contextRequested = false
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) => {
|
||||||
|
contextRequested = true
|
||||||
|
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(pendingContext('2030-01-01T00:00:01Z')) })
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
|
||||||
|
expect(contextRequested).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
73
tests/release-fidelity/capture.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
import { waitForScreenshotReady } from '../visual/helpers'
|
||||||
|
|
||||||
|
const version = process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0'
|
||||||
|
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
|
||||||
|
const activeLiveState = {
|
||||||
|
is_live: true,
|
||||||
|
data: {
|
||||||
|
Drivers: {
|
||||||
|
'1': { RacingNumber: '1', Position: 1, Interval: '' },
|
||||||
|
'44': { RacingNumber: '44', Position: 2, Interval: '+2.314' },
|
||||||
|
},
|
||||||
|
DriverInfo: {
|
||||||
|
'1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' },
|
||||||
|
'44': { RacingNumber: '44', Tla: 'HAM', TeamColour: 'E8002D' },
|
||||||
|
},
|
||||||
|
Tyres: {},
|
||||||
|
RCMessages: [],
|
||||||
|
Weather: {},
|
||||||
|
Session: { MeetingName: 'Monaco', SessionName: 'Race', SessionType: 'Race' },
|
||||||
|
TrackStatus: '1',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(page: Page, name: string, project: string): Promise<void> {
|
||||||
|
const directory = join(output, version, 'candidate', project)
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
await page.screenshot({ fullPage: true, path: join(directory, `${name}.png`) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useSeededCalendar(page: Page): Promise<void> {
|
||||||
|
await page.route(/\/api\/v1\/meetings\?year=2025&source=openf1$/, (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: '[]' }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoCommandCenterFidelityReady(page: Page): Promise<void> {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await waitForScreenshotReady(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('release-fidelity candidate captures', () => {
|
||||||
|
test('approved screen set', async ({ page }, testInfo) => {
|
||||||
|
const project = testInfo.project.name
|
||||||
|
await useSeededCalendar(page)
|
||||||
|
|
||||||
|
if (project === 'desktop') {
|
||||||
|
await page.clock.install({ time: new Date('2025-06-01T12:00:00Z') })
|
||||||
|
await gotoCommandCenterFidelityReady(page)
|
||||||
|
await expect(page.getByTestId('hero-last-race')).toBeVisible()
|
||||||
|
await capture(page, 'weekend-between-races', project)
|
||||||
|
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(activeLiveState) }),
|
||||||
|
)
|
||||||
|
await page.clock.setFixedTime(new Date('2025-05-25T14:00:00Z'))
|
||||||
|
await gotoCommandCenterFidelityReady(page)
|
||||||
|
await expect(page.getByTestId('hero-live-timing')).toBeVisible()
|
||||||
|
await capture(page, 'weekend-live', project)
|
||||||
|
} else {
|
||||||
|
await page.clock.install({ time: new Date('2025-05-25T12:00:00Z') })
|
||||||
|
await gotoCommandCenterFidelityReady(page)
|
||||||
|
await expect(page.getByTestId('hero-countdown')).toContainText('Next')
|
||||||
|
await capture(page, 'weekend-between-sessions-mobile', project)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 154 KiB |
@@ -8,6 +8,20 @@ export const VIEWPORTS = {
|
|||||||
|
|
||||||
export const FULL_SESSION = 9472
|
export const FULL_SESSION = 9472
|
||||||
|
|
||||||
|
async function routeSeededSeasonCalendar(page: Page): Promise<void> {
|
||||||
|
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
|
||||||
|
await route.continue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url.searchParams.set('source', 'local')
|
||||||
|
const response = await route.fetch({ url: url.toString() })
|
||||||
|
await route.fulfill({ response })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Wait for web fonts and layout to settle before screenshots. */
|
/** Wait for web fonts and layout to settle before screenshots. */
|
||||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||||
await page.evaluate(() => document.fonts.ready)
|
await page.evaluate(() => document.fonts.ready)
|
||||||
@@ -15,15 +29,11 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
||||||
|
await routeSeededSeasonCalendar(page)
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||||
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
||||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 })
|
||||||
// The e2e stack runs with an unreachable OpenF1 base URL, so wait for the
|
|
||||||
// season-calendar query to settle on its local fallback before screenshotting.
|
|
||||||
await expect(
|
|
||||||
page.getByText('Using local meetings because the full calendar could not load.'),
|
|
||||||
).toBeVisible()
|
|
||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||