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.
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  # ⚽ claudial
4
4
 
5
- ### Live World Cup 2026 in your terminal — goal and red-card takeovers included
5
+ ### Live football inside Claude Code
6
6
 
7
- One command. Every World Cup score, in your terminal and right inside Claude
8
- Code, while you work.
7
+ Your teams' scores sit under your prompt while you code.
8
+ When one of them scores, your terminal celebrates.
9
9
 
10
10
  ```
11
11
  npm install -g claudial && claudial setup
@@ -16,121 +16,115 @@ npm install -g claudial && claudial setup
16
16
  [![npm version](https://img.shields.io/npm/v/claudial?style=flat-square&color=CB3837&logo=npm)](https://www.npmjs.com/package/claudial)
17
17
  [![npm downloads](https://img.shields.io/npm/dm/claudial?style=flat-square&color=CB3837&logo=npm)](https://www.npmjs.com/package/claudial)
18
18
  [![node](https://img.shields.io/node/v/claudial?style=flat-square&color=339933&logo=node.js&logoColor=white)](https://nodejs.org)
19
- [![license](https://img.shields.io/npm/l/claudial?style=flat-square&color=blue)](LICENSE)
20
19
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white)](tsconfig.json)
20
+ ![backend](https://img.shields.io/badge/backend-Rust-DEA584?style=flat-square&logo=rust&logoColor=white)
21
+ [![license](https://img.shields.io/npm/l/claudial?style=flat-square&color=blue)](LICENSE)
21
22
  [![GitHub stars](https://img.shields.io/github/stars/lefProg/claudial?style=flat-square&color=f5c518&logo=github)](https://github.com/lefProg/claudial/stargazers)
22
- [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/lefProg/claudial/issues)
23
23
 
24
- </div>
24
+ <br/>
25
25
 
26
- ---
26
+ ![claudial: a live football board in the terminal, taken over by a full-screen GOAL](dashboard.gif)
27
27
 
28
- Live World Cup scores right inside Claude Code's status bar — under the input
29
- box, always visible while you work, lighting up the moment anyone scores:
28
+ **Premier League · La Liga · Serie A · Bundesliga · Ligue 1 · Champions League ·
29
+ Europa League · Conference League · Super League Greece · World Cup**
30
30
 
31
- <div align="center">
31
+ </div>
32
32
 
33
- ![claudial — live World Cup 2026 scores in Claude Code's status bar](demo.gif)
33
+ ---
34
34
 
35
- </div>
35
+ ## The score, right under your prompt
36
36
 
37
- ## Claude Code, World Cup edition
37
+ One command puts a live score line into Claude Code's status bar. It stays out
38
+ of the way while Claude works, and lights up the moment anyone scores:
38
39
 
39
- The whole point: your Claude Code, score-aware. One command and the live score
40
- sits right in Claude Code's status bar — under the input box, always visible
41
- while Claude works, with nothing to wire up by hand.
40
+ <div align="center">
42
41
 
43
- ```bash
44
- npm install -g claudial
45
- claudial setup
46
- ```
42
+ ![claudial: live scores in Claude Code's status bar, flashing GOOOL on a goal](demo.gif)
47
43
 
48
- The wizard drops a live-score statusline into your Claude Code settings:
44
+ </div>
49
45
 
50
46
  ```
