claudial 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wim Iliano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # claudial
2
+
3
+ Live World Cup 2026 fixtures in your terminal. One line, zero install:
4
+
5
+ ```
6
+ npx claudial
7
+ ```
8
+
9
+ Live matches with scores, minutes and scorers on top. Upcoming fixtures in
10
+ your local time below. Auto-refreshing while you work.
11
+
12
+ ```
13
+ ▐▛███▜▌ WORLD CUP 2026 · LIVE
14
+ ▝▜█████▛▘ ───────────────────────
15
+
16
+ ⏺ LIVE 67'
17
+ Argentina 2 — 1 Mexico
18
+ ⚽ 23' Messi · 51' Álvarez | 44' Lozano
19
+
20
+ ⏺ LIVE 12'
21
+ France 0 — 0 Senegal
22
+
23
+ ○ UPCOMING
24
+ 18:00 Brazil — Croatia
25
+ 21:00 Spain — Morocco
26
+
27
+ r refresh · q quit
28
+ ```
29
+
30
+ And when a goal goes in — anywhere in the tournament — the whole screen
31
+ takes over:
32
+
33
+ ```
34
+
35
+
36
+ █▀▀█ █▀▀█ █▀▀█ █
37
+ █ ▄▄ █ █ █▀▀█ █
38
+ ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
39
+
40
+ LIONEL MESSI · 23'
41
+
42
+ ARGENTINA 2 — 1 MEXICO
43
+
44
+
45
+ ```
46
+
47
+ Four seconds of glory, then back to the board.
48
+
49
+ ## Why a TUI
50
+
51
+ The World Cup happens during work hours somewhere. This sits in a terminal
52
+ split, costs nothing to glance at, and celebrates louder than a push
53
+ notification — without you ever opening a browser tab.
54
+
55
+ ## Usage
56
+
57
+ ```
58
+ npx claudial # the dashboard
59
+ npx claudial --ticker # 4-line strip for slim split panes
60
+ npx claudial | cat # non-interactive snapshot (pipes, scripts, CI)
61
+ ```
62
+
63
+ Or install it for good:
64
+
65
+ ```
66
+ npm install -g claudial
67
+ claudial
68
+ ```
69
+
70
+ Requires Node ≥ 18. No account, no API key, no config.
71
+
72
+ | Key | Action |
73
+ |-----|---------|
74
+ | `r` | refresh now |
75
+ | `q` | quit |
76
+
77
+ Beyond goals, every live match carries its incident feed: yellow cards,
78
+ substitutions, injury time — and a `⚖ VAR` badge the moment a review starts.
79
+ Red cards and VAR decisions get the full-screen treatment, same as goals.
80
+
81
+ ## Run it next to Claude Code
82
+
83
+ claudial is built to sit beside [Claude Code](https://claude.com/claude-code)
84
+ all day. One command opens both — Claude Code working, the World Cup ticking
85
+ in a strip below it:
86
+
87
+ ```bash
88
+ tmux new-session 'claude' \; split-window -v -l 5 'npx claudial --ticker' \; select-pane -U
89
+ ```
90
+
91
+ Prefer the full board on the side instead:
92
+
93
+ ```bash
94
+ tmux new-session 'claude' \; split-window -h -l 44 'npx claudial' \; select-pane -L
95
+ ```
96
+
97
+ Make it permanent — add to your `~/.zshrc` (or `~/.bashrc`):
98
+
99
+ ```bash
100
+ alias claude-mundial="tmux new-session 'claude' \; split-window -v -l 5 'npx claudial --ticker' \; select-pane -U"
101
+ ```
102
+
103
+ Then `claude-mundial` starts your whole World Cup workstation. When a goal
104
+ goes in, the strip lights up while Claude keeps working.
105
+
106
+ ## Status
107
+
108
+ v1 is being built live during the group stage. Follow the commits.
109
+
110
+ ## Notes
111
+
112
+ - Match data comes from SofaScore's public endpoints. This project is
113
+ unofficial and not affiliated with SofaScore.
114
+ - Not affiliated with Anthropic. The aesthetic is a love letter to
115
+ [Claude Code](https://claude.com/claude-code), whose terminal UI this
116
+ proudly imitates.
117
+ - Polling is deliberately gentle (15 s live, 5 min fixtures). Please keep
118
+ it that way.
119
+
120
+ ```js
121
+ // Canada — Bosnia & Herzegovina, 12 June 2026, was on while this was built.
122
+ // Jovo Lukić's 21' goal was the first one this codebase ever saw — it hit
123
+ // the API smoke test before any UI existed to celebrate it. Legendary.
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,114 @@
1
+ export const WORLD_CUP_ID = 16;
2
+ export function isWorldCup(raw) {
3
+ return raw?.tournament?.uniqueTournament?.id === WORLD_CUP_ID;
4
+ }
5
+ function toStatus(raw) {
6
+ const t = raw?.status?.type;
7
+ if (t === 'inprogress') {
8
+ return raw.status?.description === 'Halftime' ? 'halftime' : 'live';
9
+ }
10
+ if (t === 'finished')
11
+ return 'finished';
12
+ return 'upcoming';
13
+ }
14
+ export function toMinute(raw, nowSeconds) {
15
+ const time = raw?.time;
16
+ if (raw?.status?.type !== 'inprogress' || !time?.currentPeriodStartTimestamp)
17
+ return null;
18
+ if (raw.status?.description === 'Halftime')
19
+ return null;
20
+ const played = (time.initial ?? 0) + (nowSeconds - time.currentPeriodStartTimestamp);
21
+ // football minutes are 1-based: 0–59s of play is the 1st minute
22
+ const minute = Math.floor(played / 60) + 1;
23
+ return Math.min(minute, Math.ceil((time.max ?? 5400) / 60));
24
+ }
25
+ export function parseEvent(raw, nowSeconds) {
26
+ const group = /Group [A-L]/.exec(raw?.tournament?.name ?? '')?.[0] ?? null;
27
+ return {
28
+ id: raw.id,
29
+ group,
30
+ home: { name: raw.homeTeam?.name ?? '?', code: raw.homeTeam?.nameCode ?? raw.homeTeam?.shortName ?? '' },
31
+ away: { name: raw.awayTeam?.name ?? '?', code: raw.awayTeam?.nameCode ?? raw.awayTeam?.shortName ?? '' },
32
+ homeScore: raw.homeScore?.current ?? null,
33
+ awayScore: raw.awayScore?.current ?? null,
34
+ status: toStatus(raw),
35
+ statusText: raw.status?.description ?? '',
36
+ minute: toMinute(raw, nowSeconds),
37
+ startTimestamp: raw.startTimestamp ?? 0,
38
+ // live feed sends an object ({ homeTeam, awayTeam }); be tolerant of a plain boolean too
39
+ varInProgress: typeof raw.varInProgress === 'object' && raw.varInProgress !== null
40
+ ? !!(raw.varInProgress.homeTeam || raw.varInProgress.awayTeam)
41
+ : !!raw.varInProgress,
42
+ };
43
+ }
44
+ /** "goalNotAwarded" → "Goal not awarded" */
45
+ function humanize(klass) {
46
+ if (!klass)
47
+ return '';
48
+ const words = klass.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
49
+ return words.charAt(0).toUpperCase() + words.slice(1);
50
+ }
51
+ function toKind(i) {
52
+ switch (i.incidentType) {
53
+ case 'goal':
54
+ return 'goal';
55
+ case 'card':
56
+ if (i.incidentClass === 'yellow')
57
+ return 'yellowCard';
58
+ if (i.incidentClass === 'red' || i.incidentClass === 'yellowRed')
59
+ return 'redCard';
60
+ return null;
61
+ case 'varDecision':
62
+ return 'var';
63
+ case 'inGamePenalty':
64
+ return 'penaltyMiss';
65
+ case 'substitution':
66
+ return 'substitution';
67
+ case 'injuryTime':
68
+ return 'injuryTime';
69
+ default:
70
+ return null; // 'period' and anything unknown
71
+ }
72
+ }
73
+ function toDetail(i, kind) {
74
+ if (kind === 'var')
75
+ return humanize(i.incidentClass ?? 'decision');
76
+ if (kind === 'goal') {
77
+ if (i.incidentClass === 'penalty')
78
+ return 'Penalty';
79
+ if (i.incidentClass === 'ownGoal')
80
+ return 'Own goal';
81
+ return null;
82
+ }
83
+ if (kind === 'substitution')
84
+ return i.playerOut?.name ? `for ${i.playerOut.name}` : null;
85
+ if (kind === 'injuryTime')
86
+ return i.length != null ? `+${i.length}` : null;
87
+ if (kind === 'penaltyMiss')
88
+ return 'Penalty missed';
89
+ return null;
90
+ }
91
+ export function parseIncidents(raw) {
92
+ const out = [];
93
+ for (const i of raw?.incidents ?? []) {
94
+ const kind = toKind(i);
95
+ if (!kind)
96
+ continue;
97
+ const player = kind === 'substitution' ? i.playerIn : i.player;
98
+ out.push({
99
+ // fallback id must be stable across polls (the diff engine dedupes on it),
100
+ // so derive it from incident content, never from array position
101
+ id: String(i.id ?? `${i.incidentType}-${i.time}-${player?.id ?? 'x'}`),
102
+ kind,
103
+ minute: i.time ?? null,
104
+ player: player?.name ?? null,
105
+ playerShort: player?.shortName ?? player?.name ?? null,
106
+ detail: toDetail(i, kind),
107
+ isHome: !!i.isHome,
108
+ homeScore: kind === 'goal' ? i.homeScore ?? null : null,
109
+ awayScore: kind === 'goal' ? i.awayScore ?? null : null,
110
+ });
111
+ }
112
+ // API returns newest first; we want chronological
113
+ return out.reverse();
114
+ }
@@ -0,0 +1,51 @@
1
+ import { isWorldCup, parseEvent, parseIncidents } from './parse.js';
2
+ const HOST = 'https://api.sofascore.app';
3
+ // api.sofascore.com is challenge-blocked (403). The app host works with this
4
+ // exact recipe; Accept-Encoding is mandatory or some endpoints return empty 200s.
5
+ const HEADERS = {
6
+ 'User-Agent': 'SofaScore/5.95.2 (Linux; Android 13)',
7
+ Accept: 'application/json',
8
+ 'Accept-Encoding': 'gzip',
9
+ };
10
+ async function get(path) {
11
+ const res = await fetch(`${HOST}${path}`, { headers: HEADERS });
12
+ if (!res.ok)
13
+ throw new Error(`sofascore ${res.status} ${path}`);
14
+ const text = await res.text();
15
+ if (!text)
16
+ throw new Error(`sofascore empty body ${path}`);
17
+ try {
18
+ return JSON.parse(text);
19
+ }
20
+ catch {
21
+ throw new Error(`sofascore: malformed JSON from ${path}: ${text.slice(0, 120)}`);
22
+ }
23
+ }
24
+ const now = () => Math.floor(Date.now() / 1000);
25
+ export async function resolveSeasonId() {
26
+ const data = await get(`/api/v1/unique-tournament/16/seasons`);
27
+ if (!data.seasons?.length)
28
+ throw new Error(`sofascore: no seasons for tournament 16`);
29
+ return data.seasons[0].id; // newest first; 2026 = 58210
30
+ }
31
+ export async function fetchLive() {
32
+ const data = await get(`/api/v1/sport/football/events/live`);
33
+ const t = now();
34
+ return (data.events ?? []).filter(isWorldCup).map((e) => parseEvent(e, t));
35
+ }
36
+ export async function fetchUpcoming(seasonId) {
37
+ const data = await get(`/api/v1/unique-tournament/16/season/${seasonId}/events/next/0`);
38
+ const t = now();
39
+ return (data.events ?? []).map((e) => parseEvent(e, t));
40
+ }
41
+ export async function fetchRecent(seasonId) {
42
+ const data = await get(`/api/v1/unique-tournament/16/season/${seasonId}/events/last/0`);
43
+ const t = now();
44
+ const dayAgo = t - 86_400;
45
+ return (data.events ?? [])
46
+ .map((e) => parseEvent(e, t))
47
+ .filter((m) => m.status === 'finished' && m.startTimestamp > dayAgo);
48
+ }
49
+ export async function fetchIncidents(eventId) {
50
+ return parseIncidents(await get(`/api/v1/event/${eventId}/incidents`));
51
+ }
package/dist/banner.js ADDED
@@ -0,0 +1,30 @@
1
+ export const GOAL_ART = [
2
+ ' ██████ ██████ █████ ██ ',
3
+ '██ ██ ██ ██ ██ ██ ',
4
+ '██ ███ ██ ██ ███████ ██ ',
5
+ '██ ██ ██ ██ ██ ██ ██ ',
6
+ ' ██████ ██████ ██ ██ ███████ ',
7
+ ];
8
+ export const VAR_ART = [
9
+ '██ ██ █████ ██████ ',
10
+ '██ ██ ██ ██ ██ ██ ',
11
+ '██ ██ ███████ ██████ ',
12
+ ' ██ ██ ██ ██ ██ ██ ',
13
+ ' █████ ██ ██ ██ ██ ',
14
+ ];
15
+ // the card itself, drawn tall
16
+ export const RED_CARD_ART = [
17
+ '█████████',
18
+ '█████████',
19
+ '█████████',
20
+ '█████████',
21
+ '█████████',
22
+ '█████████',
23
+ ];
24
+ export function spacedCaps(s) {
25
+ return s
26
+ .toUpperCase()
27
+ .split(' ')
28
+ .map((word) => word.split('').join(' '))
29
+ .join(' ');
30
+ }
@@ -0,0 +1,38 @@
1
+ const total = (m) => (m.homeScore ?? 0) + (m.awayScore ?? 0);
2
+ export function diffGoals(prev, next) {
3
+ // duplicate ids in a snapshot should be impossible, but if they happen,
4
+ // keep the highest-scoring entry as baseline — we'd rather miss a goal
5
+ // than celebrate a phantom one
6
+ const before = new Map();
7
+ for (const p of prev) {
8
+ const existing = before.get(p.id);
9
+ if (!existing || total(p) > total(existing))
10
+ before.set(p.id, p);
11
+ }
12
+ const changes = [];
13
+ for (const m of next) {
14
+ const p = before.get(m.id);
15
+ if (!p)
16
+ continue;
17
+ // == null also passes NaN through, but NaN comparisons are all false,
18
+ // so corrupted scores suppress events (fail toward false negative)
19
+ if (m.homeScore == null || m.awayScore == null || p.homeScore == null || p.awayScore == null)
20
+ continue;
21
+ if (m.homeScore > p.homeScore)
22
+ changes.push({ matchId: m.id, side: 'home', homeScore: m.homeScore, awayScore: m.awayScore });
23
+ if (m.awayScore > p.awayScore)
24
+ changes.push({ matchId: m.id, side: 'away', homeScore: m.homeScore, awayScore: m.awayScore });
25
+ }
26
+ return changes;
27
+ }
28
+ /** Returns incidents not yet in `seen`, adding them to it. */
29
+ export function diffIncidents(seen, incidents) {
30
+ const fresh = [];
31
+ for (const i of incidents) {
32
+ if (seen.has(i.id))
33
+ continue;
34
+ seen.add(i.id);
35
+ fresh.push(i);
36
+ }
37
+ return fresh;
38
+ }
@@ -0,0 +1,125 @@
1
+ import { diffGoals, diffIncidents } from './diff.js';
2
+ export function startPoller(deps, opts = {}) {
3
+ const liveMs = opts.liveMs ?? 15_000;
4
+ const fixturesMs = opts.fixturesMs ?? 300_000;
5
+ let prev = [];
6
+ const seenIncidents = new Map(); // matchId → incident ids
7
+ let failures = 0;
8
+ let stopped = false;
9
+ let liveTimer;
10
+ let fixturesTimer;
11
+ let ticking = false;
12
+ function takeoverFor(i, m) {
13
+ if (i.kind === 'redCard') {
14
+ return {
15
+ kind: 'redcard', match: m, who: i.player ?? (i.isHome ? m.home.name : m.away.name), detail: null,
16
+ minute: i.minute, homeScore: m.homeScore ?? 0, awayScore: m.awayScore ?? 0,
17
+ };
18
+ }
19
+ if (i.kind === 'var') {
20
+ return {
21
+ kind: 'var', match: m, who: i.player ?? (i.isHome ? m.home.name : m.away.name), detail: i.detail,
22
+ minute: i.minute, homeScore: m.homeScore ?? 0, awayScore: m.awayScore ?? 0,
23
+ };
24
+ }
25
+ return null;
26
+ }
27
+ async function liveTick() {
28
+ if (ticking)
29
+ return;
30
+ ticking = true;
31
+ try {
32
+ const matches = await deps.fetchLive();
33
+ const scoreChanges = diffGoals(prev, matches);
34
+ prev = matches;
35
+ deps.dispatch({ type: 'live', matches, at: Date.now() });
36
+ failures = 0;
37
+ for (const m of matches) {
38
+ // A match only counts as "first sight" until its first SUCCESSFUL incidents
39
+ // fetch. We must NOT insert into seenIncidents until the fetch succeeds;
40
+ // otherwise a failed first fetch leaves an empty set in the map, and on the
41
+ // next tick old (pre-launch) incidents are diffed against that empty set and
42
+ // replayed as fresh — violating the no-replay contract.
43
+ const firstSight = !seenIncidents.has(m.id);
44
+ // Attempt to fetch incidents; on failure, incidents stays empty and we
45
+ // skip dispatching 'incidents' and running diffIncidents — but we still
46
+ // process score-diff takeovers (scorer-less) so goals are never dropped.
47
+ let incidents = [];
48
+ let incidentsFetched = false;
49
+ try {
50
+ incidents = await deps.fetchIncidents(m.id);
51
+ incidentsFetched = true;
52
+ }
53
+ catch {
54
+ // intentional: degrade to scorer-less takeovers rather than dropping them
55
+ }
56
+ if (incidentsFetched) {
57
+ // Only register the match in seenIncidents on first SUCCESSFUL fetch.
58
+ if (firstSight)
59
+ seenIncidents.set(m.id, new Set());
60
+ const seen = seenIncidents.get(m.id);
61
+ deps.dispatch({ type: 'incidents', matchId: m.id, incidents });
62
+ // Red cards & VAR come from the incident diff — but never on first sight
63
+ // (prevents replaying stale events when a match is first seen mid-game).
64
+ // Goal/yellow/sub/injuryTime incidents never fire takeovers from this path
65
+ // (goal takeovers are deduped via the score-diff path above).
66
+ // diffIncidents is always called (even on firstSight) to populate seen set.
67
+ const fresh = diffIncidents(seen, incidents);
68
+ if (!firstSight) {
69
+ for (const i of fresh) {
70
+ const t = takeoverFor(i, m);
71
+ if (t)
72
+ deps.dispatch({ type: 'takeover', takeover: t });
73
+ }
74
+ }
75
+ }
76
+ // Goal takeovers come from the score diff (faster + reliable),
77
+ // enriched with the scorer from the freshest incident list.
78
+ // If incidents fetch failed, incidents is [] so scorer will be null — degraded but not dropped.
79
+ for (const c of scoreChanges.filter((c) => c.matchId === m.id)) {
80
+ const g = [...incidents].reverse().find((x) => x.kind === 'goal' && x.isHome === (c.side === 'home'));
81
+ deps.dispatch({
82
+ type: 'takeover',
83
+ takeover: {
84
+ kind: 'goal', match: m, who: g?.player ?? (c.side === 'home' ? m.home.name : m.away.name), detail: g?.detail ?? null,
85
+ minute: g?.minute ?? m.minute, homeScore: c.homeScore, awayScore: c.awayScore,
86
+ },
87
+ });
88
+ }
89
+ }
90
+ }
91
+ catch {
92
+ failures += 1;
93
+ deps.dispatch({ type: 'stale' });
94
+ }
95
+ ticking = false;
96
+ if (!stopped) {
97
+ const delay = Math.min(liveMs * 2 ** Math.min(failures, 5), 300_000);
98
+ liveTimer = setTimeout(liveTick, delay);
99
+ }
100
+ }
101
+ async function fixturesTick() {
102
+ try {
103
+ const { upcoming, recent } = await deps.fetchFixtures();
104
+ deps.dispatch({ type: 'fixtures', upcoming, recent });
105
+ }
106
+ catch {
107
+ deps.dispatch({ type: 'stale' });
108
+ }
109
+ if (!stopped)
110
+ fixturesTimer = setTimeout(fixturesTick, fixturesMs);
111
+ }
112
+ void liveTick();
113
+ void fixturesTick();
114
+ return {
115
+ stop() {
116
+ stopped = true;
117
+ clearTimeout(liveTimer);
118
+ clearTimeout(fixturesTimer);
119
+ },
120
+ refreshNow() {
121
+ clearTimeout(liveTimer);
122
+ void liveTick();
123
+ },
124
+ };
125
+ }
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from 'ink';
4
+ import { App } from './ui/App.js';
5
+ import { resolveSeasonId } from './api/sofascore.js';
6
+ // exit quietly when the consumer of a pipe closes early (e.g. `claudial | head`)
7
+ process.stdout.on('error', (err) => {
8
+ if (err.code === 'EPIPE')
9
+ process.exit(0);
10
+ throw err;
11
+ });
12
+ async function main() {
13
+ let seasonId;
14
+ try {
15
+ seasonId = await resolveSeasonId();
16
+ }
17
+ catch {
18
+ console.error('claudial: could not reach SofaScore. Check your connection and try again.');
19
+ process.exit(1);
20
+ }
21
+ if (!process.stdout.isTTY) {
22
+ const { printSnapshot } = await import('./snapshot.js');
23
+ await printSnapshot(seasonId);
24
+ return;
25
+ }
26
+ const mode = process.argv.includes('--ticker') ? 'ticker' : 'board';
27
+ render(_jsx(App, { seasonId: seasonId, mode: mode }));
28
+ }
29
+ main().catch(() => {
30
+ console.error('claudial: could not reach SofaScore. Check your connection and try again.');
31
+ process.exit(1);
32
+ });
@@ -0,0 +1,21 @@
1
+ import { fetchLive, fetchRecent, fetchUpcoming } from './api/sofascore.js';
2
+ import { formatKickoff } from './ui/UpcomingSection.js';
3
+ function line(m) {
4
+ if (m.status === 'upcoming')
5
+ return `o ${formatKickoff(m.startTimestamp).padEnd(13)} ${m.home.name} - ${m.away.name}`;
6
+ const label = m.status === 'finished' ? 'FT' : m.status === 'halftime' ? 'HT' : `${m.minute ?? '?'}'`;
7
+ return `* ${label.padEnd(4)} ${m.home.name} ${m.homeScore ?? '-'} - ${m.awayScore ?? '-'} ${m.away.name}`;
8
+ }
9
+ export async function printSnapshot(seasonId) {
10
+ const [live, upcoming, recent] = await Promise.all([
11
+ fetchLive(), fetchUpcoming(seasonId), fetchRecent(seasonId),
12
+ ]);
13
+ console.log('claudial - WORLD CUP 2026');
14
+ const liveIds = new Set(live.map((m) => m.id));
15
+ for (const m of [...live, ...recent.filter((r) => !liveIds.has(r.id))])
16
+ console.log(line(m));
17
+ if (upcoming.length)
18
+ console.log('UPCOMING');
19
+ for (const m of upcoming.slice(0, 8))
20
+ console.log(line(m));
21
+ }
package/dist/state.js ADDED
@@ -0,0 +1,21 @@
1
+ export const initialState = {
2
+ live: [], upcoming: [], recent: [],
3
+ incidents: {}, takeovers: [],
4
+ stale: false, lastUpdated: null,
5
+ };
6
+ export function reducer(s, a) {
7
+ switch (a.type) {
8
+ case 'live':
9
+ return { ...s, live: a.matches, stale: false, lastUpdated: a.at };
10
+ case 'fixtures':
11
+ return { ...s, upcoming: a.upcoming, recent: a.recent };
12
+ case 'incidents':
13
+ return { ...s, incidents: { ...s.incidents, [a.matchId]: a.incidents } };
14
+ case 'takeover':
15
+ return { ...s, takeovers: [...s.takeovers, a.takeover] };
16
+ case 'takeoverDone':
17
+ return { ...s, takeovers: s.takeovers.slice(1) };
18
+ case 'stale':
19
+ return { ...s, stale: true };
20
+ }
21
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui/App.js ADDED
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef } from 'react';
3
+ import { Box, useApp, useInput } from 'ink';
4
+ import { initialState, reducer } from '../state.js';
5
+ import { startPoller } from '../engine/poller.js';
6
+ import { fetchIncidents, fetchLive, fetchRecent, fetchUpcoming } from '../api/sofascore.js';
7
+ import { Header } from './Header.js';
8
+ import { LiveSection } from './LiveSection.js';
9
+ import { UpcomingSection } from './UpcomingSection.js';
10
+ import { Footer } from './Footer.js';
11
+ import { TakeoverView } from './TakeoverView.js';
12
+ import { Ticker } from './Ticker.js';
13
+ export function App({ seasonId, mode = 'board' }) {
14
+ const [state, dispatch] = useReducer(reducer, initialState);
15
+ const { exit } = useApp();
16
+ const pollerRef = useRef(null);
17
+ useEffect(() => {
18
+ const deps = {
19
+ fetchLive,
20
+ fetchFixtures: async () => ({
21
+ upcoming: await fetchUpcoming(seasonId),
22
+ recent: await fetchRecent(seasonId),
23
+ }),
24
+ fetchIncidents,
25
+ dispatch,
26
+ };
27
+ pollerRef.current = startPoller(deps);
28
+ return () => pollerRef.current?.stop();
29
+ }, [seasonId]);
30
+ useInput((input) => {
31
+ if (input === 'q')
32
+ exit();
33
+ if (input === 'r')
34
+ pollerRef.current?.refreshNow();
35
+ });
36
+ const compact = mode === 'board' && (process.stdout.columns ?? 80) < 70;
37
+ const playing = state.takeovers[0] ?? null;
38
+ useEffect(() => {
39
+ if (!playing)
40
+ return;
41
+ const t = setTimeout(() => dispatch({ type: 'takeoverDone' }), 4_000);
42
+ return () => clearTimeout(t);
43
+ }, [playing]);
44
+ if (playing && mode === 'board')
45
+ return _jsx(TakeoverView, { takeover: playing });
46
+ if (mode === 'ticker') {
47
+ return _jsx(Ticker, { live: state.live, upcoming: state.upcoming, takeover: playing });
48
+ }
49
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Header, { stale: state.stale, lastUpdated: state.lastUpdated }), _jsx(LiveSection, { matches: state.live, recent: state.recent, incidents: state.incidents, compact: compact }), _jsx(UpcomingSection, { matches: state.upcoming, compact: compact }), _jsx(Footer, {})] }));
50
+ }
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ export function Footer() {
4
+ return _jsx(Text, { dimColor: true, children: "r refresh \u00B7 q quit" });
5
+ }
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const ACCENT = '#D97757';
4
+ export const RED = '#E5484D';
5
+ export function Header({ stale, lastUpdated }) {
6
+ const updated = lastUpdated
7
+ ? new Date(lastUpdated).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
8
+ : '—';
9
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: "claudial" }), _jsx(Text, { dimColor: true, children: " \u00B7 WORLD CUP 2026" }), _jsxs(Text, { dimColor: true, children: [' ', "\u273B ", stale ? 'stale · retrying' : `updated ${updated}`] })] }));
10
+ }
@@ -0,0 +1,39 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { ACCENT } from './Header.js';
4
+ function ScorerLine({ incidents }) {
5
+ const goals = incidents.filter((i) => i.kind === 'goal');
6
+ if (goals.length === 0)
7
+ return null;
8
+ const fmt = (g) => `${g.minute ?? '?'}' ${g.playerShort ?? '?'}`;
9
+ const home = goals.filter((g) => g.isHome).map(fmt).join(' · ');
10
+ const away = goals.filter((g) => !g.isHome).map(fmt).join(' · ');
11
+ return _jsxs(Text, { dimColor: true, children: [' ', "\u26BD ", [home, away].filter(Boolean).join(' | ')] });
12
+ }
13
+ function FeedLine({ incidents }) {
14
+ const feed = incidents.filter((i) => i.kind === 'yellowCard' || i.kind === 'substitution');
15
+ if (feed.length === 0)
16
+ return null;
17
+ const fmt = (i) => i.kind === 'yellowCard'
18
+ ? `${i.minute ?? '?'}' 🟨 ${i.playerShort ?? '?'}`
19
+ : `${i.minute ?? '?'}' ⇄ ${i.playerShort ?? '?'}`;
20
+ // last 4 only — the feed is a pulse, not a log
21
+ return _jsxs(Text, { dimColor: true, children: [' ', "\u25AA ", feed.slice(-4).map(fmt).join(' · ')] });
22
+ }
23
+ function statusLabel(m) {
24
+ if (m.status === 'halftime')
25
+ return 'HT';
26
+ if (m.status === 'finished')
27
+ return 'FT';
28
+ return m.minute != null ? `LIVE ${m.minute}'` : 'LIVE';
29
+ }
30
+ export function LiveSection({ matches, recent, incidents, compact }) {
31
+ const liveIds = new Set(matches.map((m) => m.id));
32
+ const rows = [...matches, ...recent.filter((r) => !liveIds.has(r.id))];
33
+ if (rows.length === 0)
34
+ return null;
35
+ if (compact) {
36
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: rows.map((m) => (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }), m.home.code, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", m.away.code, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))) }));
37
+ }
38
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: rows.map((m) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { bold: m.status !== 'finished', dimColor: m.status === 'finished', children: [" ", statusLabel(m)] }), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u00B7 \u2696 VAR" }) : null] }), _jsxs(Text, { children: [' ', m.home.name, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", m.away.name] }), _jsx(ScorerLine, { incidents: incidents[m.id] ?? [] }), _jsx(FeedLine, { incidents: incidents[m.id] ?? [] })] }, m.id))) }));
39
+ }
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { GOAL_ART, VAR_ART, RED_CARD_ART, spacedCaps } from '../banner.js';
4
+ import { ACCENT, RED } from './Header.js';
5
+ const ART = {
6
+ goal: { art: GOAL_ART, color: ACCENT, caption: null },
7
+ redcard: { art: RED_CARD_ART, color: RED, caption: 'R E D C A R D' },
8
+ var: { art: VAR_ART, color: ACCENT, caption: null },
9
+ };
10
+ export function TakeoverView({ takeover }) {
11
+ const { match, kind } = takeover;
12
+ const { art, color, caption } = ART[kind];
13
+ const fallback = takeover.who ?? (takeover.kind === 'goal'
14
+ ? (takeover.homeScore > takeover.awayScore ? match.home.name : match.away.name)
15
+ : match.home.name);
16
+ const rows = process.stdout.rows ?? 24;
17
+ if (rows < 14) {
18
+ const label = kind === 'goal' ? 'G O A L' : kind === 'redcard' ? 'R E D C A R D' : 'V A R';
19
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [_jsx(Text, { bold: true, color: color, wrap: "truncate", children: label }), _jsxs(Text, { bold: true, wrap: "truncate", children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }), takeover.detail ? _jsx(Text, { dimColor: true, wrap: "truncate", children: takeover.detail }) : null, _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [match.home.name.toUpperCase(), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", match.away.name.toUpperCase()] })] })] }));
20
+ }
21
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [art.map((line, i) => (_jsx(Text, { bold: true, color: color, wrap: "truncate", children: line }, i))), caption ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: color, wrap: "truncate", children: caption }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { bold: true, children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }) }), takeover.detail ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: takeover.detail }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [match.home.name.toUpperCase(), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", match.away.name.toUpperCase()] })] })] }));
22
+ }
@@ -0,0 +1,15 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { formatKickoff } from './UpcomingSection.js';
4
+ import { ACCENT, RED } from './Header.js';
5
+ import { spacedCaps } from '../banner.js';
6
+ function TickerTakeover({ t }) {
7
+ const color = t.kind === 'redcard' ? RED : ACCENT;
8
+ const label = t.kind === 'goal' ? 'G O A L' : t.kind === 'redcard' ? 'R E D' : 'V A R';
9
+ return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ", t.match.home.code, " ", t.homeScore, "\u2014", t.awayScore, " ", t.match.away.code] }));
10
+ }
11
+ export function Ticker({ live, upcoming, takeover }) {
12
+ if (takeover)
13
+ return _jsx(TickerTakeover, { t: takeover });
14
+ return (_jsxs(Box, { flexDirection: "column", children: [live.slice(0, 3).map((m) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", m.minute != null ? `${m.minute}'` : m.status === 'halftime' ? 'HT' : m.status === 'finished' ? 'FT' : '', " "] }), m.home.code, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", m.away.code, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", upcoming[0].home.code, "\u2014", upcoming[0].away.code, " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
15
+ }
@@ -0,0 +1,21 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function formatKickoff(ts, now = Date.now()) {
4
+ const d = new Date(ts * 1000);
5
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
6
+ const sameDay = d.toDateString() === new Date(now).toDateString();
7
+ if (sameDay)
8
+ return time;
9
+ const day = d.toLocaleDateString(undefined, { weekday: 'short' });
10
+ return `${day} ${time}`;
11
+ }
12
+ export function UpcomingSection({ matches, compact }) {
13
+ const shown = matches.slice(0, compact ? 1 : 8);
14
+ if (shown.length === 0)
15
+ return null;
16
+ if (compact) {
17
+ const m = shown[0];
18
+ return (_jsxs(Text, { dimColor: true, children: ["\u25CB ", m.home.code, "\u2014", m.away.code, " ", formatKickoff(m.startTimestamp)] }));
19
+ }
20
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "UPCOMING" }), shown.map((m) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), m.home.name, " \u2014 ", m.away.name, m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }, m.id)))] }));
21
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "claudial",
3
+ "version": "0.1.0",
4
+ "description": "Live World Cup 2026 fixtures in your terminal — goal, red-card and VAR takeovers included",
5
+ "type": "module",
6
+ "bin": { "claudial": "dist/index.js" },
7
+ "files": ["dist"],
8
+ "engines": { "node": ">=18.18" },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/index.tsx",
12
+ "test": "vitest run",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "dependencies": {
16
+ "ink": "^5.2.0",
17
+ "react": "^18.3.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.14.0",
21
+ "@types/react": "^18.3.0",
22
+ "tsx": "^4.19.0",
23
+ "typescript": "^5.5.0",
24
+ "vitest": "^2.1.0"
25
+ },
26
+ "keywords": ["worldcup", "world-cup-2026", "tui", "cli", "football", "soccer", "live-scores", "ink"],
27
+ "license": "MIT",
28
+ "repository": { "type": "git", "url": "git+https://github.com/lefProg/claudial.git" },
29
+ "homepage": "https://github.com/lefProg/claudial#readme",
30
+ "bugs": "https://github.com/lefProg/claudial/issues",
31
+ "author": "Wim Iliano"
32
+ }