soccer-cli 0.1.0 → 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.
Files changed (3) hide show
  1. package/README.md +23 -13
  2. package/dist/index.js +401 -89
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,20 +3,27 @@
3
3
  Live football scores, **ball-by-ball commentary**, and a **live ASCII pitch** — right in your terminal. Built for the 2026 World Cup, works for every major league. **No API key required.**
4
4
 
5
5
  ```
6
- ⚽ FIFA WORLD CUP 2026 ● 1 LIVE · ⟳15s
6
+ ⚽ FIFA WORLD CUP 2026 ● 1 LIVE 3 matches · ⟳15s
7
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7
8
  MATCHES
8
- ───────────────────────────────────────────────────────────
9
- 🇲🇽 Mexico 2-1 South Africa 🇿🇦 ● 67'
10
- 🇰🇷 South Korea 0-0 Czechia 🇨🇿 31'
11
-
12
- 🇲🇽 Mexico 2 : 1 South Africa 🇿🇦 ● 67'
13
- 58% Possession 42% · 12 Shots 7 · 5 On target 3
9
+ ▸ 🇫🇷 France 2-1 England 🏴 ● 67' ← selected row is highlighted
10
+ 🇧🇷 Brazil v Germany 🇩🇪 Thu 8:00 PM
11
+ 🇦🇷 Argentina 3-0 Japan 🇯🇵 FT
12
+
13
+ 🇫🇷 France ● 67' England 🏴
14
+ ▀▀█ ▀█
15
+ █▀▀ ▀▀▀ █ ← big block-digit score
16
+ ▀▀▀ ▀
17
+ ⚽ 23' Mbappé (P) · 38' Bellingham · 66' Mbappé
18
+ 0' ────────⚽──────⚽───┃───▮──────⚽──────── 90' ← match timeline
19
+ Possession 58% ███████████████████░░░░░░░░░░░░░░ 42%
20
+ Shots 14 ████████████████▏██████████▎░░░░░ 9 ← duel bars
14
21
  ╭────────────────────────────────────────────────────────╮
15
22
  │ ·····┆ ★ ┆ ┆····· │
16
- │▐ ★ ┆ ● ( • ) ┆ ▌│
23
+ │▐ ★ ┆ ● ( • ) ┆ ▌│ ← striped-grass pitch
17
24
  │ ·····┆ ┆ ┆····· │
18
25
  ╰────────────────────────────────────────────────────────╯
19
- Mexico → ← South Africa 67' ★ GOAL
26
+ France → ← England66' ★ GOAL
20
27
  ```
21
28
 
22
29
  ## Install
@@ -59,11 +66,14 @@ Any ESPN soccer slug works — a few common ones:
59
66
 
60
67
  ## What you get
61
68
 
62
- - **Live scores** that auto-refresh
63
- - **Ball-by-ball commentary** in a clean, word-wrapped table
64
- - **A live pitch** plotting where the action is — goals (★), the latest play (●), and a fading trail of recent events
65
- - **Match stats** — possession, shots, on-target, corners, fouls
69
+ - **Live scores** that auto-refresh, with a **pulsing live indicator** and a **goal flash** the moment anyone scores
70
+ - **A big block-digit scoreboard** with a scorers line (`⚽ 23' Mbappé (P) · 66' Kane`)
71
+ - **A match timeline** — goals, cards and missed penalties plotted from kickoff to full time
72
+ - **Visual stat bars** — possession as a team-colored split bar, shots / on-target / corners as mirrored duel bars
73
+ - **Ball-by-ball commentary** in a clean, word-wrapped table with color-coded event markers
74
+ - **A live pitch** with mowed-grass stripes, plotting where the action is — goals (★), the latest play (●), and a fading trail of recent events
66
75
  - **Country flags** for every nation
76
+ - **256-color visuals** that degrade gracefully — plain 16-color terminals, `NO_COLOR`, and piped output all stay clean
67
77
 
68
78
  ## Data
69
79
 
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/index.ts
4
- import process2 from "process";
4
+ import process3 from "process";
5
5
 
6
6
  // src/app.ts
7
7
  import logUpdate from "log-update";
8
8
  import readline from "readline";
9
- import process from "process";
9
+ import process2 from "process";
10
10
 
11
11
  // src/flags.ts
12
12
  import { createRequire } from "module";