51
- ⚽ QAT 🇶🇦 0—1 🇨🇭 SUI 67' · main ← during a match
52
- ○ QAT 🇶🇦 — 🇨🇭 SUI 10:00 PM · main ← between matches (next kickoff, or last result)
53
- ⚽ G O O O L · ARG 🇦🇷 1—0 🇲🇽 MEX ← for 15s whenever anyone scores
54
- 🟥 R E D · OTAMENDI · ARG 🇦🇷 — 🇲🇽 MEX ← for 15s on a red card
47
+ ⚽ ARS 1—1 LIV 77' ⚽ PAO 1—0 OLY 67' ⚽ RMA 2—2 BAR HT · main ← every match you follow
48
+ ⚽ G O O O L · ARS 2—1 LIV · main ← for 15 s after a goal
49
+ 🟥 R E D · VAN DIJK · ARS — LIV · main ← for 15 s after a red card
50
+ ○ FCB — PSG Sun 07:00 PM · main ← between matches: next kickoff
55
51
  ```
56
52
 
57
- It refreshes every few seconds and is pure Node, so it runs on macOS and
58
- Windows too, in any shell. Restart Claude Code after setup to see it.
59
- Scriptable, no prompts: `claudial setup --statusline --global --yes`.
60
-
61
- **Already run a custom status line?** `claudial setup` keeps it — it wraps your
62
- existing one and appends the score, so nothing you had is lost.
53
+ Your git branch stays at the end. **Already run a custom status line?** Setup
54
+ keeps it and appends the score, so nothing you had is lost. Pure Node, so it
55
+ works on macOS, Linux and Windows. Restart Claude Code after setup to see it.
63
56
 
64
- ## The full dashboard
57
+ ## Pick your teams in five seconds
65
58
 
66
- Prefer the full board beside Claude? `claudial` opens a live dashboard — every
67
- match with scores, minutes and scorers, upcoming fixtures in your local time,
68
- all auto-refreshing while you work. And when a goal goes in — anywhere in the
69
- tournament — the whole screen takes over:
59
+ Setup ends by asking what you follow. Type a name, tick it with space, press
60
+ esc to save:
70
61
 
71
62
  ```
63
+ claudial · what do you follow?
72
64
 
65
+ search › pana▏
73
66
 
74
- █▀▀█ █▀▀█ █▀▀█ █
75
- █ ▄▄ █ █ █▀▀█ █
76
- ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
67
+ › ◉ Panathinaikos PAO · Super League Greece
77
68
 
78
- LIONEL MESSI · 23'
69
+ Following Panathinaikos · Arsenal
70
+ ↑↓ move · space follow · type to search · esc clear search · ctrl+c cancel
71
+ ```
79
72
 
80
- ARG 🇦🇷 2 — 1 🇲🇽 MEX
73
+ Follow a whole league or just your club. Change your mind any time: press `f`
74
+ in the dashboard, or run `claudial follow`.
81
75
 
76
+ ## A goal takes over the whole screen
82
77
 
83
- ```
78
+ Want the full board beside Claude? `claudial` opens a live dashboard: every
79
+ match you follow with scores, minutes, scorers and cards, plus the next three
80
+ weeks of fixtures in your local time.
84
81
 
85
- Four seconds of glory, then back to the board. It costs nothing to glance at,
86
- and celebrates louder than a push notification — without you ever opening a
87
- browser tab.
82
+ When a goal goes in, the server pushes it within a second, and the whole
83
+ screen becomes the goal: the scorer, the minute, the new score. Four seconds
84
+ of glory, then back to the board. Red cards get the same treatment.
88
85
 
89
86
  ## Usage
90
87
 
91
88
  ```
92
- npx claudial # the dashboard
93
- npx claudial --ticker # 4-line strip for slim split panes
94
- npx claudial | cat # non-interactive snapshot (pipes, scripts, CI)
89
+ claudial # the live dashboard
90
+ claudial --ticker # a 4-line strip for slim split panes
91
+ claudial | cat # plain snapshot for pipes and scripts
92
+ claudial follow # pick leagues and teams
95
93
  ```
96
94
 
97
- Or install it for good:
98
-
99
- ```
100
- npm install -g claudial
101
- claudial
102
- ```
103
-
104
- Requires Node ≥ 18. No account, no API key, no config.
105
-
106
- | Key | Action |
107
- |-----|-------------|
95
+ | Key | Action |
96
+ |-----|--------|
97
+ | `f` | pick what you follow |
108
98
  | `r` | refresh now |
