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)
+ })
+})