claudial 0.2.1 → 0.4.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/LICENSE +1 -1
- package/README.md +65 -38
- package/dist/api/espn.js +11 -2
- package/dist/index.js +26 -0
- package/dist/setup/Wizard.js +71 -0
- package/dist/setup/apply.js +14 -0
- package/dist/setup/detect.js +34 -0
- package/dist/setup/flags.js +18 -0
- package/dist/setup/index.js +63 -0
- package/dist/setup/settings.js +37 -0
- package/dist/setup/shellrc.js +24 -0
- package/dist/snapshot.js +3 -2
- package/dist/statusline/cache.js +107 -0
- package/dist/statusline/line.js +45 -0
- package/dist/statusline/run.js +68 -0
- package/dist/ui/App.js +15 -2
- package/dist/ui/DaySection.js +9 -0
- package/dist/ui/MatchRow.js +44 -0
- package/dist/ui/TakeoverView.js +3 -2
- package/dist/ui/Ticker.js +3 -2
- package/dist/ui/UpcomingSection.js +3 -2
- package/dist/ui/fixtures.js +32 -0
- package/dist/ui/flags.js +43 -0
- package/package.json +2 -2
- package/dist/ui/LiveSection.js +0 -39
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ⚽ claudial
|
|
4
|
+
|
|
5
|
+
### Live World Cup 2026 in your terminal — goal, red-card and VAR takeovers included
|
|
6
|
+
|
|
7
|
+
One line. Zero install. Every score, while you work.
|
|
4
8
|
|
|
5
9
|
```
|
|
6
10
|
npx claudial
|
|
7
11
|
```
|
|
8
12
|
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
[](https://www.npmjs.com/package/claudial)
|
|
16
|
+
[](https://www.npmjs.com/package/claudial)
|
|
17
|
+
[](https://nodejs.org)
|
|
18
|
+
[](LICENSE)
|
|
19
|
+
[](tsconfig.json)
|
|
20
|
+
[](https://github.com/lefProg/claudial/stargazers)
|
|
21
|
+
[](https://github.com/lefProg/claudial/issues)
|
|
22
|
+
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
9
27
|
Live matches with scores, minutes and scorers on top. Upcoming fixtures in
|
|
10
28
|
your local time below. Auto-refreshing while you work.
|
|
11
29
|
|
|
@@ -14,15 +32,15 @@ your local time below. Auto-refreshing while you work.
|
|
|
14
32
|
▝▜█████▛▘ ───────────────────────
|
|
15
33
|
|
|
16
34
|
⏺ LIVE 67'
|
|
17
|
-
|
|
35
|
+
ARG 🇦🇷 2 — 1 🇲🇽 MEX
|
|
18
36
|
⚽ 23' Messi · 51' Álvarez | 44' Lozano
|
|
19
37
|
|
|
20
38
|
⏺ LIVE 12'
|
|
21
|
-
|
|
39
|
+
FRA 🇫🇷 0 — 0 🇸🇳 SEN
|
|
22
40
|
|
|
23
41
|
○ UPCOMING
|
|
24
|
-
18:00
|
|
25
|
-
21:00
|
|
42
|
+
18:00 BRA 🇧🇷 — 🇭🇷 CRO
|
|
43
|
+
21:00 ESP 🇪🇸 — 🇲🇦 MAR
|
|
26
44
|
|
|
27
45
|
r refresh · q quit
|
|
28
46
|
```
|
|
@@ -39,7 +57,7 @@ takes over:
|
|
|
39
57
|
|
|
40
58
|
LIONEL MESSI · 23'
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
ARG 🇦🇷 2 — 1 🇲🇽 MEX
|
|
43
61
|
|
|
44
62
|
|
|
45
63
|
```
|
|
@@ -48,42 +66,26 @@ Four seconds of glory, then back to the board.
|
|
|
48
66
|
|
|
49
67
|
## Claude Code, World Cup edition
|
|
50
68
|
|
|
51
|
-
The whole point
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Needs [tmux](https://github.com/tmux/tmux) (`sudo pacman -S tmux` /
|
|
56
|
-
`sudo apt install tmux` / `brew install tmux`), then:
|
|
69
|
+
The whole point: your Claude Code, score-aware. One command and the live score
|
|
70
|
+
sits right in Claude Code's status bar — under the input box, always visible
|
|
71
|
+
while Claude works, with nothing to wire up by hand.
|
|
57
72
|
|
|
58
73
|
```bash
|
|
59
74
|
npm install -g claudial
|
|
60
|
-
|
|
61
|
-
alias claude-mundial="tmux new-session 'claude' \; split-window -v -l 5 'claudial --ticker' \; select-pane -U"
|
|
62
|
-
EOF
|
|
75
|
+
claudial setup
|
|
63
76
|
```
|
|
64
77
|
|
|
65
|
-
|
|
78
|
+
The wizard drops a live-score statusline into your Claude Code settings:
|
|
66
79
|
|
|
67
80
|
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
│ ⏺ Working… │
|
|
72
|
-
│ │
|
|
73
|
-
├──────────────────────────────────────┤
|
|
74
|
-
│ ⏺ 67' QAT 0—1 SUI │
|
|
75
|
-
│ ○ BRA—MOR Sun 01:00 │
|
|
76
|
-
└──────────────────────────────────────┘
|
|
81
|
+
⚽ QAT 🇶🇦 0—1 🇨🇭 SUI 67' · main ← during a match
|
|
82
|
+
○ QAT 🇶🇦 — 🇨🇭 SUI 10:00 PM · main ← between matches (next kickoff, or last result)
|
|
83
|
+
⚽ G O O O L · ARG 🇦🇷 1—0 🇲🇽 MEX ← for 15s whenever anyone scores
|
|
77
84
|
```
|
|
78
85
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`split-window -h -l 44 'claudial'` in the alias.)
|
|
83
|
-
|
|
84
|
-
Want the score *inside* Claude Code itself — in its status bar, no tmux at
|
|
85
|
-
all? There's a [statusline integration](extras/statusline/) for that:
|
|
86
|
-
`⚽ QAT 0—1 SUI 67' · main`, refreshed every 15 s.
|
|
86
|
+
It refreshes every few seconds and is pure Node, so it runs on macOS and
|
|
87
|
+
Windows too, in any shell. Restart Claude Code after setup to see it.
|
|
88
|
+
Scriptable, no prompts: `claudial setup --statusline --global --yes`.
|
|
87
89
|
|
|
88
90
|
## Why a TUI
|
|
89
91
|
|
|
@@ -108,10 +110,10 @@ claudial
|
|
|
108
110
|
|
|
109
111
|
Requires Node ≥ 18. No account, no API key, no config.
|
|
110
112
|
|
|
111
|
-
| Key | Action
|
|
112
|
-
|
|
113
|
+
| Key | Action |
|
|
114
|
+
|-----|-------------|
|
|
113
115
|
| `r` | refresh now |
|
|
114
|
-
| `q` | quit
|
|
116
|
+
| `q` | quit |
|
|
115
117
|
|
|
116
118
|
Beyond goals, every live match carries its incident feed: yellow cards,
|
|
117
119
|
substitutions, injury time — and a `⚖ VAR` badge the moment a review starts.
|
|
@@ -139,6 +141,31 @@ v1 is being built live during the group stage. Follow the commits.
|
|
|
139
141
|
// up in a smoke test before any UI existed to celebrate it. Legendary.
|
|
140
142
|
```
|
|
141
143
|
|
|
144
|
+
## Contributing
|
|
145
|
+
|
|
146
|
+
Issues and PRs welcome — bug reports, new incident types, terminal quirks,
|
|
147
|
+
all of it. Keep the polling gentle and the spirit playful.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/lefProg/claudial.git
|
|
151
|
+
cd claudial
|
|
152
|
+
npm install
|
|
153
|
+
npm run dev # live TUI from source
|
|
154
|
+
npm test # vitest
|
|
155
|
+
```
|
|
156
|
+
|
|
142
157
|
## License
|
|
143
158
|
|
|
144
|
-
MIT
|
|
159
|
+
[MIT](LICENSE) © lefProg
|
|
160
|
+
|
|
161
|
+
<div align="center">
|
|
162
|
+
|
|
163
|
+
<br/>
|
|
164
|
+
|
|
165
|
+
**If this made a goal feel a little louder, drop a ⭐ — it helps others find it.**
|
|
166
|
+
|
|
167
|
+
[Report a bug](https://github.com/lefProg/claudial/issues) ·
|
|
168
|
+
[Request a feature](https://github.com/lefProg/claudial/issues) ·
|
|
169
|
+
[npm](https://www.npmjs.com/package/claudial)
|
|
170
|
+
|
|
171
|
+
</div>
|
package/dist/api/espn.js
CHANGED
|
@@ -40,6 +40,12 @@ function todayRange(daysAhead) {
|
|
|
40
40
|
const end = new Date(now.getTime() + daysAhead * 86_400_000);
|
|
41
41
|
return `${start}-${yyyymmdd(end)}`;
|
|
42
42
|
}
|
|
43
|
+
// Past date range "YYYYMMDD-YYYYMMDD" from (today - daysBack) through today (UTC).
|
|
44
|
+
export function pastRange(daysBack, now = Date.now()) {
|
|
45
|
+
const end = new Date(now);
|
|
46
|
+
const start = new Date(now - daysBack * 86_400_000);
|
|
47
|
+
return `${yyyymmdd(start)}-${yyyymmdd(end)}`;
|
|
48
|
+
}
|
|
43
49
|
// Kept for call-site compatibility; ESPN needs no season id to query.
|
|
44
50
|
export async function resolveSeasonId() {
|
|
45
51
|
return 2026;
|
|
@@ -56,8 +62,11 @@ export async function fetchUpcoming(_seasonId) {
|
|
|
56
62
|
.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
57
63
|
}
|
|
58
64
|
export async function fetchRecent(_seasonId) {
|
|
59
|
-
const events = await scoreboard(
|
|
60
|
-
return events
|
|
65
|
+
const events = await scoreboard(pastRange(1));
|
|
66
|
+
return events
|
|
67
|
+
.map(parseEvent)
|
|
68
|
+
.filter((m) => m.status === 'finished')
|
|
69
|
+
.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
61
70
|
}
|
|
62
71
|
export async function fetchIncidents(eventId) {
|
|
63
72
|
const events = await scoreboard(todayRange(0));
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,33 @@ process.stdout.on('error', (err) => {
|
|
|
9
9
|
process.exit(0);
|
|
10
10
|
throw err;
|
|
11
11
|
});
|
|
12
|
+
function readStdin() {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
if (process.stdin.isTTY) {
|
|
15
|
+
resolve('');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let data = '';
|
|
19
|
+
process.stdin.setEncoding('utf8');
|
|
20
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
21
|
+
process.stdin.on('end', () => resolve(data));
|
|
22
|
+
process.stdin.on('error', () => resolve(data));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
12
25
|
async function main() {
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
if (args[0] === 'setup') {
|
|
28
|
+
const { runSetup } = await import('./setup/index.js');
|
|
29
|
+
await runSetup(args.slice(1));
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
if (args.includes('--statusline')) {
|
|
33
|
+
const input = await readStdin();
|
|
34
|
+
const { runStatusline, defaultDeps } = await import('./statusline/run.js');
|
|
35
|
+
const line = await runStatusline(input, defaultDeps());
|
|
36
|
+
process.stdout.write(line);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
13
39
|
let seasonId;
|
|
14
40
|
try {
|
|
15
41
|
seasonId = await resolveSeasonId();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
4
|
+
import { ACCENT } from '../ui/Header.js';
|
|
5
|
+
export function Wizard({ defaultShell, allowTmux, onDone }) {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
const [step, setStep] = useState('statusline');
|
|
8
|
+
const [statusline, setStatusline] = useState(true);
|
|
9
|
+
const [scope, setScope] = useState('global');
|
|
10
|
+
const [tmux, setTmux] = useState(false);
|
|
11
|
+
const [shell, setShell] = useState(defaultShell);
|
|
12
|
+
const finish = () => {
|
|
13
|
+
onDone({ statusline, scope, tmux, shell, yes: true, interactive: false });
|
|
14
|
+
exit();
|
|
15
|
+
};
|
|
16
|
+
useInput((input, key) => {
|
|
17
|
+
const yes = input.toLowerCase() === 'y';
|
|
18
|
+
const no = input.toLowerCase() === 'n';
|
|
19
|
+
if (input === 'q') {
|
|
20
|
+
exit();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (step === 'statusline') {
|
|
24
|
+
if (yes || key.return) {
|
|
25
|
+
setStatusline(true);
|
|
26
|
+
setStep('scope');
|
|
27
|
+
}
|
|
28
|
+
else if (no) {
|
|
29
|
+
setStatusline(false);
|
|
30
|
+
setStep(allowTmux ? 'tmux' : 'confirm');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else if (step === 'scope') {
|
|
34
|
+
if (input === 'g' || key.return) {
|
|
35
|
+
setScope('global');
|
|
36
|
+
setStep(allowTmux ? 'tmux' : 'confirm');
|
|
37
|
+
}
|
|
38
|
+
else if (input === 'p') {
|
|
39
|
+
setScope('project');
|
|
40
|
+
setStep(allowTmux ? 'tmux' : 'confirm');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (step === 'tmux') {
|
|
44
|
+
if (yes) {
|
|
45
|
+
setTmux(true);
|
|
46
|
+
setStep('shell');
|
|
47
|
+
}
|
|
48
|
+
else if (no || key.return) {
|
|
49
|
+
setTmux(false);
|
|
50
|
+
setStep('confirm');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (step === 'shell') {
|
|
54
|
+
if (input === 'z')
|
|
55
|
+
setShell('zsh');
|
|
56
|
+
else if (input === 'b')
|
|
57
|
+
setShell('bash');
|
|
58
|
+
else if (input === 'f')
|
|
59
|
+
setShell('fish');
|
|
60
|
+
if (key.return || 'zbf'.includes(input))
|
|
61
|
+
setStep('confirm');
|
|
62
|
+
}
|
|
63
|
+
else if (step === 'confirm') {
|
|
64
|
+
if (yes || key.return)
|
|
65
|
+
finish();
|
|
66
|
+
else if (no)
|
|
67
|
+
exit();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsx(Text, { bold: true, color: ACCENT, children: "claudial setup" }) }), step === 'statusline' && _jsx(Text, { children: "Add the live-score statusline inside Claude Code? (Y/n)" }), step === 'scope' && _jsx(Text, { children: "Scope \u2014 [g]lobal (~/.claude) or [p]roject (.claude)? (G/p)" }), step === 'tmux' && _jsx(Text, { children: "Also add the tmux ticker alias (claude-mundial)? (y/N)" }), step === 'shell' && _jsxs(Text, { children: ["Shell for the alias \u2014 [z]sh [b]ash [f]ish? (default ", shell, ")"] }), step === 'confirm' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "About to apply:" }), statusline && _jsxs(Text, { dimColor: true, children: [" \u2022 statusline \u2192 ", scope] }), tmux && _jsxs(Text, { dimColor: true, children: [" \u2022 tmux alias \u2192 ", shell] }), !statusline && !tmux && _jsx(Text, { dimColor: true, children: " \u2022 nothing selected" }), _jsx(Text, { children: "Proceed? (Y/n)" })] })), _jsx(Text, { dimColor: true, children: "q to quit" })] }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { installStatusline } from './settings.js';
|
|
2
|
+
import { appendAlias } from './shellrc.js';
|
|
3
|
+
export function applySetup(opts, ctx) {
|
|
4
|
+
const report = { statuslineWritten: false, statuslineBackedUp: false, aliasAppended: false };
|
|
5
|
+
if (opts.statusline) {
|
|
6
|
+
const r = installStatusline(ctx.settingsPath, ctx.command, ctx.conflict);
|
|
7
|
+
report.statuslineWritten = r.written;
|
|
8
|
+
report.statuslineBackedUp = r.backedUp;
|
|
9
|
+
}
|
|
10
|
+
if (opts.tmux) {
|
|
11
|
+
report.aliasAppended = appendAlias(ctx.rcPath, ctx.shell).appended;
|
|
12
|
+
}
|
|
13
|
+
return report;
|
|
14
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function detectShell(env = process.env) {
|
|
4
|
+
const s = env.SHELL ?? '';
|
|
5
|
+
if (s.includes('zsh'))
|
|
6
|
+
return 'zsh';
|
|
7
|
+
if (s.includes('fish'))
|
|
8
|
+
return 'fish';
|
|
9
|
+
return 'bash';
|
|
10
|
+
}
|
|
11
|
+
export function rcPathFor(shell, home) {
|
|
12
|
+
if (shell === 'zsh')
|
|
13
|
+
return join(home, '.zshrc');
|
|
14
|
+
if (shell === 'fish')
|
|
15
|
+
return join(home, '.config', 'fish', 'config.fish');
|
|
16
|
+
return join(home, '.bashrc');
|
|
17
|
+
}
|
|
18
|
+
export function isWindows(platform = process.platform) {
|
|
19
|
+
return platform === 'win32';
|
|
20
|
+
}
|
|
21
|
+
/** True if `bin` resolves on PATH (so settings can use the bare command). */
|
|
22
|
+
export function isOnPath(bin) {
|
|
23
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
24
|
+
try {
|
|
25
|
+
execFileSync(probe, [bin], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function hasTmux() {
|
|
33
|
+
return isOnPath('tmux');
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const SHELLS = ['zsh', 'bash', 'fish'];
|
|
2
|
+
export function parseFlags(args) {
|
|
3
|
+
const has = (f) => args.includes(f);
|
|
4
|
+
const shellArg = args[args.indexOf('--shell') + 1];
|
|
5
|
+
const shell = SHELLS.includes(shellArg) ? shellArg : null;
|
|
6
|
+
const statusline = has('--statusline');
|
|
7
|
+
const tmux = has('--tmux');
|
|
8
|
+
// any actionable flag (or --yes) means run without prompting
|
|
9
|
+
const interactive = !(statusline || tmux || has('--yes'));
|
|
10
|
+
return {
|
|
11
|
+
statusline,
|
|
12
|
+
scope: has('--project') ? 'project' : 'global',
|
|
13
|
+
tmux,
|
|
14
|
+
shell,
|
|
15
|
+
yes: has('--yes'),
|
|
16
|
+
interactive,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { parseFlags } from './flags.js';
|
|
6
|
+
import { detectShell, rcPathFor, isOnPath, hasTmux, isWindows } from './detect.js';
|
|
7
|
+
import { applySetup } from './apply.js';
|
|
8
|
+
import { Wizard } from './Wizard.js';
|
|
9
|
+
function commandFor() {
|
|
10
|
+
return isOnPath('claudial') ? 'claudial --statusline' : 'npx claudial --statusline';
|
|
11
|
+
}
|
|
12
|
+
function contextFor(opts) {
|
|
13
|
+
const shell = opts.shell ?? detectShell();
|
|
14
|
+
const settingsPath = opts.scope === 'project'
|
|
15
|
+
? join(process.cwd(), '.claude', 'settings.json')
|
|
16
|
+
: join(homedir(), '.claude', 'settings.json');
|
|
17
|
+
return {
|
|
18
|
+
settingsPath,
|
|
19
|
+
rcPath: rcPathFor(shell, homedir()),
|
|
20
|
+
shell,
|
|
21
|
+
command: commandFor(),
|
|
22
|
+
conflict: 'overwrite',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function printReport(opts, ctx, report) {
|
|
26
|
+
if (report.statuslineWritten) {
|
|
27
|
+
console.log(`✓ statusline installed → ${ctx.settingsPath}`);
|
|
28
|
+
if (report.statuslineBackedUp)
|
|
29
|
+
console.log(` (previous settings backed up to ${ctx.settingsPath}.bak)`);
|
|
30
|
+
console.log(' Restart Claude Code to see the score bar.');
|
|
31
|
+
}
|
|
32
|
+
else if (opts.statusline) {
|
|
33
|
+
console.log('• statusline unchanged (an existing statusLine was kept).');
|
|
34
|
+
}
|
|
35
|
+
if (report.aliasAppended) {
|
|
36
|
+
console.log(`✓ tmux alias added → ${ctx.rcPath}`);
|
|
37
|
+
console.log(` Open a new shell, then run: claude-mundial`);
|
|
38
|
+
}
|
|
39
|
+
else if (opts.tmux) {
|
|
40
|
+
console.log('• tmux alias already present — left as-is.');
|
|
41
|
+
}
|
|
42
|
+
if (!opts.statusline && !opts.tmux)
|
|
43
|
+
console.log('Nothing selected. Run `claudial setup` again to choose options.');
|
|
44
|
+
}
|
|
45
|
+
export async function runSetup(args) {
|
|
46
|
+
const opts = parseFlags(args);
|
|
47
|
+
const run = (final) => {
|
|
48
|
+
const ctx = contextFor(final);
|
|
49
|
+
printReport(final, ctx, applySetup(final, ctx));
|
|
50
|
+
};
|
|
51
|
+
if (!opts.interactive) {
|
|
52
|
+
run(opts);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await new Promise((resolve) => {
|
|
56
|
+
const { waitUntilExit } = render(React.createElement(Wizard, {
|
|
57
|
+
defaultShell: detectShell(),
|
|
58
|
+
allowTmux: !isWindows() && hasTmux(),
|
|
59
|
+
onDone: (final) => run(final),
|
|
60
|
+
}));
|
|
61
|
+
waitUntilExit().then(() => resolve());
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export function statusLineBlock(command) {
|
|
4
|
+
return { type: 'command', command, refreshInterval: 3 };
|
|
5
|
+
}
|
|
6
|
+
/** Pure merge. Returns the new settings object and whether it changed. */
|
|
7
|
+
export function mergeSettings(existing, block, conflict) {
|
|
8
|
+
const hasExisting = existing?.statusLine != null;
|
|
9
|
+
if (hasExisting && conflict !== 'overwrite') {
|
|
10
|
+
return { settings: existing, changed: false };
|
|
11
|
+
}
|
|
12
|
+
return { settings: { ...existing, statusLine: block }, changed: true };
|
|
13
|
+
}
|
|
14
|
+
/** Read → merge → (backup) → write. Creates parent dirs and a .bak. */
|
|
15
|
+
export function installStatusline(settingsPath, command, conflict) {
|
|
16
|
+
let existing = {};
|
|
17
|
+
const fileExists = existsSync(settingsPath);
|
|
18
|
+
if (fileExists) {
|
|
19
|
+
try {
|
|
20
|
+
existing = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
existing = {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const { settings, changed } = mergeSettings(existing, statusLineBlock(command), conflict);
|
|
27
|
+
if (!changed)
|
|
28
|
+
return { written: false, backedUp: false };
|
|
29
|
+
let backedUp = false;
|
|
30
|
+
if (fileExists) {
|
|
31
|
+
copyFileSync(settingsPath, `${settingsPath}.bak`);
|
|
32
|
+
backedUp = true;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
35
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
36
|
+
return { written: true, backedUp };
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
const MARKER = '# claudial — World Cup ticker beside Claude Code';
|
|
4
|
+
/** The `claude-mundial` alias, escaped for the target shell. */
|
|
5
|
+
export function aliasLine(shell) {
|
|
6
|
+
if (shell === 'fish') {
|
|
7
|
+
return `${MARKER}\nalias claude-mundial "tmux new-session 'claude' ; split-window -v -l 5 'claudial --ticker' ; select-pane -U"\n`;
|
|
8
|
+
}
|
|
9
|
+
// zsh + bash share POSIX syntax; tmux command separators escaped as \;
|
|
10
|
+
return `${MARKER}\nalias claude-mundial="tmux new-session 'claude' \\; split-window -v -l 5 'claudial --ticker' \\; select-pane -U"\n`;
|
|
11
|
+
}
|
|
12
|
+
export function hasAlias(content) {
|
|
13
|
+
return content.includes('claude-mundial');
|
|
14
|
+
}
|
|
15
|
+
/** Append the alias unless one is already present. Creates the file/dir. */
|
|
16
|
+
export function appendAlias(rcPath, shell) {
|
|
17
|
+
const current = existsSync(rcPath) ? readFileSync(rcPath, 'utf8') : '';
|
|
18
|
+
if (hasAlias(current))
|
|
19
|
+
return { appended: false };
|
|
20
|
+
mkdirSync(dirname(rcPath), { recursive: true });
|
|
21
|
+
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
22
|
+
appendFileSync(rcPath, `${prefix}\n${aliasLine(shell)}`);
|
|
23
|
+
return { appended: true };
|
|
24
|
+
}
|
package/dist/snapshot.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { fetchLive, fetchRecent, fetchUpcoming } from './api/espn.js';
|
|
2
2
|
import { formatKickoff } from './ui/UpcomingSection.js';
|
|
3
|
+
import { homeTag, awayTag } from './ui/flags.js';
|
|
3
4
|
function line(m) {
|
|
4
5
|
if (m.status === 'upcoming')
|
|
5
|
-
return `o ${formatKickoff(m.startTimestamp).padEnd(13)} ${m.home.
|
|
6
|
+
return `o ${formatKickoff(m.startTimestamp).padEnd(13)} ${homeTag(m.home.code)} - ${awayTag(m.away.code)}`;
|
|
6
7
|
const label = m.status === 'finished' ? 'FT' : m.status === 'halftime' ? 'HT' : `${m.minute ?? '?'}'`;
|
|
7
|
-
return `* ${label.padEnd(4)} ${m.home.
|
|
8
|
+
return `* ${label.padEnd(4)} ${homeTag(m.home.code)} ${m.homeScore ?? '-'} - ${m.awayScore ?? '-'} ${awayTag(m.away.code)}`;
|
|
8
9
|
}
|
|
9
10
|
export async function printSnapshot(seasonId) {
|
|
10
11
|
const [live, upcoming, recent] = await Promise.all([
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, renameSync, statSync, existsSync, unlinkSync, utimesSync, futimesSync, } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homeTag, awayTag } from '../ui/flags.js';
|
|
5
|
+
export const TTL_MS = 10_000;
|
|
6
|
+
export const GOAL_WINDOW_MS = 15_000;
|
|
7
|
+
const LOCK_STALE_MS = 30_000;
|
|
8
|
+
// ACCENT #D97757 → truecolor; bold. Matches the old bash celebration styling.
|
|
9
|
+
const GOAL_OPEN = '\x1b[1;38;2;217;119;87m';
|
|
10
|
+
const GOAL_CLOSE = '\x1b[0m';
|
|
11
|
+
function goalText(m) {
|
|
12
|
+
return `${GOAL_OPEN}⚽ G O O O L · ${homeTag(m.home.code)} ${m.homeScore ?? 0}—${m.awayScore ?? 0} ${awayTag(m.away.code)}${GOAL_CLOSE}`;
|
|
13
|
+
}
|
|
14
|
+
export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
const cacheFile = join(dir, 'line');
|
|
17
|
+
const lockFile = join(dir, 'lock');
|
|
18
|
+
const stateFile = join(dir, 'goals.json');
|
|
19
|
+
const goalFile = join(dir, 'goal');
|
|
20
|
+
const goalTsFile = join(dir, 'goalts');
|
|
21
|
+
const atomicWrite = (path, data) => {
|
|
22
|
+
const tmp = `${path}.tmp`;
|
|
23
|
+
writeFileSync(tmp, data);
|
|
24
|
+
renameSync(tmp, path);
|
|
25
|
+
};
|
|
26
|
+
const readJson = (path) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const totals = (live) => {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const m of live)
|
|
37
|
+
out[String(m.id)] = (m.homeScore ?? 0) + (m.awayScore ?? 0);
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
read(now = Date.now()) {
|
|
42
|
+
if (!existsSync(cacheFile))
|
|
43
|
+
return null;
|
|
44
|
+
const line = readFileSync(cacheFile, 'utf8');
|
|
45
|
+
const ageMs = now - statSync(cacheFile).mtimeMs;
|
|
46
|
+
return { line, ageMs };
|
|
47
|
+
},
|
|
48
|
+
write(line, now = Date.now()) {
|
|
49
|
+
atomicWrite(cacheFile, line);
|
|
50
|
+
const t = now / 1000;
|
|
51
|
+
try {
|
|
52
|
+
utimesSync(cacheFile, t, t);
|
|
53
|
+
}
|
|
54
|
+
catch { /* best effort */ }
|
|
55
|
+
},
|
|
56
|
+
tryLock(now = Date.now()) {
|
|
57
|
+
if (existsSync(lockFile)) {
|
|
58
|
+
const age = now - statSync(lockFile).mtimeMs;
|
|
59
|
+
if (age > LOCK_STALE_MS) {
|
|
60
|
+
try {
|
|
61
|
+
unlinkSync(lockFile);
|
|
62
|
+
}
|
|
63
|
+
catch { /* race */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const fd = openSync(lockFile, 'wx');
|
|
68
|
+
const t = now / 1000;
|
|
69
|
+
try {
|
|
70
|
+
futimesSync(fd, t, t);
|
|
71
|
+
}
|
|
72
|
+
catch { /* best effort */ }
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
unlock() { try {
|
|
81
|
+
unlinkSync(lockFile);
|
|
82
|
+
}
|
|
83
|
+
catch { /* already gone */ } },
|
|
84
|
+
updateGoalState(live, now = Date.now()) {
|
|
85
|
+
const prev = readJson(stateFile);
|
|
86
|
+
const cur = totals(live);
|
|
87
|
+
for (const m of live) {
|
|
88
|
+
const id = String(m.id);
|
|
89
|
+
const was = prev[id] ?? cur[id]; // first sighting never fires
|
|
90
|
+
if (cur[id] > was) {
|
|
91
|
+
atomicWrite(goalFile, goalText(m));
|
|
92
|
+
atomicWrite(goalTsFile, String(now));
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
atomicWrite(stateFile, JSON.stringify(cur));
|
|
97
|
+
},
|
|
98
|
+
activeGoalLine(now = Date.now()) {
|
|
99
|
+
if (!existsSync(goalTsFile) || !existsSync(goalFile))
|
|
100
|
+
return null;
|
|
101
|
+
const ts = Number(readFileSync(goalTsFile, 'utf8'));
|
|
102
|
+
if (!Number.isFinite(ts) || now - ts > GOAL_WINDOW_MS)
|
|
103
|
+
return null;
|
|
104
|
+
return readFileSync(goalFile, 'utf8');
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { formatKickoff } from '../ui/UpcomingSection.js';
|
|
2
|
+
import { homeTag, awayTag } from '../ui/flags.js';
|
|
3
|
+
// Assumed wall-clock length of a match (kickoff → ~full time). ESPN exposes no
|
|
4
|
+
// end-time, so a finished match is anchored at kickoff + this for proximity math.
|
|
5
|
+
const MATCH_MS = 2 * 60 * 60 * 1000;
|
|
6
|
+
/** A live/halftime match: "⚽ QAT 🇶🇦 0—1 🇨🇭 SUI 67'" (mirrored code+flag). */
|
|
7
|
+
export function liveLine(match) {
|
|
8
|
+
const clock = match.status === 'halftime' ? ' HT'
|
|
9
|
+
: match.minute != null ? ` ${match.minute}'`
|
|
10
|
+
: '';
|
|
11
|
+
return `⚽ ${homeTag(match.home.code)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayTag(match.away.code)}${clock}`;
|
|
12
|
+
}
|
|
13
|
+
/** A finished match with its result: "⚽ QAT 🇶🇦 2—1 🇨🇭 SUI FT". */
|
|
14
|
+
export function finishedLine(match) {
|
|
15
|
+
return `⚽ ${homeTag(match.home.code)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayTag(match.away.code)} FT`;
|
|
16
|
+
}
|
|
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)}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Live matches joined by two spaces. Otherwise show whichever moment is closer
|
|
23
|
+
* to `now`: the latest finished result (end ≈ kickoff + MATCH_MS) or the next
|
|
24
|
+
* kickoff. Only one present → that one; neither → "".
|
|
25
|
+
*/
|
|
26
|
+
export function scoreLine(live, recent, upcoming, now = Date.now()) {
|
|
27
|
+
if (live.length > 0)
|
|
28
|
+
return live.map(liveLine).join(' ');
|
|
29
|
+
const lastFinished = recent.length
|
|
30
|
+
? recent.reduce((a, b) => (b.startTimestamp > a.startTimestamp ? b : a))
|
|
31
|
+
: null;
|
|
32
|
+
const next = upcoming[0] ?? null;
|
|
33
|
+
if (lastFinished && next) {
|
|
34
|
+
const finishMs = lastFinished.startTimestamp * 1000 + MATCH_MS;
|
|
35
|
+
const startMs = next.startTimestamp * 1000;
|
|
36
|
+
return Math.abs(now - finishMs) <= Math.abs(startMs - now)
|
|
37
|
+
? finishedLine(lastFinished)
|
|
38
|
+
: upcomingLine(next, now);
|
|
39
|
+
}
|
|
40
|
+
if (lastFinished)
|
|
41
|
+
return finishedLine(lastFinished);
|
|
42
|
+
if (next)
|
|
43
|
+
return upcomingLine(next, now);
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { fetchLive as realFetchLive, fetchRecent as realFetchRecent, fetchUpcoming as realFetchUpcoming } from '../api/espn.js';
|
|
3
|
+
import { scoreLine } from './line.js';
|
|
4
|
+
import { makeCache, TTL_MS } from './cache.js';
|
|
5
|
+
const PLACEHOLDER = '⚽ claudial · warming up';
|
|
6
|
+
function withTimeout(p, ms) {
|
|
7
|
+
let id;
|
|
8
|
+
const timer = new Promise((_, reject) => {
|
|
9
|
+
id = setTimeout(() => reject(new Error('timeout')), ms);
|
|
10
|
+
});
|
|
11
|
+
return Promise.race([p, timer]).finally(() => clearTimeout(id));
|
|
12
|
+
}
|
|
13
|
+
export async function runStatusline(input, deps, now = Date.now()) {
|
|
14
|
+
const { cache } = deps;
|
|
15
|
+
const cached = cache.read(now);
|
|
16
|
+
let line = cached?.line ?? null;
|
|
17
|
+
const fresh = cached != null && cached.ageMs <= TTL_MS;
|
|
18
|
+
if (!fresh && cache.tryLock(now)) {
|
|
19
|
+
try {
|
|
20
|
+
const [liveM, recentM, upcomingM] = await withTimeout(Promise.all([deps.fetchLive(), deps.fetchRecent(), deps.fetchUpcoming(2026)]), deps.timeoutMs);
|
|
21
|
+
const next = scoreLine(liveM, recentM, upcomingM, now);
|
|
22
|
+
cache.updateGoalState(liveM, now);
|
|
23
|
+
if (next) {
|
|
24
|
+
cache.write(next, now);
|
|
25
|
+
line = next;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// keep last good cache (line already set)
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
cache.unlock();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const score = cache.activeGoalLine(now) ?? line ?? PLACEHOLDER;
|
|
36
|
+
const branch = deps.branchOf(input);
|
|
37
|
+
return branch ? `${score} · ${branch}` : score;
|
|
38
|
+
}
|
|
39
|
+
/** Resolve the git branch of the workspace dir named in Claude Code's stdin JSON. */
|
|
40
|
+
export function defaultBranchOf(input) {
|
|
41
|
+
let dir = '';
|
|
42
|
+
try {
|
|
43
|
+
const j = JSON.parse(input || '{}');
|
|
44
|
+
dir = j?.workspace?.current_dir || j?.cwd || '';
|
|
45
|
+
}
|
|
46
|
+
catch { /* no/!json stdin */ }
|
|
47
|
+
if (!dir)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const out = execFileSync('git', ['-C', dir, 'branch', '--show-current'], {
|
|
51
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
52
|
+
}).trim();
|
|
53
|
+
return out || null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function defaultDeps() {
|
|
60
|
+
return {
|
|
61
|
+
fetchLive: realFetchLive,
|
|
62
|
+
fetchRecent: () => realFetchRecent(2026),
|
|
63
|
+
fetchUpcoming: realFetchUpcoming,
|
|
64
|
+
cache: makeCache(),
|
|
65
|
+
branchOf: defaultBranchOf,
|
|
66
|
+
timeoutMs: 3000,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/ui/App.js
CHANGED
|
@@ -5,11 +5,22 @@ 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
7
|
import { Header } from './Header.js';
|
|
8
|
-
import {
|
|
8
|
+
import { DaySection } from './DaySection.js';
|
|
9
|
+
import { partitionByDay } from './fixtures.js';
|
|
9
10
|
import { UpcomingSection } from './UpcomingSection.js';
|
|
10
11
|
import { Footer } from './Footer.js';
|
|
11
12
|
import { TakeoverView } from './TakeoverView.js';
|
|
12
13
|
import { Ticker } from './Ticker.js';
|
|
14
|
+
function dedupeById(matches) {
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const m of matches)
|
|
18
|
+
if (!seen.has(m.id)) {
|
|
19
|
+
seen.add(m.id);
|
|
20
|
+
out.push(m);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
13
24
|
export function App({ seasonId, mode = 'board' }) {
|
|
14
25
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
15
26
|
const { exit } = useApp();
|
|
@@ -46,5 +57,7 @@ export function App({ seasonId, mode = 'board' }) {
|
|
|
46
57
|
if (mode === 'ticker') {
|
|
47
58
|
return _jsx(Ticker, { live: state.live, upcoming: state.upcoming, takeover: playing });
|
|
48
59
|
}
|
|
49
|
-
|
|
60
|
+
const all = dedupeById([...state.live, ...state.recent, ...state.upcoming]);
|
|
61
|
+
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, {})] }));
|
|
50
63
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { MatchRow } from './MatchRow.js';
|
|
4
|
+
/** A labelled group of match rows (e.g. TODAY / YESTERDAY). Hidden when empty. */
|
|
5
|
+
export function DaySection({ label, matches, incidents, compact }) {
|
|
6
|
+
if (matches.length === 0)
|
|
7
|
+
return null;
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: label }), matches.map((m) => (_jsx(MatchRow, { m: m, incidents: incidents[m.id] ?? [], compact: compact }, m.id)))] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { ACCENT } from './Header.js';
|
|
4
|
+
import { formatKickoff } from './UpcomingSection.js';
|
|
5
|
+
import { homeTag, awayTag } from './flags.js';
|
|
6
|
+
export function ScorerLine({ incidents }) {
|
|
7
|
+
const goals = incidents.filter((i) => i.kind === 'goal');
|
|
8
|
+
if (goals.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const fmt = (g) => `${g.minute ?? '?'}' ${g.playerShort ?? '?'}`;
|
|
11
|
+
const home = goals.filter((g) => g.isHome).map(fmt).join(' · ');
|
|
12
|
+
const away = goals.filter((g) => !g.isHome).map(fmt).join(' · ');
|
|
13
|
+
return _jsxs(Text, { dimColor: true, children: [' ', "\u26BD ", [home, away].filter(Boolean).join(' | ')] });
|
|
14
|
+
}
|
|
15
|
+
export function FeedLine({ incidents }) {
|
|
16
|
+
const feed = incidents.filter((i) => i.kind === 'yellowCard' || i.kind === 'substitution');
|
|
17
|
+
if (feed.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
const fmt = (i) => i.kind === 'yellowCard'
|
|
20
|
+
? `${i.minute ?? '?'}' 🟨 ${i.playerShort ?? '?'}`
|
|
21
|
+
: `${i.minute ?? '?'}' ⇄ ${i.playerShort ?? '?'}`;
|
|
22
|
+
// last 4 only — the feed is a pulse, not a log
|
|
23
|
+
return _jsxs(Text, { dimColor: true, children: [' ', "\u25AA ", feed.slice(-4).map(fmt).join(' · ')] });
|
|
24
|
+
}
|
|
25
|
+
function statusLabel(m) {
|
|
26
|
+
if (m.status === 'halftime')
|
|
27
|
+
return 'HT';
|
|
28
|
+
if (m.status === 'finished')
|
|
29
|
+
return 'FT';
|
|
30
|
+
return m.minute != null ? `LIVE ${m.minute}'` : 'LIVE';
|
|
31
|
+
}
|
|
32
|
+
/** One match row. Upcoming → kickoff line; live/halftime/finished → score + feed. */
|
|
33
|
+
export function MatchRow({ m, incidents, compact }) {
|
|
34
|
+
if (m.status === 'upcoming') {
|
|
35
|
+
if (compact) {
|
|
36
|
+
return _jsxs(Text, { dimColor: true, children: ["\u25CB ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] });
|
|
37
|
+
}
|
|
38
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }));
|
|
39
|
+
}
|
|
40
|
+
if (compact) {
|
|
41
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }), homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayTag(m.away.code), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }));
|
|
42
|
+
}
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { bold: m.status !== 'finished', dimColor: m.status === 'finished', children: [" ", statusLabel(m)] }), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u00B7 \u2696 VAR" }) : null] }), _jsxs(Text, { children: [' ', homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", awayTag(m.away.code)] }), _jsx(ScorerLine, { incidents: incidents }), _jsx(FeedLine, { incidents: incidents })] }));
|
|
44
|
+
}
|
package/dist/ui/TakeoverView.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { GOAL_ART, VAR_ART, RED_CARD_ART, spacedCaps } from '../banner.js';
|
|
4
4
|
import { ACCENT, RED } from './Header.js';
|
|
5
|
+
import { homeTag, awayTag } from './flags.js';
|
|
5
6
|
const ART = {
|
|
6
7
|
goal: { art: GOAL_ART, color: ACCENT, caption: null },
|
|
7
8
|
redcard: { art: RED_CARD_ART, color: RED, caption: 'R E D C A R D' },
|
|
@@ -16,7 +17,7 @@ export function TakeoverView({ takeover }) {
|
|
|
16
17
|
const rows = process.stdout.rows ?? 24;
|
|
17
18
|
if (rows < 14) {
|
|
18
19
|
const label = kind === 'goal' ? 'G O A L' : kind === 'redcard' ? 'R E D C A R D' : 'V A R';
|
|
19
|
-
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [_jsx(Text, { bold: true, color: color, wrap: "truncate", children: label }), _jsxs(Text, { bold: true, wrap: "truncate", children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }), takeover.detail ? _jsx(Text, { dimColor: true, wrap: "truncate", children: takeover.detail }) : null, _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [match.home.
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [_jsx(Text, { bold: true, color: color, wrap: "truncate", children: label }), _jsxs(Text, { bold: true, wrap: "truncate", children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }), takeover.detail ? _jsx(Text, { dimColor: true, wrap: "truncate", children: takeover.detail }) : null, _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [homeTag(match.home.code), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", awayTag(match.away.code)] })] })] }));
|
|
20
21
|
}
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [art.map((line, i) => (_jsx(Text, { bold: true, color: color, wrap: "truncate", children: line }, i))), caption ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: color, wrap: "truncate", children: caption }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { bold: true, children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }) }), takeover.detail ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: takeover.detail }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [match.home.
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [art.map((line, i) => (_jsx(Text, { bold: true, color: color, wrap: "truncate", children: line }, i))), caption ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: color, wrap: "truncate", children: caption }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { bold: true, children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }) }), takeover.detail ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: takeover.detail }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [homeTag(match.home.code), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", awayTag(match.away.code)] })] })] }));
|
|
22
23
|
}
|
package/dist/ui/Ticker.js
CHANGED
|
@@ -3,13 +3,14 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { formatKickoff } from './UpcomingSection.js';
|
|
4
4
|
import { ACCENT, RED } from './Header.js';
|
|
5
5
|
import { spacedCaps } from '../banner.js';
|
|
6
|
+
import { homeTag, awayTag } from './flags.js';
|
|
6
7
|
function TickerTakeover({ t }) {
|
|
7
8
|
const color = t.kind === 'redcard' ? RED : ACCENT;
|
|
8
9
|
const label = t.kind === 'goal' ? 'G O A L' : t.kind === 'redcard' ? 'R E D' : 'V A R';
|
|
9
|
-
return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ", t.match.home.code, " ", t.homeScore, "\u2014", t.awayScore, " ", t.match.away.code] }));
|
|
10
|
+
return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ", homeTag(t.match.home.code), " ", t.homeScore, "\u2014", t.awayScore, " ", awayTag(t.match.away.code)] }));
|
|
10
11
|
}
|
|
11
12
|
export function Ticker({ live, upcoming, takeover }) {
|
|
12
13
|
if (takeover)
|
|
13
14
|
return _jsx(TickerTakeover, { t: takeover });
|
|
14
|
-
return (_jsxs(Box, { flexDirection: "column", children: [live.slice(0, 3).map((m) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", m.minute != null ? `${m.minute}'` : m.status === 'halftime' ? 'HT' : m.status === 'finished' ? 'FT' : '', " "] }), m.home.code, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", m.away.code, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", upcoming[0].home.code, "\u2014", upcoming[0].away.code, " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [live.slice(0, 3).map((m) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", m.minute != null ? `${m.minute}'` : m.status === 'halftime' ? 'HT' : m.status === 'finished' ? 'FT' : '', " "] }), homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayTag(m.away.code), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", homeTag(upcoming[0].home.code), " \u2014 ", awayTag(upcoming[0].away.code), " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
|
|
15
16
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { homeTag, awayTag } from './flags.js';
|
|
3
4
|
export function formatKickoff(ts, now = Date.now()) {
|
|
4
5
|
const d = new Date(ts * 1000);
|
|
5
6
|
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
@@ -15,7 +16,7 @@ export function UpcomingSection({ matches, compact }) {
|
|
|
15
16
|
return null;
|
|
16
17
|
if (compact) {
|
|
17
18
|
const m = shown[0];
|
|
18
|
-
return (_jsxs(Text, { dimColor: true, children: ["\u25CB ", m.home.code, "\u2014", m.away.code, " ", formatKickoff(m.startTimestamp)] }));
|
|
19
|
+
return (_jsxs(Text, { dimColor: true, children: ["\u25CB ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] }));
|
|
19
20
|
}
|
|
20
|
-
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)] }), m.home.
|
|
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)))] }));
|
|
21
22
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const DAY_MS = 86_400_000;
|
|
2
|
+
/** Local-midnight epoch (ms) for the instant `ms`. */
|
|
3
|
+
function dayKey(ms) {
|
|
4
|
+
const d = new Date(ms);
|
|
5
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Partition matches by local calendar day relative to `now`:
|
|
9
|
+
* today (diff 0), yesterday (diff -1), future (diff ≥ 1). Anything older than
|
|
10
|
+
* yesterday — or with no usable timestamp — is dropped. Each bucket is sorted
|
|
11
|
+
* ascending by kickoff. `Match.startTimestamp` is unix seconds.
|
|
12
|
+
*/
|
|
13
|
+
export function partitionByDay(matches, now = Date.now()) {
|
|
14
|
+
const todayKey = dayKey(now);
|
|
15
|
+
const out = { yesterday: [], today: [], future: [] };
|
|
16
|
+
for (const m of matches) {
|
|
17
|
+
if (!m.startTimestamp)
|
|
18
|
+
continue;
|
|
19
|
+
const diffDays = Math.round((dayKey(m.startTimestamp * 1000) - todayKey) / DAY_MS);
|
|
20
|
+
if (diffDays === 0)
|
|
21
|
+
out.today.push(m);
|
|
22
|
+
else if (diffDays === -1)
|
|
23
|
+
out.yesterday.push(m);
|
|
24
|
+
else if (diffDays >= 1)
|
|
25
|
+
out.future.push(m);
|
|
26
|
+
}
|
|
27
|
+
const byStart = (a, b) => a.startTimestamp - b.startTimestamp;
|
|
28
|
+
out.yesterday.sort(byStart);
|
|
29
|
+
out.today.sort(byStart);
|
|
30
|
+
out.future.sort(byStart);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
package/dist/ui/flags.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Country flag emoji for a team, keyed on ESPN's 3-letter abbreviation
|
|
2
|
+
// (verified against the live FIFA World Cup feed — note ESPN uses MAR for
|
|
3
|
+
// Morocco). Bracket placeholders (1A, QFW1, RD16 W1, …) have no flag.
|
|
4
|
+
// ESPN abbreviation → ISO 3166-1 alpha-2.
|
|
5
|
+
const ISO2 = {
|
|
6
|
+
ALG: 'DZ', ARG: 'AR', AUS: 'AU', AUT: 'AT', BEL: 'BE', BIH: 'BA', BRA: 'BR',
|
|
7
|
+
CAN: 'CA', CIV: 'CI', COD: 'CD', COL: 'CO', CPV: 'CV', CRO: 'HR', CUW: 'CW',
|
|
8
|
+
CZE: 'CZ', ECU: 'EC', EGY: 'EG', ESP: 'ES', FRA: 'FR', GER: 'DE', GHA: 'GH',
|
|
9
|
+
HAI: 'HT', IRN: 'IR', IRQ: 'IQ', JOR: 'JO', JPN: 'JP', KOR: 'KR', KSA: 'SA',
|
|
10
|
+
MAR: 'MA', MEX: 'MX', NED: 'NL', NOR: 'NO', NZL: 'NZ', PAN: 'PA', PAR: 'PY',
|
|
11
|
+
POR: 'PT', QAT: 'QA', RSA: 'ZA', SEN: 'SN', SUI: 'CH', SWE: 'SE', TUN: 'TN',
|
|
12
|
+
TUR: 'TR', URU: 'UY', USA: 'US', UZB: 'UZ',
|
|
13
|
+
};
|
|
14
|
+
// Home nations have no ISO2 flag — use the subdivision tag sequence.
|
|
15
|
+
const SUBDIVISION = {
|
|
16
|
+
ENG: 'gbeng', SCO: 'gbsct', WAL: 'gbwls',
|
|
17
|
+
};
|
|
18
|
+
function regionalIndicator(iso2) {
|
|
19
|
+
return String.fromCodePoint(...[...iso2].map((c) => 0x1f1e6 + (c.charCodeAt(0) - 65)));
|
|
20
|
+
}
|
|
21
|
+
function subdivisionFlag(sub) {
|
|
22
|
+
return String.fromCodePoint(0x1f3f4, ...[...sub].map((c) => 0xe0000 + c.charCodeAt(0)), 0xe007f);
|
|
23
|
+
}
|
|
24
|
+
/** Flag emoji for a team code, or '' if unknown (e.g. bracket placeholders). */
|
|
25
|
+
export function flagFor(code) {
|
|
26
|
+
const iso2 = ISO2[code];
|
|
27
|
+
if (iso2)
|
|
28
|
+
return regionalIndicator(iso2);
|
|
29
|
+
const sub = SUBDIVISION[code];
|
|
30
|
+
if (sub)
|
|
31
|
+
return subdivisionFlag(sub);
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
/** Home side: "QAT 🇶🇦" (flag trails the code), or just the code if unmapped. */
|
|
35
|
+
export function homeTag(code) {
|
|
36
|
+
const f = flagFor(code);
|
|
37
|
+
return f ? `${code} ${f}` : code;
|
|
38
|
+
}
|
|
39
|
+
/** Away side: "🇨🇭 SUI" (flag leads the code), or just the code if unmapped. */
|
|
40
|
+
export function awayTag(code) {
|
|
41
|
+
const f = flagFor(code);
|
|
42
|
+
return f ? `${f} ${code}` : code;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
|
@@ -46,5 +46,5 @@
|
|
|
46
46
|
},
|
|
47
47
|
"homepage": "https://github.com/lefProg/claudial#readme",
|
|
48
48
|
"bugs": "https://github.com/lefProg/claudial/issues",
|
|
49
|
-
"author": "
|
|
49
|
+
"author": "lefProg"
|
|
50
50
|
}
|
package/dist/ui/LiveSection.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { ACCENT } from './Header.js';
|
|
4
|
-
function ScorerLine({ incidents }) {
|
|
5
|
-
const goals = incidents.filter((i) => i.kind === 'goal');
|
|
6
|
-
if (goals.length === 0)
|
|
7
|
-
return null;
|
|
8
|
-
const fmt = (g) => `${g.minute ?? '?'}' ${g.playerShort ?? '?'}`;
|
|
9
|
-
const home = goals.filter((g) => g.isHome).map(fmt).join(' · ');
|
|
10
|
-
const away = goals.filter((g) => !g.isHome).map(fmt).join(' · ');
|
|
11
|
-
return _jsxs(Text, { dimColor: true, children: [' ', "\u26BD ", [home, away].filter(Boolean).join(' | ')] });
|
|
12
|
-
}
|
|
13
|
-
function FeedLine({ incidents }) {
|
|
14
|
-
const feed = incidents.filter((i) => i.kind === 'yellowCard' || i.kind === 'substitution');
|
|
15
|
-
if (feed.length === 0)
|
|
16
|
-
return null;
|
|
17
|
-
const fmt = (i) => i.kind === 'yellowCard'
|
|
18
|
-
? `${i.minute ?? '?'}' 🟨 ${i.playerShort ?? '?'}`
|
|
19
|
-
: `${i.minute ?? '?'}' ⇄ ${i.playerShort ?? '?'}`;
|
|
20
|
-
// last 4 only — the feed is a pulse, not a log
|
|
21
|
-
return _jsxs(Text, { dimColor: true, children: [' ', "\u25AA ", feed.slice(-4).map(fmt).join(' · ')] });
|
|
22
|
-
}
|
|
23
|
-
function statusLabel(m) {
|
|
24
|
-
if (m.status === 'halftime')
|
|
25
|
-
return 'HT';
|
|
26
|
-
if (m.status === 'finished')
|
|
27
|
-
return 'FT';
|
|
28
|
-
return m.minute != null ? `LIVE ${m.minute}'` : 'LIVE';
|
|
29
|
-
}
|
|
30
|
-
export function LiveSection({ matches, recent, incidents, compact }) {
|
|
31
|
-
const liveIds = new Set(matches.map((m) => m.id));
|
|
32
|
-
const rows = [...matches, ...recent.filter((r) => !liveIds.has(r.id))];
|
|
33
|
-
if (rows.length === 0)
|
|
34
|
-
return null;
|
|
35
|
-
if (compact) {
|
|
36
|
-
return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: rows.map((m) => (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }), m.home.code, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", m.away.code, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))) }));
|
|
37
|
-
}
|
|
38
|
-
return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: rows.map((m) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { bold: m.status !== 'finished', dimColor: m.status === 'finished', children: [" ", statusLabel(m)] }), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u00B7 \u2696 VAR" }) : null] }), _jsxs(Text, { children: [' ', m.home.name, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", m.away.name] }), _jsx(ScorerLine, { incidents: incidents[m.id] ?? [] }), _jsx(FeedLine, { incidents: incidents[m.id] ?? [] })] }, m.id))) }));
|
|
39
|
-
}
|