sportsing 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -12
- package/package.json +6 -2
- package/src/alerts.ts +47 -0
- package/src/api.ts +1 -1
- package/src/click-to-watch.ts +39 -0
- package/src/commands/_lib.ts +28 -12
- package/src/commands/analyze.ts +2 -2
- package/src/commands/ask.ts +1 -1
- package/src/commands/cross.ts +354 -0
- package/src/commands/fav.ts +19 -16
- package/src/commands/league-ai.ts +201 -0
- package/src/commands/league-bracket.ts +150 -0
- package/src/commands/league.ts +839 -0
- package/src/commands/live.ts +57 -42
- package/src/commands/me.ts +1 -1
- package/src/commands/predict.ts +2 -2
- package/src/commands/scorers.ts +1 -1
- package/src/commands/setup.ts +3 -3
- package/src/commands/subscriptions.ts +66 -0
- package/src/commands/table.ts +1 -1
- package/src/commands/watch.ts +19 -11
- package/src/config.ts +166 -28
- package/src/espn.ts +606 -33
- package/src/format.ts +87 -0
- package/src/game-events.ts +239 -0
- package/src/game.ts +51 -0
- package/src/index.ts +35 -16
- package/src/league-ai.ts +359 -0
- package/src/league-alerts.ts +139 -0
- package/src/league-detect.ts +104 -0
- package/src/league-overlay.ts +351 -0
- package/src/league-panels.ts +159 -0
- package/src/overlay.ts +96 -51
- package/src/playoff-bracket.ts +384 -0
- package/src/recap.ts +37 -8
- package/src/season.ts +182 -0
- package/src/sports/fifa.ts +9 -4
- package/src/sports/nba.ts +20 -0
- package/src/sports/nhl.ts +20 -0
- package/src/standings.ts +130 -0
- package/src/stream.ts +49 -8
- package/src/watch-route.ts +95 -0
- package/src/watchability.ts +183 -0
package/src/overlay.ts
CHANGED
|
@@ -21,16 +21,93 @@ import { fenceSafe } from "./prompt-fence.ts";
|
|
|
21
21
|
* deep-link tile-scorer (AGT-543) and the post-landing warning (AGT-544). */
|
|
22
22
|
export type WatchLang = "english" | "spanish";
|
|
23
23
|
|
|
24
|
+
// ── Shared overlay chrome (page-side JS) ────────────────────────────────────
|
|
25
|
+
// The pieces every stream overlay shares — FIFA's here and the NBA/NHL one in
|
|
26
|
+
// league-overlay.ts: helper functions, the floating gear, the settings modal
|
|
27
|
+
// (panel checkboxes + delay slider), and their wiring. Each overlay supplies
|
|
28
|
+
// its own PANELS / D (defaults) and stats window, then splices these in.
|
|
29
|
+
|
|
30
|
+
// HTML-escape AND quote-escape: textContent→innerHTML handles <>&, but these
|
|
31
|
+
// values are also interpolated into double-quoted attributes (e.g. data-watch),
|
|
32
|
+
// so escape \" and ' too — API-controlled fields (ids) must not break out of an attr.
|
|
33
|
+
export const JS_ESC =
|
|
34
|
+
"function esc(s){var d=document.createElement('div');d.appendChild(document.createTextNode(String(s==null?'':s)));return d.innerHTML.replace(/\"/g,'"').replace(/'/g,''');}";
|
|
35
|
+
export const JS_ROW =
|
|
36
|
+
"function row(label,a,b){return '<div style=\"display:flex;justify-content:space-between;margin-top:3px\"><span>'+esc(a)+'</span><span style=\"color:#8b949e\">'+label+'</span><span>'+esc(b)+'</span></div>';}";
|
|
37
|
+
export const JS_SHOW =
|
|
38
|
+
"function show(id,on){var e=document.getElementById(id);if(e)e.style.display=on?(e.dataset.disp||''):'none';}";
|
|
39
|
+
export const JS_CB =
|
|
40
|
+
"function cb(key,label,checked){return '<label style=\"display:flex;align-items:center;gap:8px;padding:3px 0;cursor:pointer\"><input type=\"checkbox\" data-k=\"'+key+'\"'+(checked?' checked':'')+'>'+esc(label)+'</label>';}";
|
|
41
|
+
// A separately-draggable element: drag from `handle`; if the press doesn't
|
|
42
|
+
// move (a click), fire onClick instead — lets the gear be both draggable and
|
|
43
|
+
// a button. Presses that start on an input/button/label don't initiate a drag.
|
|
44
|
+
export const JS_DRAGGABLE =
|
|
45
|
+
"function draggable(el,handle,onClick){handle.addEventListener('mousedown',function(e){if(e.target.closest('input,a')||(e.target.closest('button')&&e.target!==handle))return;var sx=e.clientX,sy=e.clientY,l=el.offsetLeft,t=el.offsetTop,moved=false;function mm(ev){var dx=ev.clientX-sx,dy=ev.clientY-sy;if(!moved&&Math.abs(dx)+Math.abs(dy)<4)return;moved=true;el.style.left=(l+dx)+'px';el.style.top=(t+dy)+'px';el.style.right='auto';el.style.bottom='auto';}function mu(){document.removeEventListener('mousemove',mm);document.removeEventListener('mouseup',mu);if(!moved&&onClick)onClick();}document.addEventListener('mousemove',mm);document.addEventListener('mouseup',mu);});}";
|
|
46
|
+
export const JS_CALL =
|
|
47
|
+
"function call(o){if(window.__sbCall)window.__sbCall(JSON.stringify(o));}";
|
|
48
|
+
|
|
49
|
+
/** Inside mk(): create the gear (`gear`) and the settings modal (`set`). */
|
|
50
|
+
export const JS_SHELL_CREATE = [
|
|
51
|
+
// (a) the always-present floating gear — draggable, and a click toggles settings.
|
|
52
|
+
" var gear=document.createElement('button');gear.id='sb-gear';gear.title='settings';gear.textContent='\\u2699';",
|
|
53
|
+
" gear.style.cssText='position:fixed;top:16px;right:16px;z-index:2147483647;width:34px;height:34px;border-radius:50%;background:rgba(12,12,16,0.94);color:#8b949e;border:none;cursor:pointer;font-size:17px;line-height:34px;text-align:center;box-shadow:0 4px 14px rgba(0,0,0,0.5);user-select:none';",
|
|
54
|
+
" document.body.appendChild(gear);",
|
|
55
|
+
// (b) the free-standing settings modal — toggled by the gear, draggable by its
|
|
56
|
+
// header, dismissed by its own close button.
|
|
57
|
+
" var set=document.createElement('div');set.id='sb-set';",
|
|
58
|
+
" set.style.cssText='position:fixed;top:58px;right:16px;z-index:2147483647;width:242px;border-radius:10px;background:rgba(12,12,16,0.96);color:#e6edf3;font:13px/1.45 system-ui,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,0.6);user-select:none;display:none';",
|
|
59
|
+
" set.innerHTML='"
|
|
60
|
+
+ "<div id=\"sb-set-head\" style=\"display:flex;align-items:center;justify-content:space-between;padding:9px 13px;border-bottom:1px solid #21262d;cursor:move\"><b style=\"font-size:12px\">Overlay settings</b><button id=\"sb-set-x\" title=\"close\" style=\"background:none;border:none;color:#8b949e;cursor:pointer;font-size:16px;line-height:1;padding:0\">\\u00d7</button></div>"
|
|
61
|
+
+ "<div style=\"padding:9px 13px 12px\">"
|
|
62
|
+
+ "<div style=\"color:#8b949e;font-size:11px;margin-bottom:4px\">Show panels</div><div id=\"sb-cbs\"></div>"
|
|
63
|
+
+ "<div style=\"margin-top:11px;font-size:11px\"><div style=\"display:flex;justify-content:space-between;color:#8b949e\"><span>delay</span><span id=\"sb-delay\">0s</span></div><input id=\"sb-slider\" type=\"range\" min=\"0\" max=\"300\" step=\"5\" value=\"0\" style=\"width:100%;margin-top:4px;accent-color:#1f6feb\"></div>"
|
|
64
|
+
+ "<div style=\"color:#555;font-size:10px;margin-top:6px\">delay trails your stream to avoid spoilers (up to 5 min)</div></div>';",
|
|
65
|
+
" document.body.appendChild(set);",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Style for the stats window (`stats`), created by each overlay's mk(). */
|
|
69
|
+
export const STATS_CSS =
|
|
70
|
+
"position:fixed;top:16px;left:16px;z-index:2147483646;width:262px;border-radius:10px;background:rgba(12,12,16,0.94);color:#e6edf3;font:13px/1.45 system-ui,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,0.6);user-select:none;display:none";
|
|
71
|
+
|
|
72
|
+
/** Inside mk(), after `gear`/`set`/`stats` exist: dragging, the settings close
|
|
73
|
+
* button, one checkbox per PANELS entry (checked from D), and the delay slider. */
|
|
74
|
+
export const JS_SHELL_WIRE = [
|
|
75
|
+
" function toggleSet(){set.style.display=set.style.display==='none'?'block':'none';}",
|
|
76
|
+
" draggable(gear,gear,toggleSet);",
|
|
77
|
+
" draggable(set,document.getElementById('sb-set-head'));",
|
|
78
|
+
" draggable(stats,document.getElementById('sb-stats-head'));",
|
|
79
|
+
" document.getElementById('sb-set-x').addEventListener('click',function(){set.style.display='none';});",
|
|
80
|
+
" var cbs=document.getElementById('sb-cbs');cbs.innerHTML=PANELS.map(function(p){return cb(p[0],p[1],!!D[p[0]]);}).join('');",
|
|
81
|
+
" cbs.addEventListener('change',function(e){if(e.target&&e.target.dataset.k)call({fn:'pref',key:e.target.dataset.k,on:e.target.checked});});",
|
|
82
|
+
" document.getElementById('sb-slider').addEventListener('input',function(e){call({fn:'delay',set:Number(e.target.value)});});",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/** Inside window.__sb.update(d), with `P` = d.panels: keep the settings
|
|
86
|
+
* controls (delay label, slider, checkboxes) in sync regardless of which
|
|
87
|
+
* window is visible. */
|
|
88
|
+
export const JS_SYNC_SETTINGS = [
|
|
89
|
+
" var dl=document.getElementById('sb-delay'),sl=document.getElementById('sb-slider'),dv=d.delay||0;if(dl)dl.textContent=dv>=60?(Math.floor(dv/60)+'m '+(dv%60)+'s'):(dv+'s');if(sl&&document.activeElement!==sl)sl.value=dv;",
|
|
90
|
+
" var cbx=document.querySelectorAll('#sb-cbs input[data-k]');for(var i=0;i<cbx.length;i++){var k=cbx[i].dataset.k;if(P[k]!==undefined)cbx[i].checked=!!P[k];}",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** The buffered snapshot from `delaySec` ago — what a (delayed) stream is
|
|
94
|
+
* showing: the newest one at or before the cutoff, else the oldest. Null when
|
|
95
|
+
* nothing is buffered yet. */
|
|
96
|
+
export function snapshotAtDelay<T>(buffer: { t: number; data: T }[], delaySec: number, now: number): T | null {
|
|
97
|
+
if (!buffer.length) return null;
|
|
98
|
+
const cutoff = now - delaySec * 1000;
|
|
99
|
+
let chosen = buffer[0]!;
|
|
100
|
+
for (const b of buffer) if (b.t <= cutoff) chosen = b;
|
|
101
|
+
return chosen.data;
|
|
102
|
+
}
|
|
103
|
+
|
|
24
104
|
const BOOTSTRAP = [
|
|
25
105
|
"(function(){",
|
|
26
106
|
"if(window.__sbInit)return;window.__sbInit=true;",
|
|
27
107
|
"var D=" + JSON.stringify(OVERLAY_PANEL_DEFAULTS) + ";", // panel defaults — single source of truth (config.ts)
|
|
28
108
|
"var PANELS=[['score','Score & clock'],['stats','Possession / shots'],['winprob','Win-probability breakdown'],['odds','Odds line'],['h2h','Head-to-head'],['events','Live events'],['scores','Other live scores'],['ask','Ask Claude'],['catchup','Get caught up']];",
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// so escape \" and ' too — API-controlled fields (ids) must not break out of an attr.
|
|
32
|
-
"function esc(s){var d=document.createElement('div');d.appendChild(document.createTextNode(String(s==null?'':s)));return d.innerHTML.replace(/\"/g,'"').replace(/'/g,''');}",
|
|
33
|
-
"function row(label,a,b){return '<div style=\"display:flex;justify-content:space-between;margin-top:3px\"><span>'+esc(a)+'</span><span style=\"color:#8b949e\">'+label+'</span><span>'+esc(b)+'</span></div>';}",
|
|
109
|
+
JS_ESC,
|
|
110
|
+
JS_ROW,
|
|
34
111
|
// One outcome row for the win-probability breakdown: a colour dot + label on
|
|
35
112
|
// the left, the percentage right-aligned. (The old layout reused row(), whose
|
|
36
113
|
// 3-column [a][label][b] shape scattered the three outcomes awkwardly.)
|
|
@@ -38,36 +115,19 @@ const BOOTSTRAP = [
|
|
|
38
115
|
// depth — callers pass constants, but this keeps the contract safe). label/pct
|
|
39
116
|
// are HTML-escaped via esc(); pct should already be a clamped number.
|
|
40
117
|
"function wprow(col,label,pct){var sc=/^#[0-9a-fA-F]{3,8}$/.test(col)?col:'#8b949e';return '<div style=\"display:flex;justify-content:space-between;font-size:12px;padding:1px 0\"><span><span style=\"color:'+sc+'\">\\u25cf</span> '+esc(label)+'</span><b>'+esc(pct)+'%</b></div>';}",
|
|
41
|
-
|
|
118
|
+
JS_SHOW,
|
|
42
119
|
"function fmtCd(ms){var s=Math.max(0,Math.floor(ms/1000));return 'in '+Math.floor(s/60)+':'+('0'+(s%60)).slice(-2);}",
|
|
43
120
|
"function statusCell(state,kickoff,detail,id){if(state==='in')return '<span data-watch=\"'+esc(id)+'\" style=\"color:#3fb950;cursor:pointer;font-weight:700\">\\u25cf LIVE \\u25b6</span>';if(state==='pre'){var k=Date.parse(kickoff||'');var ms=k-Date.now();if(k&&ms>0&&ms<=1800000)return '<span style=\"color:#d29922;font-size:11px\">'+fmtCd(ms)+'</span>';if(k)return '<span style=\"color:#8b949e;font-size:11px\">'+esc(new Date(k).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}))+'</span>';}return '<span style=\"color:#8b949e;font-size:11px\">'+esc(detail||'')+'</span>';}",
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// a button. Presses that start on an input/button/label don't initiate a drag.
|
|
48
|
-
"function draggable(el,handle,onClick){handle.addEventListener('mousedown',function(e){if(e.target.closest('input,a')||(e.target.closest('button')&&e.target!==handle))return;var sx=e.clientX,sy=e.clientY,l=el.offsetLeft,t=el.offsetTop,moved=false;function mm(ev){var dx=ev.clientX-sx,dy=ev.clientY-sy;if(!moved&&Math.abs(dx)+Math.abs(dy)<4)return;moved=true;el.style.left=(l+dx)+'px';el.style.top=(t+dy)+'px';el.style.right='auto';el.style.bottom='auto';}function mu(){document.removeEventListener('mousemove',mm);document.removeEventListener('mouseup',mu);if(!moved&&onClick)onClick();}document.addEventListener('mousemove',mm);document.addEventListener('mouseup',mu);});}",
|
|
49
|
-
"function call(o){if(window.__sbCall)window.__sbCall(JSON.stringify(o));}",
|
|
121
|
+
JS_CB,
|
|
122
|
+
JS_DRAGGABLE,
|
|
123
|
+
JS_CALL,
|
|
50
124
|
"function mk(){",
|
|
51
125
|
" if(!document.body){return setTimeout(mk,200);}",
|
|
52
126
|
" if(document.getElementById('sb-gear'))return;",
|
|
53
|
-
|
|
54
|
-
" var gear=document.createElement('button');gear.id='sb-gear';gear.title='settings';gear.textContent='\\u2699';",
|
|
55
|
-
" gear.style.cssText='position:fixed;top:16px;right:16px;z-index:2147483647;width:34px;height:34px;border-radius:50%;background:rgba(12,12,16,0.94);color:#8b949e;border:none;cursor:pointer;font-size:17px;line-height:34px;text-align:center;box-shadow:0 4px 14px rgba(0,0,0,0.5);user-select:none';",
|
|
56
|
-
" document.body.appendChild(gear);",
|
|
57
|
-
// (b) the free-standing settings modal — toggled by the gear, draggable by its
|
|
58
|
-
// header, dismissed by its own close button.
|
|
59
|
-
" var set=document.createElement('div');set.id='sb-set';",
|
|
60
|
-
" set.style.cssText='position:fixed;top:58px;right:16px;z-index:2147483647;width:242px;border-radius:10px;background:rgba(12,12,16,0.96);color:#e6edf3;font:13px/1.45 system-ui,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,0.6);user-select:none;display:none';",
|
|
61
|
-
" set.innerHTML='"
|
|
62
|
-
+ "<div id=\"sb-set-head\" style=\"display:flex;align-items:center;justify-content:space-between;padding:9px 13px;border-bottom:1px solid #21262d;cursor:move\"><b style=\"font-size:12px\">Overlay settings</b><button id=\"sb-set-x\" title=\"close\" style=\"background:none;border:none;color:#8b949e;cursor:pointer;font-size:16px;line-height:1;padding:0\">\\u00d7</button></div>"
|
|
63
|
-
+ "<div style=\"padding:9px 13px 12px\">"
|
|
64
|
-
+ "<div style=\"color:#8b949e;font-size:11px;margin-bottom:4px\">Show panels</div><div id=\"sb-cbs\"></div>"
|
|
65
|
-
+ "<div style=\"margin-top:11px;font-size:11px\"><div style=\"display:flex;justify-content:space-between;color:#8b949e\"><span>delay</span><span id=\"sb-delay\">0s</span></div><input id=\"sb-slider\" type=\"range\" min=\"0\" max=\"300\" step=\"5\" value=\"0\" style=\"width:100%;margin-top:4px;accent-color:#1f6feb\"></div>"
|
|
66
|
-
+ "<div style=\"color:#555;font-size:10px;margin-top:6px\">delay trails your stream to avoid spoilers (up to 5 min)</div></div>';",
|
|
67
|
-
" document.body.appendChild(set);",
|
|
127
|
+
...JS_SHELL_CREATE,
|
|
68
128
|
// (c) the free-standing stats window — appears only when >=1 panel is enabled.
|
|
69
129
|
" var stats=document.createElement('div');stats.id='sb-stats';",
|
|
70
|
-
" stats.style.cssText='
|
|
130
|
+
" stats.style.cssText='" + STATS_CSS + "';",
|
|
71
131
|
" stats.innerHTML='"
|
|
72
132
|
+ "<div id=\"sb-stats-head\" style=\"display:flex;align-items:center;justify-content:space-between;padding:7px 11px;cursor:move;color:#8b949e;font-size:11px;border-bottom:1px solid #21262d\"><span>\\u26bd live</span><span id=\"sb-fresh\"></span></div>"
|
|
73
133
|
+ "<div style=\"padding:9px 13px 11px\">"
|
|
@@ -84,14 +144,7 @@ const BOOTSTRAP = [
|
|
|
84
144
|
+ "</div>';",
|
|
85
145
|
" document.body.appendChild(stats);",
|
|
86
146
|
// wiring
|
|
87
|
-
|
|
88
|
-
" draggable(gear,gear,toggleSet);",
|
|
89
|
-
" draggable(set,document.getElementById('sb-set-head'));",
|
|
90
|
-
" draggable(stats,document.getElementById('sb-stats-head'));",
|
|
91
|
-
" document.getElementById('sb-set-x').addEventListener('click',function(){set.style.display='none';});",
|
|
92
|
-
" var cbs=document.getElementById('sb-cbs');cbs.innerHTML=PANELS.map(function(p){return cb(p[0],p[1],!!D[p[0]]);}).join('');",
|
|
93
|
-
" cbs.addEventListener('change',function(e){if(e.target&&e.target.dataset.k)call({fn:'pref',key:e.target.dataset.k,on:e.target.checked});});",
|
|
94
|
-
" document.getElementById('sb-slider').addEventListener('input',function(e){call({fn:'delay',set:Number(e.target.value)});});",
|
|
147
|
+
...JS_SHELL_WIRE,
|
|
95
148
|
" stats.addEventListener('click',function(e){var t=e.target;while(t&&t!==stats){if(t.getAttribute&&t.getAttribute('data-watch')!==null){call({fn:'watch',id:t.getAttribute('data-watch')});return;}t=t.parentElement;}});",
|
|
96
149
|
" document.getElementById('sb-h2h').addEventListener('click',function(){document.getElementById('sb-h2h-out').textContent='Loading…';call({fn:'headToHead'});});",
|
|
97
150
|
" var askIn=document.getElementById('sb-ask-in');function doAsk(){var q=(askIn.value||'').trim();if(!q)return;document.getElementById('sb-ask-out').textContent='Asking Claude…';call({fn:'ask',q:q});}",
|
|
@@ -104,9 +157,7 @@ const BOOTSTRAP = [
|
|
|
104
157
|
"function matchList(g){var h='';for(var i=0;i<g.length;i++){h+='<div style=\"display:flex;justify-content:space-between;gap:8px;padding:2px 0\"><span>'+esc(g[i].home)+' '+esc(g[i].hs)+' – '+esc(g[i].as)+' '+esc(g[i].away)+'</span>'+statusCell(g[i].state,g[i].kickoff,g[i].detail,g[i].id)+'</div>';}return h;}",
|
|
105
158
|
"window.__sb={",
|
|
106
159
|
" update:function(d){mk();var P=d.panels||{};",
|
|
107
|
-
|
|
108
|
-
" var dl=document.getElementById('sb-delay'),sl=document.getElementById('sb-slider'),dv=d.delay||0;if(dl)dl.textContent=dv>=60?(Math.floor(dv/60)+'m '+(dv%60)+'s'):(dv+'s');if(sl&&document.activeElement!==sl)sl.value=dv;",
|
|
109
|
-
" var cbx=document.querySelectorAll('#sb-cbs input[data-k]');for(var i=0;i<cbx.length;i++){var k=cbx[i].dataset.k;if(P[k]!==undefined)cbx[i].checked=!!P[k];}",
|
|
160
|
+
...JS_SYNC_SETTINGS,
|
|
110
161
|
// today (hub) view — the stats window shows the day's matches regardless of panels
|
|
111
162
|
" if(d.mode==='today'){show('sb-stats',true);['sb-pl-score','sb-pl-stats','sb-pl-winprob','sb-pl-odds','sb-pl-events','sb-pl-scores','sb-pl-h2h','sb-pl-ask','sb-pl-catchup'].forEach(function(i){show(i,false);});var td=document.getElementById('sb-pl-today');show('sb-pl-today',true);td.innerHTML=matchList(d.games||[])||'<span style=\"color:#8b949e\">no matches today</span>';var fr0=document.getElementById('sb-fresh');if(fr0)fr0.textContent='';return;}",
|
|
112
163
|
// match view — the window appears only when something is enabled
|
|
@@ -118,8 +169,8 @@ const BOOTSTRAP = [
|
|
|
118
169
|
" show('sb-pl-events',!!P.events);if(P.events){var ev=d.events||[],eh='<div style=\"color:#8b949e;font-size:11px;margin-bottom:4px\">Live events</div>';if(!ev.length)eh+='<div style=\"color:#8b949e;font-size:11px\">none yet</div>';for(var j=0;j<ev.length;j++){eh+='<div style=\"display:flex;gap:8px;padding:2px 0;font-size:12px\"><span style=\"color:#8b949e;width:2.6rem\">'+esc(ev[j].clock)+'</span><b style=\"width:2.4rem\">'+esc(ev[j].team)+'</b><span style=\"flex:1\">'+esc(ev[j].text||ev[j].type)+'</span></div>';}document.getElementById('sb-pl-events').innerHTML=eh;}",
|
|
119
170
|
" show('sb-pl-scores',!!P.scores);if(P.scores){var os=d.otherScores||[],sh='<div style=\"color:#8b949e;font-size:11px;margin-bottom:4px\">Other live scores</div>';sh+=matchList(os)||'<div style=\"color:#8b949e;font-size:11px\">no other live matches</div>';document.getElementById('sb-pl-scores').innerHTML=sh;}",
|
|
120
171
|
" show('sb-pl-h2h',!!P.h2h);",
|
|
121
|
-
" show('sb-pl-ask',!!P.ask);if(P.ask){var ast=document.getElementById('sb-ask-status');if(ast){if(d.serving){ast.textContent='\\u25cf Claude agent connected';ast.style.color='#3fb950';}else{ast.textContent='\\u25cb No agent \\u2014 run /loop sportsing serve to enable answers';ast.style.color='#d29922';}}}",
|
|
122
|
-
" show('sb-pl-catchup',!!P.catchup);if(P.catchup){var cst=document.getElementById('sb-catchup-status');if(cst){if(d.serving){cst.textContent='\\u25cf Claude agent connected';cst.style.color='#3fb950';}else{cst.textContent='\\u25cb No agent \\u2014 run /loop sportsing serve to enable recaps';cst.style.color='#d29922';}}}",
|
|
172
|
+
" show('sb-pl-ask',!!P.ask);if(P.ask){var ast=document.getElementById('sb-ask-status');if(ast){if(d.serving){ast.textContent='\\u25cf Claude agent connected';ast.style.color='#3fb950';}else{ast.textContent='\\u25cb No agent \\u2014 run /loop sportsing fifa serve to enable answers';ast.style.color='#d29922';}}}",
|
|
173
|
+
" show('sb-pl-catchup',!!P.catchup);if(P.catchup){var cst=document.getElementById('sb-catchup-status');if(cst){if(d.serving){cst.textContent='\\u25cf Claude agent connected';cst.style.color='#3fb950';}else{cst.textContent='\\u25cb No agent \\u2014 run /loop sportsing fifa serve to enable recaps';cst.style.color='#d29922';}}}",
|
|
123
174
|
" var fr=document.getElementById('sb-fresh');if(fr)fr.textContent=d.at?'⟳ '+d.at:'';},",
|
|
124
175
|
" result:function(d){mk();var o=document.getElementById('sb-h2h-out');if(!o)return;var g=(d&&d.games)||[];var h='<div style=\"color:#8b949e;font-size:11px;margin-bottom:4px\">Past meetings — '+esc((d&&d.team)||'')+'</div>';if(!g.length)h+='<div style=\"color:#8b949e\">none found</div>';for(var i=0;i<g.length;i++){var col=g[i].result==='W'?'#3fb950':g[i].result==='L'?'#f85149':'#8b949e';h+='<div style=\"display:flex;gap:8px;padding:2px 0;border-bottom:1px solid #21262d\"><span style=\"color:#8b949e;width:5.5rem\">'+esc(g[i].date)+'</span><b style=\"width:2.5rem\">'+esc(g[i].score)+'</b><span style=\"color:'+col+'\">'+esc(g[i].result)+'</span></div>';}o.innerHTML=h;},",
|
|
125
176
|
" askResult:function(t){mk();var o=document.getElementById('sb-ask-out');if(o)o.textContent=t||'(no response)';},",
|
|
@@ -145,7 +196,7 @@ async function askViaBus(ev: EspnEvent, question: string): Promise<string> {
|
|
|
145
196
|
// question would just sit until the 2-min timeout. This is the common gotcha:
|
|
146
197
|
// opening the stream does NOT start an answerer.
|
|
147
198
|
if (!(await isServing())) {
|
|
148
|
-
return "No Claude agent is serving. In a Claude session, run: /loop sportsing serve — then ask again.";
|
|
199
|
+
return "No Claude agent is serving. In a Claude session, run: /loop sportsing fifa serve — then ask again.";
|
|
149
200
|
}
|
|
150
201
|
const live = await getLiveMatch(ev.id).catch(() => null);
|
|
151
202
|
const ctx = live
|
|
@@ -183,7 +234,7 @@ async function askViaBus(ev: EspnEvent, question: string): Promise<string> {
|
|
|
183
234
|
// Generous window — a human-paced serving agent (sportsing fifa serve) needs
|
|
184
235
|
// time to read, think, and reply; 30s was too tight and dropped questions.
|
|
185
236
|
const answer = await waitForAnswer(id, 120_000);
|
|
186
|
-
return answer ?? "No Claude agent answered (waited 2 min). Keep one serving: /loop sportsing serve";
|
|
237
|
+
return answer ?? "No Claude agent answered (waited 2 min). Keep one serving: /loop sportsing fifa serve";
|
|
187
238
|
}
|
|
188
239
|
|
|
189
240
|
async function todaySnapshot(): Promise<Record<string, unknown>> {
|
|
@@ -385,7 +436,7 @@ export async function runOverlayStream(
|
|
|
385
436
|
if (!(await isServing())) {
|
|
386
437
|
console.log(c.yellow('💬 The overlay\'s "Ask Claude" / "Get caught up" need an answerer, or they show "No agent". In a Claude session, run either:'));
|
|
387
438
|
console.log(" " + c.bold("/loop agent-setup") + c.dim(" — the blessed setup (see: sportsing fifa agent-setup)"));
|
|
388
|
-
console.log(" " + c.bold("/loop sportsing serve") + c.dim(" — the answerer loop on its own"));
|
|
439
|
+
console.log(" " + c.bold("/loop sportsing fifa serve") + c.dim(" — the answerer loop on its own"));
|
|
389
440
|
}
|
|
390
441
|
|
|
391
442
|
let session: CdpSession | undefined;
|
|
@@ -434,13 +485,7 @@ export async function runOverlayStream(
|
|
|
434
485
|
|
|
435
486
|
// The buffered snapshot from `delaySec` ago — what the user's (delayed) stream
|
|
436
487
|
// is actually showing. Used both to render and to build a spoiler-safe recap.
|
|
437
|
-
const delayedSnapshot = (): Record<string, unknown> | null =>
|
|
438
|
-
if (!buffer.length) return null;
|
|
439
|
-
const cutoff = Date.now() - delaySec * 1000;
|
|
440
|
-
let chosen = buffer[0]!;
|
|
441
|
-
for (const b of buffer) if (b.t <= cutoff) chosen = b;
|
|
442
|
-
return chosen.data;
|
|
443
|
-
};
|
|
488
|
+
const delayedSnapshot = (): Record<string, unknown> | null => snapshotAtDelay(buffer, delaySec, Date.now());
|
|
444
489
|
|
|
445
490
|
const renderDelayed = () => {
|
|
446
491
|
const data = delayedSnapshot();
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Playoff brackets for ESPN-backed leagues (NBA, NHL): grouping postseason
|
|
2
|
+
// games into series, ordering them as a bracket, projecting a bracket from
|
|
3
|
+
// standings before the postseason, and rendering both. Pure — every fetch
|
|
4
|
+
// happens in the command (src/commands/league-bracket.ts) — so it's unit-tested
|
|
5
|
+
// without network.
|
|
6
|
+
//
|
|
7
|
+
// Formats are keyed by ESPN league path, like standings.ts's layouts: they
|
|
8
|
+
// describe each league's postseason shape, not anything about the CLI.
|
|
9
|
+
|
|
10
|
+
import { c, pad } from "./ansi.ts";
|
|
11
|
+
import { LEAGUES, type Conference, type EspnStandingsEntry, type EspnStandingsGroup, type League, type PlayoffGame } from "./espn.ts";
|
|
12
|
+
import { fmtDate } from "./format.ts";
|
|
13
|
+
import type { Game } from "./game.ts";
|
|
14
|
+
import { sortEntries } from "./standings.ts";
|
|
15
|
+
|
|
16
|
+
export interface BracketFormat {
|
|
17
|
+
/** How a bracket is projected from standings (and which standings level it
|
|
18
|
+
* needs: NBA conference tables, NHL division tables). */
|
|
19
|
+
projection: "nba" | "nhl";
|
|
20
|
+
/** Whether the league has a play-in (ESPN season type 5) to fetch and show. */
|
|
21
|
+
playIn: boolean;
|
|
22
|
+
/** Round names, first round → league final. */
|
|
23
|
+
rounds: [string, string, string, string];
|
|
24
|
+
/** Wins needed to take a series (every round is best-of-7 in both leagues). */
|
|
25
|
+
winsNeeded: number;
|
|
26
|
+
/** Standings stat the projection ranks by, as in the standings view. */
|
|
27
|
+
rankBy: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const BRACKET_FORMATS: Partial<Record<League, BracketFormat>> = {
|
|
31
|
+
[LEAGUES.nba]: {
|
|
32
|
+
projection: "nba",
|
|
33
|
+
playIn: true,
|
|
34
|
+
rounds: ["First Round", "Conference Semifinals", "Conference Finals", "NBA Finals"],
|
|
35
|
+
winsNeeded: 4,
|
|
36
|
+
rankBy: "winPercent",
|
|
37
|
+
},
|
|
38
|
+
[LEAGUES.nhl]: {
|
|
39
|
+
projection: "nhl",
|
|
40
|
+
playIn: false,
|
|
41
|
+
rounds: ["First Round", "Second Round", "Conference Final", "Stanley Cup Final"],
|
|
42
|
+
winsNeeded: 4,
|
|
43
|
+
rankBy: "points",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `--season` input → ESPN season year (the year the season ends): "2026" or
|
|
49
|
+
* "2025-26" → 2026. Null if it's neither.
|
|
50
|
+
*/
|
|
51
|
+
export function parseSeasonArg(input: string): number | null {
|
|
52
|
+
const q = input.trim();
|
|
53
|
+
if (/^\d{4}$/.test(q)) return Number(q);
|
|
54
|
+
const m = /^(\d{4})-(\d{2})$/.exec(q);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
const start = Number(m[1]);
|
|
57
|
+
return (start + 1) % 100 === Number(m[2]) ? start + 1 : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** ESPN season year → display name, e.g. 2026 → "2025-26". */
|
|
61
|
+
export function seasonLabel(year: number): string {
|
|
62
|
+
return `${year - 1}-${String(year % 100).padStart(2, "0")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CONFERENCES: Conference[] = ["East", "West"];
|
|
66
|
+
const CONFERENCE_NAMES: Record<Conference, string> = { East: "Eastern Conference", West: "Western Conference" };
|
|
67
|
+
|
|
68
|
+
/** "Eastern Conference" / "East" / "Eastern" → East; null if neither. */
|
|
69
|
+
export function conferenceOf(name: string): Conference | null {
|
|
70
|
+
const q = name.trim().toLowerCase();
|
|
71
|
+
if (q.startsWith("east")) return "East";
|
|
72
|
+
if (q.startsWith("west")) return "West";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Series (the postseason itself) ───────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export interface SeriesTeam {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
abbreviation: string;
|
|
82
|
+
/** Seed label, e.g. "1"; "" when unknown. */
|
|
83
|
+
seed: string;
|
|
84
|
+
wins: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Series {
|
|
88
|
+
round: number;
|
|
89
|
+
conference: Conference | null;
|
|
90
|
+
/** Higher seed first (home in game 1). */
|
|
91
|
+
teams: [SeriesTeam, SeriesTeam];
|
|
92
|
+
/** The series' games, oldest first. */
|
|
93
|
+
games: Game[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pairKey = (a: string, b: string) => [a, b].sort().join("|");
|
|
97
|
+
|
|
98
|
+
/** Winner's team id of a finished game; null if not final or level. */
|
|
99
|
+
export function gameWinner(g: Game): string | null {
|
|
100
|
+
if (g.state !== "post") return null;
|
|
101
|
+
const h = Number(g.home.score);
|
|
102
|
+
const a = Number(g.away.score);
|
|
103
|
+
if (!Number.isFinite(h) || !Number.isFinite(a) || h === a) return null;
|
|
104
|
+
return h > a ? g.home.id : g.away.id;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Group postseason games (play-in excluded) into series: one per pair of team
|
|
109
|
+
* ids, wins counted from finished games. Game-one home team is listed first —
|
|
110
|
+
* that's the higher seed. Duplicates (the same game from both teams'
|
|
111
|
+
* schedules) count once; games with an unknown side (TBD) are skipped.
|
|
112
|
+
*/
|
|
113
|
+
export function buildSeries(games: PlayoffGame[], seeds: ReadonlyMap<string, string>): Series[] {
|
|
114
|
+
const byPair = new Map<string, { pg: PlayoffGame; games: Map<string, Game> }>();
|
|
115
|
+
for (const pg of games) {
|
|
116
|
+
const g = pg.game;
|
|
117
|
+
if (pg.playIn || pg.round === null || !g.home.id || !g.away.id) continue;
|
|
118
|
+
const key = pairKey(g.home.id, g.away.id);
|
|
119
|
+
const entry = byPair.get(key) ?? byPair.set(key, { pg, games: new Map() }).get(key)!;
|
|
120
|
+
entry.games.set(g.id, g);
|
|
121
|
+
}
|
|
122
|
+
return [...byPair.values()].map(({ pg, games: byId }) => {
|
|
123
|
+
const list = [...byId.values()].sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
|
|
124
|
+
const first = list[0]!;
|
|
125
|
+
const team = (side: Game["home"]): SeriesTeam => ({
|
|
126
|
+
id: side.id,
|
|
127
|
+
name: side.name,
|
|
128
|
+
abbreviation: side.abbreviation,
|
|
129
|
+
seed: seeds.get(side.id) ?? "",
|
|
130
|
+
wins: list.filter((g) => gameWinner(g) === side.id).length,
|
|
131
|
+
});
|
|
132
|
+
return { round: pg.round!, conference: pg.conference, teams: [team(first.home), team(first.away)], games: list };
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Finished series' winner, else null. */
|
|
137
|
+
export function seriesWinner(s: Series, winsNeeded: number): SeriesTeam | null {
|
|
138
|
+
return s.teams.find((t) => t.wins >= winsNeeded) ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "OKC wins 4-0", "NY leads 3-1", "Tied 2-2", "Not started". */
|
|
142
|
+
export function seriesStatus(s: Series, winsNeeded: number): string {
|
|
143
|
+
const [a, b] = s.teams;
|
|
144
|
+
const w = seriesWinner(s, winsNeeded);
|
|
145
|
+
const label = (t: SeriesTeam) => t.abbreviation || t.name;
|
|
146
|
+
if (w) {
|
|
147
|
+
const l = w === a ? b : a;
|
|
148
|
+
return `${label(w)} wins ${w.wins}-${l.wins}`;
|
|
149
|
+
}
|
|
150
|
+
if (a.wins === 0 && b.wins === 0) return "Not started";
|
|
151
|
+
if (a.wins === b.wins) return `Tied ${a.wins}-${b.wins}`;
|
|
152
|
+
const [lead, trail] = a.wins > b.wins ? [a, b] : [b, a];
|
|
153
|
+
return `${label(lead)} leads ${lead.wins}-${trail.wins}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const seedNum = (t: SeriesTeam) => Number(t.seed) || Infinity;
|
|
157
|
+
const bestSeed = (s: Series) => Math.min(...s.teams.map(seedNum));
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Order one conference's (or the final's) series as a bracket: the latest round
|
|
161
|
+
* by best seed, then each earlier round by following every team back to the
|
|
162
|
+
* series it came from, so the two series that feed a later one sit together.
|
|
163
|
+
* Series no later round reaches fall in after, by best seed.
|
|
164
|
+
*/
|
|
165
|
+
export function bracketOrder(series: Series[]): Series[] {
|
|
166
|
+
const rounds = [...new Set(series.map((s) => s.round))].sort((a, b) => b - a);
|
|
167
|
+
const out: Series[] = [];
|
|
168
|
+
let later: Series[] = [];
|
|
169
|
+
for (const r of rounds) {
|
|
170
|
+
const inRound = series.filter((s) => s.round === r).sort((a, b) => bestSeed(a) - bestSeed(b));
|
|
171
|
+
const ordered: Series[] = [];
|
|
172
|
+
for (const parent of later) {
|
|
173
|
+
for (const t of parent.teams) {
|
|
174
|
+
const feeder = inRound.find((s) => !ordered.includes(s) && s.teams.some((x) => x.id === t.id));
|
|
175
|
+
if (feeder) ordered.push(feeder);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const s of inRound) if (!ordered.includes(s)) ordered.push(s);
|
|
179
|
+
out.unshift(...ordered);
|
|
180
|
+
later = ordered;
|
|
181
|
+
}
|
|
182
|
+
return out.sort((a, b) => a.round - b.round);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Projection (before the postseason) ───────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export interface Slot {
|
|
188
|
+
/** Seed label, e.g. "1", "WC2", "C3". */
|
|
189
|
+
seed: string;
|
|
190
|
+
/** Null for a slot decided later (an NBA play-in winner). */
|
|
191
|
+
team: EspnStandingsEntry | null;
|
|
192
|
+
/** Shown instead of a team, e.g. "Play-in winner". */
|
|
193
|
+
placeholder?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ProjectedConference {
|
|
197
|
+
conference: Conference;
|
|
198
|
+
/** First-round matchups in bracket order (adjacent pairs meet next round). */
|
|
199
|
+
matchups: [Slot, Slot][];
|
|
200
|
+
/** NBA play-in games (7 v 8, 9 v 10); empty for the NHL. */
|
|
201
|
+
playIn: [Slot, Slot][];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const slot = (seed: string, team: EspnStandingsEntry): Slot => ({ seed, team });
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* NBA: per conference, rank by record (seed breaks ties); 1-6 are in, 7-10 go
|
|
208
|
+
* to the play-in (7 v 8 for the 7 seed, 9 v 10 for a shot at the 8 seed).
|
|
209
|
+
* First round in bracket order: 1 v 8, 4 v 5, 3 v 6, 2 v 7. `groups` are
|
|
210
|
+
* conference tables.
|
|
211
|
+
*/
|
|
212
|
+
export function projectNba(groups: EspnStandingsGroup[], rankBy: string): ProjectedConference[] {
|
|
213
|
+
const out: ProjectedConference[] = [];
|
|
214
|
+
for (const conference of CONFERENCES) {
|
|
215
|
+
const g = groups.find((x) => conferenceOf(x.name) === conference);
|
|
216
|
+
if (!g) continue;
|
|
217
|
+
const ranked = sortEntries(g.entries, rankBy);
|
|
218
|
+
if (ranked.length < 10) continue;
|
|
219
|
+
const s = (n: number) => slot(String(n), ranked[n - 1]!);
|
|
220
|
+
const tbd = (n: number): Slot => ({ seed: String(n), team: null, placeholder: "Play-in winner" });
|
|
221
|
+
out.push({
|
|
222
|
+
conference,
|
|
223
|
+
matchups: [
|
|
224
|
+
[s(1), tbd(8)],
|
|
225
|
+
[s(4), s(5)],
|
|
226
|
+
[s(3), s(6)],
|
|
227
|
+
[s(2), tbd(7)],
|
|
228
|
+
],
|
|
229
|
+
playIn: [
|
|
230
|
+
[s(7), s(8)],
|
|
231
|
+
[s(9), s(10)],
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* NHL: per conference, the top three of each division plus two wild cards (the
|
|
240
|
+
* next-best two in the conference). The better division winner plays WC2, the
|
|
241
|
+
* other WC1; second and third in each division meet. `groups` are division
|
|
242
|
+
* tables whose `parent` is the conference. Seeds are labelled by division
|
|
243
|
+
* initial ("C1") or wild card ("WC1").
|
|
244
|
+
*/
|
|
245
|
+
export function projectNhl(groups: EspnStandingsGroup[], rankBy: string): ProjectedConference[] {
|
|
246
|
+
const out: ProjectedConference[] = [];
|
|
247
|
+
for (const conference of CONFERENCES) {
|
|
248
|
+
const divisions = groups
|
|
249
|
+
.filter((g) => g.parent && conferenceOf(g.parent.name) === conference)
|
|
250
|
+
.map((g) => ({ g, ranked: sortEntries(g.entries, rankBy) }));
|
|
251
|
+
if (divisions.length !== 2 || divisions.some((d) => d.ranked.length < 3)) continue;
|
|
252
|
+
const qualified = new Set(divisions.flatMap((d) => d.ranked.slice(0, 3).map((e) => e.teamId)));
|
|
253
|
+
const rest = sortEntries(
|
|
254
|
+
divisions.flatMap((d) => d.ranked.filter((e) => !qualified.has(e.teamId))),
|
|
255
|
+
rankBy,
|
|
256
|
+
);
|
|
257
|
+
if (rest.length < 2) continue;
|
|
258
|
+
const winners = sortEntries(divisions.map((d) => d.ranked[0]!), rankBy);
|
|
259
|
+
const ordered = winners.map((w) => divisions.find((d) => d.ranked[0] === w)!);
|
|
260
|
+
const wildcards = [slot("WC1", rest[0]!), slot("WC2", rest[1]!)];
|
|
261
|
+
const matchups: [Slot, Slot][] = [];
|
|
262
|
+
ordered.forEach((d, i) => {
|
|
263
|
+
const initial = (d.g.name.trim()[0] ?? "?").toUpperCase();
|
|
264
|
+
const s = (n: number) => slot(`${initial}${n}`, d.ranked[n - 1]!);
|
|
265
|
+
matchups.push([s(1), wildcards[1 - i]!], [s(2), s(3)]);
|
|
266
|
+
});
|
|
267
|
+
out.push({ conference, matchups, playIn: [] });
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
const NAME_W = 26;
|
|
275
|
+
|
|
276
|
+
function teamCell(name: string, seed: string, fav: boolean): string {
|
|
277
|
+
const label = `${seed ? `(${seed}) ` : ""}${name}`;
|
|
278
|
+
return fav ? c.bold(c.yellow(`★ ${label}`)) : ` ${label}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function slotCell(s: Slot, favoriteIds: ReadonlySet<string>): string {
|
|
282
|
+
if (!s.team) return c.dim(` (${s.seed}) ${s.placeholder ?? "TBD"}`);
|
|
283
|
+
return teamCell(s.team.team, s.seed, favoriteIds.has(s.team.teamId));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function matchupLine(a: string, b: string): string {
|
|
287
|
+
return " " + pad(a, NAME_W + 2) + c.dim(" vs ") + b;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function seriesLine(s: Series, fmt: BracketFormat, favoriteIds: ReadonlySet<string>, now: number): string {
|
|
291
|
+
const [a, b] = s.teams;
|
|
292
|
+
const winner = seriesWinner(s, fmt.winsNeeded);
|
|
293
|
+
const cell = (t: SeriesTeam) => {
|
|
294
|
+
const txt = teamCell(t.name, t.seed, favoriteIds.has(t.id));
|
|
295
|
+
return winner && winner !== t ? c.dim(txt) : txt;
|
|
296
|
+
};
|
|
297
|
+
const mark = winner ? c.green("✓") : c.dim("·");
|
|
298
|
+
let tail = seriesStatus(s, fmt.winsNeeded);
|
|
299
|
+
if (!winner) {
|
|
300
|
+
const live = s.games.find((g) => g.state === "in");
|
|
301
|
+
const next = s.games.find((g) => g.state === "pre" && Date.parse(g.date) >= now);
|
|
302
|
+
if (live) tail += ` · ${c.red("LIVE")} ${live.away.abbreviation} ${live.away.score}-${live.home.score} ${live.home.abbreviation}`;
|
|
303
|
+
else if (next) tail += ` · Game ${s.games.indexOf(next) + 1} ${fmtDate(next.date)}`;
|
|
304
|
+
}
|
|
305
|
+
const score = c.bold(`${a.wins}-${b.wins}`);
|
|
306
|
+
return ` ${mark} ` + pad(cell(a), NAME_W + 2) + " " + pad(score, 5) + " " + pad(cell(b), NAME_W + 2) + " " + c.dim(tail);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function playInLine(pg: PlayoffGame, favoriteIds: ReadonlySet<string>): string {
|
|
310
|
+
const g = pg.game;
|
|
311
|
+
const cell = (t: Game["home"]) => teamCell(t.name, "", favoriteIds.has(t.id));
|
|
312
|
+
const score = g.state === "pre" ? c.dim(fmtDate(g.date)) : c.bold(`${g.away.score}-${g.home.score}`);
|
|
313
|
+
const w = gameWinner(g);
|
|
314
|
+
const mark = w ? c.green("✓") : g.state === "in" ? c.red("●") : c.dim("·");
|
|
315
|
+
return ` ${mark} ` + pad(cell(g.away), NAME_W + 2) + " @ " + pad(cell(g.home), NAME_W + 2) + " " + score + " " + c.dim(pg.label);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Render a postseason in progress (or finished): the NBA play-in, then each
|
|
320
|
+
* conference's rounds, then the league final. Series in bracket order; series
|
|
321
|
+
* scores, the winner ticked, and the next game or live score for open series.
|
|
322
|
+
*/
|
|
323
|
+
export function renderBracket(
|
|
324
|
+
fmt: BracketFormat,
|
|
325
|
+
series: Series[],
|
|
326
|
+
playIn: PlayoffGame[],
|
|
327
|
+
favoriteIds: ReadonlySet<string>,
|
|
328
|
+
now = Date.now(),
|
|
329
|
+
): string {
|
|
330
|
+
const lines: string[] = [];
|
|
331
|
+
if (playIn.length > 0) {
|
|
332
|
+
lines.push("", c.bold(c.cyan("Play-In")));
|
|
333
|
+
for (const conference of [...CONFERENCES, null]) {
|
|
334
|
+
const games = playIn
|
|
335
|
+
.filter((pg) => pg.conference === conference)
|
|
336
|
+
.sort((a, b) => Date.parse(a.game.date) - Date.parse(b.game.date));
|
|
337
|
+
if (games.length === 0) continue;
|
|
338
|
+
lines.push(c.bold(` ${conference ? CONFERENCE_NAMES[conference] : "Other"}`));
|
|
339
|
+
for (const pg of games) lines.push(" " + playInLine(pg, favoriteIds));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Conference rounds under their conference (round headings shown); the
|
|
343
|
+
// league final on its own; anything ESPN didn't tag with a conference under
|
|
344
|
+
// "Other" rather than dropped.
|
|
345
|
+
const conferenceRound = (s: Series) => s.round < 4;
|
|
346
|
+
const sections: [string, Series[], boolean][] = [
|
|
347
|
+
...CONFERENCES.map((conf): [string, Series[], boolean] => [
|
|
348
|
+
CONFERENCE_NAMES[conf],
|
|
349
|
+
series.filter((s) => s.conference === conf && conferenceRound(s)),
|
|
350
|
+
true,
|
|
351
|
+
]),
|
|
352
|
+
["Other", series.filter((s) => s.conference === null && conferenceRound(s)), true],
|
|
353
|
+
[fmt.rounds[3], series.filter((s) => !conferenceRound(s)), false],
|
|
354
|
+
];
|
|
355
|
+
for (const [heading, list, showRounds] of sections) {
|
|
356
|
+
if (list.length === 0) continue;
|
|
357
|
+
lines.push("", c.bold(c.cyan(heading)));
|
|
358
|
+
let round = 0;
|
|
359
|
+
for (const s of bracketOrder(list)) {
|
|
360
|
+
if (showRounds && s.round !== round) {
|
|
361
|
+
round = s.round;
|
|
362
|
+
lines.push(c.bold(` ${fmt.rounds[round - 1] ?? `Round ${round}`}`));
|
|
363
|
+
}
|
|
364
|
+
lines.push(" " + seriesLine(s, fmt, favoriteIds, now));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return lines.join("\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Render a projected bracket: per conference, the play-in (NBA) and the
|
|
371
|
+
* first-round matchups in bracket order. */
|
|
372
|
+
export function renderProjection(fmt: BracketFormat, conferences: ProjectedConference[], favoriteIds: ReadonlySet<string>): string {
|
|
373
|
+
const lines: string[] = [];
|
|
374
|
+
for (const p of conferences) {
|
|
375
|
+
lines.push("", c.bold(c.cyan(CONFERENCE_NAMES[p.conference])));
|
|
376
|
+
if (p.playIn.length > 0) {
|
|
377
|
+
lines.push(c.bold(" Play-In"));
|
|
378
|
+
for (const [a, b] of p.playIn) lines.push(matchupLine(slotCell(a, favoriteIds), slotCell(b, favoriteIds)));
|
|
379
|
+
}
|
|
380
|
+
lines.push(c.bold(` ${fmt.rounds[0]}`));
|
|
381
|
+
for (const [a, b] of p.matchups) lines.push(matchupLine(slotCell(a, favoriteIds), slotCell(b, favoriteIds)));
|
|
382
|
+
}
|
|
383
|
+
return lines.join("\n");
|
|
384
|
+
}
|