From f24ec4be948bb9c20cfb2ad0a3776b4448b01b9f Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:14:55 -0400
Subject: [PATCH] feat(frontend): calendar-aware paddock briefing digest (#26)
Reframe Briefing into GP-window sections with driver/team tag chips, a sticky
"since last race" header, and client-side digest helpers (gpWindows, groupByWindow,
tagItems). Overlapping inter-race windows resolve to the latest GP; undated items
land in a Recent bucket. Vitest covers digest lib + page render/filter behaviour.
Co-authored-by: Cursor
---
frontend/src/components/PaddockBriefing.tsx | 80 ++++-
frontend/src/lib/digest.ts | 293 +++++++++++++++++
frontend/src/pages/BriefingPage.tsx | 336 ++++++++++++++------
frontend/src/styles/digest.css | 178 +++++++++++
frontend/src/test/BriefingPage.test.tsx | 209 ++++++++++++
frontend/src/test/digest.test.ts | 265 +++++++++++++++
6 files changed, 1269 insertions(+), 92 deletions(-)
create mode 100644 frontend/src/lib/digest.ts
create mode 100644 frontend/src/styles/digest.css
create mode 100644 frontend/src/test/BriefingPage.test.tsx
create mode 100644 frontend/src/test/digest.test.ts
diff --git a/frontend/src/components/PaddockBriefing.tsx b/frontend/src/components/PaddockBriefing.tsx
index 83d9ee8..3f44aeb 100644
--- a/frontend/src/components/PaddockBriefing.tsx
+++ b/frontend/src/components/PaddockBriefing.tsx
@@ -1,7 +1,23 @@
+import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
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 '../styles/digest.css'
const SOURCE_DISPLAY: Record = {
'fia': 'FIA',
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record = {
}
export function PaddockBriefing() {
+ const now = useMemo(() => new Date(), [])
+
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
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 preview = news?.slice(0, 5) ?? []
+ const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
return (
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
+ {meetings.length > 0 && (
+
+
Since {sinceLabel}
+
·
+
{sinceItems.length} items
+ {sinceTags.length > 0 && (
+
+ {sinceTags.map((tag) => (
+
+ {tag.label}
+
+ ))}
+
+ )}
+
+ )}
+
{isLoading && loading…
}
{isError && Failed to load briefing
}
diff --git a/frontend/src/lib/digest.ts b/frontend/src/lib/digest.ts
new file mode 100644
index 0000000..ec01629
--- /dev/null
+++ b/frontend/src/lib/digest.ts
@@ -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()
+ 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()
+ 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()
+ 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(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
+ })
+}
diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx
index a283f05..d0ceda3 100644
--- a/frontend/src/pages/BriefingPage.tsx
+++ b/frontend/src/pages/BriefingPage.tsx
@@ -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 { 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 type { ArticleContent, NewsItem } from '../types'
+import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
-const PAGE_SIZE = 16
-
const CATEGORY_LABELS: Record = {
all: 'All',
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 (
+
+ )
+}
+
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
function BriefingCard({
item,
isActive,
+ activeTag,
onSelect,
+ onTagClick,
}: {
- item: NewsItem
+ item: TaggedNewsItem
isActive: boolean
+ activeTag: string | null
onSelect: (item: NewsItem) => void
+ onTagClick: (tag: DigestTag) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
@@ -129,6 +178,25 @@ function BriefingCard({
{stripHtml(item.og_description || item.summary || '')}
)}
+ {item.tags.length > 0 && (
+
+ {item.tags.map((tag) => (
+
+ ))}
+
+ )}
)
@@ -146,7 +214,6 @@ function ReaderPanel({
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
- // Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -155,7 +222,6 @@ function ReaderPanel({
return () => document.removeEventListener('keydown', handler)
}, [onClose])
- // Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
@@ -175,9 +241,14 @@ function ReaderPanel({
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : ''])
- // Scroll panel to top when item changes
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])
const isOpen = item !== null
@@ -297,7 +368,6 @@ function ReaderPanel({
{article && !loading && article.content && (
@@ -332,8 +402,9 @@ function ReaderPanel({
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState('all')
const [selectedItem, setSelectedItem] = useState(null)
- const [page, setPage] = useState(1)
+ const [activeTag, setActiveTag] = useState(null)
const queryClient = useQueryClient()
+ const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
@@ -341,13 +412,74 @@ export function BriefingPage() {
staleTime: 60_000,
})
- const filtered = allNews.filter(
- (item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
+ 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 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 pageStart = (safePage - 1) * PAGE_SIZE
- const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
+
+ const categoryFiltered = useMemo(
+ () => taggedNews.filter(
+ (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()
+ 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 = {
all: allNews.length,
@@ -374,22 +506,14 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), [])
- useEffect(() => {
- setPage((current) => Math.min(current, pageCount))
- }, [pageCount])
-
- const handlePageChange = useCallback(
- (nextPage: number) => {
- setPage(Math.min(Math.max(nextPage, 1), pageCount))
- setSelectedItem(null)
- window.scrollTo({ top: 0, behavior: 'smooth' })
- },
- [pageCount],
- )
+ const handleTagClick = useCallback((tag: DigestTag) => {
+ setActiveTag((current) => (current === tag.key ? null : tag.key))
+ setSelectedItem(null)
+ }, [])
const unreadCount = allNews.filter((i) => !i.read_at).length
- const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
- const showPagination = filtered.length > PAGE_SIZE
+ const hasDigest = meetings.length > 0
+ const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
return (
@@ -408,74 +532,106 @@ export function BriefingPage() {
{ setActiveCategory(c); setSelectedItem(null); setPage(1) }}
+ onChange={(c) => {
+ setActiveCategory(c)
+ setSelectedItem(null)
+ setActiveTag(null)
+ }}
/>
- {filtered.length === 0 ? (
+ {hasDigest && (
+
+
+
Since {sinceLabel}
+
+ {sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
+
+
+ {(sinceLastTags.length > 0 || activeTag) && (
+
+ {sinceLastTags.map((tag) => (
+ handleTagClick(tag)}
+ />
+ ))}
+ {activeTag && (
+
+ )}
+
+ )}
+
+ )}
+
+ {showEmpty ? (
No {activeCategory !== 'all' ? activeCategory : ''} items available.
- Run box-box --ingest-news to refresh feeds.
+ {activeTag ? ' Try clearing the tag filter.' : ''}
+ {!activeTag && (
+ <>
+ {' '}
+ Run box-box --ingest-news to refresh feeds.
+ >
+ )}
) : (
<>
- {showPagination && (
-
-
- {pageStart + 1}-{pageEnd} of {filtered.length}
-
-
-
-
- Page {safePage} / {pageCount}
-
-
+ {windowSections.map(({ window, items }) => (
+
+
+
{window.meeting_name}
+ {items.length}
-
- )}
+
+ {items.map((item) => {
+ const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
+ return (
+
+ )
+ })}
+
+
+ ))}
-
- {pagedItems.map((item) => (
-
- ))}
-
-
- {showPagination && (
-
-
-
- Page {safePage} / {pageCount}
-
-
-
+ {recentTagged.length > 0 && (
+
+
+
Recent
+ {recentTagged.length}
+
+
+ {recentTagged.map((item) => (
+
+ ))}
+
+
)}
>
)}
diff --git a/frontend/src/styles/digest.css b/frontend/src/styles/digest.css
new file mode 100644
index 0000000..bc7be90
--- /dev/null
+++ b/frontend/src/styles/digest.css
@@ -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);
+}
diff --git a/frontend/src/test/BriefingPage.test.tsx b/frontend/src/test/BriefingPage.test.tsx
new file mode 100644
index 0000000..59ba5b9
--- /dev/null
+++ b/frontend/src/test/BriefingPage.test.tsx
@@ -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(
+
+
+ ,
+ )
+}
+
+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: '
Body
' })
+ 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()
+ })
+})
diff --git a/frontend/src/test/digest.test.ts b/frontend/src/test/digest.test.ts
new file mode 100644
index 0000000..c052339
--- /dev/null
+++ b/frontend/src/test/digest.test.ts
@@ -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_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 => ({
+ source: 'autosport-f1',
+ title: 'Headline',
+ url: `https://example.com/${Math.random()}`,
+ fetched_at: '2025-04-01T12:00:00Z',
+ ...overrides,
+})
+
+const driver = (over: Partial): 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 => ({
+ 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)
+ })
+})