claudial 0.6.4 → 0.7.1

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,129 @@ 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
+
24
+ <br/>
25
+
26
+ ![claudial: Liverpool 6–0 Manchester United live, then a full-screen GOAL for Firmino's 88th-minute seventh](dashboard.gif)
27
+
28
+ **Premier League · La Liga · Serie A · Bundesliga · Ligue 1 · Champions League ·
29
+ Europa League · Conference League · Super League Greece · World Cup**
23
30
 
24
31
  </div>
25
32
 
26
33
  ---
27
34
 
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:
35
+ ## Get started
30
36
 
31
- <div align="center">
37
+ | You are… | Run |
38
+ |---|---|
39
+ | **New to claudial** | `npm install -g claudial`<br>`claudial setup` |
40
+ | **Back from the World Cup** (you have 0.6.x) | `npm install -g claudial@latest`<br>`claudial --version` (should print 0.7.0 or newer)<br>`claudial follow` |
32
41
 
33
- ![claudial — live World Cup 2026 scores in Claude Code's status bar](demo.gif)
42
+ Then restart Claude Code. World Cup users keep their status line as it is; `claudial follow`
43
+ picks your leagues and teams, because the new version starts following nothing.
34
44
 
35
- </div>
45
+ Still seeing "WORLD CUP 2026" after updating? An old copy comes first on your PATH.
46
+ `which -a claudial` lists every copy; remove the old one with
47
+ `npm uninstall -g --prefix <its folder> claudial` (e.g. `--prefix ~/.npm-global`).
36
48
 
37
- ## Claude Code, World Cup edition
49
+ ## The score, right under your prompt
38
50
 
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.
51
+ One command puts a live score line into Claude Code's status bar. It stays out
52
+ of the way while Claude works, and lights up the moment anyone scores:
42
53
 
43
- ```bash
44
- npm install -g claudial
45
- claudial setup
46
- ```
54
+ <div align="center">
55
+
56
+ ![claudial: Liverpool 7–0 Manchester United flashing GOOOL in Claude Code's status bar](demo.gif)
47
57
 
48
- The wizard drops a live-score statusline into your Claude Code settings:
58
+ </div>
49
59
 
50
60
  ```
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
61
+ ⚽ Liverpool 6—0 Manchester United 87' · main ← every match you follow
62
+ ⚽ G O O O L · Liverpool 7—0 Manchester United · main ← for 15 s after a goal
63
+ 🟥 R E D · CASEMIRO · Manchester United — Southampton · main ← for 15 s after a red card
64
+ ○ Liverpool — Arsenal Sat 05:30 PM · main ← between matches: next kickoff
55
65
  ```
56
66
 
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`.
67
+ Your git branch stays at the end. **Already run a custom status line?** Setup
68
+ keeps it and appends the score, so nothing you had is lost. Pure Node, so it
69
+ works on macOS, Linux and Windows. Restart Claude Code after setup to see it.
60
70
 
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.
71
+ ## Pick your teams in five seconds
63
72
 
64
- ## The full dashboard
65
-
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:
73
+ Setup ends by asking what you follow. Type a name, tick it with space, press
74
+ esc to save:
70
75
 
71
76
  ```
77
+ claudial · what do you follow?
72
78
 
79
+ search › liver▏
73
80
 
74
- █▀▀█ █▀▀█ █▀▀█ █
75
- █ ▄▄ █ █ █▀▀█ █
76
- ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
81
+ › ◉ Liverpool LIV · Premier League
77
82
 
78
- LIONEL MESSI · 23'
83
+ Following Liverpool
84
+ ↑↓ move · space follow · type to search · esc clear search · ctrl+c cancel
85
+ ```
79
86
 
80
- ARG 🇦🇷 2 — 1 🇲🇽 MEX
87
+ Follow a whole league or just your club. Change your mind any time: press `f`
88
+ in the dashboard, or run `claudial follow`.
81
89
 
90
+ ## A goal takes over the whole screen
82
91
 
83
- ```
92
+ Want the full board beside Claude? `claudial` opens a live dashboard: every
93
+ match you follow with scores, minutes, scorers and cards, plus the next three
94
+ weeks of fixtures in your local time.
84
95
 
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.
96
+ When a goal goes in, the server pushes it within a second, and the whole
97
+ screen becomes the goal: the scorer, the minute, the new score. Four seconds
98
+ of glory, then back to the board. Red cards get the same treatment.
88
99
 
89
100
  ## Usage
90
101
 
91
102
  ```
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)
95
- ```
96
-
97
- Or install it for good:
98
-
99
- ```
100
- npm install -g claudial
101
- claudial
103
+ claudial # the live dashboard
104
+ claudial --ticker # a 4-line strip for slim split panes
105
+ claudial | cat # plain snapshot for pipes and scripts
106
+ claudial follow # pick leagues and teams
102
107
  ```
103
108
 
104
- Requires Node ≥ 18. No account, no API key, no config.
105
-
106
- | Key | Action |
107
- |-----|-------------|
109
+ | Key | Action |
110
+ |-----|--------|
111
+ | `f` | pick what you follow |
108
112
  | `r` | refresh now |
109
- | `q` | quit |
113
+ | `q` | quit |
114
+
115
+ Requires Node ≥ 18. No account, no sign-up, no API key.
110
116
 
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.
117
+ Scripting it? `claudial setup --statusline --yes` installs without
118
+ prompts, and `claudial leagues`, `claudial teams <league>`,
119
+ `claudial follow <id>...`, `claudial unfollow <id>...` and `claudial following`
120
+ work without the UI. `claudial forget` deletes your device from the server.
114
121
 
115
122
  ## Status
116
123
 
117
- v1 is being built live during the group stage. Follow the commits.
124
+ claudial started as a World Cup 2026 side project. It now follows ten leagues
125
+ through its own server, written in Rust: one set of polls for everyone, goals
126
+ pushed to every terminal over server-sent events.
118
127
 
119
128
  ## Notes
120
129
 
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.
130
+ - Privacy: the server stores a random device id, what you follow, and when it
131
+ last saw you. No accounts, emails, names or analytics, and your IP is never
132
+ written down. Inactive devices are deleted after 400 days; `claudial forget`
133
+ deletes yours right away.
134
+ - Match facts (scores, scorers, cards) come from ESPN's public site API, through
135
+ the claudial server. No logos or branding. Not affiliated with or endorsed by
136
+ ESPN, FIFA, UEFA or any league.
137
+ - Seeing `AR` instead of `🇦🇷` for national teams? Your terminal doesn't render
138
+ flag emoji. macOS terminals and [Konsole](https://konsole.kde.org/) do;
139
+ Windows Terminal doesn't.
140
+ - Not affiliated with Anthropic. The look is a love letter to
141
+ [Claude Code](https://claude.com/claude-code).
134
142
 
135
143
  ```js
136
144
  // Canada — Bosnia & Herzegovina, 12 June 2026, was on while this was built.
@@ -140,8 +148,8 @@ v1 is being built live during the group stage. Follow the commits.
140
148
 
141
149
  ## Contributing
142
150
 
143
- Issues and PRs welcome — bug reports, new incident types, terminal quirks,
144
- all of it. Keep the polling gentle and the spirit playful.
151
+ Issues and PRs welcome: bug reports, new incident types, terminal quirks, all
152
+ of it. Keep the spirit playful.
145
153
 
146
154
  ```bash
147
155
  git clone https://github.com/lefProg/claudial.git
@@ -159,10 +167,14 @@ npm test # vitest
159
167
 
160
168
  <br/>
161
169
 
162
- **If this made a goal feel a little louder, drop a ⭐ — it helps others find it.**
170
+ **If this made a goal feel a little louder, drop a ⭐. It helps others find it.**
163
171
 
164
172
  [Report a bug](https://github.com/lefProg/claudial/issues) ·
165
173
  [Request a feature](https://github.com/lefProg/claudial/issues) ·
166
174
  [npm](https://www.npmjs.com/package/claudial)
167
175
 
176
+ <br/>
177
+
178
+ **YNWA** 🔴
179
+
168
180
  </div>
@@ -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, homeName: m.home.name, awayName: m.away.name, 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.