mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
5 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24ec4be94 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
7212eb9b44 | ||
|
|
785dccdca2 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: groom
|
name: groom
|
||||||
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
|
description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
|
||||||
argument-hint: <issue-number> [--lens architect]
|
argument-hint: <issue-number> [--lens architect]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: implement
|
name: implement
|
||||||
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.
|
description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
|
||||||
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: lens-architect
|
name: lens-architect
|
||||||
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
|
description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
|
||||||
argument-hint: <issue-number>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: review
|
name: review
|
||||||
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
|
description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
|
||||||
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: write-spec
|
name: write-spec
|
||||||
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage.
|
description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
|
||||||
argument-hint: <issue-number>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchNews } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
} from '../lib/digest'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
const SOURCE_DISPLAY: Record<string, string> = {
|
const SOURCE_DISPLAY: Record<string, string> = {
|
||||||
'fia': 'FIA',
|
'fia': 'FIA',
|
||||||
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PaddockBriefing() {
|
export function PaddockBriefing() {
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: () => fetchNews(100),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', latestSeason],
|
||||||
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const hub = hubQuery.data
|
||||||
|
const tagged = useMemo(
|
||||||
|
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
||||||
|
[news, hub],
|
||||||
|
)
|
||||||
|
|
||||||
|
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||||
|
const activeWindow = useMemo(
|
||||||
|
() => activeDigestWindow(windows, meetings, now),
|
||||||
|
[windows, meetings, now],
|
||||||
|
)
|
||||||
|
const sinceItems = useMemo(
|
||||||
|
() => itemsForWindow(tagged, activeWindow),
|
||||||
|
[tagged, activeWindow],
|
||||||
|
)
|
||||||
|
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
||||||
|
const sinceLabel = sinceLastLabel(meetings, now)
|
||||||
|
|
||||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||||
const preview = news?.slice(0, 5) ?? []
|
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||||
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
||||||
|
<span>Since {sinceLabel}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{sinceItems.length} items</span>
|
||||||
|
{sinceTags.length > 0 && (
|
||||||
|
<div className="cc-brief-digest-tags">
|
||||||
|
{sinceTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.key}
|
||||||
|
className="cc-brief-tag"
|
||||||
|
style={{ borderColor: tagColour(tag.colour) }}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||||
|
|
||||||
|
|||||||
293
frontend/src/lib/digest.ts
Normal file
293
frontend/src/lib/digest.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
|
||||||
|
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||||
|
|
||||||
|
export interface GPWindow {
|
||||||
|
meeting_key: number
|
||||||
|
meeting_name: string
|
||||||
|
/** Inclusive lower bound: end of previous GP, or open for season opener. */
|
||||||
|
start: Date | null
|
||||||
|
/** Exclusive upper bound: start of next GP, or open after the final round. */
|
||||||
|
end: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestTag {
|
||||||
|
kind: 'driver' | 'team'
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
|
||||||
|
|
||||||
|
export const RECENT_BUCKET_KEY = '__recent__'
|
||||||
|
|
||||||
|
interface MatchCandidate {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
tag: DigestTag
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagPattern {
|
||||||
|
pattern: string
|
||||||
|
tag: DigestTag
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMeetings(meetings: Meeting[]): Meeting[] {
|
||||||
|
return [...meetings].sort((a, b) => {
|
||||||
|
const left = meetingStartTime(a)?.getTime() ?? 0
|
||||||
|
const right = meetingStartTime(b)?.getTime() ?? 0
|
||||||
|
if (left !== right) return left - right
|
||||||
|
return a.meeting_name.localeCompare(b.meeting_name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
|
||||||
|
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
|
||||||
|
const sorted = sortMeetings(meetings)
|
||||||
|
return sorted.map((meeting, index) => ({
|
||||||
|
meeting_key: meeting.meeting_key,
|
||||||
|
meeting_name: meeting.meeting_name,
|
||||||
|
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
|
||||||
|
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemPublishedAt(item: NewsItem): Date | null {
|
||||||
|
if (!item.published_at) return null
|
||||||
|
const parsed = Date.parse(item.published_at)
|
||||||
|
return Number.isNaN(parsed) ? null : new Date(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemInWindow(published: Date, window: GPWindow): boolean {
|
||||||
|
if (window.start && published < window.start) return false
|
||||||
|
if (window.end && published >= window.end) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
|
||||||
|
const matches = windows.filter((window) => itemInWindow(published, window))
|
||||||
|
if (matches.length === 0) return null
|
||||||
|
return matches.reduce((best, window) => {
|
||||||
|
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||||
|
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||||
|
return windowStart >= bestStart ? window : best
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupedDigest {
|
||||||
|
windows: Array<{ window: GPWindow; items: NewsItem[] }>
|
||||||
|
recent: NewsItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket news items into GP windows; undated items land in `recent`. */
|
||||||
|
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
|
||||||
|
const buckets = new Map<number, NewsItem[]>()
|
||||||
|
const recent: NewsItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const published = itemPublishedAt(item)
|
||||||
|
if (!published) {
|
||||||
|
recent.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const match = windowForDate(windows, published)
|
||||||
|
if (!match) {
|
||||||
|
recent.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const list = buckets.get(match.meeting_key) ?? []
|
||||||
|
list.push(item)
|
||||||
|
buckets.set(match.meeting_key, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = windows
|
||||||
|
.map((window) => ({
|
||||||
|
window,
|
||||||
|
items: buckets.get(window.meeting_key) ?? [],
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.items.length > 0)
|
||||||
|
|
||||||
|
return { windows: grouped, recent }
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverLastName(fullName: string): string | null {
|
||||||
|
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
return parts[parts.length - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
|
||||||
|
const patterns: TagPattern[] = []
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
if (!team.team_name.trim()) continue
|
||||||
|
patterns.push({
|
||||||
|
pattern: team.team_name.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
kind: 'team',
|
||||||
|
key: `team:${team.team_name}`,
|
||||||
|
label: team.team_name,
|
||||||
|
colour: team.team_colour,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const driver of drivers) {
|
||||||
|
const colour = driver.team_colour
|
||||||
|
const base = {
|
||||||
|
kind: 'driver' as const,
|
||||||
|
colour,
|
||||||
|
}
|
||||||
|
if (driver.full_name.trim()) {
|
||||||
|
patterns.push({
|
||||||
|
pattern: driver.full_name.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
...base,
|
||||||
|
key: `driver:${driver.name_acronym}`,
|
||||||
|
label: driver.name_acronym,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const lastName = driverLastName(driver.full_name)
|
||||||
|
if (lastName) {
|
||||||
|
patterns.push({
|
||||||
|
pattern: lastName.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
...base,
|
||||||
|
key: `driver:${driver.name_acronym}`,
|
||||||
|
label: driver.name_acronym,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (driver.name_acronym.trim()) {
|
||||||
|
patterns.push({
|
||||||
|
pattern: driver.name_acronym.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
...base,
|
||||||
|
key: `driver:${driver.name_acronym}`,
|
||||||
|
label: driver.name_acronym,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
||||||
|
return a.start < b.end && b.start < a.end
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag items via case-insensitive substring match on title + summary.
|
||||||
|
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
|
||||||
|
*
|
||||||
|
* Known limitations: common-word last names, team names inside other words,
|
||||||
|
* and driver TLAs that appear in unrelated acronyms can false-positive.
|
||||||
|
*/
|
||||||
|
export function tagItems(
|
||||||
|
items: NewsItem[],
|
||||||
|
drivers: ChampHubDriver[],
|
||||||
|
teams: ChampHubTeam[],
|
||||||
|
): TaggedNewsItem[] {
|
||||||
|
const patterns = buildTagPatterns(drivers, teams)
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
|
||||||
|
const accepted: MatchCandidate[] = []
|
||||||
|
|
||||||
|
for (const { pattern, tag } of patterns) {
|
||||||
|
if (!pattern) continue
|
||||||
|
let from = 0
|
||||||
|
while (from <= haystack.length - pattern.length) {
|
||||||
|
const index = haystack.indexOf(pattern, from)
|
||||||
|
if (index === -1) break
|
||||||
|
const candidate: MatchCandidate = {
|
||||||
|
start: index,
|
||||||
|
end: index + pattern.length,
|
||||||
|
tag,
|
||||||
|
}
|
||||||
|
if (!accepted.some((match) => overlaps(match, candidate))) {
|
||||||
|
accepted.push(candidate)
|
||||||
|
}
|
||||||
|
from = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsByKey = new Map<string, DigestTag>()
|
||||||
|
for (const match of accepted) {
|
||||||
|
tagsByKey.set(match.tag.key, match.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeDigestWindow(
|
||||||
|
windows: GPWindow[],
|
||||||
|
meetings: Meeting[],
|
||||||
|
now: Date,
|
||||||
|
): GPWindow | null {
|
||||||
|
const next = nextUpcomingMeeting(meetings, now)
|
||||||
|
if (next) {
|
||||||
|
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
|
||||||
|
}
|
||||||
|
const recent = mostRecentPastMeeting(meetings, now)
|
||||||
|
if (recent) {
|
||||||
|
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
|
||||||
|
}
|
||||||
|
return windows[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
|
||||||
|
const last = mostRecentPastMeeting(meetings, now)
|
||||||
|
if (last) return last.meeting_name
|
||||||
|
const next = nextUpcomingMeeting(meetings, now)
|
||||||
|
if (next) return `Before ${next.meeting_name}`
|
||||||
|
return 'Pre-season'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
|
||||||
|
if (!window) return []
|
||||||
|
return items.filter((item) => {
|
||||||
|
const published = itemPublishedAt(item)
|
||||||
|
return published != null && itemInWindow(published, window)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
|
||||||
|
const counts = new Map<string, { tag: DigestTag; count: number }>()
|
||||||
|
for (const item of items) {
|
||||||
|
for (const tag of item.tags) {
|
||||||
|
const current = counts.get(tag.key)
|
||||||
|
if (current) current.count += 1
|
||||||
|
else counts.set(tag.key, { tag, count: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.values()]
|
||||||
|
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((entry) => entry.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
|
||||||
|
if (!tagKey) return items
|
||||||
|
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagColour(colour: string): string {
|
||||||
|
if (!colour) return 'var(--text-3)'
|
||||||
|
return colour.startsWith('#') ? colour : `#${colour}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
|
||||||
|
return [...buckets].sort((a, b) => {
|
||||||
|
const left = a.window.start?.getTime() ?? 0
|
||||||
|
const right = b.window.start?.getTime() ?? 0
|
||||||
|
return right - left
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchNewsArticle,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
markNewsRead,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
filterByTag,
|
||||||
|
groupByWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
sortWindowBucketsNewestFirst,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
type DigestTag,
|
||||||
|
type TaggedNewsItem,
|
||||||
|
} from '../lib/digest'
|
||||||
import { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
|
|
||||||
const PAGE_SIZE = 16
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<Category, string> = {
|
const CATEGORY_LABELS: Record<Category, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
official: 'Official',
|
official: 'Official',
|
||||||
@@ -71,6 +91,31 @@ function CategoryTabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TagChip({
|
||||||
|
tag,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
className = 'digest-tag-chip',
|
||||||
|
}: {
|
||||||
|
tag: DigestTag
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${className}${active ? ' active' : ''}`}
|
||||||
|
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="digest-tag-dot" aria-hidden="true" />
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function OGImage({ url, title }: { url?: string; title: string }) {
|
function OGImage({ url, title }: { url?: string; title: string }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const initial = (title[0] ?? '?').toUpperCase()
|
const initial = (title[0] ?? '?').toUpperCase()
|
||||||
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
|
|||||||
function BriefingCard({
|
function BriefingCard({
|
||||||
item,
|
item,
|
||||||
isActive,
|
isActive,
|
||||||
|
activeTag,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onTagClick,
|
||||||
}: {
|
}: {
|
||||||
item: NewsItem
|
item: TaggedNewsItem
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
activeTag: string | null
|
||||||
onSelect: (item: NewsItem) => void
|
onSelect: (item: NewsItem) => void
|
||||||
|
onTagClick: (tag: DigestTag) => void
|
||||||
}) {
|
}) {
|
||||||
const isRead = !!item.read_at
|
const isRead = !!item.read_at
|
||||||
const isVideo = categoryOf(item) === 'video'
|
const isVideo = categoryOf(item) === 'video'
|
||||||
@@ -129,6 +178,25 @@ function BriefingCard({
|
|||||||
{stripHtml(item.og_description || item.summary || '')}
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="bp-card-tags">
|
||||||
|
{item.tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.key}
|
||||||
|
type="button"
|
||||||
|
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
|
||||||
|
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onTagClick(tag)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="digest-tag-dot" aria-hidden="true" />
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -146,7 +214,6 @@ function ReaderPanel({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -155,7 +222,6 @@ function ReaderPanel({
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
// Fetch article when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
setArticle(null)
|
setArticle(null)
|
||||||
@@ -175,9 +241,14 @@ function ReaderPanel({
|
|||||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||||
}, [item?.url, item ? categoryOf(item) : ''])
|
}, [item?.url, item ? categoryOf(item) : ''])
|
||||||
|
|
||||||
// Scroll panel to top when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
panelRef.current?.scrollTo({ top: 0 })
|
const panel = panelRef.current
|
||||||
|
if (!panel) return
|
||||||
|
if (typeof panel.scrollTo === 'function') {
|
||||||
|
panel.scrollTo({ top: 0 })
|
||||||
|
} else {
|
||||||
|
panel.scrollTop = 0
|
||||||
|
}
|
||||||
}, [item?.url])
|
}, [item?.url])
|
||||||
|
|
||||||
const isOpen = item !== null
|
const isOpen = item !== null
|
||||||
@@ -297,7 +368,6 @@ function ReaderPanel({
|
|||||||
{article && !loading && article.content && (
|
{article && !loading && article.content && (
|
||||||
<div
|
<div
|
||||||
className="bp-reader-body"
|
className="bp-reader-body"
|
||||||
// readability strips scripts/iframes; sources are all known news outlets
|
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||||
/>
|
/>
|
||||||
@@ -332,8 +402,9 @@ function ReaderPanel({
|
|||||||
export function BriefingPage() {
|
export function BriefingPage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
@@ -341,13 +412,74 @@ export function BriefingPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = allNews.filter(
|
const seasonsQuery = useQuery({
|
||||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', latestSeason],
|
||||||
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const hub = hubQuery.data
|
||||||
|
|
||||||
|
const taggedNews = useMemo(
|
||||||
|
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
|
||||||
|
[allNews, hub],
|
||||||
)
|
)
|
||||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
||||||
const safePage = Math.min(page, pageCount)
|
const categoryFiltered = useMemo(
|
||||||
const pageStart = (safePage - 1) * PAGE_SIZE
|
() => taggedNews.filter(
|
||||||
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||||
|
),
|
||||||
|
[taggedNews, activeCategory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagFiltered = useMemo(
|
||||||
|
() => filterByTag(categoryFiltered, activeTag),
|
||||||
|
[categoryFiltered, activeTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||||
|
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
|
||||||
|
const windowSections = useMemo(
|
||||||
|
() => sortWindowBucketsNewestFirst(grouped.windows),
|
||||||
|
[grouped.windows],
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeWindow = useMemo(
|
||||||
|
() => activeDigestWindow(windows, meetings, now),
|
||||||
|
[windows, meetings, now],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sinceLastItems = useMemo(
|
||||||
|
() => itemsForWindow(tagFiltered, activeWindow),
|
||||||
|
[tagFiltered, activeWindow],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
|
||||||
|
const sinceLabel = sinceLastLabel(meetings, now)
|
||||||
|
|
||||||
|
const taggedByUrl = useMemo(() => {
|
||||||
|
const map = new Map<string, TaggedNewsItem>()
|
||||||
|
for (const item of tagFiltered) map.set(item.url, item)
|
||||||
|
return map
|
||||||
|
}, [tagFiltered])
|
||||||
|
|
||||||
|
const recentTagged = useMemo(() => {
|
||||||
|
const urls = new Set(grouped.recent.map((item) => item.url))
|
||||||
|
return tagFiltered.filter((item) => urls.has(item.url))
|
||||||
|
}, [grouped.recent, tagFiltered])
|
||||||
|
|
||||||
const counts: Record<Category, number> = {
|
const counts: Record<Category, number> = {
|
||||||
all: allNews.length,
|
all: allNews.length,
|
||||||
@@ -374,22 +506,14 @@ export function BriefingPage() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTagClick = useCallback((tag: DigestTag) => {
|
||||||
setPage((current) => Math.min(current, pageCount))
|
setActiveTag((current) => (current === tag.key ? null : tag.key))
|
||||||
}, [pageCount])
|
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
|
||||||
(nextPage: number) => {
|
|
||||||
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
}, [])
|
||||||
},
|
|
||||||
[pageCount],
|
|
||||||
)
|
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||||
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
const hasDigest = meetings.length > 0
|
||||||
const showPagination = filtered.length > PAGE_SIZE
|
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
@@ -408,74 +532,106 @@ export function BriefingPage() {
|
|||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
onChange={(c) => {
|
||||||
|
setActiveCategory(c)
|
||||||
|
setSelectedItem(null)
|
||||||
|
setActiveTag(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{hasDigest && (
|
||||||
<div className="bp-empty">
|
<div className="digest-sticky" data-testid="digest-sticky-header">
|
||||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
<div className="digest-sticky-head">
|
||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
||||||
</div>
|
<span className="digest-sticky-count">
|
||||||
) : (
|
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
||||||
<>
|
|
||||||
{showPagination && (
|
|
||||||
<div className="bp-pagination bp-pagination-top">
|
|
||||||
<span className="bp-pagination-meta mono">
|
|
||||||
{pageStart + 1}-{pageEnd} of {filtered.length}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="bp-pagination-actions">
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="bp-page-current mono">
|
|
||||||
Page {safePage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage + 1)}
|
|
||||||
disabled={safePage === pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{(sinceLastTags.length > 0 || activeTag) && (
|
||||||
|
<div className="digest-sticky-tags">
|
||||||
|
{sinceLastTags.map((tag) => (
|
||||||
|
<TagChip
|
||||||
|
key={tag.key}
|
||||||
|
tag={tag}
|
||||||
|
active={activeTag === tag.key}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeTag && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="digest-filter-clear"
|
||||||
|
onClick={() => setActiveTag(null)}
|
||||||
|
data-testid="digest-filter-clear"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<div className="bp-empty">
|
||||||
|
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
||||||
|
{activeTag ? ' Try clearing the tag filter.' : ''}
|
||||||
|
{!activeTag && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{windowSections.map(({ window, items }) => (
|
||||||
|
<section
|
||||||
|
key={window.meeting_key}
|
||||||
|
className="digest-section"
|
||||||
|
data-testid={`digest-window-${window.meeting_key}`}
|
||||||
|
>
|
||||||
|
<div className="digest-section-head">
|
||||||
|
<h3 className="digest-section-title">{window.meeting_name}</h3>
|
||||||
|
<span className="digest-section-count">{items.length}</span>
|
||||||
|
</div>
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
{pagedItems.map((item) => (
|
{items.map((item) => {
|
||||||
|
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
|
||||||
|
return (
|
||||||
|
<BriefingCard
|
||||||
|
key={item.url}
|
||||||
|
item={tagged}
|
||||||
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{recentTagged.length > 0 && (
|
||||||
|
<section className="digest-section digest-recent-section" data-testid="digest-recent">
|
||||||
|
<div className="digest-section-head">
|
||||||
|
<h3 className="digest-section-title">Recent</h3>
|
||||||
|
<span className="digest-section-count">{recentTagged.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
|
{recentTagged.map((item) => (
|
||||||
<BriefingCard
|
<BriefingCard
|
||||||
key={item.url}
|
key={item.url}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={selectedItem?.url === item.url}
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{showPagination && (
|
|
||||||
<div className="bp-pagination bp-pagination-bottom">
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="bp-page-current mono">
|
|
||||||
Page {safePage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage + 1)}
|
|
||||||
disabled={safePage === pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
178
frontend/src/styles/digest.css
Normal file
178
frontend/src/styles/digest.css
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/* ── Calendar-aware paddock digest ── */
|
||||||
|
|
||||||
|
.digest-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s, color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-chip:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-chip.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--tag-colour, var(--red));
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tag-colour, var(--text-3));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-filter-clear {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-filter-clear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section {
|
||||||
|
margin-bottom: var(--s6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-recent-section .digest-section-title {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tag:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tag.active {
|
||||||
|
border-color: var(--tag-colour, var(--red));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-brief-digest-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-brief-digest-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-brief-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
}
|
||||||
209
frontend/src/test/BriefingPage.test.tsx
Normal file
209
frontend/src/test/BriefingPage.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { BriefingPage } from '../pages/BriefingPage'
|
||||||
|
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchNews: vi.fn(),
|
||||||
|
fetchNewsArticle: vi.fn(),
|
||||||
|
markNewsRead: vi.fn(),
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchSeasonMeetings: vi.fn(),
|
||||||
|
fetchChampionshipHub: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchNews,
|
||||||
|
fetchNewsArticle,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchChampionshipHub,
|
||||||
|
markNewsRead,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
const mockFetchNews = vi.mocked(fetchNews)
|
||||||
|
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||||
|
const mockFetchHub = vi.mocked(fetchChampionshipHub)
|
||||||
|
const mockMarkNewsRead = vi.mocked(markNewsRead)
|
||||||
|
|
||||||
|
const bahrain: Meeting = {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Bahrain',
|
||||||
|
meeting_official_name: 'Bahrain GP',
|
||||||
|
location: 'Sakhir',
|
||||||
|
country_name: 'Bahrain',
|
||||||
|
country_code: 'BRN',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Sakhir',
|
||||||
|
date_start: '2025-03-14T00:00:00+00:00',
|
||||||
|
date_end: '2025-03-16T23:59:59+00:00',
|
||||||
|
year: 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
const monaco: Meeting = {
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'Monaco GP',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
year: 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
const drivers: ChampHubDriver[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 100,
|
||||||
|
position: 1,
|
||||||
|
wins: 3,
|
||||||
|
podiums: 5,
|
||||||
|
poles: 2,
|
||||||
|
form: [25, 18, 25],
|
||||||
|
cumulative: [25, 43, 68, 100],
|
||||||
|
teammate_wins: 4,
|
||||||
|
teammate_losses: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 80,
|
||||||
|
position: 2,
|
||||||
|
wins: 1,
|
||||||
|
podiums: 4,
|
||||||
|
poles: 1,
|
||||||
|
form: [18, 25, 18],
|
||||||
|
cumulative: [18, 36, 54, 80],
|
||||||
|
teammate_wins: 3,
|
||||||
|
teammate_losses: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const teams: ChampHubTeam[] = [
|
||||||
|
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
|
||||||
|
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2025,
|
||||||
|
round: 2,
|
||||||
|
total_rounds: 2,
|
||||||
|
rounds_left: 0,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1', 'R2'],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsItems: NewsItem[] = [
|
||||||
|
{
|
||||||
|
source: 'autosport-f1',
|
||||||
|
title: 'Verstappen sets the pace in Bahrain',
|
||||||
|
url: 'https://example.com/bahrain-ver',
|
||||||
|
published_at: '2025-03-15T10:00:00Z',
|
||||||
|
fetched_at: '2025-03-15T11:00:00Z',
|
||||||
|
summary: 'Red Bull dominate opening practice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'racefans-f1',
|
||||||
|
title: 'Norris targets Monaco upgrade',
|
||||||
|
url: 'https://example.com/monaco-nor',
|
||||||
|
published_at: '2025-04-20T10:00:00Z',
|
||||||
|
fetched_at: '2025-04-20T11:00:00Z',
|
||||||
|
summary: 'McLaren bring new floor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'bbc-f1',
|
||||||
|
title: 'Undated paddock rumour',
|
||||||
|
url: 'https://example.com/undated',
|
||||||
|
fetched_at: '2025-04-21T11:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BriefingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BriefingPage digest layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
|
||||||
|
mockFetchHub.mockResolvedValue(hub)
|
||||||
|
mockFetchNews.mockResolvedValue(newsItems)
|
||||||
|
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
|
||||||
|
mockMarkNewsRead.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
|
||||||
|
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
|
||||||
|
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters the digest when a tag chip is clicked', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sticky = screen.getByTestId('digest-sticky-header')
|
||||||
|
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
|
||||||
|
fireEvent.click(norTag)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('digest-filter-clear'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves category tabs', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
|||||||
race_control: [],
|
race_control: [],
|
||||||
weather: [],
|
weather: [],
|
||||||
laps: [],
|
laps: [],
|
||||||
|
chapters: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
|||||||
is_pit_out_lap: false,
|
is_pit_out_lap: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chapters: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekend: Weekend = {
|
const weekend: Weekend = {
|
||||||
|
|||||||
265
frontend/src/test/digest.test.ts
Normal file
265
frontend/src/test/digest.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
filterByTag,
|
||||||
|
groupByWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
sortWindowBucketsNewestFirst,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
windowForDate,
|
||||||
|
} from '../lib/digest'
|
||||||
|
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||||
|
|
||||||
|
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Bahrain',
|
||||||
|
meeting_official_name: 'Bahrain GP',
|
||||||
|
location: 'Sakhir',
|
||||||
|
country_name: 'Bahrain',
|
||||||
|
country_code: 'BRN',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Sakhir',
|
||||||
|
date_start: '2025-03-14T00:00:00+00:00',
|
||||||
|
date_end: '2025-03-16T23:59:59+00:00',
|
||||||
|
year: 2025,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const news = (overrides: Partial<NewsItem> = {}): NewsItem => ({
|
||||||
|
source: 'autosport-f1',
|
||||||
|
title: 'Headline',
|
||||||
|
url: `https://example.com/${Math.random()}`,
|
||||||
|
fetched_at: '2025-04-01T12:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 100,
|
||||||
|
position: 1,
|
||||||
|
wins: 3,
|
||||||
|
podiums: 5,
|
||||||
|
poles: 2,
|
||||||
|
form: [25, 18, 25],
|
||||||
|
cumulative: [25, 43, 68, 100],
|
||||||
|
teammate_wins: 4,
|
||||||
|
teammate_losses: 1,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const team = (over: Partial<ChampHubTeam>): ChampHubTeam => ({
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 150,
|
||||||
|
position: 1,
|
||||||
|
wins: 4,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gpWindows', () => {
|
||||||
|
const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
|
||||||
|
const monaco = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
const canada = meeting({
|
||||||
|
meeting_key: 3,
|
||||||
|
meeting_name: 'Canada',
|
||||||
|
date_start: '2025-06-13T00:00:00+00:00',
|
||||||
|
date_end: '2025-06-15T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives inter-race windows mid-season', () => {
|
||||||
|
const now = new Date('2025-04-10T12:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco, canada], now)
|
||||||
|
|
||||||
|
expect(windows).toHaveLength(3)
|
||||||
|
expect(windows[0].start).toBeNull()
|
||||||
|
expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
|
||||||
|
expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
|
||||||
|
expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
|
||||||
|
expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
|
||||||
|
expect(windows[2].end).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles before the first race of the season', () => {
|
||||||
|
const now = new Date('2025-01-01T00:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco], now)
|
||||||
|
expect(windows[0].start).toBeNull()
|
||||||
|
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
|
||||||
|
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles after the final race of the season', () => {
|
||||||
|
const now = new Date('2025-12-01T00:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco], now)
|
||||||
|
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
|
||||||
|
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
|
||||||
|
expect(windows[1].end).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groupByWindow', () => {
|
||||||
|
const bahrain = meeting({ meeting_key: 1 })
|
||||||
|
const monaco = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
|
||||||
|
|
||||||
|
it('buckets dated items into the matching GP window', () => {
|
||||||
|
const items = [
|
||||||
|
news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
|
||||||
|
news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
|
||||||
|
news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const grouped = groupByWindow(items, windows)
|
||||||
|
const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
|
||||||
|
const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
|
||||||
|
|
||||||
|
expect(bahrainItems).toHaveLength(1)
|
||||||
|
expect(bahrainItems[0].title).toBe('Pre-Bahrain')
|
||||||
|
expect(monacoItems).toHaveLength(2)
|
||||||
|
expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts undated items in the recent bucket', () => {
|
||||||
|
const items = [
|
||||||
|
news({ url: 'https://u', title: 'Undated story' }),
|
||||||
|
news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
|
||||||
|
]
|
||||||
|
const grouped = groupByWindow(items, windows)
|
||||||
|
expect(grouped.recent).toHaveLength(1)
|
||||||
|
expect(grouped.recent[0].url).toBe('https://u')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts window buckets newest first for display', () => {
|
||||||
|
const items = [
|
||||||
|
news({ published_at: '2025-03-10T10:00:00Z' }),
|
||||||
|
news({ published_at: '2025-05-24T10:00:00Z' }),
|
||||||
|
]
|
||||||
|
const grouped = groupByWindow(items, windows)
|
||||||
|
const sorted = sortWindowBucketsNewestFirst(grouped.windows)
|
||||||
|
expect(sorted[0].window.meeting_key).toBe(2)
|
||||||
|
expect(sorted[1].window.meeting_key).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tagItems', () => {
|
||||||
|
const drivers = [
|
||||||
|
driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const teams = [
|
||||||
|
team({ team_name: 'Red Bull' }),
|
||||||
|
team({ team_name: 'McLaren', team_colour: 'ff8000' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
it('tags drivers and teams case-insensitively', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
const labels = item.tags.map((tag) => tag.label).sort()
|
||||||
|
expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no tags when nothing matches', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'Generic paddock update' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
expect(item.tags).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the longest overlapping match', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'Max Verstappen extends championship lead' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
expect(item.tags).toHaveLength(1)
|
||||||
|
expect(item.tags[0].label).toBe('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches a driver last name inside prose', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'Verstappen was untouchable in qualifying' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('digest helpers', () => {
|
||||||
|
it('collects top tags by frequency', () => {
|
||||||
|
const tagged = tagItems(
|
||||||
|
[
|
||||||
|
news({ title: 'VER wins', url: 'https://a' }),
|
||||||
|
news({ title: 'VER dominates', url: 'https://b' }),
|
||||||
|
news({ title: 'Norris podium', url: 'https://c' }),
|
||||||
|
],
|
||||||
|
[driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
|
||||||
|
[team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
|
||||||
|
)
|
||||||
|
expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters items by tag key', () => {
|
||||||
|
const tagged = tagItems(
|
||||||
|
[
|
||||||
|
news({ title: 'VER wins', url: 'https://a' }),
|
||||||
|
news({ title: 'Neutral headline', url: 'https://b' }),
|
||||||
|
],
|
||||||
|
[driver({})],
|
||||||
|
[team({})],
|
||||||
|
)
|
||||||
|
const filtered = filterByTag(tagged, 'driver:VER')
|
||||||
|
expect(filtered).toHaveLength(1)
|
||||||
|
expect(filtered[0].url).toBe('https://a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects items for the active digest window', () => {
|
||||||
|
const bahrain = meeting({ meeting_key: 1 })
|
||||||
|
const monaco = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
const now = new Date('2025-04-10T12:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco], now)
|
||||||
|
const active = activeDigestWindow(windows, [bahrain, monaco], now)
|
||||||
|
const tagged = tagItems(
|
||||||
|
[
|
||||||
|
news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
|
||||||
|
news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const inWindow = itemsForWindow(tagged, active)
|
||||||
|
expect(inWindow).toHaveLength(1)
|
||||||
|
expect(inWindow[0].url).toBe('https://in')
|
||||||
|
expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -91,6 +91,17 @@ export interface RaceHub {
|
|||||||
race_control: RaceControlMessage[]
|
race_control: RaceControlMessage[]
|
||||||
weather: WeatherSample[]
|
weather: WeatherSample[]
|
||||||
laps: Lap[]
|
laps: Lap[]
|
||||||
|
chapters: Chapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||||
|
title: string
|
||||||
|
start_lap: number
|
||||||
|
end_lap: number
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
driver_numbers: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stint {
|
export interface Stint {
|
||||||
|
|||||||
628
internal/chapters/chapters.go
Normal file
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindStart = "start"
|
||||||
|
KindSafetyCar = "safety_car"
|
||||||
|
KindVirtualSafetyCar = "virtual_safety_car"
|
||||||
|
KindRedFlag = "red_flag"
|
||||||
|
KindPitPhase = "pit_phase"
|
||||||
|
KindDecisiveSwing = "decisive_swing"
|
||||||
|
KindFinish = "finish"
|
||||||
|
|
||||||
|
pitPhaseWindowLaps = 3
|
||||||
|
pitPhaseShare = 0.30
|
||||||
|
minPitPhaseStops = 2
|
||||||
|
maxDecisiveSwings = 3
|
||||||
|
decisiveAfterLap = 5
|
||||||
|
structuralPriority = 110
|
||||||
|
flagPriority = 100
|
||||||
|
pitPhasePriority = 50
|
||||||
|
decisivePriority = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
type RaceControl = models.RaceControl
|
||||||
|
type PositionSample = models.Position
|
||||||
|
type Lap = models.Lap
|
||||||
|
|
||||||
|
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||||
|
type Chapter struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
StartLap int `json:"start_lap"`
|
||||||
|
EndLap int `json:"end_lap"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
DriverNumbers []int `json:"driver_numbers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||||
|
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||||
|
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||||
|
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||||
|
return []Chapter{}
|
||||||
|
}
|
||||||
|
if totalLaps <= 0 {
|
||||||
|
totalLaps = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
lapIndex := buildLapIndex(laps)
|
||||||
|
chapters := []Chapter{
|
||||||
|
{
|
||||||
|
Kind: KindStart,
|
||||||
|
Title: "Start",
|
||||||
|
StartLap: 1,
|
||||||
|
EndLap: minInt(1, totalLaps),
|
||||||
|
StartTime: lapIndex.lapStart(1),
|
||||||
|
EndTime: lapIndex.lapEnd(1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||||
|
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||||
|
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||||
|
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||||
|
|
||||||
|
return resolveConflicts(chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.LapNumber > totalLaps {
|
||||||
|
totalLaps = l.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, msg := range rc {
|
||||||
|
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||||
|
totalLaps = *msg.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalLaps
|
||||||
|
}
|
||||||
|
|
||||||
|
type lapIndex struct {
|
||||||
|
byLap map[int]string
|
||||||
|
events []lapEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type lapEvent struct {
|
||||||
|
lap int
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLapIndex(laps []Lap) lapIndex {
|
||||||
|
idx := lapIndex{byLap: map[int]string{}}
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||||
|
idx.byLap[l.LapNumber] = l.DateStart
|
||||||
|
}
|
||||||
|
at, ok := parseTime(l.DateStart)
|
||||||
|
if ok {
|
||||||
|
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(idx.events, func(i, j int) bool {
|
||||||
|
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||||
|
return idx.events[i].lap < idx.events[j].lap
|
||||||
|
}
|
||||||
|
return idx.events[i].at.Before(idx.events[j].at)
|
||||||
|
})
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapStart(lap int) string {
|
||||||
|
return idx.byLap[lap]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapEnd(lap int) string {
|
||||||
|
if v := idx.byLap[lap+1]; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return idx.byLap[lap]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapForTime(raw string) int {
|
||||||
|
at, ok := parseTime(raw)
|
||||||
|
if !ok || len(idx.events) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lap := 0
|
||||||
|
for _, event := range idx.events {
|
||||||
|
if event.at.After(at) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lap = event.lap
|
||||||
|
}
|
||||||
|
if lap == 0 {
|
||||||
|
return idx.events[0].lap
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|
||||||
|
type flagState struct {
|
||||||
|
startLap int
|
||||||
|
startTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||||
|
var chapters []Chapter
|
||||||
|
active := map[string]flagState{}
|
||||||
|
for _, msg := range rc {
|
||||||
|
kind, ok := flagKind(msg)
|
||||||
|
if !ok && greenFlagClear(msg) {
|
||||||
|
for activeKind, st := range active {
|
||||||
|
lap := messageLap(msg, idx)
|
||||||
|
if lap <= 0 {
|
||||||
|
lap = st.startLap
|
||||||
|
}
|
||||||
|
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: activeKind,
|
||||||
|
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||||
|
})
|
||||||
|
delete(active, activeKind)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := messageLap(msg, idx)
|
||||||
|
if lap <= 0 {
|
||||||
|
lap = 1
|
||||||
|
}
|
||||||
|
if flagCleared(msg) {
|
||||||
|
st, ok := active[kind]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: kind,
|
||||||
|
Title: flagTitle(kind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||||
|
})
|
||||||
|
delete(active, kind)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if flagStarted(msg) {
|
||||||
|
active[kind] = flagState{
|
||||||
|
startLap: clampLap(lap, 1, totalLaps),
|
||||||
|
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kind, st := range active {
|
||||||
|
endLap := totalLaps
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: kind,
|
||||||
|
Title: flagTitle(kind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: idx.lapEnd(endLap),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagKind(msg RaceControl) (string, bool) {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||||
|
return KindVirtualSafetyCar, true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||||
|
return KindRedFlag, true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||||
|
return KindSafetyCar, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagStarted(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(text, "DEPLOY") ||
|
||||||
|
strings.Contains(text, "RED FLAG") ||
|
||||||
|
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||||
|
strings.Contains(text, "VSC") ||
|
||||||
|
strings.Contains(text, "SAFETY CAR") ||
|
||||||
|
string(msg.Flag) == string(models.FlagRed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagCleared(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
return strings.Contains(text, "CLEAR") ||
|
||||||
|
strings.Contains(text, "ENDING") ||
|
||||||
|
strings.Contains(text, "IN THIS LAP") ||
|
||||||
|
strings.Contains(text, "GREEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func greenFlagClear(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Flag), msg.Message)
|
||||||
|
return strings.Contains(text, "GREEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagTitle(kind string, startLap, endLap int) string {
|
||||||
|
name := "Flag period"
|
||||||
|
switch kind {
|
||||||
|
case KindSafetyCar:
|
||||||
|
name = "Safety Car"
|
||||||
|
case KindVirtualSafetyCar:
|
||||||
|
name = "Virtual Safety Car"
|
||||||
|
case KindRedFlag:
|
||||||
|
name = "Red Flag"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||||
|
type pitOut struct {
|
||||||
|
lap int
|
||||||
|
driver int
|
||||||
|
}
|
||||||
|
var stops []pitOut
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||||
|
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stops) < minPitPhaseStops {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(stops, func(i, j int) bool {
|
||||||
|
if stops[i].lap == stops[j].lap {
|
||||||
|
return stops[i].driver < stops[j].driver
|
||||||
|
}
|
||||||
|
return stops[i].lap < stops[j].lap
|
||||||
|
})
|
||||||
|
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||||
|
if needed < minPitPhaseStops {
|
||||||
|
needed = minPitPhaseStops
|
||||||
|
}
|
||||||
|
|
||||||
|
var windows []Chapter
|
||||||
|
for i := 0; i < len(stops); i++ {
|
||||||
|
start := stops[i].lap
|
||||||
|
end := start + pitPhaseWindowLaps - 1
|
||||||
|
drivers := map[int]bool{}
|
||||||
|
count := 0
|
||||||
|
for _, stop := range stops {
|
||||||
|
if stop.lap < start || stop.lap > end {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
drivers[stop.driver] = true
|
||||||
|
}
|
||||||
|
if count < needed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindPitPhase,
|
||||||
|
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||||
|
StartLap: start,
|
||||||
|
EndLap: end,
|
||||||
|
StartTime: idx.lapStart(start),
|
||||||
|
EndTime: idx.lapEnd(end),
|
||||||
|
DriverNumbers: sortedDriverNumbers(drivers),
|
||||||
|
}
|
||||||
|
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||||
|
last := &windows[len(windows)-1]
|
||||||
|
if ch.EndLap > last.EndLap {
|
||||||
|
last.EndLap = ch.EndLap
|
||||||
|
last.EndTime = idx.lapEnd(last.EndLap)
|
||||||
|
}
|
||||||
|
drivers := sliceToSet(last.DriverNumbers)
|
||||||
|
for _, driver := range ch.DriverNumbers {
|
||||||
|
drivers[driver] = true
|
||||||
|
}
|
||||||
|
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||||
|
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
windows = append(windows, ch)
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
type swingCandidate struct {
|
||||||
|
chapter Chapter
|
||||||
|
significance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||||
|
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
final := snapshots[totalLaps]
|
||||||
|
if len(final) == 0 {
|
||||||
|
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||||
|
if len(snapshots[lap]) > 0 {
|
||||||
|
final = snapshots[lap]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var candidates []swingCandidate
|
||||||
|
seenDriver := map[int]bool{}
|
||||||
|
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||||
|
prev := snapshots[lap-1]
|
||||||
|
curr := snapshots[lap]
|
||||||
|
if len(prev) == 0 || len(curr) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for driver, pos := range curr {
|
||||||
|
prevPos, ok := prev[driver]
|
||||||
|
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finalPos, ok := final[driver]
|
||||||
|
if !ok || finalPos > pos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||||
|
drivers := []int{driver}
|
||||||
|
if overtaken != 0 {
|
||||||
|
drivers = append(drivers, overtaken)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, swingCandidate{
|
||||||
|
chapter: Chapter{
|
||||||
|
Kind: KindDecisiveSwing,
|
||||||
|
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||||
|
StartLap: lap,
|
||||||
|
EndLap: lap,
|
||||||
|
StartTime: idx.lapStart(lap),
|
||||||
|
EndTime: idx.lapEnd(lap),
|
||||||
|
DriverNumbers: drivers,
|
||||||
|
},
|
||||||
|
significance: (prevPos-pos)*10 + (6 - pos),
|
||||||
|
})
|
||||||
|
seenDriver[driver] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].significance == candidates[j].significance {
|
||||||
|
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||||
|
}
|
||||||
|
return candidates[i].significance > candidates[j].significance
|
||||||
|
})
|
||||||
|
if len(candidates) > maxDecisiveSwings {
|
||||||
|
candidates = candidates[:maxDecisiveSwings]
|
||||||
|
}
|
||||||
|
out := make([]Chapter, 0, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
out = append(out, c.chapter)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||||
|
byLap := map[int][]PositionSample{}
|
||||||
|
for _, p := range positions {
|
||||||
|
if p.Position <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := idx.lapForTime(p.Date)
|
||||||
|
if lap <= 0 || lap > totalLaps {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byLap[lap] = append(byLap[lap], p)
|
||||||
|
}
|
||||||
|
last := map[int]int{}
|
||||||
|
snapshots := map[int]map[int]int{}
|
||||||
|
for lap := 1; lap <= totalLaps; lap++ {
|
||||||
|
for _, p := range byLap[lap] {
|
||||||
|
last[p.DriverNumber] = p.Position
|
||||||
|
}
|
||||||
|
if len(last) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := make(map[int]int, len(last))
|
||||||
|
for driver, pos := range last {
|
||||||
|
cp[driver] = pos
|
||||||
|
}
|
||||||
|
snapshots[lap] = cp
|
||||||
|
}
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||||
|
for driver, driverPos := range snapshot {
|
||||||
|
if driver != exclude && driverPos == pos {
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||||
|
finishLap := totalLaps
|
||||||
|
finishTime := idx.lapEnd(totalLaps)
|
||||||
|
for _, msg := range rc {
|
||||||
|
text := upperText(string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||||
|
if lap := messageLap(msg, idx); lap > 0 {
|
||||||
|
finishLap = lap
|
||||||
|
}
|
||||||
|
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startLap := finishLap - 1
|
||||||
|
if startLap < 1 {
|
||||||
|
startLap = 1
|
||||||
|
}
|
||||||
|
return Chapter{
|
||||||
|
Kind: KindFinish,
|
||||||
|
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||||
|
StartLap: startLap,
|
||||||
|
EndLap: finishLap,
|
||||||
|
StartTime: idx.lapStart(startLap),
|
||||||
|
EndTime: finishTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||||
|
normalized := make([]Chapter, 0, len(chapters))
|
||||||
|
for _, ch := range chapters {
|
||||||
|
if ch.StartLap <= 0 {
|
||||||
|
ch.StartLap = 1
|
||||||
|
}
|
||||||
|
if ch.EndLap <= 0 {
|
||||||
|
ch.EndLap = ch.StartLap
|
||||||
|
}
|
||||||
|
if ch.EndLap < ch.StartLap {
|
||||||
|
ch.EndLap = ch.StartLap
|
||||||
|
}
|
||||||
|
if ch.DriverNumbers == nil {
|
||||||
|
ch.DriverNumbers = []int{}
|
||||||
|
}
|
||||||
|
normalized = append(normalized, ch)
|
||||||
|
}
|
||||||
|
sort.SliceStable(normalized, func(i, j int) bool {
|
||||||
|
if normalized[i].StartLap == normalized[j].StartLap {
|
||||||
|
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||||
|
}
|
||||||
|
return normalized[i].StartLap < normalized[j].StartLap
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]Chapter, 0, len(normalized))
|
||||||
|
for _, ch := range normalized {
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last := &out[len(out)-1]
|
||||||
|
if ch.StartLap > last.EndLap {
|
||||||
|
out = append(out, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if priority(ch.Kind) > priority(last.Kind) {
|
||||||
|
if last.StartLap < ch.StartLap {
|
||||||
|
last.EndLap = ch.StartLap - 1
|
||||||
|
out = append(out, ch)
|
||||||
|
} else {
|
||||||
|
*last = ch
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch.EndLap > last.EndLap {
|
||||||
|
ch.StartLap = last.EndLap + 1
|
||||||
|
if ch.StartLap <= ch.EndLap {
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFlag(kind string) bool {
|
||||||
|
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(kind string) int {
|
||||||
|
switch kind {
|
||||||
|
case KindStart, KindFinish:
|
||||||
|
return structuralPriority
|
||||||
|
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||||
|
return flagPriority
|
||||||
|
case KindPitPhase:
|
||||||
|
return pitPhasePriority
|
||||||
|
case KindDecisiveSwing:
|
||||||
|
return decisivePriority
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||||
|
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||||
|
return *msg.LapNumber
|
||||||
|
}
|
||||||
|
return idx.lapForTime(msg.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampLap(lap, minLap, maxLap int) int {
|
||||||
|
if lap < minLap {
|
||||||
|
return minLap
|
||||||
|
}
|
||||||
|
if maxLap > 0 && lap > maxLap {
|
||||||
|
return maxLap
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(raw string) (time.Time, bool) {
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return at, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func upperText(parts ...string) string {
|
||||||
|
return strings.ToUpper(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||||
|
out := make([]int, 0, len(drivers))
|
||||||
|
for driver := range drivers {
|
||||||
|
out = append(out, driver)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sliceToSet(values []int) map[int]bool {
|
||||||
|
out := make(map[int]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out[value] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
218
internal/chapters/chapters_test.go
Normal file
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectStructuralChapters(t *testing.T) {
|
||||||
|
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||||
|
|
||||||
|
if len(chapters) < 2 {
|
||||||
|
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||||
|
}
|
||||||
|
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||||
|
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||||
|
}
|
||||||
|
got := chapters[len(chapters)-1]
|
||||||
|
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||||
|
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||||
|
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||||
|
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFlagPeriods(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
start RaceControl
|
||||||
|
end RaceControl
|
||||||
|
wantKind string
|
||||||
|
wantTitle string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "safety car",
|
||||||
|
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
wantKind: KindSafetyCar,
|
||||||
|
wantTitle: "Safety Car (L12-L15)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "virtual safety car",
|
||||||
|
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||||
|
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||||
|
wantKind: KindVirtualSafetyCar,
|
||||||
|
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "red flag",
|
||||||
|
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||||
|
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||||
|
wantKind: KindRedFlag,
|
||||||
|
wantTitle: "Red Flag (L31-L33)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||||
|
got := findKind(chapters, tt.wantKind)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||||
|
}
|
||||||
|
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||||
|
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||||
|
pitOuts := map[int][]int{
|
||||||
|
5: {1},
|
||||||
|
10: {2},
|
||||||
|
20: {3},
|
||||||
|
21: {4},
|
||||||
|
22: {5},
|
||||||
|
30: {6},
|
||||||
|
35: {7},
|
||||||
|
40: {8},
|
||||||
|
45: {9},
|
||||||
|
50: {10},
|
||||||
|
}
|
||||||
|
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||||
|
|
||||||
|
got := findKind(chapters, KindPitPhase)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||||
|
}
|
||||||
|
if got.StartLap != 20 || got.EndLap != 22 {
|
||||||
|
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||||
|
}
|
||||||
|
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||||
|
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||||
|
positions := []PositionSample{
|
||||||
|
pos(1, 1, 1),
|
||||||
|
pos(1, 16, 4),
|
||||||
|
pos(1, 55, 3),
|
||||||
|
pos(6, 16, 3),
|
||||||
|
pos(6, 55, 4),
|
||||||
|
pos(8, 44, 5),
|
||||||
|
pos(8, 63, 6),
|
||||||
|
pos(10, 44, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||||
|
got := findKind(chapters, KindDecisiveSwing)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||||
|
}
|
||||||
|
if got.StartLap != 6 || got.EndLap != 6 {
|
||||||
|
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||||
|
}
|
||||||
|
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||||
|
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||||
|
pitOuts := map[int][]int{
|
||||||
|
12: {1, 2},
|
||||||
|
13: {3, 4},
|
||||||
|
14: {5, 6},
|
||||||
|
}
|
||||||
|
rcs := []RaceControl{
|
||||||
|
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||||
|
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||||
|
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||||
|
}
|
||||||
|
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||||
|
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(chapters); i++ {
|
||||||
|
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||||
|
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rcLap(r RaceControl) int {
|
||||||
|
if r.LapNumber == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *r.LapNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||||
|
for i := range chapters {
|
||||||
|
if chapters[i].Kind == kind {
|
||||||
|
return &chapters[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||||
|
var laps []Lap
|
||||||
|
for lap := 1; lap <= total; lap++ {
|
||||||
|
drivers := []int{1}
|
||||||
|
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||||
|
drivers = pitDrivers
|
||||||
|
}
|
||||||
|
for _, driver := range drivers {
|
||||||
|
laps = append(laps, Lap{
|
||||||
|
DriverNumber: driver,
|
||||||
|
LapNumber: lap,
|
||||||
|
DateStart: lapTime(lap),
|
||||||
|
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return laps
|
||||||
|
}
|
||||||
|
|
||||||
|
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||||
|
return RaceControl{
|
||||||
|
Category: category,
|
||||||
|
Flag: flag,
|
||||||
|
Message: message,
|
||||||
|
LapNumber: &lap,
|
||||||
|
Date: lapTime(lap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pos(lap int, driver int, position int) PositionSample {
|
||||||
|
return PositionSample{
|
||||||
|
DriverNumber: driver,
|
||||||
|
Position: position,
|
||||||
|
Date: lapTime(lap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lapTime(lap int) string {
|
||||||
|
minute := lap - 1
|
||||||
|
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsDriver(drivers []int, driver int) bool {
|
||||||
|
for _, candidate := range drivers {
|
||||||
|
if candidate == driver {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
|||||||
RaceControl []models.RaceControl `json:"race_control"`
|
RaceControl []models.RaceControl `json:"race_control"`
|
||||||
Weather []models.Weather `json:"weather"`
|
Weather []models.Weather `json:"weather"`
|
||||||
Laps []models.Lap `json:"laps"`
|
Laps []models.Lap `json:"laps"`
|
||||||
|
Chapters []chapters.Chapter `json:"chapters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
RaceControl: []models.RaceControl{},
|
RaceControl: []models.RaceControl{},
|
||||||
Weather: []models.Weather{},
|
Weather: []models.Weather{},
|
||||||
Laps: []models.Lap{},
|
Laps: []models.Lap{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(sessionKey)
|
sess, err := s.store.GetSession(sessionKey)
|
||||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||||
hub.Source = responseSource(hub.Datasets)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
return hub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||||
|
total := 0
|
||||||
|
for _, result := range results {
|
||||||
|
if result.NumberOfLaps > total {
|
||||||
|
total = result.NumberOfLaps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, lap := range laps {
|
||||||
|
if lap.LapNumber > total {
|
||||||
|
total = lap.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
|||||||
Drivers: []models.Driver{},
|
Drivers: []models.Driver{},
|
||||||
Results: []query.EnrichedResult{},
|
Results: []query.EnrichedResult{},
|
||||||
StartingGrid: []query.EnrichedGrid{},
|
StartingGrid: []query.EnrichedGrid{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedRaceHubStore(t, st)
|
||||||
|
sessionKey := 9472
|
||||||
|
meetingKey := 1229
|
||||||
|
|
||||||
|
for lap := 1; lap <= 12; lap++ {
|
||||||
|
if err := st.UpsertLap(store.Lap{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
LapNumber: lap,
|
||||||
|
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||||
|
LapDuration: 75,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sample := range []store.PositionSample{
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||||
|
} {
|
||||||
|
if err := st.UpsertPositionSample(sample); err != nil {
|
||||||
|
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := testServer(t, st)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleRaceHub(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub query.RaceHub
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(hub.Chapters) == 0 {
|
||||||
|
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||||
|
}
|
||||||
|
if hub.Chapters[0].Kind != "start" {
|
||||||
|
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||||
|
}
|
||||||
|
foundSwing := false
|
||||||
|
for _, chapter := range hub.Chapters {
|
||||||
|
if chapter.Kind == "decisive_swing" {
|
||||||
|
foundSwing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundSwing {
|
||||||
|
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
seedRaceHubStore(t, st)
|
seedRaceHubStore(t, st)
|
||||||
|
|||||||
Reference in New Issue
Block a user