-
Notifications
You must be signed in to change notification settings - Fork 1
feat: worldcup-live-feed - Show match info via inbox #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fd18d94
d7cab11
41a1527
be60f88
097f778
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # @webhook-objects/worldcup-live-feed | ||
|
|
||
| Polls [football-data.org](https://www.football-data.org/) for World Cup matches | ||
| and drives a `status` webhook object: | ||
|
|
||
| - **status**: `working` when idle, `on` while any match is live, briefly `alert` | ||
| on a goal (reverts to `on` after `--alert-seconds`) | ||
| - **info.name** (on-map, no click needed): `worldcup.town - ⚽ ESP 2-0 AUT`, | ||
| `worldcup.town - ⚽ 3 matches live`, or just `worldcup.town` when idle | ||
| - **activity feed** (popover, click to open): | ||
| - `🔴 LIVE: ...` — one entry per live match, score + minute | ||
| - `⚽🎉 GOAL! ...` — one entry per goal, names the scoring team | ||
| - `⏰ Next: ...` — countdown to the next scheduled match when idle (or | ||
| "No live World Cup matches right now" if none scheduled) | ||
| - `🏆 FT: ...` — the moment a live match ends (same day), plus a daily batch | ||
| for the previous day | ||
|
|
||
| ## Usage | ||
|
|
||
| ```sh | ||
| pnpm --filter @webhook-objects/worldcup-live-feed start \ | ||
| --url https://your-object-url \ | ||
| --secret whsec_... \ | ||
| --football-token your-football-data-org-token \ | ||
| --interval 30 \ | ||
| --alert-seconds 60 | ||
| ``` | ||
|
|
||
| | flag | required | default | meaning | | ||
| | ------------------- | -------- | ------- | ---------------------------------------------------- | | ||
| | `--url` | yes | — | object's webhook URL | | ||
| | `--secret` | yes | — | Standard Webhooks secret | | ||
| | `--football-token` | yes\* | — | football-data.org API token | | ||
| | `--interval` | no | `30` | poll interval in seconds (min `6`) | | ||
| | `--alert-seconds` | no | `60` | how long status stays `alert` after a goal | | ||
|
|
||
| \* `--football-token` can also come from the `FOOTBALL_DATA_API_TOKEN` env var. | ||
|
|
||
| Runs until `Ctrl+C`. | ||
|
|
||
| ## Notes | ||
|
|
||
| - Free tier: 12 competitions (World Cup included, code `WC`), basic | ||
| fixtures/results/tables, **10 req/min**. No card/booking data on this tier — | ||
| checked live, match detail has no `bookings` field. | ||
| - `--interval` refuses to go below 6s to protect the rate limit. Steady-state | ||
| usage is ~2 req/min live/idle, plus a cached (5 min TTL) lookup for the | ||
| idle countdown and one lookup per calendar day for the previous day's | ||
| results. | ||
| - On `429`, football-data.org calls back off using `Retry-After` and retry. | ||
| - Entries only re-dispatch when their text actually changes. The feed's sort | ||
| key (`at`) is server-stamped from dispatch time, not sendable via payload — | ||
| re-dispatching unchanged text would bump an old entry ahead of genuinely | ||
| newer ones. The known-entries cache is seeded from `webhook.ping` on start, | ||
| so this holds across restarts too. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| { | ||
| "name": "@webhook-objects/worldcup-live-feed", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "bin": { | ||
| "worldcup-live-feed": "src/index.ts" | ||
| }, | ||
| "scripts": { | ||
| "start": "tsx src/index.ts", | ||
| "test": "vitest run" | ||
| }, | ||
| "licenses": [ | ||
| { | ||
| "type": "Apache-2.0" | ||
| }, | ||
| { | ||
| "type": "MIT" | ||
| } | ||
| ], | ||
| "dependencies": { | ||
| "@gathertown/webhook-object-sdk": "^0.1.1" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { expect, test } from "vitest"; | ||
| import { | ||
| countdownText, | ||
| goalEntryText, | ||
| liveDisplayName, | ||
| matchEntryText, | ||
| nextMatchEntryText, | ||
| resultEntryText, | ||
| } from "./entries"; | ||
| import type { Match } from "./football-data"; | ||
|
|
||
| function match(overrides: Partial<Match> = {}): Match { | ||
| return { | ||
| id: 1, | ||
| utcDate: "2026-07-02T20:00:00Z", | ||
| status: "IN_PLAY", | ||
| minute: 67, | ||
| homeTeam: { name: "Spain", tla: "ESP" }, | ||
| awayTeam: { name: "Austria", tla: "AUT" }, | ||
| score: { fullTime: { home: 2, away: 0 } }, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| test("resultEntryText formats the final score", () => { | ||
| expect(resultEntryText(match())).toBe("🏆 FT: Spain 2-0 Austria"); | ||
| }); | ||
|
|
||
| test("matchEntryText includes the minute when present", () => { | ||
| expect(matchEntryText(match())).toBe("🔴 LIVE: Spain 2-0 Austria (67')"); | ||
| }); | ||
|
|
||
| test("matchEntryText omits the minute suffix when absent", () => { | ||
| expect(matchEntryText(match({ minute: null }))).toBe( | ||
| "🔴 LIVE: Spain 2-0 Austria", | ||
| ); | ||
| }); | ||
|
|
||
| test("goalEntryText names the scoring side", () => { | ||
| expect(goalEntryText(match(), "home")).toBe( | ||
| "⚽🎉 GOAL! Spain — Spain 2-0 Austria", | ||
| ); | ||
| expect(goalEntryText(match(), "away")).toBe( | ||
| "⚽🎉 GOAL! Austria — Spain 2-0 Austria", | ||
| ); | ||
| }); | ||
|
|
||
| test("liveDisplayName uses team codes for a single match", () => { | ||
| expect(liveDisplayName([match()])).toBe("worldcup.town - ⚽ ESP 2-0 AUT"); | ||
| }); | ||
|
|
||
| test("liveDisplayName falls back to shortName/name when no tla", () => { | ||
| expect( | ||
| liveDisplayName([ | ||
| match({ | ||
| homeTeam: { name: "United States" }, | ||
| awayTeam: { name: "Bosnia-Herzegovina" }, | ||
| }), | ||
| ]), | ||
| ).toBe("worldcup.town - ⚽ United States 2-0 Bosnia-Herzegovina"); | ||
| }); | ||
|
|
||
| test("liveDisplayName summarizes multiple live matches", () => { | ||
| expect(liveDisplayName([match(), match({ id: 2 })])).toBe( | ||
| "worldcup.town - ⚽ 2 matches live", | ||
| ); | ||
| }); | ||
|
|
||
| test("countdownText reports hours and minutes ahead of now", () => { | ||
| const now = new Date("2026-07-02T18:00:00Z").getTime(); | ||
| expect(countdownText("2026-07-02T20:15:00Z", now)).toBe("in 2h 15m"); | ||
| }); | ||
|
|
||
| test("countdownText drops the hours segment under an hour", () => { | ||
| const now = new Date("2026-07-02T19:40:00Z").getTime(); | ||
| expect(countdownText("2026-07-02T20:00:00Z", now)).toBe("in 20m"); | ||
| }); | ||
|
|
||
| test("countdownText reports starting soon once the kickoff has passed", () => { | ||
| const now = new Date("2026-07-02T20:00:01Z").getTime(); | ||
| expect(countdownText("2026-07-02T20:00:00Z", now)).toBe("starting soon"); | ||
| }); | ||
|
|
||
| test("nextMatchEntryText combines team names and the countdown", () => { | ||
| const now = new Date("2026-07-02T18:00:00Z").getTime(); | ||
| expect( | ||
| nextMatchEntryText(match({ utcDate: "2026-07-02T19:00:00Z" }), now), | ||
| ).toBe("⏰ Next: ⚽ Spain vs Austria — in 1h 0m"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| /** | ||
| * Pure text/formatting helpers for the worldcup-live-feed `status` object: | ||
| * activity feed entries and the on-map `info.name` summary. | ||
| * | ||
| * @module | ||
| */ | ||
| import type { Match } from "./football-data"; | ||
|
|
||
| /** Prefix applied to `info.name` while any match is live. */ | ||
| export const NAME_PREFIX = "worldcup.town - "; | ||
| /** `info.name` value when no match is live. */ | ||
| export const IDLE_DISPLAY_NAME = "worldcup.town"; | ||
| /** Stable activity entry id for the idle/next-match message. */ | ||
| export const IDLE_ENTRY_ID = "idle"; | ||
|
|
||
| function goals(match: Pick<Match, "score">) { | ||
| return { | ||
| home: match.score.fullTime.home ?? 0, | ||
| away: match.score.fullTime.away ?? 0, | ||
| }; | ||
| } | ||
|
|
||
| /** `🏆 FT: Home 2-1 Away` — a finished match, for a same-day conversion or the daily batch. */ | ||
| export function resultEntryText(match: Match): string { | ||
| const { home, away } = goals(match); | ||
| return `🏆 FT: ${match.homeTeam.name} ${home}-${away} ${match.awayTeam.name}`; | ||
| } | ||
|
|
||
| /** `🔴 LIVE: Home 2-1 Away (67')` — a currently in-progress match. */ | ||
| export function matchEntryText(match: Match): string { | ||
| const { home, away } = goals(match); | ||
| const minuteSuffix = match.minute != null ? ` (${match.minute}')` : ""; | ||
| return `🔴 LIVE: ${match.homeTeam.name} ${home}-${away} ${match.awayTeam.name}${minuteSuffix}`; | ||
| } | ||
|
|
||
| /** `⚽🎉 GOAL! Home — Home 1-0 Away` — appended the moment a goal is detected. */ | ||
| export function goalEntryText( | ||
| match: Match, | ||
| scoringSide: "home" | "away", | ||
| ): string { | ||
| const { home, away } = goals(match); | ||
| const scorer = | ||
| scoringSide === "home" ? match.homeTeam.name : match.awayTeam.name; | ||
| return `⚽🎉 GOAL! ${scorer} — ${match.homeTeam.name} ${home}-${away} ${match.awayTeam.name}`; | ||
| } | ||
|
|
||
| /** On-map `info.name` while one or more matches are live. */ | ||
| export function liveDisplayName(matches: Match[]): string { | ||
| if (matches.length === 1) { | ||
| const m = matches[0]; | ||
| const home = m.homeTeam.tla ?? m.homeTeam.shortName ?? m.homeTeam.name; | ||
| const away = m.awayTeam.tla ?? m.awayTeam.shortName ?? m.awayTeam.name; | ||
| const { home: h, away: a } = goals(m); | ||
| return `${NAME_PREFIX}⚽ ${home} ${h}-${a} ${away}`; | ||
| } | ||
| return `${NAME_PREFIX}⚽ ${matches.length} matches live`; | ||
| } | ||
|
|
||
| /** `in 2h 15m` / `in 40m` / `starting soon`, relative to `now`. */ | ||
| export function countdownText(utcDate: string, now = Date.now()): string { | ||
| const diffMs = new Date(utcDate).getTime() - now; | ||
| if (diffMs <= 0) return "starting soon"; | ||
| const hours = Math.floor(diffMs / (60 * 60 * 1000)); | ||
| const minutes = Math.floor((diffMs % (60 * 60 * 1000)) / (60 * 1000)); | ||
| return hours > 0 ? `in ${hours}h ${minutes}m` : `in ${minutes}m`; | ||
| } | ||
|
|
||
| /** `⏰ Next: ⚽ Home vs Away — in 2h 15m` — shown while idle, if a match is scheduled. */ | ||
| export function nextMatchEntryText(match: Match, now = Date.now()): string { | ||
| return `⏰ Next: ⚽ ${match.homeTeam.name} vs ${match.awayTeam.name} — ${countdownText(match.utcDate, now)}`; | ||
| } | ||
|
|
||
| /** Fallback idle message when nothing is live and nothing is scheduled. */ | ||
| export const NO_MATCHES_TEXT = "No live World Cup matches right now"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /** | ||
| * Minimal client for the football-data.org v4 API, scoped to the World Cup | ||
| * competition (`WC`). Free tier: basic fixtures/results/tables, 10 req/min — | ||
| * callers are responsible for pacing their own polling. | ||
| * | ||
| * @module | ||
| */ | ||
|
|
||
| const BASE_URL = "https://api.football-data.org/v4"; | ||
| const LIVE_STATUSES = new Set(["LIVE", "IN_PLAY", "PAUSED"]); | ||
|
|
||
| export type Team = { | ||
| name: string; | ||
| tla?: string; | ||
| shortName?: string; | ||
| }; | ||
|
|
||
| export type Match = { | ||
| id: number; | ||
| utcDate: string; | ||
| status: string; | ||
| minute?: number | null; | ||
| homeTeam: Team; | ||
| awayTeam: Team; | ||
| score: { | ||
| fullTime: { | ||
| home: number | null; | ||
| away: number | null; | ||
| }; | ||
| }; | ||
| }; | ||
|
|
||
| /** GET a football-data.org path, retrying once on 429 per `Retry-After`. */ | ||
| async function footballDataFetch( | ||
| path: string, | ||
| apiToken: string, | ||
| // biome-ignore lint/suspicious/noExplicitAny: football-data.org's response shape isn't worth hand-typing in full | ||
| ): Promise<any> { | ||
| const res = await fetch(`${BASE_URL}${path}`, { | ||
| headers: { "X-Auth-Token": apiToken }, | ||
| }); | ||
| if (res.status === 429) { | ||
| const retryAfter = Number(res.headers.get("Retry-After") ?? "60") * 1000; | ||
| await new Promise((r) => setTimeout(r, retryAfter)); | ||
| return footballDataFetch(path, apiToken); | ||
| } | ||
| if (!res.ok) { | ||
| throw new Error( | ||
| `football-data.org error ${res.status}: ${await res.text()}`, | ||
| ); | ||
| } | ||
| return res.json(); | ||
| } | ||
|
|
||
| const byUtcDateAsc = (a: Match, b: Match) => | ||
| new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime(); | ||
|
|
||
| /** | ||
| * Currently in-progress World Cup matches (`LIVE`/`IN_PLAY`/`PAUSED`). | ||
| * | ||
| * football-data.org's match `status` field never actually takes the value | ||
| * `"LIVE"` (real values are `IN_PLAY`/`PAUSED`/`FINISHED`/etc) — that's only | ||
| * a filter shorthand on the cross-competition `/v4/matches` endpoint, not on | ||
| * `/v4/competitions/{id}/matches`. Querying `?status=LIVE` here server-side | ||
| * filters out real in-progress matches, so we fetch today's matches | ||
| * unfiltered and do the live check ourselves. | ||
| */ | ||
| export async function fetchLiveMatches( | ||
| apiToken: string, | ||
| now = Date.now(), | ||
| ): Promise<Match[]> { | ||
| const today = new Date(now).toISOString().slice(0, 10); | ||
| const json = await footballDataFetch( | ||
| `/competitions/WC/matches?dateFrom=${today}&dateTo=${today}`, | ||
| apiToken, | ||
| ); | ||
| return json.matches.filter((m: Match) => LIVE_STATUSES.has(m.status)); | ||
| } | ||
|
|
||
| /** Upcoming scheduled World Cup matches, soonest first. */ | ||
| export async function fetchUpcomingMatches( | ||
| apiToken: string, | ||
| now = Date.now(), | ||
| ): Promise<Match[]> { | ||
| const today = new Date(now).toISOString().slice(0, 10); | ||
| // dateTo is required alongside dateFrom by the API; 60 days comfortably covers the tournament. | ||
| const dateTo = new Date(now + 60 * 24 * 60 * 60 * 1000) | ||
| .toISOString() | ||
| .slice(0, 10); | ||
| const json = await footballDataFetch( | ||
| `/competitions/WC/matches?status=SCHEDULED&dateFrom=${today}&dateTo=${dateTo}`, | ||
| apiToken, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Upcoming filter misses TIMED matchesHigh Severity
Reviewed by Cursor Bugbot for commit 097f778. Configure here. |
||
| ); | ||
| return (json.matches as Match[]).sort(byUtcDateAsc); | ||
| } | ||
|
|
||
| /** Finished World Cup matches on a given `YYYY-MM-DD` date, earliest first. */ | ||
| export async function fetchResultsForDate( | ||
| apiToken: string, | ||
| dateStr: string, | ||
| ): Promise<Match[]> { | ||
| const json = await footballDataFetch( | ||
| `/competitions/WC/matches?dateFrom=${dateStr}&dateTo=${dateStr}&status=FINISHED`, | ||
| apiToken, | ||
| ); | ||
| return (json.matches as Match[]).sort(byUtcDateAsc); | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Live match lost at midnight
High Severity
fetchLiveMatchesonly loads WC fixtures for the current UTC calendar day. A match stillIN_PLAYafter UTC midnight is tied to its kickoff date, so it disappears from the live query, is treated as finished, and the object can flip to idle with a premature full-time entry while the game is still running.Additional Locations (1)
packages/worldcup-live-feed/src/index.ts#L150-L185Reviewed by Cursor Bugbot for commit be60f88. Configure here.