Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/worldcup-live-feed/README.md
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.
24 changes: 24 additions & 0 deletions packages/worldcup-live-feed/package.json
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"
}
}
89 changes: 89 additions & 0 deletions packages/worldcup-live-feed/src/entries.spec.ts
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");
});
74 changes: 74 additions & 0 deletions packages/worldcup-live-feed/src/entries.ts
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";
107 changes: 107 additions & 0 deletions packages/worldcup-live-feed/src/football-data.ts
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));

Copy link
Copy Markdown

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

fetchLiveMatches only loads WC fixtures for the current UTC calendar day. A match still IN_PLAY after 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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be60f88. Configure here.

}

/** 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upcoming filter misses TIMED matches

High Severity

fetchUpcomingMatches filters with status=SCHEDULED only. On football-data.org, fixtures with a known kickoff are usually TIMED, a separate status from SCHEDULED. The idle "Next match" countdown will often see an empty list and fall back to No live World Cup matches right now even when games are scheduled.

Fix in Cursor Fix in Web

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);
}
Loading
Loading