claudial 0.6.0 โ†’ 0.6.2

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.
@@ -106,6 +106,12 @@ export function startPoller(deps, opts = {}) {
106
106
  catch {
107
107
  deps.dispatch({ type: 'stale' });
108
108
  }
109
+ // Predictions ride the gentle fixtures cadence. fetchPredictions is
110
+ // fail-silent (returns [] when disabled or on error), so this never
111
+ // disturbs the board.
112
+ const predictions = await deps.fetchPredictions();
113
+ if (predictions.length > 0)
114
+ deps.dispatch({ type: 'predictions', predictions });
109
115
  if (!stopped)
110
116
  fixturesTimer = setTimeout(fixturesTick, fixturesMs);
111
117
  }
@@ -0,0 +1,90 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, statSync, existsSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
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/';
9
+ // Predictions only change every few hours server-side, so poll gently.
10
+ const CACHE_TTL_MS = 30 * 60 * 1000;
11
+ /** Resolve the API URL: explicit env var wins, else the shipped default. */
12
+ export function predictionsUrl(env = process.env) {
13
+ return env[ENV] || DEFAULT_PREDICTIONS_URL;
14
+ }
15
+ /** A URL is always resolvable now (shipped default), so predictions are on. */
16
+ export function predictionsEnabled(env = process.env) {
17
+ return !!predictionsUrl(env);
18
+ }
19
+ function cachePath(dir) {
20
+ return join(dir, 'predictions.json');
21
+ }
22
+ function readCache(file, now) {
23
+ if (!existsSync(file))
24
+ return null;
25
+ if (now - statSync(file).mtimeMs > CACHE_TTL_MS)
26
+ return null;
27
+ try {
28
+ return JSON.parse(readFileSync(file, 'utf8'));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function writeCache(file, preds) {
35
+ const tmp = `${file}.tmp`;
36
+ try {
37
+ writeFileSync(tmp, JSON.stringify(preds));
38
+ renameSync(tmp, file);
39
+ }
40
+ catch {
41
+ /* cache is best-effort */
42
+ }
43
+ }
44
+ /**
45
+ * Fetch predictions, fail-silent. NEVER rejects โ€” returns [] on any problem
46
+ * (disabled, network error, bad JSON, timeout) so callers can drop predictions
47
+ * and render fixtures exactly as before. Uses a disk cache to avoid hammering
48
+ * the API on every statusline tick.
49
+ */
50
+ export async function fetchPredictions(opts = {}) {
51
+ const { env = process.env, now = Date.now(), timeoutMs = 2500, dir = join(tmpdir(), 'claudial-statusline'), fetchImpl = fetch, } = opts;
52
+ const url = predictionsUrl(env);
53
+ if (!url)
54
+ return [];
55
+ mkdirSync(dir, { recursive: true });
56
+ const file = cachePath(dir);
57
+ const cached = readCache(file, now);
58
+ if (cached)
59
+ return cached;
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
62
+ try {
63
+ const resp = await fetchImpl(url, { signal: controller.signal });
64
+ if (!resp.ok)
65
+ return cached ?? [];
66
+ const data = (await resp.json());
67
+ if (!Array.isArray(data))
68
+ return [];
69
+ writeCache(file, data);
70
+ return data;
71
+ }
72
+ catch {
73
+ // Stale-but-present cache beats nothing; otherwise empty.
74
+ return readCacheStale(file) ?? [];
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+ /** Last-resort read ignoring TTL (used only when a live fetch fails). */
81
+ function readCacheStale(file) {
82
+ if (!existsSync(file))
83
+ return null;
84
+ try {
85
+ return JSON.parse(readFileSync(file, 'utf8'));
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
@@ -0,0 +1,23 @@
1
+ const pct = (p) => `${Math.round(p * 100)}%`;
2
+ /** Compact one-liner for the statusline: "๐Ÿ”ฎ SUI win 64% ยท 2-1". */
3
+ export function headline(p) {
4
+ return `๐Ÿ”ฎ ${p.pick.label} ${pct(p.pick.confidence)} ยท ${p.markets.scoreline}`;
5
+ }
6
+ /**
7
+ * Full-slate lines for the dashboard, shown beside the next fixture. Returns an
8
+ * array of plain strings (the caller wraps them in ink <Text>).
9
+ */
10
+ export function fullSlate(p) {
11
+ const { result, over_under_2_5: ou, btts, scoreline } = p.markets;
12
+ const fav = result.home >= result.draw && result.home >= result.away
13
+ ? `${p.home_code} ${pct(result.home)}`
14
+ : result.away >= result.draw
15
+ ? `${p.away_code} ${pct(result.away)}`
16
+ : `Draw ${pct(result.draw)}`;
17
+ const ouPick = ou.over >= ou.under ? `O2.5 ${pct(ou.over)}` : `U2.5 ${pct(ou.under)}`;
18
+ const bttsPick = btts.yes >= btts.no ? `BTTS ${pct(btts.yes)}` : `No BTTS ${pct(btts.no)}`;
19
+ return [
20
+ `๐Ÿ”ฎ PICK: ${p.pick.label} (${pct(p.pick.confidence)}) ยท stake โ‚ฌ${p.suggested_stake_eur}`,
21
+ ` 1X2 ${fav} ${ouPick} ${bttsPick} score ${scoreline}`,
22
+ ];
23
+ }
@@ -0,0 +1,3 @@
1
+ export { predictionsEnabled, fetchPredictions } from './client.js';
2
+ export { matchPrediction } from './match.js';
3
+ export { headline, fullSlate } from './format.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Find the prediction for a claudial `Match`, matching on FIFA codes. Two teams
3
+ * can meet more than once (group + knockout), so when several candidates share
4
+ * the same code pair we take the one whose kickoff is closest to the match's.
5
+ * Returns null when there's no prediction (engine disabled, API down, or the
6
+ * fixture simply isn't predicted yet).
7
+ */
8
+ export function matchPrediction(m, preds) {
9
+ const candidates = preds.filter((p) => p.home_code === m.home.code && p.away_code === m.away.code);
10
+ if (candidates.length === 0)
11
+ return null;
12
+ if (candidates.length === 1)
13
+ return candidates[0];
14
+ return candidates.reduce((best, p) => Math.abs(p.kickoff_ts - m.startTimestamp) < Math.abs(best.kickoff_ts - m.startTimestamp)
15
+ ? p
16
+ : best);
17
+ }
@@ -0,0 +1,3 @@
1
+ // Mirrors the predictions API contract (predictions/ Django service).
2
+ // Kept structurally identical to the JSON so parsing is a straight cast.
3
+ export {};
package/dist/state.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export const initialState = {
2
2
  live: [], upcoming: [], recent: [],
3
- incidents: {}, takeovers: [],
3
+ incidents: {}, predictions: [], takeovers: [],
4
4
  stale: false, lastUpdated: null,
5
5
  };
6
6
  export function reducer(s, a) {
@@ -11,6 +11,8 @@ export function reducer(s, a) {
11
11
  return { ...s, upcoming: a.upcoming, recent: a.recent };
12
12
  case 'incidents':
13
13
  return { ...s, incidents: { ...s.incidents, [a.matchId]: a.incidents } };
14
+ case 'predictions':
15
+ return { ...s, predictions: a.predictions };
14
16
  case 'takeover':
15
17
  return { ...s, takeovers: [...s.takeovers, a.takeover] };
16
18
  case 'takeoverDone':
@@ -1,5 +1,7 @@
1
1
  import { formatKickoff } from '../ui/UpcomingSection.js';
2
2
  import { homeTag, awayTag } from '../ui/flags.js';
3
+ import { matchPrediction } from '../predictions/match.js';
4
+ import { headline } from '../predictions/format.js';
3
5
  // Assumed wall-clock length of a match (kickoff โ†’ ~full time). ESPN exposes no
4
6
  // end-time, so a finished match is anchored at kickoff + this for proximity math.
5
7
  const MATCH_MS = 2 * 60 * 60 * 1000;
@@ -14,16 +16,24 @@ export function liveLine(match) {
14
16
  export function finishedLine(match) {
15
17
  return `โšฝ ${homeTag(match.home.code)} ${match.homeScore ?? 0}โ€”${match.awayScore ?? 0} ${awayTag(match.away.code)} FT`;
16
18
  }
17
- /** The next kickoff: "โ—‹ BRA ๐Ÿ‡ง๐Ÿ‡ท โ€” ๐Ÿ‡ฒ๐Ÿ‡ฆ MAR Sun 01:00". */
18
- function upcomingLine(match, now) {
19
- return `โ—‹ ${homeTag(match.home.code)} โ€” ${awayTag(match.away.code)} ${formatKickoff(match.startTimestamp, now)}`;
19
+ /**
20
+ * The next kickoff: "โ—‹ BRA ๐Ÿ‡ง๐Ÿ‡ท โ€” ๐Ÿ‡ฒ๐Ÿ‡ฆ MAR Sun 01:00", with a prediction headline
21
+ * appended when one is available: "โ€ฆ ยท ๐Ÿ”ฎ BRA win 64% ยท 2-1".
22
+ */
23
+ function upcomingLine(match, now, preds) {
24
+ const base = `โ—‹ ${homeTag(match.home.code)} โ€” ${awayTag(match.away.code)} ${formatKickoff(match.startTimestamp, now)}`;
25
+ const pred = matchPrediction(match, preds);
26
+ return pred ? `${base} ยท ${headline(pred)}` : base;
20
27
  }
21
28
  /**
22
29
  * Live matches joined by two spaces. Otherwise show whichever moment is closer
23
30
  * to `now`: the latest finished result (end โ‰ˆ kickoff + MATCH_MS) or the next
24
- * kickoff. Only one present โ†’ that one; neither โ†’ "".
31
+ * kickoff. Only one present โ†’ that one; neither โ†’ "". When the chosen line is
32
+ * the next kickoff, a matching prediction headline is appended (`preds` is
33
+ * empty unless the predictions engine is enabled, so default behavior is
34
+ * unchanged).
25
35
  */
26
- export function scoreLine(live, recent, upcoming, now = Date.now()) {
36
+ export function scoreLine(live, recent, upcoming, now = Date.now(), preds = []) {
27
37
  if (live.length > 0)
28
38
  return live.map(liveLine).join(' ');
29
39
  const lastFinished = recent.length
@@ -35,11 +45,11 @@ export function scoreLine(live, recent, upcoming, now = Date.now()) {
35
45
  const startMs = next.startTimestamp * 1000;
36
46
  return Math.abs(now - finishMs) <= Math.abs(startMs - now)
37
47
  ? finishedLine(lastFinished)
38
- : upcomingLine(next, now);
48
+ : upcomingLine(next, now, preds);
39
49
  }
40
50
  if (lastFinished)
41
51
  return finishedLine(lastFinished);
42
52
  if (next)
43
- return upcomingLine(next, now);
53
+ return upcomingLine(next, now, preds);
44
54
  return '';
45
55
  }
@@ -3,6 +3,7 @@ import { fetchLive as realFetchLive, fetchRecent as realFetchRecent, fetchUpcomi
3
3
  import { scoreLine } from './line.js';
4
4
  import { makeCache, TTL_MS } from './cache.js';
5
5
  import { goalKickFrame } from './anim.js';
6
+ import { fetchPredictions as realFetchPredictions } from '../predictions/client.js';
6
7
  const PLACEHOLDER = 'โšฝ claudial ยท warming up';
7
8
  function withTimeout(p, ms) {
8
9
  let id;
@@ -18,8 +19,8 @@ export async function runStatusline(input, deps, now = Date.now()) {
18
19
  const fresh = cached != null && cached.ageMs <= TTL_MS;
19
20
  if (!fresh && cache.tryLock(now)) {
20
21
  try {
21
- const [liveM, recentM, upcomingM, redsM] = await withTimeout(Promise.all([deps.fetchLive(), deps.fetchRecent(), deps.fetchUpcoming(2026), deps.fetchRedCards()]), deps.timeoutMs);
22
- const next = scoreLine(liveM, recentM, upcomingM, now);
22
+ 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);
23
24
  cache.updateGoalState(liveM, now);
24
25
  cache.updateRedCards(redsM, now);
25
26
  if (next) {
@@ -67,6 +68,7 @@ export function defaultDeps() {
67
68
  fetchRecent: () => realFetchRecent(2026),
68
69
  fetchUpcoming: realFetchUpcoming,
69
70
  fetchRedCards: realFetchLiveRedCards,
71
+ fetchPredictions: () => realFetchPredictions(),
70
72
  cache: makeCache(),
71
73
  branchOf: defaultBranchOf,
72
74
  timeoutMs: 3000,
package/dist/ui/App.js CHANGED
@@ -4,6 +4,7 @@ import { Box, useApp, useInput } from 'ink';
4
4
  import { initialState, reducer } from '../state.js';
5
5
  import { startPoller } from '../engine/poller.js';
6
6
  import { fetchIncidents, fetchLive, fetchRecent, fetchUpcoming } from '../api/espn.js';
7
+ import { fetchPredictions } from '../predictions/client.js';
7
8
  import { Header } from './Header.js';
8
9
  import { DaySection } from './DaySection.js';
9
10
  import { partitionByDay } from './fixtures.js';
@@ -33,6 +34,7 @@ export function App({ seasonId, mode = 'board' }) {
33
34
  recent: await fetchRecent(seasonId),
34
35
  }),
35
36
  fetchIncidents,
37
+ fetchPredictions: () => fetchPredictions(),
36
38
  dispatch,
37
39
  };
38
40
  pollerRef.current = startPoller(deps);
@@ -59,5 +61,5 @@ export function App({ seasonId, mode = 'board' }) {
59
61
  }
60
62
  const all = dedupeById([...state.live, ...state.recent, ...state.upcoming]);
61
63
  const { yesterday, today, future } = partitionByDay(all, Date.now());
62
- 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 }), _jsx(Footer, {})] }));
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, {})] }));
63
65
  }
@@ -1,6 +1,8 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { homeTag, awayTag } from './flags.js';
4
+ import { matchPrediction } from '../predictions/match.js';
5
+ import { fullSlate, headline } from '../predictions/format.js';
4
6
  export function formatKickoff(ts, now = Date.now()) {
5
7
  const d = new Date(ts * 1000);
6
8
  const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
@@ -10,13 +12,22 @@ export function formatKickoff(ts, now = Date.now()) {
10
12
  const day = d.toLocaleDateString(undefined, { weekday: 'short' });
11
13
  return `${day} ${time}`;
12
14
  }
13
- export function UpcomingSection({ matches, compact }) {
15
+ export function UpcomingSection({ matches, compact, predictions = [], }) {
14
16
  const shown = matches.slice(0, compact ? 1 : 8);
15
17
  if (shown.length === 0)
16
18
  return null;
17
19
  if (compact) {
18
20
  const m = shown[0];
19
- return (_jsxs(Text, { dimColor: true, children: ["\u25CB ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] }));
21
+ 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] }));
20
23
  }
21
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "UPCOMING" }), shown.map((m) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }, m.id)))] }));
24
+ // Every fixture shows its pick; the NEXT one (shown[0]) gets the full slate.
25
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "UPCOMING" }), shown.map((m, i) => {
26
+ 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
28
+ ? fullSlate(pred).map((ln, j) => _jsx(Text, { color: "yellow", children: ln }, j))
29
+ : pred
30
+ ? _jsxs(Text, { color: "yellow", children: [" ", headline(pred)] })
31
+ : null] }, m.id));
32
+ })] }));
22
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudial",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Live World Cup 2026 fixtures in your terminal โ€” goal, red-card and VAR takeovers included",
5
5
  "type": "module",
6
6
  "bin": {