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 +21 -0
- package/README.md +128 -0
- package/dist/api/parse.js +114 -0
- package/dist/api/sofascore.js +51 -0
- package/dist/banner.js +30 -0
- package/dist/engine/diff.js +38 -0
- package/dist/engine/poller.js +125 -0
- package/dist/index.js +32 -0
- package/dist/snapshot.js +21 -0
- package/dist/state.js +21 -0
- package/dist/types.js +1 -0
- package/dist/ui/App.js +50 -0
- package/dist/ui/Footer.js +5 -0
- package/dist/ui/Header.js +10 -0
- package/dist/ui/LiveSection.js +39 -0
- package/dist/ui/TakeoverView.js +22 -0
- package/dist/ui/Ticker.js +15 -0
- package/dist/ui/UpcomingSection.js +21 -0
- package/package.json +32 -0
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
|
+
});
|
package/dist/snapshot.js
ADDED
|
@@ -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,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
|
+
}
|