mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
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 <cursoragent@cursor.com>
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user