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 +90 -78
- package/dist/api/backend.js +216 -0
- package/dist/api/device.js +28 -0
- package/dist/cli/follow.js +136 -0
- package/dist/engine/poller.js +9 -1
- package/dist/index.js +29 -4
- package/dist/predictions/client.js +6 -7
- package/dist/setup/index.js +11 -0
- package/dist/snapshot.js +10 -6
- package/dist/statusline/cache.js +5 -3
- package/dist/statusline/line.js +4 -4
- package/dist/statusline/run.js +22 -7
- package/dist/ui/App.js +39 -6
- package/dist/ui/Footer.js +1 -1
- package/dist/ui/Header.js +1 -1
- package/dist/ui/MatchRow.js +20 -13
- package/dist/ui/TakeoverView.js +3 -3
- package/dist/ui/Ticker.js +3 -3
- package/dist/ui/UpcomingSection.js +13 -4
- package/dist/ui/flags.js +21 -7
- package/dist/ui/notice.js +9 -0
- package/dist/ui/picker/Picker.js +152 -0
- package/dist/ui/picker/run.js +13 -0
- package/dist/ui/picker/state.js +120 -0
- package/package.json +5 -4
- package/dist/api/espn.js +0 -130
- package/dist/api/parse.js +0 -103
package/dist/index.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { render } from 'ink';
|
|
4
4
|
import { App } from './ui/App.js';
|
|
5
|
-
import {
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { FOLLOW_COMMANDS, USAGE } from './cli/follow.js';
|
|
7
|
+
import { resolveSeasonId } from './api/backend.js';
|
|
6
8
|
// exit quietly when the consumer of a pipe closes early (e.g. `claudial | head`)
|
|
7
9
|
process.stdout.on('error', (err) => {
|
|
8
10
|
if (err.code === 'EPIPE')
|
|
@@ -22,8 +24,31 @@ function readStdin() {
|
|
|
22
24
|
process.stdin.on('error', () => resolve(data));
|
|
23
25
|
});
|
|
24
26
|
}
|
|
27
|
+
const HELP = `claudial: live football in your terminal and Claude Code's status bar
|
|
28
|
+
|
|
29
|
+
claudial the live dashboard (keys: f follow · r refresh · q quit)
|
|
30
|
+
claudial --ticker a 4-line strip for slim split panes
|
|
31
|
+
claudial | cat plain snapshot for pipes and scripts
|
|
32
|
+
claudial setup install the Claude Code status line and pick your teams
|
|
33
|
+
claudial setup --statusline --yes install the status line only, no prompts
|
|
34
|
+
${USAGE.replace(/^usage:\n/, '')}
|
|
35
|
+
|
|
36
|
+
CLAUDIAL_API_URL=<url> use another claudial server`;
|
|
25
37
|
async function main() {
|
|
26
38
|
const args = process.argv.slice(2);
|
|
39
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
40
|
+
console.log(HELP);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
44
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
45
|
+
console.log(pkg.version);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (args[0] && FOLLOW_COMMANDS.includes(args[0])) {
|
|
49
|
+
const { runFollow } = await import('./cli/follow.js');
|
|
50
|
+
process.exit(await runFollow(args));
|
|
51
|
+
}
|
|
27
52
|
if (args[0] === 'setup') {
|
|
28
53
|
const { runSetup } = await import('./setup/index.js');
|
|
29
54
|
await runSetup(args.slice(1));
|
|
@@ -54,7 +79,7 @@ async function main() {
|
|
|
54
79
|
}
|
|
55
80
|
if (args.includes('--mock-redcard')) {
|
|
56
81
|
const { makeCache } = await import('./statusline/cache.js');
|
|
57
|
-
makeCache().armRedCard({ player: 'Otamendi', homeCode: 'ARG', awayCode: 'ENG' });
|
|
82
|
+
makeCache().armRedCard({ player: 'Otamendi', homeCode: 'ARG', awayCode: 'ENG', homeName: 'Argentina', awayName: 'England' });
|
|
58
83
|
process.stdout.write('🟥 RED armed — the Claude Code status bar flashes a red card (ARG—ENG) for 15s.\n');
|
|
59
84
|
process.exit(0);
|
|
60
85
|
}
|
|
@@ -82,7 +107,7 @@ async function main() {
|
|
|
82
107
|
seasonId = await resolveSeasonId();
|
|
83
108
|
}
|
|
84
109
|
catch {
|
|
85
|
-
console.error('claudial: could not reach
|
|
110
|
+
console.error('claudial: could not reach the claudial server. Check your connection and try again.');
|
|
86
111
|
process.exit(1);
|
|
87
112
|
}
|
|
88
113
|
if (!process.stdout.isTTY) {
|
|
@@ -94,6 +119,6 @@ async function main() {
|
|
|
94
119
|
render(_jsx(App, { seasonId: seasonId, mode: mode }));
|
|
95
120
|
}
|
|
96
121
|
main().catch(() => {
|
|
97
|
-
console.error('claudial: could not reach
|
|
122
|
+
console.error('claudial: could not reach the claudial server. Check your connection and try again.');
|
|
98
123
|
process.exit(1);
|
|
99
124
|
});
|
|
@@ -2,17 +2,16 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync, statSync, existsSyn
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
const ENV = 'CLAUDIAL_PREDICTIONS_URL';
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// by
|
|
8
|
-
const DEFAULT_PREDICTIONS_URL = 'http://161.97.67.12:8000/api/predictions/';
|
|
5
|
+
// Betting predictions are OFF unless the user opts in by setting
|
|
6
|
+
// CLAUDIAL_PREDICTIONS_URL. They show stakes and bookmaker odds, which is
|
|
7
|
+
// gambling content: never on by default, and only for adults (README).
|
|
9
8
|
// Predictions only change every few hours server-side, so poll gently.
|
|
10
9
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
|
11
|
-
/**
|
|
10
|
+
/** The predictions API URL, or '' when the user has not opted in. */
|
|
12
11
|
export function predictionsUrl(env = process.env) {
|
|
13
|
-
return env[ENV] ||
|
|
12
|
+
return env[ENV] || '';
|
|
14
13
|
}
|
|
15
|
-
/**
|
|
14
|
+
/** Predictions are opt-in: on only when CLAUDIAL_PREDICTIONS_URL is set. */
|
|
16
15
|
export function predictionsEnabled(env = process.env) {
|
|
17
16
|
return !!predictionsUrl(env);
|
|
18
17
|
}
|
package/dist/setup/index.js
CHANGED
|
@@ -60,4 +60,15 @@ export async function runSetup(args) {
|
|
|
60
60
|
}));
|
|
61
61
|
waitUntilExit().then(() => resolve());
|
|
62
62
|
});
|
|
63
|
+
// Second step: what to follow. Skippable with esc; `claudial follow` reopens it.
|
|
64
|
+
if (process.stdin.isTTY) {
|
|
65
|
+
const { runPicker } = await import('../ui/picker/run.js');
|
|
66
|
+
const r = await runPicker('Last step: pick your leagues and teams. Type a team name to find it.');
|
|
67
|
+
if (!r?.saved) {
|
|
68
|
+
const following = r && r.settings.leagues.length + r.settings.teams.length > 0;
|
|
69
|
+
console.log(following
|
|
70
|
+
? 'Keeping what you follow. Change it any time: claudial follow'
|
|
71
|
+
: 'You can pick your teams any time: claudial follow');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
63
74
|
}
|
package/dist/snapshot.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { fetchLive, fetchRecent, fetchUpcoming } from './api/
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { fetchLive, fetchRecent, fetchUpcoming, followsNothing } from './api/backend.js';
|
|
2
|
+
import { kickoffColumn } from './ui/UpcomingSection.js';
|
|
3
|
+
import { homeName, awayName } from './ui/flags.js';
|
|
4
4
|
function line(m) {
|
|
5
5
|
if (m.status === 'upcoming')
|
|
6
|
-
return `o ${
|
|
6
|
+
return `o ${kickoffColumn(m.startTimestamp)}${homeName(m.home)} - ${awayName(m.away)}`;
|
|
7
7
|
const label = m.status === 'finished' ? 'FT' : m.status === 'halftime' ? 'HT' : `${m.minute ?? '?'}'`;
|
|
8
|
-
return `* ${label.padEnd(4)} ${
|
|
8
|
+
return `* ${label.padEnd(4)} ${homeName(m.home)} ${m.homeScore ?? '-'} - ${m.awayScore ?? '-'} ${awayName(m.away)}`;
|
|
9
9
|
}
|
|
10
10
|
export async function printSnapshot(seasonId) {
|
|
11
11
|
const [live, upcoming, recent] = await Promise.all([
|
|
12
12
|
fetchLive(), fetchUpcoming(seasonId), fetchRecent(seasonId),
|
|
13
13
|
]);
|
|
14
|
-
console.log('claudial -
|
|
14
|
+
console.log('claudial - LIVE FOOTBALL');
|
|
15
|
+
if (await followsNothing().catch(() => false))
|
|
16
|
+
console.log('You follow nothing yet. Run: claudial follow');
|
|
17
|
+
else if (live.length + upcoming.length + recent.length === 0)
|
|
18
|
+
console.log('No upcoming matches for what you follow yet.');
|
|
15
19
|
const liveIds = new Set(live.map((m) => m.id));
|
|
16
20
|
for (const m of [...live, ...recent.filter((r) => !liveIds.has(r.id))])
|
|
17
21
|
console.log(line(m));
|
package/dist/statusline/cache.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, renameSync, statSync, existsSync, unlinkSync, utimesSync, futimesSync, } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { homeName, awayName } from '../ui/flags.js';
|
|
5
5
|
export const TTL_MS = 10_000;
|
|
6
6
|
export const GOAL_WINDOW_MS = 15_000;
|
|
7
7
|
const LOCK_STALE_MS = 30_000;
|
|
@@ -11,10 +11,12 @@ const GOAL_CLOSE = '\x1b[0m';
|
|
|
11
11
|
// RED #E5484D — the red-card flash.
|
|
12
12
|
const RED_OPEN = '\x1b[1;38;2;229;72;77m';
|
|
13
13
|
function goalText(m) {
|
|
14
|
-
return `${GOAL_OPEN}⚽ G O O O L · ${
|
|
14
|
+
return `${GOAL_OPEN}⚽ G O O O L · ${homeName(m.home)} ${m.homeScore ?? 0}—${m.awayScore ?? 0} ${awayName(m.away)}${GOAL_CLOSE}`;
|
|
15
15
|
}
|
|
16
16
|
function redCardText(rc) {
|
|
17
|
-
|
|
17
|
+
const home = homeName({ name: rc.homeName ?? rc.homeCode, code: rc.homeCode, national: rc.national });
|
|
18
|
+
const away = awayName({ name: rc.awayName ?? rc.awayCode, code: rc.awayCode, national: rc.national });
|
|
19
|
+
return `${RED_OPEN}🟥 R E D · ${rc.player.toUpperCase()} · ${home} — ${away}${GOAL_CLOSE}`;
|
|
18
20
|
}
|
|
19
21
|
export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
|
|
20
22
|
mkdirSync(dir, { recursive: true });
|
package/dist/statusline/line.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatKickoff } from '../ui/UpcomingSection.js';
|
|
2
|
-
import {
|
|
2
|
+
import { homeName, awayName } from '../ui/flags.js';
|
|
3
3
|
import { matchPrediction } from '../predictions/match.js';
|
|
4
4
|
import { headline } from '../predictions/format.js';
|
|
5
5
|
// Assumed wall-clock length of a match (kickoff → ~full time). ESPN exposes no
|
|
@@ -10,18 +10,18 @@ export function liveLine(match) {
|
|
|
10
10
|
const clock = match.status === 'halftime' ? ' HT'
|
|
11
11
|
: match.minute != null ? ` ${match.minute}'`
|
|
12
12
|
: '';
|
|
13
|
-
return `⚽ ${
|
|
13
|
+
return `⚽ ${homeName(match.home)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayName(match.away)}${clock}`;
|
|
14
14
|
}
|
|
15
15
|
/** A finished match with its result: "⚽ QAT 🇶🇦 2—1 🇨🇭 SUI FT". */
|
|
16
16
|
export function finishedLine(match) {
|
|
17
|
-
return `⚽ ${
|
|
17
|
+
return `⚽ ${homeName(match.home)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayName(match.away)} FT`;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* The next kickoff: "○ BRA 🇧🇷 — 🇲🇦 MAR Sun 01:00", with a prediction headline
|
|
21
21
|
* appended when one is available: "… · 🔮 BRA win 64% · 2-1".
|
|
22
22
|
*/
|
|
23
23
|
function upcomingLine(match, now, preds) {
|
|
24
|
-
const base = `○ ${
|
|
24
|
+
const base = `○ ${homeName(match.home)} — ${awayName(match.away)} ${formatKickoff(match.startTimestamp, now)}`;
|
|
25
25
|
const pred = matchPrediction(match, preds);
|
|
26
26
|
return pred ? `${base} · ${headline(pred)}` : base;
|
|
27
27
|
}
|
package/dist/statusline/run.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { fetchLive as realFetchLive, fetchRecent as realFetchRecent, fetchUpcoming as realFetchUpcoming, fetchLiveRedCards as realFetchLiveRedCards } from '../api/
|
|
2
|
+
import { fetchLive as realFetchLive, fetchRecent as realFetchRecent, fetchUpcoming as realFetchUpcoming, fetchLiveRedCards as realFetchLiveRedCards } from '../api/backend.js';
|
|
3
3
|
import { scoreLine } from './line.js';
|
|
4
|
+
import { followsNothing } from '../api/backend.js';
|
|
4
5
|
import { makeCache, TTL_MS } from './cache.js';
|
|
5
6
|
import { goalKickFrame } from './anim.js';
|
|
6
7
|
import { fetchPredictions as realFetchPredictions } from '../predictions/client.js';
|
|
7
8
|
const PLACEHOLDER = '⚽ claudial · warming up';
|
|
9
|
+
/** Shown until the user follows something: a new device starts with nothing. */
|
|
10
|
+
export const FOLLOW_HINT = '⚽ claudial · run `claudial follow` to pick your teams';
|
|
11
|
+
/** Following something, but nothing is scheduled for it yet (e.g. an international break). */
|
|
12
|
+
export const NO_MATCHES = '⚽ claudial · no upcoming matches for your teams yet';
|
|
13
|
+
/** The server has not answered for a while (or ever, on this machine). */
|
|
14
|
+
export const OFFLINE = "⚽ claudial · can't reach the server, retrying";
|
|
15
|
+
/** How long a failing server may keep showing the last good line before OFFLINE replaces it. */
|
|
16
|
+
export const OFFLINE_AFTER_MS = 120_000;
|
|
8
17
|
function withTimeout(p, ms) {
|
|
9
18
|
let id;
|
|
10
19
|
const timer = new Promise((_, reject) => {
|
|
@@ -20,16 +29,21 @@ export async function runStatusline(input, deps, now = Date.now()) {
|
|
|
20
29
|
if (!fresh && cache.tryLock(now)) {
|
|
21
30
|
try {
|
|
22
31
|
const [liveM, recentM, upcomingM, redsM, predsM] = await withTimeout(Promise.all([deps.fetchLive(), deps.fetchRecent(), deps.fetchUpcoming(2026), deps.fetchRedCards(), deps.fetchPredictions()]), deps.timeoutMs);
|
|
23
|
-
|
|
32
|
+
let next = scoreLine(liveM, recentM, upcomingM, now, predsM);
|
|
33
|
+
// Nothing to show: say why, so the bar never looks stuck.
|
|
34
|
+
if (!next)
|
|
35
|
+
next = (await withTimeout(deps.followsNothing(), deps.timeoutMs)) ? FOLLOW_HINT : NO_MATCHES;
|
|
24
36
|
cache.updateGoalState(liveM, now);
|
|
25
37
|
cache.updateRedCards(redsM, now);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
line = next;
|
|
29
|
-
}
|
|
38
|
+
cache.write(next, now);
|
|
39
|
+
line = next;
|
|
30
40
|
}
|
|
31
41
|
catch {
|
|
32
|
-
//
|
|
42
|
+
// Keep the last good line through a short outage; after that, say so.
|
|
43
|
+
if (!line || (cached && cached.ageMs > OFFLINE_AFTER_MS)) {
|
|
44
|
+
line = OFFLINE;
|
|
45
|
+
cache.write(OFFLINE, now);
|
|
46
|
+
}
|
|
33
47
|
}
|
|
34
48
|
finally {
|
|
35
49
|
cache.unlock();
|
|
@@ -71,6 +85,7 @@ export function defaultDeps() {
|
|
|
71
85
|
fetchPredictions: () => realFetchPredictions(),
|
|
72
86
|
cache: makeCache(),
|
|
73
87
|
branchOf: defaultBranchOf,
|
|
88
|
+
followsNothing,
|
|
74
89
|
timeoutMs: 3000,
|
|
75
90
|
};
|
|
76
91
|
}
|
package/dist/ui/App.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
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';
|
|
2
|
+
import { useEffect, useReducer, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
4
|
import { initialState, reducer } from '../state.js';
|
|
5
5
|
import { startPoller } from '../engine/poller.js';
|
|
6
|
-
import { fetchIncidents, fetchLive, fetchRecent, fetchUpcoming } from '../api/
|
|
6
|
+
import { fetchIncidents, fetchLive, fetchRecent, fetchUpcoming, followsNothing, invalidateState, subscribeEvents } from '../api/backend.js';
|
|
7
7
|
import { fetchPredictions } from '../predictions/client.js';
|
|
8
|
-
import { Header } from './Header.js';
|
|
8
|
+
import { ACCENT, Header } from './Header.js';
|
|
9
9
|
import { DaySection } from './DaySection.js';
|
|
10
10
|
import { partitionByDay } from './fixtures.js';
|
|
11
11
|
import { UpcomingSection } from './UpcomingSection.js';
|
|
12
12
|
import { Footer } from './Footer.js';
|
|
13
13
|
import { TakeoverView } from './TakeoverView.js';
|
|
14
14
|
import { Ticker } from './Ticker.js';
|
|
15
|
+
import { Picker } from './picker/Picker.js';
|
|
16
|
+
import { boardNotice } from './notice.js';
|
|
15
17
|
function dedupeById(matches) {
|
|
16
18
|
const seen = new Set();
|
|
17
19
|
const out = [];
|
|
@@ -26,6 +28,15 @@ export function App({ seasonId, mode = 'board' }) {
|
|
|
26
28
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
27
29
|
const { exit } = useApp();
|
|
28
30
|
const pollerRef = useRef(null);
|
|
31
|
+
const [picking, setPicking] = useState(false);
|
|
32
|
+
// Bumped after the picker saves: the event stream reads settings at connect
|
|
33
|
+
// time, so it has to reconnect to see the new subscriptions.
|
|
34
|
+
const [streamGen, setStreamGen] = useState(0);
|
|
35
|
+
// A new device follows nothing: the board says how to start instead of staying blank.
|
|
36
|
+
const [followsNone, setFollowsNone] = useState(false);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
followsNothing().then(setFollowsNone, () => { });
|
|
39
|
+
}, [streamGen]);
|
|
29
40
|
useEffect(() => {
|
|
30
41
|
const deps = {
|
|
31
42
|
fetchLive,
|
|
@@ -40,12 +51,23 @@ export function App({ seasonId, mode = 'board' }) {
|
|
|
40
51
|
pollerRef.current = startPoller(deps);
|
|
41
52
|
return () => pollerRef.current?.stop();
|
|
42
53
|
}, [seasonId]);
|
|
54
|
+
// Push: any server event (goal, card, kickoff...) triggers an immediate refresh,
|
|
55
|
+
// so takeovers fire within a second instead of on the next 15 s poll.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const unsubscribe = subscribeEvents(() => {
|
|
58
|
+
invalidateState();
|
|
59
|
+
pollerRef.current?.refreshNow();
|
|
60
|
+
});
|
|
61
|
+
return unsubscribe;
|
|
62
|
+
}, [streamGen]);
|
|
43
63
|
useInput((input) => {
|
|
44
64
|
if (input === 'q')
|
|
45
65
|
exit();
|
|
46
66
|
if (input === 'r')
|
|
47
67
|
pollerRef.current?.refreshNow();
|
|
48
|
-
|
|
68
|
+
if (input === 'f' && mode === 'board')
|
|
69
|
+
setPicking(true);
|
|
70
|
+
}, { isActive: !picking });
|
|
49
71
|
const compact = mode === 'board' && (process.stdout.columns ?? 80) < 70;
|
|
50
72
|
const playing = state.takeovers[0] ?? null;
|
|
51
73
|
useEffect(() => {
|
|
@@ -54,6 +76,16 @@ export function App({ seasonId, mode = 'board' }) {
|
|
|
54
76
|
const t = setTimeout(() => dispatch({ type: 'takeoverDone' }), 4_000);
|
|
55
77
|
return () => clearTimeout(t);
|
|
56
78
|
}, [playing]);
|
|
79
|
+
if (picking) {
|
|
80
|
+
return (_jsx(Picker, { onDone: (r) => {
|
|
81
|
+
setPicking(false);
|
|
82
|
+
if (r.saved) {
|
|
83
|
+
invalidateState();
|
|
84
|
+
pollerRef.current?.refreshNow();
|
|
85
|
+
setStreamGen((g) => g + 1);
|
|
86
|
+
}
|
|
87
|
+
} }));
|
|
88
|
+
}
|
|
57
89
|
if (playing && mode === 'board')
|
|
58
90
|
return _jsx(TakeoverView, { takeover: playing });
|
|
59
91
|
if (mode === 'ticker') {
|
|
@@ -61,5 +93,6 @@ export function App({ seasonId, mode = 'board' }) {
|
|
|
61
93
|
}
|
|
62
94
|
const all = dedupeById([...state.live, ...state.recent, ...state.upcoming]);
|
|
63
95
|
const { yesterday, today, future } = partitionByDay(all, Date.now());
|
|
64
|
-
|
|
96
|
+
const notice = boardNotice({ followsNone, stale: state.stale, lastUpdated: state.lastUpdated, matchCount: all.length });
|
|
97
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Header, { stale: state.stale, lastUpdated: state.lastUpdated }), notice ? (_jsxs(Box, { marginBottom: 1, children: [notice === 'offline' ? _jsx(Text, { children: "Can't reach the claudial server. Retrying\u2026" }) : null, notice === 'follow-nothing' ? (_jsxs(Text, { children: ["You don't follow anything yet. Press ", _jsx(Text, { bold: true, color: ACCENT, children: "f" }), " to pick your leagues and teams."] })) : null, notice === 'no-matches' ? (_jsxs(Text, { children: ["No upcoming matches for what you follow yet. They show up here as soon as they're scheduled. ", _jsxs(Text, { dimColor: true, children: ["(", _jsx(Text, { bold: true, color: ACCENT, children: "f" }), " to follow more)"] })] })) : null] })) : null, _jsx(DaySection, { label: "TODAY", matches: today, incidents: state.incidents, compact: compact }), _jsx(DaySection, { label: "YESTERDAY", matches: yesterday, incidents: state.incidents, compact: compact }), _jsx(UpcomingSection, { matches: future, compact: compact, predictions: state.predictions }), _jsx(Footer, {})] }));
|
|
65
98
|
}
|
package/dist/ui/Footer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Text } from 'ink';
|
|
3
3
|
export function Footer() {
|
|
4
|
-
return _jsx(Text, { dimColor: true, children: "r refresh \u00B7 q quit" });
|
|
4
|
+
return _jsx(Text, { dimColor: true, children: "r refresh \u00B7 f follow teams \u00B7 q quit" });
|
|
5
5
|
}
|
package/dist/ui/Header.js
CHANGED
|
@@ -6,5 +6,5 @@ export function Header({ stale, lastUpdated }) {
|
|
|
6
6
|
const updated = lastUpdated
|
|
7
7
|
? new Date(lastUpdated).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
8
8
|
: '—';
|
|
9
|
-
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: "claudial" }), _jsx(Text, { dimColor: true, children: " \u00B7
|
|
9
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: "claudial" }), _jsx(Text, { dimColor: true, children: " \u00B7 LIVE FOOTBALL" }), _jsxs(Text, { dimColor: true, children: [' ', "\u273B ", stale ? 'stale · retrying' : `updated ${updated}`] })] }));
|
|
10
10
|
}
|
package/dist/ui/MatchRow.js
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { ACCENT } from './Header.js';
|
|
4
|
-
import { formatKickoff } from './UpcomingSection.js';
|
|
5
|
-
import {
|
|
4
|
+
import { formatKickoff, kickoffColumn } from './UpcomingSection.js';
|
|
5
|
+
import { homeName, awayName } from './flags.js';
|
|
6
|
+
/** One side's goals grouped by scorer, in order of their first goal: "43' 50' C. Gakpo · 47' D. Núñez". */
|
|
7
|
+
export function scorers(goals) {
|
|
8
|
+
const byPlayer = new Map();
|
|
9
|
+
for (const g of goals) {
|
|
10
|
+
const who = g.playerShort ?? '?';
|
|
11
|
+
byPlayer.set(who, [...(byPlayer.get(who) ?? []), `${g.minute ?? '?'}'`]);
|
|
12
|
+
}
|
|
13
|
+
return [...byPlayer].map(([who, mins]) => `${mins.join(' ')} ${who}`).join(' · ');
|
|
14
|
+
}
|
|
6
15
|
export function ScorerLine({ incidents }) {
|
|
7
16
|
const goals = incidents.filter((i) => i.kind === 'goal');
|
|
8
17
|
if (goals.length === 0)
|
|
9
18
|
return null;
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const away = goals.filter((g) => !g.isHome).map(fmt).join(' · ');
|
|
19
|
+
const home = scorers(goals.filter((g) => g.isHome));
|
|
20
|
+
const away = scorers(goals.filter((g) => !g.isHome));
|
|
13
21
|
return _jsxs(Text, { dimColor: true, children: [' ', "\u26BD ", [home, away].filter(Boolean).join(' | ')] });
|
|
14
22
|
}
|
|
15
23
|
export function FeedLine({ incidents }) {
|
|
16
|
-
const feed = incidents.filter((i) => i.kind === 'yellowCard' || i.kind === 'substitution');
|
|
24
|
+
const feed = incidents.filter((i) => i.kind === 'yellowCard' || i.kind === 'redCard' || i.kind === 'substitution');
|
|
17
25
|
if (feed.length === 0)
|
|
18
26
|
return null;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
: `${i.minute ?? '?'}' ⇄ ${i.playerShort ?? '?'}`;
|
|
27
|
+
const mark = { yellowCard: '🟨', redCard: '🟥' };
|
|
28
|
+
const fmt = (i) => `${i.minute ?? '?'}' ${mark[i.kind] ?? '⇄'} ${i.playerShort ?? '?'}`;
|
|
22
29
|
// last 4 only — the feed is a pulse, not a log
|
|
23
30
|
return _jsxs(Text, { dimColor: true, children: [' ', "\u25AA ", feed.slice(-4).map(fmt).join(' · ')] });
|
|
24
31
|
}
|
|
@@ -33,12 +40,12 @@ function statusLabel(m) {
|
|
|
33
40
|
export function MatchRow({ m, incidents, compact }) {
|
|
34
41
|
if (m.status === 'upcoming') {
|
|
35
42
|
if (compact) {
|
|
36
|
-
return _jsxs(Text, { dimColor: true, children: ["\u25CB ",
|
|
43
|
+
return _jsxs(Text, { dimColor: true, children: ["\u25CB ", homeName(m.home), " \u2014 ", awayName(m.away), " ", formatKickoff(m.startTimestamp)] });
|
|
37
44
|
}
|
|
38
|
-
return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ",
|
|
45
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", kickoffColumn(m.startTimestamp)] }), homeName(m.home), " \u2014 ", awayName(m.away), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }));
|
|
39
46
|
}
|
|
40
47
|
if (compact) {
|
|
41
|
-
return (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }),
|
|
48
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }), homeName(m.home), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayName(m.away), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }));
|
|
42
49
|
}
|
|
43
|
-
return (_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: [' ',
|
|
50
|
+
return (_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: [' ', homeName(m.home), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", awayName(m.away)] }), _jsx(ScorerLine, { incidents: incidents }), _jsx(FeedLine, { incidents: incidents })] }));
|
|
44
51
|
}
|
package/dist/ui/TakeoverView.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { GOAL_ART, VAR_ART, RED_CARD_ART, spacedCaps } from '../banner.js';
|
|
4
4
|
import { ACCENT, RED } from './Header.js';
|
|
5
|
-
import {
|
|
5
|
+
import { homeName, awayName } from './flags.js';
|
|
6
6
|
const ART = {
|
|
7
7
|
goal: { art: GOAL_ART, color: ACCENT, caption: null },
|
|
8
8
|
redcard: { art: RED_CARD_ART, color: RED, caption: 'R E D C A R D' },
|
|
@@ -17,7 +17,7 @@ export function TakeoverView({ takeover }) {
|
|
|
17
17
|
const rows = process.stdout.rows ?? 24;
|
|
18
18
|
if (rows < 14) {
|
|
19
19
|
const label = kind === 'goal' ? 'G O A L' : kind === 'redcard' ? 'R E D C A R D' : 'V A R';
|
|
20
|
-
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, {
|
|
20
|
+
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, { color: "gray", children: [homeName(match.home), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { color: "gray", children: [" ", awayName(match.away)] })] })] }));
|
|
21
21
|
}
|
|
22
|
-
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, {
|
|
22
|
+
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, { color: "gray", children: [homeName(match.home), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { color: "gray", children: [" ", awayName(match.away)] })] })] }));
|
|
23
23
|
}
|
package/dist/ui/Ticker.js
CHANGED
|
@@ -3,14 +3,14 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { formatKickoff } from './UpcomingSection.js';
|
|
4
4
|
import { ACCENT, RED } from './Header.js';
|
|
5
5
|
import { spacedCaps } from '../banner.js';
|
|
6
|
-
import {
|
|
6
|
+
import { homeName, awayName } from './flags.js';
|
|
7
7
|
function TickerTakeover({ t }) {
|
|
8
8
|
const color = t.kind === 'redcard' ? RED : ACCENT;
|
|
9
9
|
const label = t.kind === 'goal' ? 'G O A L' : t.kind === 'redcard' ? 'R E D' : 'V A R';
|
|
10
|
-
return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ",
|
|
10
|
+
return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ", homeName(t.match.home), " ", t.homeScore, "\u2014", t.awayScore, " ", awayName(t.match.away)] }));
|
|
11
11
|
}
|
|
12
12
|
export function Ticker({ live, upcoming, takeover }) {
|
|
13
13
|
if (takeover)
|
|
14
14
|
return _jsx(TickerTakeover, { t: takeover });
|
|
15
|
-
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' : '', " "] }),
|
|
15
|
+
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' : '', " "] }), homeName(m.home), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayName(m.away), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", homeName(upcoming[0].home), " \u2014 ", awayName(upcoming[0].away), " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { homeName, awayName } from './flags.js';
|
|
4
4
|
import { matchPrediction } from '../predictions/match.js';
|
|
5
5
|
import { fullSlate, headline } from '../predictions/format.js';
|
|
6
6
|
export function formatKickoff(ts, now = Date.now()) {
|
|
@@ -9,22 +9,31 @@ export function formatKickoff(ts, now = Date.now()) {
|
|
|
9
9
|
const sameDay = d.toDateString() === new Date(now).toDateString();
|
|
10
10
|
if (sameDay)
|
|
11
11
|
return time;
|
|
12
|
-
|
|
12
|
+
// Within the week a weekday is enough; further out (fixtures reach ~3 weeks) add the date.
|
|
13
|
+
const soon = ts * 1000 - now < 6 * 86_400_000;
|
|
14
|
+
const day = d.toLocaleDateString(undefined, soon ? { weekday: 'short' } : { weekday: 'short', day: 'numeric', month: 'short' });
|
|
13
15
|
return `${day} ${time}`;
|
|
14
16
|
}
|
|
17
|
+
/** The kickoff padded to `width` (default: fits the longest date format), always followed by a space. */
|
|
18
|
+
export function kickoffColumn(ts, now = Date.now(), width = 22) {
|
|
19
|
+
const k = formatKickoff(ts, now);
|
|
20
|
+
return k.length >= width - 1 ? `${k} ` : k.padEnd(width);
|
|
21
|
+
}
|
|
15
22
|
export function UpcomingSection({ matches, compact, predictions = [], }) {
|
|
16
23
|
const shown = matches.slice(0, compact ? 1 : 8);
|
|
17
24
|
if (shown.length === 0)
|
|
18
25
|
return null;
|
|
26
|
+
// One column for the whole list: as wide as its longest kickoff, plus a gap.
|
|
27
|
+
const width = Math.max(...shown.map((m) => formatKickoff(m.startTimestamp).length)) + 2;
|
|
19
28
|
if (compact) {
|
|
20
29
|
const m = shown[0];
|
|
21
30
|
const pred = matchPrediction(m, predictions);
|
|
22
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ",
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", homeName(m.home), " \u2014 ", awayName(m.away), " ", formatKickoff(m.startTimestamp)] }), pred ? _jsx(Text, { color: "yellow", children: headline(pred) }) : null] }));
|
|
23
32
|
}
|
|
24
33
|
// Every fixture shows its pick; the NEXT one (shown[0]) gets the full slate.
|
|
25
34
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "UPCOMING" }), shown.map((m, i) => {
|
|
26
35
|
const pred = matchPrediction(m, predictions);
|
|
27
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ",
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", kickoffColumn(m.startTimestamp, Date.now(), width)] }), homeName(m.home), " \u2014 ", awayName(m.away), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }), pred && i === 0
|
|
28
37
|
? fullSlate(pred).map((ln, j) => _jsx(Text, { color: "yellow", children: ln }, j))
|
|
29
38
|
: pred
|
|
30
39
|
? _jsxs(Text, { color: "yellow", children: [" ", headline(pred)] })
|
package/dist/ui/flags.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Country flag emoji for a team, keyed on
|
|
1
|
+
// Country flag emoji for a national team, keyed on its 3-letter abbreviation
|
|
2
2
|
// (verified against the live FIFA World Cup feed — note ESPN uses MAR for
|
|
3
3
|
// Morocco). Bracket placeholders (1A, QFW1, RD16 W1, …) have no flag.
|
|
4
4
|
// ESPN abbreviation → ISO 3166-1 alpha-2.
|
|
@@ -31,13 +31,27 @@ export function flagFor(code) {
|
|
|
31
31
|
return subdivisionFlag(sub);
|
|
32
32
|
return '';
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Home side: "QAT 🇶🇦" (flag trails the code), or just the code if unmapped.
|
|
36
|
+
* Clubs (`national === false`) never get a flag: their codes collide with
|
|
37
|
+
* countries (PAN is Panetolikos, not Panama).
|
|
38
|
+
*/
|
|
39
|
+
export function homeTag(code, national = true) {
|
|
40
|
+
const f = national ? flagFor(code) : '';
|
|
37
41
|
return f ? `${code} ${f}` : code;
|
|
38
42
|
}
|
|
39
|
-
/** Away side: "🇨🇭 SUI" (flag leads the code), or just the code if unmapped. */
|
|
40
|
-
export function awayTag(code) {
|
|
41
|
-
const f = flagFor(code);
|
|
43
|
+
/** Away side: "🇨🇭 SUI" (flag leads the code), or just the code if unmapped. Clubs get no flag. */
|
|
44
|
+
export function awayTag(code, national = true) {
|
|
45
|
+
const f = national ? flagFor(code) : '';
|
|
42
46
|
return f ? `${f} ${code}` : code;
|
|
43
47
|
}
|
|
48
|
+
/** Home side by full name, for the roomy views: "Qatar 🇶🇦", or "Arsenal" for a club. */
|
|
49
|
+
export function homeName(t) {
|
|
50
|
+
const f = t.national !== false ? flagFor(t.code) : '';
|
|
51
|
+
return f ? `${t.name} ${f}` : t.name;
|
|
52
|
+
}
|
|
53
|
+
/** Away side by full name: "🇨🇭 Switzerland", or "Liverpool" for a club. */
|
|
54
|
+
export function awayName(t) {
|
|
55
|
+
const f = t.national !== false ? flagFor(t.code) : '';
|
|
56
|
+
return f ? `${f} ${t.name}` : t.name;
|
|
57
|
+
}
|