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