@@ -327,10 +327,11 @@ async function fetchSummary(league2, eventId, signal) {
327
327
  // src/render.ts
328
328
  import Table from "cli-table3";
329
329
  import pc3 from "picocolors";
330
- import stringWidth2 from "string-width";
330
+ import stringWidth3 from "string-width";
331
331
 
332
332
  // src/theme.ts
333
333
  import pc from "picocolors";
334
+ import process from "process";
334
335
  var SYM = {
335
336
  live: "\u25CF",
336
337
  goal: "\u26BD",
@@ -347,6 +348,74 @@ var SYM = {
347
348
  ball: "\u26BD",
348
349
  sel: "\u25B8"
349
350
  };
351
+ var supports256 = pc.isColorSupported && (!!process.env.COLORTERM || /256color|truecolor/i.test(process.env.TERM ?? ""));
352
+ function ansi256(n) {
353
+ return (s) => supports256 ? `\x1B[38;5;${n}m${s}\x1B[39m` : s;
354
+ }
355
+ function bg256(n) {
356
+ return (s) => supports256 ? `\x1B[48;5;${n}m${s}\x1B[49m` : s;
357
+ }
358
+ function fg(n, fallback) {
359
+ return supports256 ? ansi256(n) : fallback;
360
+ }
361
+ var HOME_C = fg(45, pc.cyan);
362
+ var AWAY_C = fg(213, pc.magenta);
363
+ var GRAD_TITLE = [201, 200, 199, 198, 197, 203, 209, 215];
364
+ var GRAD_RULE = [54, 92, 128, 164, 200, 206, 212, 218];
365
+ function gradient(text, palette) {
366
+ if (!supports256 || palette.length === 0) return c.title(text);
367
+ const chars = [...text];
368
+ let out = "";
369
+ for (let i = 0; i < chars.length; i++) {
370
+ const ch = chars[i];
371
+ if (ch === " ") {
372
+ out += ch;
373
+ continue;
374
+ }
375
+ const n = palette[Math.min(palette.length - 1, Math.floor(i / chars.length * palette.length))];
376
+ out += `\x1B[38;5;${n}m${ch}`;
377
+ }
378
+ return `\x1B[1m${out}\x1B[22m\x1B[39m`;
379
+ }
380
+ function badge(text, bgN, fallback) {
381
+ const t = ` ${text} `;
382
+ if (supports256) return `\x1B[48;5;${bgN}m\x1B[38;5;231m\x1B[1m${t}\x1B[22m\x1B[39m\x1B[49m`;
383
+ return pc.inverse(fallback(t));
384
+ }
385
+ function splitBar(left, right, width) {
386
+ const total = left + right;
387
+ if (total <= 0 || width <= 0) return pc.gray("\u2591".repeat(Math.max(0, width)));
388
+ const lw = Math.max(0, Math.min(width, Math.round(left / total * width)));
389
+ if (!pc.isColorSupported) return "\u2588".repeat(lw) + "\u2591".repeat(width - lw);
390
+ return HOME_C("\u2588".repeat(lw)) + AWAY_C("\u2588".repeat(width - lw));
391
+ }
392
+ var EIGHTHS = ["", "\u258F", "\u258E", "\u258D", "\u258C", "\u258B", "\u258A", "\u2589"];
393
+ function miniBar(value, max, width, color) {
394
+ if (width <= 0) return "";
395
+ const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
396
+ const cells = ratio * width;
397
+ const full = Math.floor(cells);
398
+ const frac = EIGHTHS[Math.round((cells - full) * 7)];
399
+ const bar = "\u2588".repeat(full) + frac;
400
+ const pad = width - full - (frac ? 1 : 0);
401
+ return color(bar) + pc.gray("\u2591".repeat(Math.max(0, pad)));
402
+ }
403
+ function miniBarRev(value, max, width, color) {
404
+ if (width <= 0) return "";
405
+ const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
406
+ const cells = ratio * width;
407
+ const full = Math.floor(cells);
408
+ const frac = cells - full >= 0.5 ? "\u2590" : "";
409
+ const pad = width - full - (frac ? 1 : 0);
410
+ return pc.gray("\u2591".repeat(Math.max(0, pad))) + color(frac + "\u2588".repeat(full));
411
+ }
412
+ var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
413
+ function spinner(tick) {
414
+ return SPINNER[tick % SPINNER.length];
415
+ }
416
+ function pulse(tick) {
417
+ return tick % 2 === 0 ? "\u25CF" : "\u25CB";
418
+ }
350
419
  var c = {
351
420
  title: (s) => pc.bold(pc.magenta(s)),
352
421
  live: (s) => pc.green(s),
@@ -372,11 +441,31 @@ function commentaryColor(text, isGoal) {
372
441
  if (t.includes("substitution") || t.includes("replaces")) return pc.cyan;
373
442
  return (s) => s;
374
443
  }
444
+ function commentaryMark(text, isGoal) {
445
+ const t = text.toLowerCase();
446
+ if (isGoal) return pc.bold(pc.green("\u258C"));
447
+ if (t.includes("red card")) return pc.bold(pc.red("\u258C"));
448
+ if (t.includes("yellow card") || t.includes("booked")) return pc.yellow("\u258C");
449
+ if (t.includes("penalty")) return c.accent("\u258C");
450
+ if (t.includes("substitution") || t.includes("replaces")) return pc.cyan("\u258C");
451
+ if (t.includes("var")) return fg(141, pc.magenta)("\u258C");
452
+ return pc.gray("\u258F");
453
+ }
375
454
 
376
455
  // src/format.ts
456
+ import stringWidth from "string-width";
377
457
  function truncate(s, max) {
378
- if (max <= 1) return s.slice(0, Math.max(0, max));
379
- return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
458
+ if (max <= 0) return "";
459
+ if (stringWidth(s) <= max) return s;
460
+ let out = "";
461
+ let w = 0;
462
+ for (const ch of s) {
463
+ const cw = stringWidth(ch);
464
+ if (w + cw > max - 1) break;
465
+ out += ch;
466
+ w += cw;
467
+ }
468
+ return out + "\u2026";
380
469
  }
381
470
  function kickoffLocal(iso) {
382
471
  if (!iso) return "";
@@ -391,10 +480,11 @@ function kickoffLocal(iso) {
391
480
 
392
481
  // src/pitch.ts
393
482
  import pc2 from "picocolors";
394
- import stringWidth from "string-width";
395
- var HOME = pc2.cyan;
396
- var AWAY = pc2.magenta;
483
+ import stringWidth2 from "string-width";
397
484
  var LINE = (s) => pc2.gray(s);
485
+ var STRIPE_A = "\x1B[48;5;22m";
486
+ var STRIPE_B = "\x1B[48;5;28m";
487
+ var BG_OFF = "\x1B[49m";
398
488
  function mapEvent(x, y, isHome, IW, IH) {
399
489
  const xAbs = isHome ? 1 - x : x;
400
490
  const yAbs = isHome ? y : 1 - y;
@@ -434,8 +524,8 @@ function renderPitch(detail, innerWidth) {
434
524
  }
435
525
  for (let r = rmid - 1; r <= rmid + 1; r++) {
436
526
  if (r < 0 || r >= IH) continue;
437
- grid[r][0] = LINE("\u2590");
438
- grid[r][IW - 1] = LINE("\u258C");
527
+ grid[r][0] = HOME_C("\u2590");
528
+ grid[r][IW - 1] = AWAY_C("\u258C");
439
529
  }
440
530
  const events = detail.commentary.filter((c2) => c2.x != null && c2.y != null);
441
531
  const homeId = detail.home.id;
@@ -446,26 +536,67 @@ function renderPitch(detail, innerWidth) {
446
536
  const recent = events.slice(0, 7);
447
537
  for (let i = recent.length - 1; i >= 1; i--) {
448
538
  const e = recent[i];
449
- plot(e, (e.teamId === homeId ? HOME : AWAY)(pc2.dim("\u2219")));
539
+ plot(e, (e.teamId === homeId ? HOME_C : AWAY_C)(pc2.dim("\u2219")));
450
540
  }
451
541
  for (const g of events.filter((e) => e.isGoal)) plot(g, pc2.bold(pc2.green("\u2605")));
452
542
  const newest = recent[0];
453
543
  if (newest) {
454
- plot(newest, newest.isGoal ? pc2.bold(pc2.green("\u2605")) : pc2.bold((newest.teamId === homeId ? HOME : AWAY)("\u25CF")));
544
+ plot(newest, newest.isGoal ? pc2.bold(pc2.green("\u2605")) : pc2.bold((newest.teamId === homeId ? HOME_C : AWAY_C)("\u25CF")));
455
545
  }
546
+ const joinRow = (r) => {
547
+ if (!supports256) return r.join("");
548
+ let out = "";
549
+ for (let i = 0; i < r.length; i++) {
550
+ out += (Math.floor(i / 5) % 2 === 0 ? STRIPE_A : STRIPE_B) + r[i];
551
+ }
552
+ return out + BG_OFF;
553
+ };
456
554
  const top = LINE(` \u256D${"\u2500".repeat(IW)}\u256E`);
457
555
  const bot = LINE(` \u2570${"\u2500".repeat(IW)}\u256F`);
458
- const body = grid.map((r) => `${LINE(" \u2502")}${r.join("")}${LINE("\u2502")}`);
556
+ const body = grid.map((r) => `${LINE(" \u2502")}${joinRow(r)}${LINE("\u2502")}`);
459
557
  const latest = events[0];
460
- let legend = ` ${HOME("\u25CF")} ${detail.home.flag} ${pc2.bold(truncate(detail.home.name, 16))} ${pc2.gray("\u2192")} ${pc2.gray("\u2190")} ${pc2.bold(truncate(detail.away.name, 16))} ${detail.away.flag} ${AWAY("\u25CF")}`;
461
- if (latest) {
462
- const tag = latest.isGoal ? pc2.bold(pc2.green("\u2605 GOAL")) : pc2.gray(latest.playType || "ball");
463
- legend += ` ${pc2.gray(latest.time)} ${tag}`;
464
- }
465
- void stringWidth;
558
+ const buildLegend = (nameW, withTag) => {
559
+ let l = ` ${HOME_C("\u25CF")} ${detail.home.flag} ${pc2.bold(truncate(detail.home.name, nameW))} ${pc2.gray("\u2192")} ${pc2.gray("\u2190")} ${pc2.bold(truncate(detail.away.name, nameW))} ${detail.away.flag} ${AWAY_C("\u25CF")}`;
560
+ if (latest && withTag) {
561
+ const tag = latest.isGoal ? pc2.bold(pc2.green("\u2605 GOAL")) : pc2.gray(latest.playType || "ball");
562
+ l += ` ${pc2.gray(latest.time)} ${tag}`;
563
+ }
564
+ return l;
565
+ };
566
+ let legend = buildLegend(16, true);
567
+ if (stringWidth2(legend) > IW + 4) legend = buildLegend(16, false);
568
+ if (stringWidth2(legend) > IW + 4) legend = buildLegend(10, false);
466
569
  return [top, ...body, bot, legend].join("\n");
467
570
  }
468
571
 
572
+ // src/digits.ts
573
+ var FONT = {
574
+ "0": ["\u2588\u2580\u2588", "\u2588 \u2588", "\u2580\u2580\u2580"],
575
+ "1": [" \u2580\u2588", " \u2588", " \u2580"],
576
+ "2": ["\u2580\u2580\u2588", "\u2588\u2580\u2580", "\u2580\u2580\u2580"],
577
+ "3": ["\u2580\u2580\u2588", " \u2580\u2588", "\u2580\u2580\u2580"],
578
+ "4": ["\u2588 \u2588", "\u2580\u2580\u2588", " \u2580"],
579
+ "5": ["\u2588\u2580\u2580", "\u2580\u2580\u2588", "\u2580\u2580\u2580"],
580
+ "6": ["\u2588\u2580\u2580", "\u2588\u2580\u2588", "\u2580\u2580\u2580"],
581
+ "7": ["\u2580\u2580\u2588", " \u2588", " \u2580"],
582
+ "8": ["\u2588\u2580\u2588", "\u2588\u2580\u2588", "\u2580\u2580\u2580"],
583
+ "9": ["\u2588\u2580\u2588", "\u2580\u2580\u2588", "\u2580\u2580\u2580"],
584
+ "-": [" ", "\u2580\u2580\u2580", " "],
585
+ "\u2013": [" ", "\u2580\u2580\u2580", " "],
586
+ ":": [" \u2580 ", " \u2580 ", " "],
587
+ " ": [" ", " ", " "]
588
+ };
589
+ function bigDigits(text) {
590
+ const rows = [[], [], []];
591
+ for (const ch of text) {
592
+ const g = FONT[ch] ?? FONT[" "];
593
+ rows[0].push(g[0]);
594
+ rows[1].push(g[1]);
595
+ rows[2].push(g[2]);
596
+ }
597
+ return [rows[0].join(" "), rows[1].join(" "), rows[2].join(" ")];
598
+ }
599
+
469
600
  // src/render.ts
470
601
  var CHARS = {
471
602
  top: "\u2500",
@@ -484,6 +615,7 @@ var CHARS = {
484
615
  "right-mid": "\u2524",
485
616
  middle: "\u2502"
486
617
  };
618
+ var liveClock = fg(46, pc3.green);
487
619
  function makeTable(opts) {
488
620
  return new Table({
489
621
  chars: CHARS,
@@ -492,82 +624,207 @@ function makeTable(opts) {
492
624
  });
493
625
  }
494
626
  function padEndW(s, w) {
495
- const d = w - stringWidth2(s);
627
+ const d = w - stringWidth3(s);
496
628
  return d > 0 ? s + " ".repeat(d) : s;
497
629
  }
498
630
  function padCenterW(s, w) {
499
- const d = Math.max(0, w - stringWidth2(s));
631
+ const d = Math.max(0, w - stringWidth3(s));
500
632
  const left = Math.floor(d / 2);
501
633
  return " ".repeat(left) + s + " ".repeat(d - left);
502
634
  }
503
635
  function headerLine(s, width) {
504
- const left = c.title(`${SYM.ball} ${s.title}`);
636
+ const left = `${SYM.ball} ${gradient(s.title, GRAD_TITLE)}`;
505
637
  const live = s.matches.filter((m) => m.state === "in").length;
506
- const liveTxt = live > 0 ? `${c.live(`${SYM.live} ${live} LIVE`)} ` : "";
638
+ const liveTxt = live > 0 ? `${badge(`${SYM.live} ${live} LIVE`, 196, pc3.red)} ` : "";
507
639
  const upd = s.updatedAt ? new Date(s.updatedAt).toLocaleTimeString() : "\u2014";
508
- const cd = s.countdown != null ? ` ${SYM.refresh}${s.countdown}s` : "";
509
- const right = `${liveTxt}${pc3.gray(`${s.matches.length} matches \xB7 ${upd}${cd}`)}`;
510
- const pad = Math.max(1, width - stringWidth2(left) - stringWidth2(right));
640
+ const cdTxt = s.countdown == null ? "" : s.countdown <= 3 ? ` ${SYM.refresh}${pc3.bold(pc3.white(`${s.countdown}s`))}` : ` ${SYM.refresh}${s.countdown}s`;
641
+ const right = `${liveTxt}${pc3.gray(`${s.matches.length} matches \xB7 ${upd}${cdTxt}`)}`;
642
+ const pad = Math.max(1, width - stringWidth3(left) - stringWidth3(right));
511
643
  return `${left}${" ".repeat(pad)}${right}`;
512
644
  }
645
+ function headerRule(width) {
646
+ const w = Math.max(10, Math.min(width, 118));
647
+ if (!supports256) return pc3.gray("\u2501".repeat(w));
648
+ return gradient("\u2501".repeat(w), GRAD_RULE);
649
+ }
513
650
  function fixturesList(s, width) {
514
651
  const NAME = 14;
515
652
  const COLW = NAME + 3;
516
- const lines = [` ${pc3.bold(pc3.gray("MATCHES"))}`, pc3.gray(` ${"\u2500".repeat(Math.min(width - 2, 64))}`)];
653
+ const lines = [` ${pc3.bold(pc3.gray("MATCHES"))}`];
517
654
  s.matches.forEach((m, i) => {
518
655
  const sel = i === s.selected;
519
- const home = padEndW(`${m.home.flag} ${truncate(m.home.name, NAME)}`, COLW);
520
- const away = padEndW(`${truncate(m.away.name, NAME)} ${m.away.flag}`, COLW);
521
- const score = m.state === "pre" ? pc3.gray(" v ") : padCenterW(c.score(`${m.home.score ?? 0}-${m.away.score ?? 0}`), 5);
522
- const status = m.state === "in" ? c.live(`${SYM.live} ${m.clock || "LIVE"}`) : m.state === "post" ? c.finished(m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
656
+ const post = m.state === "post";
657
+ const hs = m.home.score ?? 0;
658
+ const as = m.away.score ?? 0;
659
+ let hn = truncate(m.home.name, NAME);
660
+ let an = truncate(m.away.name, NAME);
661
+ if (post && hs !== as) {
662
+ if (hs > as) hn = pc3.bold(hn);
663
+ else an = pc3.bold(an);
664
+ }
665
+ const home = padEndW(`${m.home.flag} ${hn}`, COLW);
666
+ const away = padEndW(`${an} ${m.away.flag}`, COLW);
667
+ const flashing = s.flashIds.includes(m.id) && s.tick % 2 === 0;
668
+ let score;
669
+ if (m.state === "pre") {
670
+ score = padCenterW(pc3.gray("v"), 5);
671
+ } else if (flashing) {
672
+ score = padCenterW(pc3.inverse(c.goal(` ${hs}-${as} `)), 5);
673
+ } else {
674
+ const ch = hs >= as ? c.score : pc3.gray;
675
+ const ca = as >= hs ? c.score : pc3.gray;
676
+ score = padCenterW(`${ch(String(hs))}${pc3.gray("-")}${ca(String(as))}`, 5);
677
+ }
678
+ const status = m.state === "in" ? `${c.live(pulse(s.tick))} ${pc3.bold(liveClock(m.clock || "LIVE"))}` : post ? c.finished(m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
523
679
  const arrow = sel ? c.sel(SYM.sel) : " ";
524
- const row = ` ${arrow} ${sel ? c.sel(home) : home} ${score} ${sel ? c.sel(away) : away} ${status}`;
680
+ let row = ` ${arrow} ${home} ${score} ${away} ${status}`;
681
+ if (post && !sel) row = pc3.dim(row);
682
+ if (sel && supports256) row = bg256(236)(padEndW(row, width));
683
+ else if (sel) row = ` ${c.sel(SYM.sel)} ${c.sel(home)} ${score} ${c.sel(away)} ${status}`;
525
684
  lines.push(row);
526
685
  });
527
686
  return lines.join("\n");
528
687
  }
529
- function scoreStrip(s, m) {
688
+ function scoreboard(s, m) {
530
689
  const d = s.detail && s.detail.id === m.id ? s.detail : null;
531
690
  const home = d?.home ?? m.home;
532
691
  const away = d?.away ?? m.away;
533
692
  const state = d?.state ?? m.state;
534
693
  const clock = d?.clock || m.clock;
535
- const badge = state === "in" ? c.live(`${SYM.live} ${clock || "LIVE"}`) : state === "post" ? c.finished(d?.shortDetail || m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
536
- const hs = home.score ?? "\u2013";
537
- const as = away.score ?? "\u2013";
538
- const hn = pc3.bold(truncate(home.name, 22));
539
- const an = pc3.bold(truncate(away.name, 22));
540
- return ` ${home.flag} ${hn} ${c.score(String(hs))} ${pc3.gray(":")} ${c.score(String(as))} ${an} ${away.flag} ${badge}`;
541
- }
542
- function statsStrip(d, width) {
543
- if (!d || d.stats.length === 0) return "";
544
- const sep = pc3.gray(" \xB7 ");
545
- let line = " ";
546
- let count = 0;
547
- for (const st of d.stats) {
548
- const piece = `${pc3.white(st.home)} ${pc3.gray(st.label)} ${pc3.white(st.away)}`;
549
- const next = count === 0 ? line + piece : line + sep + piece;
550
- if (stringWidth2(next) > width) break;
551
- line = next;
552
- count += 1;
694
+ const W = Math.min(s.cols - 2, 118);
695
+ const badgeTxt = state === "in" ? badge(`${pulse(s.tick)} ${clock || "LIVE"}`, 28, pc3.green) : state === "post" ? badge(d?.shortDetail || m.shortDetail || "FT", 240, pc3.cyan) : c.upcoming(kickoffLocal(m.date) || "Scheduled");
696
+ const left = ` ${home.flag} ${pc3.bold(HOME_C(truncate(home.name, 24)))}`;
697
+ const right = `${pc3.bold(AWAY_C(truncate(away.name, 24)))} ${away.flag} `;
698
+ const gap = Math.max(2, W - stringWidth3(left) - stringWidth3(badgeTxt) - stringWidth3(right));
699
+ const g1 = Math.floor(gap / 2);
700
+ const nameLine = `${left}${" ".repeat(g1)}${badgeTxt}${" ".repeat(gap - g1)}${right}`;
701
+ const lines = [nameLine];
702
+ if (state !== "pre") {
703
+ const flashing = s.flashIds.includes(m.id) && s.tick % 2 === 0;
704
+ const colorScore = flashing ? (t) => pc3.bold(pc3.green(t)) : c.score;
705
+ const rows = bigDigits(`${home.score ?? "-"}-${away.score ?? "-"}`);
706
+ rows.forEach((r, i) => {
707
+ let line = padCenterW(colorScore(r), W);
708
+ if (i === 1 && s.flashIds.includes(m.id)) line = line.trimEnd();
709
+ lines.push(line);
710
+ });
711
+ if (s.flashIds.includes(m.id)) {
712
+ const mid = lines.length - 2;
713
+ lines[mid] = `${lines[mid]} ${badge("GOAL!", 28, pc3.green)}`;
714
+ }
715
+ const scorers = scorersLine(d, home.id, W);
716
+ if (scorers) lines.push(scorers);
553
717
  }
554
- return count > 0 ? line : "";
718
+ return lines;
719
+ }
720
+ function scorersLine(d, homeId, width) {
721
+ if (!d) return "";
722
+ const goals = d.keyEvents.filter((k) => ["goal", "penalty-goal", "own-goal"].includes(k.kind));
723
+ if (goals.length === 0) return "";
724
+ let line = ` ${SYM.goal} `;
725
+ let used = stringWidth3(line);
726
+ let added = 0;
727
+ for (const g of goals) {
728
+ const tag = g.kind === "penalty-goal" ? " (P)" : g.kind === "own-goal" ? " (OG)" : "";
729
+ const plain = `${g.clock} ${g.player ?? g.team ?? "?"}${tag}`;
730
+ const sepW = added > 0 ? 3 : 0;
731
+ if (used + sepW + stringWidth3(plain) > width - 2) {
732
+ line += pc3.gray(" \u2026");
733
+ break;
734
+ }
735
+ const color = g.teamId === homeId ? HOME_C : AWAY_C;
736
+ line += (added > 0 ? pc3.gray(" \xB7 ") : "") + color(plain);
737
+ used += sepW + stringWidth3(plain);
738
+ added += 1;
739
+ }
740
+ return line;
741
+ }
742
+ function timeline(d, width) {
743
+ const evs = d.keyEvents.map((k) => ({ k, min: parseInt(k.clock, 10) })).filter((e) => Number.isFinite(e.min) && e.min >= 0);
744
+ if (evs.length === 0) return "";
745
+ const duration = evs.some((e) => e.min > 90) ? 120 : 90;
746
+ const T = Math.max(20, Math.min(width - 14, 64));
747
+ const cells = Array.from({ length: T }, () => ({
748
+ txt: pc3.gray("\u2500"),
749
+ w: 1,
750
+ used: false
751
+ }));
752
+ const htPos = Math.round(45 / duration * (T - 1));
753
+ cells[htPos] = { txt: pc3.white("\u2503"), w: 1, used: false };
754
+ const markFor = (k) => {
755
+ switch (k.kind) {
756
+ case "goal":
757
+ case "penalty-goal":
758
+ case "own-goal":
759
+ return { txt: SYM.goal, w: 2 };
760
+ case "yellow":
761
+ return { txt: pc3.yellow("\u25AE"), w: 1 };
762
+ case "red":
763
+ case "yellow-red":
764
+ return { txt: pc3.bold(pc3.red("\u25AE")), w: 1 };
765
+ case "penalty-miss":
766
+ return { txt: pc3.red("\xD7"), w: 1 };
767
+ default:
768
+ return null;
769
+ }
770
+ };
771
+ for (const { k, min } of evs.sort((a, b) => a.min - b.min)) {
772
+ const mark = markFor(k);
773
+ if (!mark) continue;
774
+ let pos = Math.round(Math.min(min, duration) / duration * (T - 1));
775
+ while (pos < T - 1 && cells[pos].used) pos += 1;
776
+ if (cells[pos].used) continue;
777
+ cells[pos] = { ...mark, used: true };
778
+ if (mark.w === 2 && pos + 1 < T && !cells[pos + 1].used) cells[pos + 1] = { txt: "", w: 0, used: true };
779
+ }
780
+ const endLabel = d.state === "post" ? "FT" : `${duration}'`;
781
+ return ` ${pc3.gray("0'")} ${cells.map((cl) => cl.txt).join("")} ${pc3.gray(endLabel)}`;
782
+ }
783
+ function statBars(d, width, maxRows) {
784
+ if (!d || d.stats.length === 0 || maxRows <= 0) return [];
785
+ const LABELW = 11;
786
+ const lines = [];
787
+ const B2 = Math.max(6, Math.min(Math.floor((width - LABELW - 16) / 2), 16));
788
+ const poss = d.stats.find((st) => st.label === "Possession");
789
+ if (poss) {
790
+ const h = parseFloat(poss.home);
791
+ const a = parseFloat(poss.away);
792
+ if (Number.isFinite(h) && Number.isFinite(a)) {
793
+ lines.push(
794
+ ` ${pc3.gray(padEndW("Possession", LABELW))} ${pc3.bold(String(poss.home).padStart(4))} ${splitBar(h, a, B2 * 2 + 1)} ${pc3.bold(poss.away)}`
795
+ );
796
+ }
797
+ }
798
+ for (const label of ["Shots", "On target", "Corners", "Saves", "Fouls"]) {
799
+ if (lines.length >= maxRows) break;
800
+ const st = d.stats.find((x) => x.label === label);
801
+ if (!st) continue;
802
+ const h = parseFloat(st.home);
803
+ const a = parseFloat(st.away);
804
+ if (!Number.isFinite(h) || !Number.isFinite(a)) continue;
805
+ const max = Math.max(h, a, 1);
806
+ lines.push(
807
+ ` ${pc3.gray(padEndW(label, LABELW))} ${pc3.bold(String(st.home).padStart(4))} ${miniBarRev(h, max, B2, HOME_C)}${pc3.gray("\u258F")}${miniBar(a, max, B2, AWAY_C)} ${pc3.bold(st.away)}`
808
+ );
809
+ }
810
+ return lines.slice(0, maxRows);
555
811
  }
556
812
  function commentaryTable(d, width, budget) {
557
- const textCol = Math.max(30, width - 9);
813
+ const textCol = Math.max(28, width - 13);
558
814
  const wrapW = textCol - 2;
559
815
  const t = makeTable({
560
- head: [pc3.gray("Min"), pc3.gray("Commentary")],
561
- colWidths: [6, textCol],
816
+ head: ["", pc3.gray("Min"), pc3.gray("Commentary")],
817
+ colWidths: [3, 6, textCol],
562
818
  wordWrap: true
563
819
  });
564
820
  let used = 0;
565
821
  for (const line of d.commentary) {
566
822
  const text = truncate(line.text, wrapW * 2);
567
- const wrapped = Math.max(1, Math.ceil(stringWidth2(text) / wrapW));
823
+ const wrapped = Math.max(1, Math.ceil(stringWidth3(text) / wrapW));
568
824
  if (used + wrapped > budget && used > 0) break;
569
825
  used += wrapped;
570
- t.push([c.accent(line.time), commentaryColor(line.text, line.isGoal)(text)]);
826
+ const colorize = commentaryColor(line.text, line.isGoal);
827
+ t.push([commentaryMark(line.text, line.isGoal), colorize(line.time), colorize(text)]);
571
828
  }
572
829
  if (used === 0) return pc3.gray(" Awaiting first updates\u2026");
573
830
  return t.toString();
@@ -582,35 +839,38 @@ function preNote(m, width) {
582
839
  }
583
840
  function footer(s) {
584
841
  const pitch = s.showPitch ? "[p] pitch\u2713" : "[p] pitch";
585
- return pc3.gray(` [\u2191\u2193] navigate \xB7 ${pitch} \xB7 [q] quit \xB7 data: ESPN (unofficial) \xB7 ${s.league}`);
842
+ return pc3.gray(` [\u2191\u2193/jk] navigate \xB7 ${pitch} \xB7 [r] refresh \xB7 [q] quit \xB7 data: ESPN (unofficial) \xB7 ${s.league}`);
586
843
  }
587
844
  function renderFrame(s) {
588
845
  const W = Math.min((s.cols || 80) - 2, 118);
589
846
  if (s.error && s.matches.length === 0) {
590
- return `${pc3.red(`\u26A0 Could not reach ESPN: ${s.error}`)}
591
- ${pc3.gray("Will keep retrying\u2026")}`;
847
+ return [
848
+ `${badge("OFFLINE", 196, pc3.red)} ${pc3.red(`Could not reach ESPN: ${s.error}`)}`,
849
+ `${c.accent(spinner(s.tick))} ${pc3.gray("Will keep retrying\u2026")}`
850
+ ].join("\n");
592
851
  }
593
852
  if (s.matches.length === 0) {
594
853
  return `${headerLine(s, W)}
854
+ ${headerRule(W)}
595
855
  ${pc3.gray(" No matches found for this view.")}`;
596
856
  }
597
857
  const cur = s.matches[s.selected] ?? null;
598
- const out = [headerLine(s, W), "", fixturesList(s, W)];
858
+ const out = [headerLine(s, W), headerRule(W), fixturesList(s, W)];
599
859
  if (cur) {
600
- out.push("", scoreStrip(s, cur));
860
+ out.push("", ...scoreboard(s, cur));
601
861
  const d = s.detail && s.detail.id === cur.id ? s.detail : null;
602
862
  if (cur.state === "pre") {
603
863
  out.push(preNote(cur, W));
604
864
  } else if (s.detailLoading && !d) {
605
- out.push(pc3.gray(" loading match\u2026"));
865
+ out.push(` ${c.accent(spinner(s.tick))} ${pc3.gray("loading match\u2026")}`);
606
866
  } else if (d) {
607
- const stats = statsStrip(d, W);
608
- if (stats) out.push(stats);
867
+ const tl = timeline(d, W);
868
+ if (tl) out.push(tl);
869
+ out.push(...statBars(d, W, 4));
609
870
  const hasPos = d.commentary.some((cl) => cl.x != null && cl.y != null);
610
- const pitchH = s.showPitch && hasPos ? 12 : 0;
611
- if (pitchH) out.push(renderPitch(d, W));
612
- const overhead = 2 + (s.matches.length + 2) + 2 + (stats ? 1 : 0) + 1 + 4 + pitchH;
613
- const budget = Math.min(40, Math.max(4, (s.rows || 24) - overhead));
871
+ if (s.showPitch && hasPos) out.push(renderPitch(d, W));
872
+ const onScreen = out.reduce((n, blk) => n + 1 + (blk.match(/\n/g)?.length ?? 0), 0);
873
+ const budget = Math.min(40, Math.max(4, (s.rows || 24) - onScreen - 6));
614
874
  out.push(commentaryTable(d, W, budget));
615
875
  }
616
876
  }
@@ -619,6 +879,7 @@ ${pc3.gray(" No matches found for this view.")}`;
619
879
  }
620
880
 
621
881
  // src/app.ts
882
+ var FLASH_TICKS = 6;
622
883
  async function runApp(opts) {
623
884
  const boardMs = Math.max(5, opts.refreshSec) * 1e3;
624
885
  const detailMs = Math.max(5, Math.min(opts.refreshSec, 12)) * 1e3;
@@ -630,7 +891,10 @@ async function runApp(opts) {
630
891
  updatedAt: null,
631
892
  detailLoading: false,
632
893
  countdown: null,
633
- showPitch: true
894
+ showPitch: true,
895
+ tick: 0,
896
+ lastScores: /* @__PURE__ */ new Map(),
897
+ flashUntil: /* @__PURE__ */ new Map()
634
898
  };
635
899
  const draw = () => logUpdate(
636
900
  renderFrame({
@@ -645,13 +909,23 @@ async function runApp(opts) {
645
909
  detailLoading: state.detailLoading,
646
910
  countdown: state.countdown,
647
911
  showPitch: state.showPitch,
648
- cols: process.stdout.columns ?? (Number(process.env.COLUMNS) || 80),
649
- rows: process.stdout.rows ?? (Number(process.env.LINES) || 24)
912
+ tick: state.tick,
913
+ flashIds: [...state.flashUntil.entries()].filter(([, u]) => u > state.tick).map(([id]) => id),
914
+ cols: process2.stdout.columns ?? (Number(process2.env.COLUMNS) || 80),
915
+ rows: process2.stdout.rows ?? (Number(process2.env.LINES) || 24)
650
916
  })
651
917
  );
652
918
  const loadBoard = async () => {
653
919
  try {
654
920
  const matches = await fetchScoreboard(opts.league, opts.date);
921
+ for (const m of matches) {
922
+ const key = `${m.home.score ?? ""}-${m.away.score ?? ""}`;
923
+ const prev = state.lastScores.get(m.id);
924
+ if (prev !== void 0 && prev !== key && m.state === "in") {
925
+ state.flashUntil.set(m.id, state.tick + FLASH_TICKS);
926
+ }
927
+ state.lastScores.set(m.id, key);
928
+ }
655
929
  state.matches = matches;
656
930
  state.error = null;
657
931
  state.updatedAt = Date.now();
@@ -683,12 +957,13 @@ async function runApp(opts) {
683
957
  const liveIdx = state.matches.findIndex((m) => m.state === "in");
684
958
  if (liveIdx >= 0) state.selected = liveIdx;
685
959
  await loadDetail();
686
- draw();
687
- if (opts.once || !process.stdin.isTTY) {
960
+ if (opts.once || !process2.stdin.isTTY) {
961
+ draw();
688
962
  logUpdate.done();
689
963
  return;
690
964
  }
691
965
  state.countdown = Math.round(boardMs / 1e3);
966
+ draw();
692
967
  const boardTimer = setInterval(async () => {
693
968
  await loadBoard();
694
969
  state.countdown = Math.round(boardMs / 1e3);
@@ -699,21 +974,28 @@ async function runApp(opts) {
699
974
  draw();
700
975
  }, detailMs);
701
976
  const tick = setInterval(() => {
977
+ state.tick += 1;
702
978
  if (state.countdown != null) state.countdown = Math.max(0, state.countdown - 1);
979
+ for (const [id, until] of state.flashUntil) {
980
+ if (until <= state.tick) state.flashUntil.delete(id);
981
+ }
703
982
  draw();
704
983
  }, 1e3);
984
+ const onResize = () => draw();
985
+ process2.stdout.on("resize", onResize);
705
986
  const cleanup = () => {
706
987
  clearInterval(boardTimer);
707
988
  clearInterval(detailTimer);
708
989
  clearInterval(tick);
709
- process.stdin.off("keypress", onKey);
990
+ process2.stdout.off("resize", onResize);
991
+ process2.stdin.off("keypress", onKey);
710
992
  try {
711
- process.stdin.setRawMode(false);
993
+ process2.stdin.setRawMode(false);
712
994
  } catch {
713
995
  }
714
996
  logUpdate.done();
715
- process.stdout.write("\n");
716
- process.exit(0);
997
+ process2.stdout.write("\n");
998
+ process2.exit(0);
717
999
  };
718
1000
  const reselect = async (delta) => {
719
1001
  const next = Math.min(state.matches.length - 1, Math.max(0, state.selected + delta));
@@ -735,23 +1017,34 @@ async function runApp(opts) {
735
1017
  else if (key.name === "p") {
736
1018
  state.showPitch = !state.showPitch;
737
1019
  draw();
738
- } else if (key.name === "r") void loadBoard().then(draw);
1020
+ } else if (key.name === "r") {
1021
+ void loadBoard().then(() => {
1022
+ state.countdown = Math.round(boardMs / 1e3);
1023
+ draw();
1024
+ });
1025
+ }
739
1026
  };
740
- readline.emitKeypressEvents(process.stdin);
741
- process.stdin.setRawMode(true);
742
- process.stdin.resume();
743
- process.stdin.on("keypress", onKey);
1027
+ readline.emitKeypressEvents(process2.stdin);
1028
+ process2.stdin.setRawMode(true);
1029
+ process2.stdin.resume();
1030
+ process2.stdin.on("keypress", onKey);
744
1031
  }
745
1032
 
746
1033
  // bin/index.ts
747
- var argv = process2.argv.slice(2);
1034
+ var argv = process3.argv.slice(2);
748
1035
  function opt(name) {
749
1036
  const i = argv.indexOf(name);
750
- return i >= 0 ? argv[i + 1] : void 0;
1037
+ if (i < 0) return void 0;
1038
+ const v = argv[i + 1];
1039
+ return v !== void 0 && !v.startsWith("--") ? v : void 0;
751
1040
  }
752
1041
  function flag(name) {
753
1042
  return argv.includes(name);
754
1043
  }
1044
+ function fail(msg) {
1045
+ console.error(`soccer: ${msg}`);
1046
+ process3.exit(1);
1047
+ }
755
1048
  if (flag("--help") || flag("-h")) {
756
1049
  console.log(`
757
1050
  \u26BD soccer \u2014 live football scores & commentary in your terminal
@@ -770,6 +1063,7 @@ if (flag("--help") || flag("-h")) {
770
1063
 
771
1064
  Keys
772
1065
  \u2191/\u2193 or j/k Move between matches
1066
+ p Toggle pitch view
773
1067
  r Force refresh
774
1068
  q or Esc Quit
775
1069
 
@@ -780,12 +1074,30 @@ if (flag("--help") || flag("-h")) {
780
1074
 
781
1075
  Data: ESPN's public (unofficial) feed. No API key required.
782
1076
  `);
783
- process2.exit(0);
1077
+ process3.exit(0);
784
1078
  }
785
1079
  var league = opt("--league") ?? "fifa.world";
786
1080
  var date = opt("--date");
787
- var refreshSec = Number(opt("--refresh") ?? "15") || 15;
788
- var once = flag("--once") || !process2.stdin.isTTY;
1081
+ if (flag("--date")) {
1082
+ if (!date || !/^\d{8}$/.test(date)) {
1083
+ fail(`invalid --date "${date ?? ""}" \u2014 expected YYYYMMDD, e.g. 20260611`);
1084
+ }
1085
+ const mm = Number(date.slice(4, 6));
1086
+ const dd = Number(date.slice(6, 8));
1087
+ if (mm < 1 || mm > 12 || dd < 1 || dd > 31) {
1088
+ fail(`invalid --date "${date}" \u2014 month must be 01-12 and day 01-31`);
1089
+ }
1090
+ }
1091
+ var refreshSec = 15;
1092
+ if (flag("--refresh")) {
1093
+ const raw = opt("--refresh");
1094
+ const n = Number(raw);
1095
+ if (raw === void 0 || !Number.isFinite(n) || n <= 0) {
1096
+ fail(`invalid --refresh "${raw ?? ""}" \u2014 expected a positive number of seconds`);
1097
+ }
1098
+ refreshSec = Math.max(5, n);
1099
+ }
1100
+ var once = flag("--once") || !process3.stdin.isTTY;
789
1101
  var TITLES = {
790
1102
  "fifa.world": "FIFA WORLD CUP 2026",
791
1103
  "eng.1": "PREMIER LEAGUE",
@@ -798,5 +1110,5 @@ var TITLES = {
798
1110
  var title = opt("--title") ?? TITLES[league] ?? league.toUpperCase();
799
1111
  runApp({ league, title, date, refreshSec, once }).catch((err) => {
800
1112
  console.error(err);
801
- process2.exit(1);
1113
+ process3.exit(1);
802
1114
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soccer-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Live football scores, ball-by-ball commentary, and a live ASCII pitch in your terminal. Built for the 2026 World Cup, works for every major league. No API key required.",
5
5
  "type": "module",
6
6
  "bin": {