109
- | `q` | quit |
99
+ | `q` | quit |
100
+
101
+ Requires Node ≥ 18. No account, no sign-up, no API key.
110
102
 
111
- Beyond goals, every live match carries its incident feed — yellow cards,
112
- substitutions, injury time — and red cards get the full-screen treatment, same
113
- as goals.
103
+ Scripting it? `claudial setup --statusline --yes` installs without
104
+ prompts, and `claudial leagues`, `claudial teams <league>`,
105
+ `claudial follow <id>...`, `claudial unfollow <id>...` and `claudial following`
106
+ work without the UI. `claudial forget` deletes your device from the server.
114
107
 
115
108
  ## Status
116
109
 
117
- v1 is being built live during the group stage. Follow the commits.
110
+ claudial started as a World Cup 2026 side project. It now follows ten leagues
111
+ through its own server, written in Rust: one set of polls for everyone, goals
112
+ pushed to every terminal over server-sent events.
118
113
 
119
114
  ## Notes
120
115
 
121
- - Seeing letter codes instead of country flags (`🇦🇷` showing as `AR`)? Your
122
- terminal isn't rendering flag emoji. Install [Konsole](https://konsole.kde.org/)
123
- — it shows them out of the box. (macOS terminals already do; Windows
124
- Terminal does not.)
125
- - Match data comes from [ESPN's public soccer scoreboard](https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard)
126
- — facts only (scores, scorers, cards), no logos or branding. See
127
- [DATA.md](DATA.md) for why this source. Not affiliated with or endorsed by
128
- ESPN or FIFA.
129
- - Not affiliated with Anthropic. The aesthetic is a love letter to
130
- [Claude Code](https://claude.com/claude-code), whose terminal UI this
131
- proudly imitates.
132
- - Polling is deliberately gentle (15 s live, 5 min fixtures), and a single
133
- scoreboard call serves all live matches at once. Please keep it that way.
116
+ - Privacy: the server stores a random device id, what you follow, and when it
117
+ last saw you. No accounts, emails, names or analytics, and your IP is never
118
+ written down. Inactive devices are deleted after 400 days; `claudial forget`
119
+ deletes yours right away.
120
+ - Match facts (scores, scorers, cards) come from ESPN's public site API, through
121
+ the claudial server. No logos or branding. Not affiliated with or endorsed by
122
+ ESPN, FIFA, UEFA or any league.
123
+ - Seeing `AR` instead of `🇦🇷` for national teams? Your terminal doesn't render
124
+ flag emoji. macOS terminals and [Konsole](https://konsole.kde.org/) do;
125
+ Windows Terminal doesn't.
126
+ - Not affiliated with Anthropic. The look is a love letter to
127
+ [Claude Code](https://claude.com/claude-code).
134
128
 
135
129
  ```js
136
130
  // Canada — Bosnia & Herzegovina, 12 June 2026, was on while this was built.
@@ -140,8 +134,8 @@ v1 is being built live during the group stage. Follow the commits.
140
134
 
141
135
  ## Contributing
142
136
 
143
- Issues and PRs welcome — bug reports, new incident types, terminal quirks,
144
- all of it. Keep the polling gentle and the spirit playful.
137
+ Issues and PRs welcome: bug reports, new incident types, terminal quirks, all
138
+ of it. Keep the spirit playful.
145
139
 
146
140
  ```bash
147
141
  git clone https://github.com/lefProg/claudial.git
@@ -159,7 +153,7 @@ npm test # vitest
159
153
 
160
154
  <br/>
161
155
 
162
- **If this made a goal feel a little louder, drop a ⭐ — it helps others find it.**
156
+ **If this made a goal feel a little louder, drop a ⭐. It helps others find it.**
163
157
 
164
158
  [Report a bug](https://github.com/lefProg/claudial/issues) ·
165
159
  [Request a feature](https://github.com/lefProg/claudial/issues) ·
@@ -0,0 +1,216 @@
1
+ import { deviceId } from './device.js';
2
+ // Where the server lives: the deployed VPS. Plain HTTP until it has a domain and
3
+ // TLS; scripts/check-release.mjs blocks an npm publish until this is https://.
4
+ // CLAUDIAL_API_URL always wins (local dev: http://localhost:8080).
5
+ export const DEFAULT_API_URL = 'http://161.97.67.12:30083';
6
+ export function apiUrl(env = process.env) {
7
+ return (env.CLAUDIAL_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
8
+ }
9
+ /** Thrown for any non-2xx answer, carrying the server's own error message. */
10
+ export class ApiError extends Error {
11
+ status;
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+ const TIMEOUT_MS = 8_000;
18
+ export async function api(method, path, body, fetchImpl = fetch) {
19
+ const res = await fetchImpl(`${apiUrl()}${path}`, {
20
+ method,
21
+ headers: {
22
+ Authorization: `Bearer ${deviceId()}`,
23
+ Accept: 'application/json',
24
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
25
+ },
26
+ body: body !== undefined ? JSON.stringify(body) : undefined,
27
+ signal: AbortSignal.timeout(TIMEOUT_MS),
28
+ });
29
+ const text = await res.text();
30
+ if (!res.ok) {
31
+ let message = `server answered ${res.status}`;
32
+ try {
33
+ message = JSON.parse(text).error ?? message;
34
+ }
35
+ catch { /* not JSON */ }
36
+ throw new ApiError(res.status, message);
37
+ }
38
+ return (text ? JSON.parse(text) : undefined);
39
+ }
40
+ // ── Adapting server matches to the client's Match ───────────────────────────
41
+ export function toMatch(m) {
42
+ const national = m.league === 'fifa.world';
43
+ const status = m.status === 'postponed' ? 'finished' : m.status;
44
+ return {
45
+ id: Number(m.id),
46
+ group: null,
47
+ home: { name: m.home.name, code: m.home.code, national },
48
+ away: { name: m.away.name, code: m.away.code, national },
49
+ homeScore: m.homeScore,
50
+ awayScore: m.awayScore,
51
+ status,
52
+ statusText: m.status === 'postponed' ? (m.statusText || 'Postponed') : m.statusText,
53
+ minute: m.minute,
54
+ startTimestamp: m.startTimestamp,
55
+ varInProgress: false,
56
+ };
57
+ }
58
+ export function toIncidents(m) {
59
+ return m.incidents.map((i) => ({ ...i, homeScore: null, awayScore: null }));
60
+ }
61
+ // One /v1/state request serves every caller in a tick (dashboard or statusline):
62
+ // the in-flight promise is shared, and the answer is reused for a few seconds.
63
+ const CACHE_MS = 5_000;
64
+ let cached = null;
65
+ export function fetchState(now = Date.now()) {
66
+ if (MOCK)
67
+ return Promise.resolve(mockState());
68
+ if (cached && now - cached.at < CACHE_MS)
69
+ return cached.state;
70
+ const state = api('GET', '/v1/state');
71
+ cached = { at: now, state };
72
+ state.catch(() => { if (cached?.state === state)
73
+ cached = null; });
74
+ return state;
75
+ }
76
+ /** Forget the cached state (after the event stream says something changed). */
77
+ export function invalidateState() { cached = null; }
78
+ const isLive = (m) => m.status === 'live' || m.status === 'halftime';
79
+ // Kept for call-site compatibility; the server needs no season id.
80
+ export async function resolveSeasonId() {
81
+ return 2026;
82
+ }
83
+ export async function fetchLive() {
84
+ if (MOCK)
85
+ mockLiveCalls++;
86
+ return (await fetchState()).matches.map(toMatch).filter(isLive);
87
+ }
88
+ export async function fetchUpcoming(_seasonId) {
89
+ return (await fetchState()).matches
90
+ .map(toMatch)
91
+ .filter((m) => m.status === 'upcoming')
92
+ .sort((a, b) => a.startTimestamp - b.startTimestamp);
93
+ }
94
+ export async function fetchRecent(_seasonId) {
95
+ return (await fetchState()).matches
96
+ .map(toMatch)
97
+ .filter((m) => m.status === 'finished')
98
+ .sort((a, b) => a.startTimestamp - b.startTimestamp);
99
+ }
100
+ export async function fetchIncidents(eventId) {
101
+ const m = (await fetchState()).matches.find((x) => Number(x.id) === eventId);
102
+ return m ? toIncidents(m) : [];
103
+ }
104
+ // Red cards in live matches, from the same /v1/state answer.
105
+ export async function fetchLiveRedCards() {
106
+ const out = [];
107
+ for (const sm of (await fetchState()).matches) {
108
+ const m = toMatch(sm);
109
+ if (!isLive(m))
110
+ continue;
111
+ for (const inc of sm.incidents) {
112
+ if (inc.kind === 'redCard') {
113
+ out.push({ id: inc.id, homeCode: m.home.code, awayCode: m.away.code, player: inc.playerShort ?? inc.player ?? '?', minute: inc.minute, national: m.home.national !== false });
114
+ }
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ // ── Subscriptions ────────────────────────────────────────────────────────────
120
+ export const listLeagues = () => api('GET', '/v1/leagues');
121
+ export const listTeams = (slug) => api('GET', `/v1/leagues/${encodeURIComponent(slug)}/teams`);
122
+ export const getSettings = () => api('GET', '/v1/me/settings');
123
+ export const putSettings = (s) => api('PUT', '/v1/me/settings', s);
124
+ export const forgetDevice = () => api('DELETE', '/v1/me/settings');
125
+ /**
126
+ * A new device follows nothing until the user picks something. True when this
127
+ * device follows no league and no team, so the UI can say how to start.
128
+ */
129
+ export async function followsNothing() {
130
+ if (MOCK)
131
+ return false;
132
+ const s = await getSettings();
133
+ return s.leagues.length === 0 && s.teams.length === 0;
134
+ }
135
+ // ── Push: the server's event stream ─────────────────────────────────────────
136
+ /** Three missed 15 s heartbeats. */
137
+ const STALL_MS = 45_000;
138
+ /**
139
+ * Hold GET /v1/events open and call `onEvent` with each event name. Reconnects
140
+ * with backoff after errors or when the server ends the stream. Returns a stop
141
+ * function. The caller refetches /v1/state on each event.
142
+ */
143
+ export function subscribeEvents(onEvent, fetchImpl = fetch, stallMs = STALL_MS) {
144
+ let stopped = false;
145
+ let controller = null;
146
+ let failures = 0;
147
+ const loop = async () => {
148
+ while (!stopped) {
149
+ controller = new AbortController();
150
+ try {
151
+ const res = await fetchImpl(`${apiUrl()}/v1/events`, {
152
+ headers: { Authorization: `Bearer ${deviceId()}`, Accept: 'text/event-stream' },
153
+ signal: controller.signal,
154
+ });
155
+ if (!res.ok || !res.body)
156
+ throw new ApiError(res.status, `events answered ${res.status}`);
157
+ failures = 0;
158
+ // The server writes a heartbeat every 15 s. Silence for longer than that
159
+ // means a dead connection nobody closed (a sleeping laptop, a NAT drop):
160
+ // abort and reconnect instead of waiting forever (final review N9).
161
+ const current = controller;
162
+ let stall = setTimeout(() => current.abort(), stallMs);
163
+ const decoder = new TextDecoder();
164
+ let buf = '';
165
+ for await (const chunk of res.body) {
166
+ clearTimeout(stall);
167
+ stall = setTimeout(() => current.abort(), stallMs);
168
+ buf += decoder.decode(chunk, { stream: true });
169
+ let cut;
170
+ while ((cut = buf.indexOf('\n\n')) >= 0) {
171
+ const frame = buf.slice(0, cut);
172
+ buf = buf.slice(cut + 2);
173
+ const name = frame.split('\n').find((l) => l.startsWith('event:'))?.slice(6).trim();
174
+ if (name)
175
+ onEvent(name);
176
+ }
177
+ }
178
+ clearTimeout(stall);
179
+ }
180
+ catch {
181
+ failures++;
182
+ }
183
+ if (stopped)
184
+ return;
185
+ const wait = Math.min(1_000 * 2 ** Math.min(failures, 6), 60_000);
186
+ await new Promise((r) => setTimeout(r, wait));
187
+ }
188
+ };
189
+ void loop();
190
+ return () => { stopped = true; controller?.abort(); };
191
+ }
192
+ // ── Mock mode for demos (CLAUDIAL_MOCK) ──────────────────────────────────────
193
+ // A fake live Argentina–England match. The first live fetch is 1–1; from the
194
+ // second fetch on, Harry Kane has scored to make it 1–2 — so the next poll (or
195
+ // pressing `r`) fires the goal takeover with his name. Inert unless the env var
196
+ // is set. Run: `CLAUDIAL_MOCK=1 claudial`, then press `r`.
197
+ const MOCK = !!process.env.CLAUDIAL_MOCK;
198
+ let mockLiveCalls = 0;
199
+ function mockState() {
200
+ const scored = mockLiveCalls >= 2; // Kane's goal lands on the 2nd live fetch
201
+ const goal = (id, minute, player, short, isHome) => ({ id, kind: 'goal', minute, player, playerShort: short, detail: null, isHome });
202
+ return {
203
+ generatedAt: Math.floor(Date.now() / 1000),
204
+ matches: [{
205
+ id: '9001', league: 'fifa.world',
206
+ home: { id: '202', name: 'Argentina', code: 'ARG' }, away: { id: '448', name: 'England', code: 'ENG' },
207
+ homeScore: 1, awayScore: scored ? 2 : 1, status: 'live', statusText: scored ? "78'" : "76'",
208
+ minute: scored ? 78 : 76, startTimestamp: Math.floor(Date.now() / 1000) - 78 * 60,
209
+ incidents: [
210
+ goal('9001-137-600-1', 10, 'Lionel Messi', 'L. Messi', true),
211
+ goal('9001-137-2400-2', 40, 'Jude Bellingham', 'J. Bellingham', false),
212
+ ...(scored ? [goal('9001-137-4680-3', 78, 'Harry Kane', 'H. Kane', false)] : []),
213
+ ],
214
+ }],
215
+ };
216
+ }
@@ -0,0 +1,28 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // This device's identity for the claudial server: a random UUID made once and
6
+ // kept in ~/.config/claudial/device-id. It is the only credential; there are no
7
+ // accounts. Deleting the file makes the server see a brand-new device.
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ export function configDir(env = process.env) {
10
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'claudial');
11
+ }
12
+ /** Read the device id, creating it (mode 0600) on first use. */
13
+ export function deviceId(dir = configDir()) {
14
+ const file = join(dir, 'device-id');
15
+ if (existsSync(file)) {
16
+ const id = readFileSync(file, 'utf8').trim();
17
+ if (UUID.test(id))
18
+ return id;
19
+ }
20
+ mkdirSync(dir, { recursive: true });
21
+ const id = randomUUID();
22
+ writeFileSync(file, `${id}\n`, { mode: 0o600 });
23
+ try {
24
+ chmodSync(file, 0o600);
25
+ }
26
+ catch { /* best effort */ }
27
+ return id;
28
+ }
@@ -0,0 +1,136 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ApiError, apiUrl, forgetDevice, getSettings, listLeagues, listTeams, putSettings, } from '../api/backend.js';
4
+ import { configDir } from '../api/device.js';
5
+ // `claudial leagues | teams | follow | unfollow | following | forget`:
6
+ // what this device follows on the claudial server.
7
+ export const FOLLOW_COMMANDS = ['leagues', 'teams', 'follow', 'unfollow', 'following', 'forget'];
8
+ export const USAGE = `usage:
9
+ claudial follow pick your leagues and teams from a list
10
+ claudial leagues the leagues you can follow (✓ = following)
11
+ claudial teams <league> [search] a league's teams and their ids, e.g. claudial teams gre.1 pana
12
+ claudial follow <league|team id>... e.g. claudial follow gre.1 443
13
+ claudial unfollow <league|team id>...
14
+ claudial following what this device follows
15
+ claudial forget delete this device from the server`;
16
+ /** Pure: apply follow/unfollow targets to settings. Slugs are leagues, digits are team ids. */
17
+ export function applyTargets(s, targets, follow, known) {
18
+ const leagues = new Set(s.leagues);
19
+ const teams = new Set(s.teams);
20
+ for (const t of targets) {
21
+ if (/^\d+$/.test(t)) {
22
+ if (follow) {
23
+ teams.add(t);
24
+ }
25
+ else {
26
+ teams.delete(t);
27
+ }
28
+ }
29
+ else if (known.some((l) => l.slug === t)) {
30
+ if (follow) {
31
+ leagues.add(t);
32
+ }
33
+ else {
34
+ leagues.delete(t);
35
+ }
36
+ }
37
+ else {
38
+ throw new Error(`"${t}" is neither a league slug (see: claudial leagues) nor a team id (see: claudial teams <league>)`);
39
+ }
40
+ }
41
+ return { leagues: [...leagues], teams: [...teams] };
42
+ }
43
+ /** Pure: case- and accent-insensitive match on a team's name or code. */
44
+ export function matchesSearch(t, search) {
45
+ const fold = (x) => x.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
46
+ const q = fold(search);
47
+ return fold(t.name).includes(q) || fold(t.code).includes(q);
48
+ }
49
+ /** One line for a failed command: the server's own message, or a plain "can't reach it". */
50
+ export function describeError(e) {
51
+ if (e instanceof ApiError)
52
+ return `server said: ${e.message}`;
53
+ // fetch rejects with a TypeError when the network fails, and a Timeout/AbortError on our 8 s limit.
54
+ const name = e instanceof Error ? e.name : '';
55
+ if (e instanceof TypeError || name === 'TimeoutError' || name === 'AbortError') {
56
+ return `can't reach the claudial server (${apiUrl()}). Check your connection and try again.`;
57
+ }
58
+ return e instanceof Error ? e.message : String(e);
59
+ }
60
+ export async function runFollow(args, out = console.log) {
61
+ const [cmd, ...rest] = args;
62
+ try {
63
+ switch (cmd) {
64
+ case 'leagues': {
65
+ const [leagues, s] = await Promise.all([listLeagues(), getSettings()]);
66
+ for (const l of leagues)
67
+ out(`${s.leagues.includes(l.slug) ? '✓' : ' '} ${l.slug.padEnd(18)} ${l.name}`);
68
+ return 0;
69
+ }
70
+ case 'teams': {
71
+ const [league, search] = rest;
72
+ if (!league) {
73
+ out(USAGE);
74
+ return 2;
75
+ }
76
+ const [teams, s] = await Promise.all([listTeams(league), getSettings()]);
77
+ const shown = search ? teams.filter((t) => matchesSearch(t, search)) : teams;
78
+ for (const t of shown)
79
+ out(`${s.teams.includes(t.id) ? '✓' : ' '} ${t.id.padStart(7)} ${t.code.padEnd(10)} ${t.name}`);
80
+ if (shown.length === 0)
81
+ out('no team matches');
82
+ return 0;
83
+ }
84
+ case 'follow':
85
+ case 'unfollow': {
86
+ if (rest.length === 0 && cmd === 'follow' && process.stdin.isTTY) {
87
+ const { runPicker } = await import('../ui/picker/run.js');
88
+ await runPicker();
89
+ return 0;
90
+ }
91
+ if (rest.length === 0) {
92
+ out(USAGE);
93
+ return 2;
94
+ }
95
+ const [leagues, s] = await Promise.all([listLeagues(), getSettings()]);
96
+ const next = applyTargets(s, rest, cmd === 'follow', leagues);
97
+ const saved = await putSettings(next);
98
+ out(`following ${saved.leagues.length} league(s) and ${saved.teams.length} team(s)`);
99
+ return 0;
100
+ }
101
+ case 'following': {
102
+ const [leagues, s] = await Promise.all([listLeagues(), getSettings()]);
103
+ const name = new Map(leagues.map((l) => [l.slug, l.name]));
104
+ out(s.leagues.length ? `leagues: ${s.leagues.map((l) => `${l} (${name.get(l) ?? '?'})`).join(', ')}` : 'leagues: none');
105
+ if (s.teams.length === 0) {
106
+ out('teams: none');
107
+ return 0;
108
+ }
109
+ // Settings hold team ids only; look their names up in every league's list.
110
+ const lists = await Promise.all(leagues.map((l) => listTeams(l.slug).catch(() => [])));
111
+ const team = new Map();
112
+ for (const t of lists.flat())
113
+ if (!team.has(t.id))
114
+ team.set(t.id, t);
115
+ out(`teams: ${s.teams.map((id) => { const t = team.get(id); return t ? `${t.name} (${id})` : id; }).join(', ')}`);
116
+ return 0;
117
+ }
118
+ case 'forget': {
119
+ await forgetDevice();
120
+ // A new identity next time. `onboarded` was written by versions that auto-followed.
121
+ const dir = configDir();
122
+ rmSync(join(dir, 'device-id'), { force: true });
123
+ rmSync(join(dir, 'onboarded'), { force: true });
124
+ out('the server no longer has anything about this device');
125
+ return 0;
126
+ }
127
+ default:
128
+ out(USAGE);
129
+ return 2;
130
+ }
131
+ }
132
+ catch (e) {
133
+ out(`claudial: ${describeError(e)}`);
134
+ return 1;
135
+ }
136
+ }
@@ -31,7 +31,12 @@ export function startPoller(deps, opts = {}) {
31
31
  try {
32
32
  const matches = await deps.fetchLive();
33
33
  const scoreChanges = diffGoals(prev, matches);
34
+ // A match that left the live list has finished (or was postponed): refresh
35
+ // the finished/upcoming lists now, or it vanishes until the next fixtures tick.
36
+ const ended = prev.some((p) => !matches.some((m) => m.id === p.id));
34
37
  prev = matches;
38
+ if (ended)
39
+ void refreshFixtures();
35
40
  deps.dispatch({ type: 'live', matches, at: Date.now() });
36
41
  failures = 0;
37
42
  for (const m of matches) {
@@ -98,7 +103,7 @@ export function startPoller(deps, opts = {}) {
98
103
  liveTimer = setTimeout(liveTick, delay);
99
104
  }
100
105
  }
101
- async function fixturesTick() {
106
+ async function refreshFixtures() {
102
107
  try {
103
108
  const { upcoming, recent } = await deps.fetchFixtures();
104
109
  deps.dispatch({ type: 'fixtures', upcoming, recent });
@@ -106,6 +111,9 @@ export function startPoller(deps, opts = {}) {
106
111
  catch {
107
112
  deps.dispatch({ type: 'stale' });
108
113
  }
114
+ }
115
+ async function fixturesTick() {
116
+ await refreshFixtures();
109
117
  // Predictions ride the gentle fixtures cadence. fetchPredictions is
110
118
  // fail-silent (returns [] when disabled or on error), so this never
111
119
  // disturbs the board.