claudial 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { ACCENT, RED } from '../Header.js';
5
+ import { getSettings, listLeagues, listTeams, putSettings, } from '../../api/backend.js';
6
+ import { followedIn, initialPickerState, pickerReducer, rows, settingsOf, stillLoading, teamName, } from './state.js';
7
+ const defaultApi = { listLeagues, listTeams, getSettings, putSettings };
8
+ export function Picker({ onDone, api = defaultApi, intro }) {
9
+ const [s, dispatch] = useReducer(pickerReducer, initialPickerState);
10
+ const [phase, setPhase] = useState({ kind: 'loading' });
11
+ const { stdout } = useStdout();
12
+ // Every line of chrome counted (padding 2, title 2, intro 2, search 1, list margins 2,
13
+ // more-arrows 2, summary 1, status 1, help 1), plus one spare: a frame taller than the
14
+ // terminal cannot be cleared by Ink and leaves stale lines behind.
15
+ const height = Math.max(3, (stdout?.rows ?? 24) - (intro ? 16 : 14));
16
+ // Load leagues and current settings, then every league's teams in parallel,
17
+ // so search works across all of them.
18
+ useEffect(() => {
19
+ let alive = true;
20
+ (async () => {
21
+ try {
22
+ const [leagues, settings] = await Promise.all([api.listLeagues(), api.getSettings()]);
23
+ if (!alive)
24
+ return;
25
+ dispatch({ type: 'loaded', leagues, settings });
26
+ setPhase({ kind: 'ready' });
27
+ for (const l of leagues) {
28
+ dispatch({ type: 'teamsLoaded', slug: l.slug, teams: 'loading' });
29
+ api.listTeams(l.slug).then((teams) => alive && dispatch({ type: 'teamsLoaded', slug: l.slug, teams }), () => alive && dispatch({ type: 'teamsLoaded', slug: l.slug, teams: 'error' }));
30
+ }
31
+ }
32
+ catch (e) {
33
+ if (alive)
34
+ setPhase({ kind: 'error', message: `could not reach the claudial server (${e instanceof Error ? e.message : String(e)})` });
35
+ }
36
+ })();
37
+ return () => { alive = false; };
38
+ }, [api]);
39
+ // Esc on the home screen: save if anything changed, then hand back.
40
+ useEffect(() => {
41
+ if (!s.finished)
42
+ return;
43
+ const settings = settingsOf(s);
44
+ if (!s.dirty) {
45
+ onDone({ saved: false, settings });
46
+ return;
47
+ }
48
+ setPhase({ kind: 'saving' });
49
+ api.putSettings(settings)
50
+ .then((stored) => {
51
+ setPhase({ kind: 'saved', settings: stored });
52
+ setTimeout(() => onDone({ saved: true, settings: stored }), 900);
53
+ })
54
+ .catch((e) => {
55
+ setPhase({ kind: 'error', message: `not saved: ${e instanceof Error ? e.message : String(e)}. Press esc to try again.` });
56
+ dispatch({ type: 'resume' });
57
+ });
58
+ }, [s.finished]); // eslint-disable-line react-hooks/exhaustive-deps
59
+ useInput((input, key) => {
60
+ if (phase.kind === 'saving' || phase.kind === 'saved')
61
+ return;
62
+ if (phase.kind === 'loading') {
63
+ if (key.escape)
64
+ onDone({ saved: false, settings: settingsOf(s) });
65
+ return;
66
+ }
67
+ if (phase.kind === 'error' && s.leagues.length === 0) {
68
+ if (key.escape)
69
+ onDone({ saved: false, settings: settingsOf(s) });
70
+ return;
71
+ }
72
+ if (key.upArrow)
73
+ dispatch({ type: 'up' });
74
+ else if (key.downArrow)
75
+ dispatch({ type: 'down' });
76
+ else if (key.pageUp)
77
+ dispatch({ type: 'pageUp', by: height });
78
+ else if (key.pageDown)
79
+ dispatch({ type: 'pageDown', by: height });
80
+ else if (key.escape)
81
+ dispatch({ type: 'escape' });
82
+ else if (key.leftArrow)
83
+ dispatch({ type: 'back' });
84
+ else if (key.return || key.rightArrow)
85
+ dispatch({ type: 'open' });
86
+ else if (key.backspace || key.delete)
87
+ dispatch({ type: 'backspace' });
88
+ else if (input === ' ')
89
+ dispatch({ type: 'toggle' });
90
+ else if (input && !key.ctrl && !key.meta && !key.tab)
91
+ dispatch({ type: 'type', text: input });
92
+ });
93
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: "claudial" }), _jsx(Text, { dimColor: true, children: " \u00B7 what do you follow?" })] }), intro && _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: intro }) }), _jsx(Body, { s: s, phase: phase, height: height })] }));
94
+ }
95
+ function Body({ s, phase, height }) {
96
+ if (phase.kind === 'loading')
97
+ return _jsx(Text, { dimColor: true, children: "loading leagues\u2026" });
98
+ if (phase.kind === 'error' && s.leagues.length === 0) {
99
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: RED, children: phase.message }), _jsx(Text, { dimColor: true, children: "Is the server reachable? Set CLAUDIAL_API_URL if you run your own. esc to close" })] }));
100
+ }
101
+ if (phase.kind === 'saved') {
102
+ const names = [
103
+ ...phase.settings.leagues.map((slug) => s.leagues.find((l) => l.slug === slug)?.name ?? slug),
104
+ ...phase.settings.teams.map((id) => teamName(s, id)),
105
+ ];
106
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u2713 Saved." }), ' ', names.length ? _jsxs(_Fragment, { children: ["You follow ", _jsx(Text, { bold: true, children: names.join(' · ') }), "."] }) : 'You follow nothing for now.'] }), _jsx(Text, { dimColor: true, children: "Your Claude Code status bar picks it up on its next refresh. Change it any time: claudial follow" })] }));
107
+ }
108
+ const list = rows(s);
109
+ const start = Math.max(0, Math.min(s.cursor - Math.floor(height / 2), list.length - height));
110
+ const shown = list.slice(start, start + height);
111
+ const leagueName = (slug) => s.leagues.find((l) => l.slug === slug)?.name ?? slug;
112
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SearchLine, { s: s, leagueName: leagueName }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [start > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", start, " more"] }), shown.map((row, i) => (_jsx(RowLine, { row: row, s: s, active: start + i === s.cursor, showLeague: s.view.kind === 'home', leagueName: leagueName }, row.kind === 'league' ? `l-${row.slug}` : `t-${row.id}`))), list.length === 0 && _jsx(Empty, { s: s }), start + height < list.length && _jsxs(Text, { dimColor: true, children: [" \u2193 ", list.length - start - height, " more"] })] }), _jsx(Summary, { s: s, leagueName: leagueName }), phase.kind === 'saving' && _jsx(Text, { dimColor: true, children: "saving\u2026" }), phase.kind === 'error' && _jsx(Text, { color: RED, children: phase.message }), _jsx(Help, { s: s })] }));
113
+ }
114
+ function SearchLine({ s, leagueName }) {
115
+ const where = s.view.kind === 'league' ? `${leagueName(s.view.slug)} › ` : '';
116
+ return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [where, "search \u203A "] }), s.query
117
+ ? _jsxs(Text, { bold: true, children: [s.query, _jsx(Text, { color: ACCENT, children: "\u258F" })] })
118
+ : _jsxs(Text, { dimColor: true, children: [s.view.kind === 'league' ? 'type to filter teams' : 'type a team name, e.g. "arsenal"', _jsx(Text, { color: ACCENT, children: "\u258F" })] })] }));
119
+ }
120
+ function RowLine({ row, s, active, showLeague, leagueName }) {
121
+ const on = row.kind === 'league' ? s.followLeagues.includes(row.slug) : s.followTeams.includes(row.id);
122
+ const mark = on ? _jsx(Text, { color: ACCENT, children: "\u25C9" }) : _jsx(Text, { dimColor: true, children: "\u25CB" });
123
+ const pointer = active ? _jsx(Text, { color: ACCENT, children: "\u203A" }) : ' ';
124
+ if (row.kind === 'league') {
125
+ const teams = followedIn(s, row.slug);
126
+ return (_jsxs(Text, { wrap: "truncate", children: [pointer, " ", mark, " ", _jsx(Text, { bold: active, children: row.name }), teams.length > 0 && _jsxs(Text, { color: ACCENT, children: [' ', teams.join(', ')] }), active && _jsxs(Text, { dimColor: true, children: [' ', "enter: teams"] })] }));
127
+ }
128
+ return (_jsxs(Text, { wrap: "truncate", children: [pointer, " ", mark, " ", _jsx(Text, { bold: active, children: row.name }), _jsxs(Text, { dimColor: true, children: [' ', row.code, showLeague ? ` · ${leagueName(row.league)}` : ''] })] }));
129
+ }
130
+ function Empty({ s }) {
131
+ if (s.view.kind === 'league') {
132
+ const list = s.teams[s.view.slug];
133
+ if (list === 'loading' || list === undefined)
134
+ return _jsx(Text, { dimColor: true, children: " loading teams\u2026" });
135
+ if (list === 'error')
136
+ return _jsx(Text, { dimColor: true, children: " this league's teams are not available yet, try again in a minute" });
137
+ }
138
+ if (s.query && stillLoading(s))
139
+ return _jsx(Text, { dimColor: true, children: " searching\u2026 (team lists still loading)" });
140
+ return _jsxs(Text, { dimColor: true, children: [" no team matches \"", s.query, "\""] });
141
+ }
142
+ function Summary({ s, leagueName }) {
143
+ const parts = [...s.followLeagues.map(leagueName), ...s.followTeams.map((id) => teamName(s, id))];
144
+ if (parts.length === 0)
145
+ return _jsx(Text, { dimColor: true, children: "You follow nothing yet: tick a league or a team." });
146
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { dimColor: true, children: "Following " }), _jsx(Text, { color: ACCENT, children: parts.join(' · ') })] }));
147
+ }
148
+ function Help({ s }) {
149
+ const exit = s.query ? 'esc clear search' : s.view.kind === 'league' ? 'esc back' : 'esc save & close';
150
+ const enter = s.view.kind === 'home' && !s.query ? ' · enter open league' : '';
151
+ return _jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u2191\u2193 move \u00B7 space follow", enter, " \u00B7 type to search \u00B7 ", exit, " \u00B7 ctrl+c cancel"] });
152
+ }
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { Picker } from './Picker.js';
4
+ /** Open the picker full-screen and resolve when it closes (esc saves, ctrl+c cancels). */
5
+ export async function runPicker(intro) {
6
+ let result = null;
7
+ const app = render(React.createElement(Picker, {
8
+ intro,
9
+ onDone: (r) => { result = r; app.unmount(); },
10
+ }));
11
+ await app.waitUntilExit();
12
+ return result;
13
+ }
@@ -0,0 +1,120 @@
1
+ export const initialPickerState = {
2
+ leagues: [], teams: {}, followLeagues: [], followTeams: [],
3
+ view: { kind: 'home' }, query: '', cursor: 0, dirty: false, finished: false,
4
+ };
5
+ const fold = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
6
+ function matches(t, query) {
7
+ const q = fold(query.trim());
8
+ return q === '' || fold(t.name).includes(q) || fold(t.code).includes(q);
9
+ }
10
+ /** International competitions (European cups, World Cup): a club's "home" is its league. */
11
+ const isCup = (slug) => slug.startsWith('uefa.') || slug.startsWith('fifa.');
12
+ /** What the list shows right now, in order. */
13
+ export function rows(s) {
14
+ if (s.view.kind === 'league') {
15
+ const slug = s.view.slug;
16
+ const list = s.teams[slug];
17
+ if (!Array.isArray(list))
18
+ return [];
19
+ return list.filter((t) => matches(t, s.query)).map((t) => ({ kind: 'team', ...t, league: slug }));
20
+ }
21
+ if (s.query.trim() === '') {
22
+ return s.leagues.map((l) => ({ kind: 'league', slug: l.slug, name: l.name }));
23
+ }
24
+ // Search across every league. A club in two competitions (league and a
25
+ // European cup) has one id: list it once, under its domestic league.
26
+ const seen = new Set();
27
+ const out = [];
28
+ const domesticFirst = [...s.leagues].sort((a, b) => Number(isCup(a.slug)) - Number(isCup(b.slug)));
29
+ for (const l of domesticFirst) {
30
+ const list = s.teams[l.slug];
31
+ if (!Array.isArray(list))
32
+ continue;
33
+ for (const t of list) {
34
+ if (!seen.has(t.id) && matches(t, s.query)) {
35
+ seen.add(t.id);
36
+ out.push({ kind: 'team', ...t, league: l.slug });
37
+ }
38
+ }
39
+ }
40
+ return out.sort((a, b) => a.name.localeCompare(b.name));
41
+ }
42
+ /** Leagues whose team lists are still loading (so search results may be partial). */
43
+ export function stillLoading(s) {
44
+ return s.leagues.some((l) => s.teams[l.slug] === 'loading' || s.teams[l.slug] === undefined);
45
+ }
46
+ /** A followed team's display name, from any loaded league; falls back to its id. */
47
+ export function teamName(s, id) {
48
+ for (const list of Object.values(s.teams)) {
49
+ if (Array.isArray(list)) {
50
+ const t = list.find((x) => x.id === id);
51
+ if (t)
52
+ return t.name;
53
+ }
54
+ }
55
+ return `team ${id}`;
56
+ }
57
+ /** Names of followed teams that play in `slug`. */
58
+ export function followedIn(s, slug) {
59
+ const list = s.teams[slug];
60
+ if (!Array.isArray(list))
61
+ return [];
62
+ return list.filter((t) => s.followTeams.includes(t.id)).map((t) => t.name);
63
+ }
64
+ export function settingsOf(s) {
65
+ return { leagues: [...s.followLeagues], teams: [...s.followTeams] };
66
+ }
67
+ const clamp = (n, len) => (len === 0 ? 0 : Math.max(0, Math.min(n, len - 1)));
68
+ const toggled = (list, x) => (list.includes(x) ? list.filter((y) => y !== x) : [...list, x]);
69
+ export function pickerReducer(s, a) {
70
+ switch (a.type) {
71
+ case 'loaded':
72
+ return { ...s, leagues: a.leagues, followLeagues: a.settings.leagues, followTeams: a.settings.teams };
73
+ case 'teamsLoaded':
74
+ return { ...s, teams: { ...s.teams, [a.slug]: a.teams } };
75
+ case 'type':
76
+ return { ...s, query: s.query + a.text, cursor: 0 };
77
+ case 'backspace':
78
+ return { ...s, query: s.query.slice(0, -1), cursor: 0 };
79
+ case 'up':
80
+ return { ...s, cursor: clamp(s.cursor - 1, rows(s).length) };
81
+ case 'down':
82
+ return { ...s, cursor: clamp(s.cursor + 1, rows(s).length) };
83
+ case 'pageUp':
84
+ return { ...s, cursor: clamp(s.cursor - a.by, rows(s).length) };
85
+ case 'pageDown':
86
+ return { ...s, cursor: clamp(s.cursor + a.by, rows(s).length) };
87
+ case 'toggle': {
88
+ const row = rows(s)[s.cursor];
89
+ if (!row)
90
+ return s;
91
+ if (row.kind === 'league')
92
+ return { ...s, followLeagues: toggled(s.followLeagues, row.slug), dirty: true };
93
+ return { ...s, followTeams: toggled(s.followTeams, row.id), dirty: true };
94
+ }
95
+ case 'open': {
96
+ const row = rows(s)[s.cursor];
97
+ if (!row)
98
+ return s;
99
+ if (row.kind === 'league')
100
+ return { ...s, view: { kind: 'league', slug: row.slug }, query: '', cursor: 0 };
101
+ return pickerReducer(s, { type: 'toggle' }); // enter on a team ticks it
102
+ }
103
+ case 'back': {
104
+ if (s.view.kind !== 'league')
105
+ return s;
106
+ const at = s.leagues.findIndex((l) => l.slug === s.view.slug);
107
+ return { ...s, view: { kind: 'home' }, query: '', cursor: Math.max(0, at) };
108
+ }
109
+ case 'escape':
110
+ // Peel one layer at a time: search text, then the league view, then done.
111
+ if (s.query !== '')
112
+ return { ...s, query: '', cursor: 0 };
113
+ if (s.view.kind === 'league')
114
+ return pickerReducer(s, { type: 'back' });
115
+ return { ...s, finished: true };
116
+ case 'resume':
117
+ // Saving failed: stay open so the user can retry.
118
+ return { ...s, finished: false };
119
+ }
120
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claudial",
3
- "version": "0.6.3",
4
- "description": "Live World Cup 2026 fixtures in your terminal — goal, red-card and VAR takeovers included",
3
+ "version": "0.7.0",
4
+ "description": "Live football scores for your leagues and teams, in your terminal and Claude Code's status bar — goal and red-card takeovers included",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claudial": "dist/index.js"
@@ -16,7 +16,7 @@
16
16
  "build": "rm -rf dist && tsc && chmod +x dist/index.js",
17
17
  "dev": "tsx src/index.tsx",
18
18
  "test": "vitest run",
19
- "prepublishOnly": "npm run build"
19
+ "prepublishOnly": "node scripts/check-release.mjs && npm run build"
20
20
  },
