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/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 { resolveSeasonId } from './api/espn.js';
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 ESPN. Check your connection and try again.');
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 ESPN. Check your connection and try again.');
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
- // Shipped default so predictions work out of the box from the published package.
6
- // Override with CLAUDIAL_PREDICTIONS_URL (e.g. your own backend, or to disable
7
- // by pointing it elsewhere).
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
- /** Resolve the API URL: explicit env var wins, else the shipped default. */
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] || DEFAULT_PREDICTIONS_URL;
12
+ return env[ENV] || '';
14
13
  }
15
- /** A URL is always resolvable now (shipped default), so predictions are on. */
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
  }
@@ -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/espn.js';
2
- import { formatKickoff } from './ui/UpcomingSection.js';
3
- import { homeTag, awayTag } from './ui/flags.js';
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 ${formatKickoff(m.startTimestamp).padEnd(13)} ${homeTag(m.home.code)} - ${awayTag(m.away.code)}`;
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)} ${homeTag(m.home.code)} ${m.homeScore ?? '-'} - ${m.awayScore ?? '-'} ${awayTag(m.away.code)}`;
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 - WORLD CUP 2026');
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));
@@ -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 { homeTag, awayTag } from '../ui/flags.js';
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 · ${homeTag(m.home.code)} ${m.homeScore ?? 0}—${m.awayScore ?? 0} ${awayTag(m.away.code)}${GOAL_CLOSE}`;
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
- return `${RED_OPEN}🟥 R E D · ${rc.player.toUpperCase()} · ${homeTag(rc.homeCode)} — ${awayTag(rc.awayCode)}${GOAL_CLOSE}`;
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 });
@@ -1,5 +1,5 @@
1
1
  import { formatKickoff } from '../ui/UpcomingSection.js';
2
- import { homeTag, awayTag } from '../ui/flags.js';
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 `⚽ ${homeTag(match.home.code)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayTag(match.away.code)}${clock}`;
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 `⚽ ${homeTag(match.home.code)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayTag(match.away.code)} FT`;
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 = `○ ${homeTag(match.home.code)} — ${awayTag(match.away.code)} ${formatKickoff(match.startTimestamp, now)}`;
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
  }
@@ -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/espn.js';
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
- const next = scoreLine(liveM, recentM, upcomingM, now, predsM);
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
- if (next) {
27
- cache.write(next, now);
28
- line = next;
29
- }
38
+ cache.write(next, now);
39
+ line = next;
30
40
  }
31
41
  catch {
32
- // keep last good cache (line already set)
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/espn.js';
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
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Header, { stale: state.stale, lastUpdated: state.lastUpdated }), _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, {})] }));
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 WORLD CUP 2026" }), _jsxs(Text, { dimColor: true, children: [' ', "\u273B ", stale ? 'stale · retrying' : `updated ${updated}`] })] }));
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
  }
@@ -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 { homeTag, awayTag } from './flags.js';
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 fmt = (g) => `${g.minute ?? '?'}' ${g.playerShort ?? '?'}`;
11
- const home = goals.filter((g) => g.isHome).map(fmt).join(' · ');
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 fmt = (i) => i.kind === 'yellowCard'
20
- ? `${i.minute ?? '?'}' 🟨 ${i.playerShort ?? '?'}`
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 ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] });
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 ", formatKickoff(m.startTimestamp).padEnd(13)] }), homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }));
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), " "] }), homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayTag(m.away.code), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }));
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: [' ', homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", awayTag(m.away.code)] }), _jsx(ScorerLine, { incidents: incidents }), _jsx(FeedLine, { incidents: incidents })] }));
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
  }
@@ -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 { homeTag, awayTag } from './flags.js';
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, { dimColor: true, children: [homeTag(match.home.code), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", awayTag(match.away.code)] })] })] }));
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, { dimColor: true, children: [homeTag(match.home.code), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", awayTag(match.away.code)] })] })] }));
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 { homeTag, awayTag } from './flags.js';
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 ", homeTag(t.match.home.code), " ", t.homeScore, "\u2014", t.awayScore, " ", awayTag(t.match.away.code)] }));
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' : '', " "] }), homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayTag(m.away.code), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", homeTag(upcoming[0].home.code), " \u2014 ", awayTag(upcoming[0].away.code), " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
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 { homeTag, awayTag } from './flags.js';
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
- const day = d.toLocaleDateString(undefined, { weekday: 'short' });
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 ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] }), pred ? _jsx(Text, { color: "yellow", children: headline(pred) }) : null] }));
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 ", formatKickoff(m.startTimestamp).padEnd(13)] }), homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }), pred && i === 0
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 ESPN's 3-letter abbreviation
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
- /** Home side: "QAT 🇶🇦" (flag trails the code), or just the code if unmapped. */
35
- export function homeTag(code) {
36
- const f = flagFor(code);
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
+ }
@@ -0,0 +1,9 @@
1
+ export function boardNotice(s) {
2
+ if (s.stale && s.lastUpdated == null)
3
+ return 'offline'; // never reached the server
4
+ if (s.followsNone)
5
+ return 'follow-nothing';
6
+ if (s.lastUpdated != null && s.matchCount === 0)
7
+ return 'no-matches';
8
+ return null;
9
+ }