21
21
  "dependencies": {
22
22
  "ink": "^5.2.0",
@@ -30,8 +30,9 @@
30
30
  "vitest": "^2.1.0"
31
31
  },
32
32
  "keywords": [
33
+ "football",
34
+ "premier-league",
33
35
  "worldcup",
34
- "world-cup-2026",
35
36
  "tui",
36
37
  "cli",
37
38
  "football",
package/dist/api/espn.js DELETED
@@ -1,130 +0,0 @@
1
- import { parseEvent, parseIncidents } from './parse.js';
2
- // ESPN's public soccer scoreboard for the FIFA World Cup (league fifa.world).
3
- // Keyless, served openly to ESPN's own web/apps, facts-only. One call returns
4
- // every match for a date (or date range) with goals and cards inline.
5
- const BASE = 'https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world';
6
- async function get(path) {
7
- const res = await fetch(`${BASE}${path}`, { headers: { Accept: 'application/json' } });
8
- if (!res.ok)
9
- throw new Error(`espn ${res.status} ${path}`);
10
- const text = await res.text();
11
- if (!text)
12
- throw new Error(`espn empty body ${path}`);
13
- try {
14
- return JSON.parse(text);
15
- }
16
- catch {
17
- throw new Error(`espn: malformed JSON from ${path}: ${text.slice(0, 120)}`);
18
- }
19
- }
20
- function yyyymmdd(d) {
21
- return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
22
- }
23
- // Brief cache so one live tick (live + per-match incidents) is a single fetch.
24
- const cache = new Map();
25
- const CACHE_MS = 5_000;
26
- async function scoreboard(dates) {
27
- const hit = cache.get(dates);
28
- if (hit && Date.now() - hit.at < CACHE_MS)
29
- return hit.events;
30
- const data = await get(`/scoreboard?dates=${dates}`);
31
- const events = data.events ?? [];
32
- cache.set(dates, { at: Date.now(), events });
33
- return events;
34
- }
35
- function todayRange(daysAhead) {
36
- const now = new Date();
37
- const start = yyyymmdd(now);
38
- if (daysAhead <= 0)
39
- return start;
40
- const end = new Date(now.getTime() + daysAhead * 86_400_000);
41
- return `${start}-${yyyymmdd(end)}`;
42
- }
43
- // Past date range "YYYYMMDD-YYYYMMDD" from (today - daysBack) through today (UTC).
44
- export function pastRange(daysBack, now = Date.now()) {
45
- const end = new Date(now);
46
- const start = new Date(now - daysBack * 86_400_000);
47
- return `${yyyymmdd(start)}-${yyyymmdd(end)}`;
48
- }
49
- // ── Mock mode for demos (CLAUDIAL_MOCK) ──────────────────────────────────────
50
- // A fake live Argentina–England match. The first live fetch is 1–1; from the
51
- // second fetch on, Harry Kane has scored to make it 1–2 — so the next poll (or
52
- // pressing `r`) fires the goal takeover with his name. Inert unless the env var
53
- // is set. Run: `CLAUDIAL_MOCK=1 claudial`, then press `r`.
54
- const MOCK = !!process.env.CLAUDIAL_MOCK;
55
- let mockLiveCalls = 0;
56
- function mockEvent() {
57
- const scored = mockLiveCalls >= 2; // Kane's goal lands on the 2nd live fetch
58
- return {
59
- id: '9001',
60
- date: new Date(Date.now() - 78 * 60_000).toISOString(),
61
- status: { type: { state: 'in', name: 'STATUS_SECOND_HALF', shortDetail: scored ? "78'" : "76'" }, displayClock: scored ? "78'" : "76'" },
62
- competitions: [{
63
- competitors: [
64
- { homeAway: 'home', team: { id: '202', abbreviation: 'ARG', displayName: 'Argentina' }, score: '1' },
65
- { homeAway: 'away', team: { id: '448', abbreviation: 'ENG', displayName: 'England' }, score: scored ? '2' : '1' },
66
- ],
67
- details: [
68
- { type: { id: '137' }, clock: { value: 600, displayValue: "10'" }, scoringPlay: true, team: { id: '202' }, athletesInvolved: [{ id: '1', displayName: 'Lionel Messi', shortName: 'L. Messi' }] },
69
- { type: { id: '137' }, clock: { value: 2400, displayValue: "40'" }, scoringPlay: true, team: { id: '448' }, athletesInvolved: [{ id: '2', displayName: 'Jude Bellingham', shortName: 'J. Bellingham' }] },
70
- ...(scored ? [{ type: { id: '137' }, clock: { value: 4680, displayValue: "78'" }, scoringPlay: true, team: { id: '448' }, athletesInvolved: [{ id: '3', displayName: 'Harry Kane', shortName: 'H. Kane' }] }] : []),
71
- ],
72
- }],
73
- };
74
- }
75
- // Kept for call-site compatibility; ESPN needs no season id to query.
76
- export async function resolveSeasonId() {
77
- return 2026;
78
- }
79
- export async function fetchLive() {
80
- if (MOCK) {
81
- mockLiveCalls++;
82
- return [parseEvent(mockEvent())];
83
- }
84
- const events = await scoreboard(todayRange(0));
85
- return events.map(parseEvent).filter((m) => m.status === 'live' || m.status === 'halftime');
86
- }
87
- export async function fetchUpcoming(_seasonId) {
88
- if (MOCK)
89
- return [];
90
- const events = await scoreboard(todayRange(10));
91
- return events
92
- .map(parseEvent)
93
- .filter((m) => m.status === 'upcoming')
94
- .sort((a, b) => a.startTimestamp - b.startTimestamp);
95
- }
96
- export async function fetchRecent(_seasonId) {
97
- if (MOCK)
98
- return [];
99
- const events = await scoreboard(pastRange(1));
100
- return events
101
- .map(parseEvent)
102
- .filter((m) => m.status === 'finished')
103
- .sort((a, b) => a.startTimestamp - b.startTimestamp);
104
- }
105
- export async function fetchIncidents(eventId) {
106
- if (MOCK)
107
- return parseIncidents(mockEvent());
108
- const events = await scoreboard(todayRange(0));
109
- const event = events.find((e) => Number(e.id) === eventId);
110
- return event ? parseIncidents(event) : [];
111
- }
112
- // Red cards in currently-live matches, drawn from the same scoreboard response
113
- // fetchLive uses (its details carry cards inline) — so no extra network call.
114
- export async function fetchLiveRedCards() {
115
- if (MOCK)
116
- return [];
117
- const events = await scoreboard(todayRange(0));
118
- const out = [];
119
- for (const e of events) {
120
- const m = parseEvent(e);
121
- if (m.status !== 'live' && m.status !== 'halftime')
122
- continue;
123
- for (const inc of parseIncidents(e)) {
124
- if (inc.kind === 'redCard') {
125
- out.push({ id: inc.id, homeCode: m.home.code, awayCode: m.away.code, player: inc.playerShort ?? inc.player ?? '?', minute: inc.minute });
126
- }
127
- }
128
- }
129
- return out;
130
- }
package/dist/api/parse.js DELETED
@@ -1,103 +0,0 @@
1
- // claudial parses ESPN's public soccer scoreboard (league fifa.world).
2
- // One scoreboard response carries every match for a date plus, inline, each
3
- // match's goals and cards — so a single request powers scores, scorer lines,
4
- // and goal/red-card takeovers. Substitutions and VAR live only in ESPN's heavy
5
- // per-match summary endpoint and are intentionally not used here.
6
- function toStatus(raw) {
7
- const t = raw?.status?.type;
8
- const state = t?.state;
9
- if (state === 'in')
10
- return t?.name === 'STATUS_HALFTIME' ? 'halftime' : 'live';
11
- if (state === 'post')
12
- return 'finished';
13
- return 'upcoming';
14
- }
15
- function competitor(raw, side) {
16
- const comps = raw?.competitions?.[0]?.competitors ?? [];
17
- return comps.find((c) => c.homeAway === side) ?? {};
18
- }
19
- function teamOf(c) {
20
- const t = c?.team ?? {};
21
- return {
22
- name: t.displayName ?? t.shortDisplayName ?? t.name ?? '?',
23
- code: t.abbreviation ?? t.shortDisplayName ?? '?',
24
- };
25
- }
26
- function scoreOf(c, status) {
27
- if (status === 'upcoming')
28
- return null;
29
- const n = Number(c?.score);
30
- return Number.isFinite(n) ? n : null;
31
- }
32
- /** "21'" -> 21, "90'+7'" -> 90, "HT"/"" -> null */
33
- function minuteOf(raw, status) {
34
- if (status !== 'live')
35
- return null;
36
- const m = /(\d+)/.exec(raw?.status?.displayClock ?? '');
37
- return m ? Number(m[1]) : null;
38
- }
39
- export function parseEvent(raw) {
40
- const status = toStatus(raw);
41
- const home = competitor(raw, 'home');
42
- const away = competitor(raw, 'away');
43
- return {
44
- id: Number(raw.id),
45
- group: null, // ESPN's scoreboard does not expose the group letter
46
- home: teamOf(home),
47
- away: teamOf(away),
48
- homeScore: scoreOf(home, status),
49
- awayScore: scoreOf(away, status),
50
- status,
51
- statusText: raw?.status?.type?.shortDetail ?? raw?.status?.type?.description ?? '',
52
- minute: minuteOf(raw, status),
53
- startTimestamp: Math.floor(Date.parse(raw?.date ?? '') / 1000) || 0,
54
- varInProgress: false, // not surfaced by the scoreboard feed
55
- };
56
- }
57
- function kindOf(det) {
58
- if (det?.scoringPlay)
59
- return 'goal';
60
- if (det?.redCard)
61
- return 'redCard';
62
- if (det?.yellowCard)
63
- return 'yellowCard';
64
- return null; // substitutions / VAR are not in the scoreboard feed
65
- }
66
- function detailOf(det, kind) {
67
- if (kind !== 'goal')
68
- return null;
69
- if (det?.ownGoal)
70
- return 'Own goal';
71
- if (det?.penaltyKick)
72
- return 'Penalty';
73
- return null;
74
- }
75
- function minuteFromClock(det) {
76
- const m = /(\d+)/.exec(det?.clock?.displayValue ?? '');
77
- return m ? Number(m[1]) : null;
78
- }
79
- export function parseIncidents(raw) {
80
- const homeTeamId = competitor(raw, 'home')?.team?.id;
81
- const details = raw?.competitions?.[0]?.details ?? [];
82
- const out = [];
83
- for (const det of details) {
84
- const kind = kindOf(det);
85
- if (!kind)
86
- continue;
87
- const athlete = det?.athletesInvolved?.[0];
88
- out.push({
89
- // content-derived, stable across polls (the diff engine dedupes on it)
90
- id: `${raw.id}-${det?.type?.id ?? '?'}-${det?.clock?.value ?? '?'}-${athlete?.id ?? '?'}`,
91
- kind,
92
- minute: minuteFromClock(det),
93
- player: athlete?.displayName ?? null,
94
- playerShort: athlete?.shortName ?? athlete?.displayName ?? null,
95
- detail: detailOf(det, kind),
96
- isHome: det?.team?.id != null && det.team.id === homeTeamId,
97
- homeScore: null, // running score not provided per incident; unused downstream
98
- awayScore: null,
99
- });
100
- }
101
- // ESPN already lists details chronologically (earliest first)
102
- return out;
103
- }