@jjlmoya/utils-sports 1.60.0 → 1.61.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.60.0",
3
+ "version": "1.61.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -8,16 +8,21 @@ interface Props {
8
8
  }
9
9
 
10
10
  const { ui } = Astro.props;
11
- const t = (ui ?? {}) as RugbyScoreKeeperUI;
11
+ const t = (ui ?? {}) as unknown as RugbyScoreKeeperUI;
12
12
  ---
13
13
 
14
14
  <div class="rg-app" id="rg-app" data-rg-ui={JSON.stringify(t)}>
15
15
 
16
16
  <div class="rg-main-card">
17
17
 
18
+ <div class="rg-save-status" id="rg-save-status" role="status">
19
+ <span class="rg-save-dot" aria-hidden="true"></span>
20
+ <span id="rg-save-status-text">{t.savedLocally}</span>
21
+ </div>
22
+
18
23
  <div class="rg-strip">
19
- <div class="rg-team">
20
- <input type="text" class="rg-name" id="rg-name-home" value={t.home} aria-label={t.home} />
24
+ <div class="rg-team rg-team-home">
25
+ <input type="text" class="rg-name" id="rg-name-home" value={t.home} maxlength="40" aria-label={t.home} />
21
26
  <div class="rg-score" id="rg-score-home" aria-live="polite">0</div>
22
27
  <div class="rg-bonus" id="rg-bonus-home"></div>
23
28
  </div>
@@ -25,8 +30,8 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
25
30
  <span class="rg-vs">VS</span>
26
31
  <div class="rg-half-badge" id="rg-half-badge">{t.half1}</div>
27
32
  </div>
28
- <div class="rg-team">
29
- <input type="text" class="rg-name" id="rg-name-away" value={t.away} aria-label={t.away} />
33
+ <div class="rg-team rg-team-away">
34
+ <input type="text" class="rg-name" id="rg-name-away" value={t.away} maxlength="40" aria-label={t.away} />
30
35
  <div class="rg-score" id="rg-score-away" aria-live="polite">0</div>
31
36
  <div class="rg-bonus" id="rg-bonus-away"></div>
32
37
  </div>
@@ -34,23 +39,24 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
34
39
 
35
40
  <div class="rg-columns">
36
41
  <div class="rg-col" data-team="home">
37
- <div class="rg-col-label">{t.home}</div>
38
- <button class="rg-btn rg-btn-try" data-action="try">+5 {t.tryLabel}</button>
39
- <button class="rg-btn rg-btn-conv" id="rg-conv-home" data-action="conv" disabled>+2 {t.conversion}</button>
40
- <button class="rg-btn rg-btn-pen" data-action="pen">+3 {t.penalty}</button>
41
- <button class="rg-btn rg-btn-drop" data-action="drop">+3 {t.dropGoal}</button>
42
+ <div class="rg-col-label" id="rg-col-label-home">{t.home}</div>
43
+ <button type="button" class="rg-btn rg-btn-try" data-action="try">{t.tryLabel}</button>
44
+ <button type="button" class="rg-btn rg-btn-conv" id="rg-conv-home" data-action="conv" disabled>{t.conversion}</button>
45
+ <button type="button" class="rg-btn rg-btn-pen" data-action="pen">{t.penalty}</button>
46
+ <button type="button" class="rg-btn rg-btn-drop" data-action="drop">{t.dropGoal}</button>
42
47
  </div>
43
48
  <div class="rg-col" data-team="away">
44
- <div class="rg-col-label">{t.away}</div>
45
- <button class="rg-btn rg-btn-try" data-action="try">+5 {t.tryLabel}</button>
46
- <button class="rg-btn rg-btn-conv" id="rg-conv-away" data-action="conv" disabled>+2 {t.conversion}</button>
47
- <button class="rg-btn rg-btn-pen" data-action="pen">+3 {t.penalty}</button>
48
- <button class="rg-btn rg-btn-drop" data-action="drop">+3 {t.dropGoal}</button>
49
+ <div class="rg-col-label" id="rg-col-label-away">{t.away}</div>
50
+ <button type="button" class="rg-btn rg-btn-try" data-action="try">{t.tryLabel}</button>
51
+ <button type="button" class="rg-btn rg-btn-conv" id="rg-conv-away" data-action="conv" disabled>{t.conversion}</button>
52
+ <button type="button" class="rg-btn rg-btn-pen" data-action="pen">{t.penalty}</button>
53
+ <button type="button" class="rg-btn rg-btn-drop" data-action="drop">{t.dropGoal}</button>
49
54
  </div>
50
55
  </div>
51
56
 
52
57
  <div class="rg-mid-row">
53
58
  <div class="rg-clock-panel">
59
+ <div class="rg-panel-header">{t.matchClock}</div>
54
60
  <div class="rg-clock-ring">
55
61
  <svg class="rg-clock-svg" viewBox="0 0 120 120">
56
62
  <circle class="rg-clock-bg" cx="60" cy="60" r="54" />
@@ -61,17 +67,23 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
61
67
  <div class="rg-clock-label">{t.half} <span id="rg-half-label">1</span></div>
62
68
  </div>
63
69
  </div>
64
- <button class="rg-btn rg-btn-clock" id="rg-btn-clock">{t.startMatch}</button>
70
+ <button type="button" class="rg-btn rg-btn-clock" id="rg-btn-clock">{t.startMatch}</button>
65
71
  </div>
66
72
  <div class="rg-sinbin-panel">
67
- <div class="rg-panel-header">{t.sinBin}</div>
73
+ <div class="rg-panel-header">{t.sinBinTitle}</div>
68
74
  <div class="rg-sinbin-form">
69
- <input type="text" class="rg-input" id="rg-sinbin-input" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
70
- <select class="rg-select" id="rg-sinbin-duration" aria-label={t.sinBin + " duration"}>
71
- <option value="600">10 {t.minutesShort}</option>
72
- <option value="300">5 {t.minutesShort}</option>
73
- </select>
74
- <button class="rg-btn rg-btn-yellow" id="rg-btn-sinbin">{t.sinBinAdd}</button>
75
+ <input type="text" class="rg-input" id="rg-sinbin-input" maxlength="80" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
76
+ <div class="rg-choice-row">
77
+ <div class="rg-choice-group" role="group" aria-label={t.teamLabel}>
78
+ <button type="button" class="rg-choice rg-choice-home is-active" data-sinbin-team="home" aria-pressed="true">{t.home}</button>
79
+ <button type="button" class="rg-choice rg-choice-away" data-sinbin-team="away" aria-pressed="false">{t.away}</button>
80
+ </div>
81
+ <div class="rg-choice-group" role="group" aria-label={t.durationLabel}>
82
+ <button type="button" class="rg-choice is-active" data-sinbin-duration="600" aria-pressed="true">10 {t.minutesShort}</button>
83
+ <button type="button" class="rg-choice" data-sinbin-duration="300" aria-pressed="false">5 {t.minutesShort}</button>
84
+ </div>
85
+ </div>
86
+ <button type="button" class="rg-btn rg-btn-yellow" id="rg-btn-sinbin">{t.sinBinAdd}</button>
75
87
  </div>
76
88
  <div class="rg-sinbin-list" id="rg-sinbin-list">
77
89
  <div class="rg-sinbin-empty">{t.sinBinEmpty}</div>
@@ -85,13 +97,13 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
85
97
  <div class="rg-history-list" id="rg-history-list">
86
98
  <div class="rg-history-empty">{t.eventEmpty}</div>
87
99
  </div>
88
- <button class="rg-btn rg-btn-undo" id="rg-btn-undo" disabled>{t.undoBtn}</button>
100
+ <button type="button" class="rg-btn rg-btn-undo" id="rg-btn-undo" disabled>{t.undoBtn}</button>
89
101
  </div>
90
102
  <div class="rg-summary">
91
103
  <div class="rg-panel-header">{t.scoringSummary}</div>
92
104
  <table class="rg-table">
93
105
  <thead>
94
- <tr><th></th><th>{t.home}</th><th>{t.away}</th></tr>
106
+ <tr><th></th><th class="rg-th-home" id="rg-summary-home">{t.home}</th><th class="rg-th-away" id="rg-summary-away">{t.away}</th></tr>
95
107
  </thead>
96
108
  <tbody id="rg-summary-body">
97
109
  <tr><td>{t.tryScored}</td><td>0</td><td>0</td></tr>
@@ -114,25 +126,25 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
114
126
  <div class="rg-banner-text" id="rg-banner-text"></div>
115
127
  </div>
116
128
 
117
- <div class="rg-reset-modal" id="rg-modal" role="dialog" aria-modal="true" aria-labelledby="rg-modal-title">
129
+ <div class="rg-reset-modal" id="rg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="rg-modal-title">
118
130
  <div class="rg-modal-content">
119
131
  <h2 class="rg-modal-title" id="rg-modal-title">{t.resetMatch}</h2>
120
132
  <div class="rg-modal-text">{t.resetConfirm}</div>
121
133
  <div class="rg-modal-btns">
122
- <button class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
123
- <button class="rg-btn rg-btn-modal-confirm" id="rg-modal-confirm">{t.confirm}</button>
134
+ <button type="button" class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
135
+ <button type="button" class="rg-btn rg-btn-modal-confirm" id="rg-modal-confirm">{t.confirm}</button>
124
136
  </div>
125
137
  </div>
126
138
  </div>
127
139
 
128
140
  <div class="rg-actions">
129
- <button class="rg-btn rg-btn-reset" id="rg-btn-reset">{t.resetMatch}</button>
141
+ <button type="button" class="rg-btn rg-btn-reset" id="rg-btn-reset">{t.resetMatch}</button>
130
142
  </div>
131
143
 
132
144
  </div>
133
145
  </div>
134
146
 
135
147
  <script>
136
- import { initRugbyScorekeeper } from './ui-init';
148
+ import { initRugbyScorekeeper } from './ui-runtime';
137
149
  initRugbyScorekeeper();
138
150
  </script>
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Zeit Läuft',
217
217
  fullTime: 'Spielende',
218
218
  minutesShort: 'Min.',
219
+ savedLocally: 'Auf diesem Gerät gespeichert',
220
+ saveUnavailable: 'Speichern nicht verfügbar',
221
+ teamLabel: 'Team',
222
+ durationLabel: 'Dauer',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Time On',
217
217
  fullTime: 'Full time',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Saved on this device',
220
+ saveUnavailable: 'Saving unavailable',
221
+ teamLabel: 'Team',
222
+ durationLabel: 'Duration',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Tiempo en Marcha',
217
217
  fullTime: 'Fin del partido',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Guardado en este dispositivo',
220
+ saveUnavailable: 'No se puede guardar',
221
+ teamLabel: 'Equipo',
222
+ durationLabel: 'Duración',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Temps en Marche',
217
217
  fullTime: 'Fin du match',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Enregistré sur cet appareil',
220
+ saveUnavailable: 'Enregistrement indisponible',
221
+ teamLabel: 'Équipe',
222
+ durationLabel: 'Durée',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Waktu Berjalan',
217
217
  fullTime: 'Pertandingan Selesai',
218
218
  minutesShort: 'mnt',
219
+ savedLocally: 'Tersimpan di perangkat ini',
220
+ saveUnavailable: 'Penyimpanan tidak tersedia',
221
+ teamLabel: 'Tim',
222
+ durationLabel: 'Durasi',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Tempo in Gioco',
217
217
  fullTime: 'Fine partita',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Salvato su questo dispositivo',
220
+ saveUnavailable: 'Salvataggio non disponibile',
221
+ teamLabel: 'Squadra',
222
+ durationLabel: 'Durata',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'タイムオン',
217
217
  fullTime: '試合終了',
218
218
  minutesShort: '分',
219
+ savedLocally: 'この端末に保存済み',
220
+ saveUnavailable: '保存できません',
221
+ teamLabel: 'チーム',
222
+ durationLabel: '時間',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: '타임 온',
217
217
  fullTime: '경기 종료',
218
218
  minutesShort: '분',
219
+ savedLocally: '이 기기에 저장됨',
220
+ saveUnavailable: '저장할 수 없음',
221
+ teamLabel: '팀',
222
+ durationLabel: '시간',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Tijd Loopt',
217
217
  fullTime: 'Einde wedstrijd',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Op dit apparaat opgeslagen',
220
+ saveUnavailable: 'Opslaan niet beschikbaar',
221
+ teamLabel: 'Team',
222
+ durationLabel: 'Duur',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Czas włączony',
217
217
  fullTime: 'Koniec meczu',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Zapisano na tym urządzeniu',
220
+ saveUnavailable: 'Zapisywanie niedostępne',
221
+ teamLabel: 'Drużyna',
222
+ durationLabel: 'Czas',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Tempo em Jogo',
217
217
  fullTime: 'Fim de jogo',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Guardado neste dispositivo',
220
+ saveUnavailable: 'Não foi possível guardar',
221
+ teamLabel: 'Equipa',
222
+ durationLabel: 'Duração',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Время вкл',
217
217
  fullTime: 'Матч окончен',
218
218
  minutesShort: 'мин',
219
+ savedLocally: 'Сохранено на этом устройстве',
220
+ saveUnavailable: 'Сохранение недоступно',
221
+ teamLabel: 'Команда',
222
+ durationLabel: 'Длительность',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Tid igång',
217
217
  fullTime: 'Matchen är slut',
218
218
  minutesShort: 'min',
219
+ savedLocally: 'Sparat på den här enheten',
220
+ saveUnavailable: 'Det går inte att spara',
221
+ teamLabel: 'Lag',
222
+ durationLabel: 'Längd',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: 'Süre başladı',
217
217
  fullTime: 'Maç bitti',
218
218
  minutesShort: 'dk',
219
+ savedLocally: 'Bu cihazda kaydedildi',
220
+ saveUnavailable: 'Kaydetme kullanılamıyor',
221
+ teamLabel: 'Takım',
222
+ durationLabel: 'Süre',
219
223
  },
220
224
  };
@@ -216,5 +216,9 @@ export const content: RugbyScoreKeeperLocaleContent = {
216
216
  timeOn: '时间开',
217
217
  fullTime: '比赛结束',
218
218
  minutesShort: '分钟',
219
+ savedLocally: '已保存到此设备',
220
+ saveUnavailable: '无法保存',
221
+ teamLabel: '球队',
222
+ durationLabel: '时长',
219
223
  },
220
224
  };
@@ -70,9 +70,11 @@ describe('rugbyScoreKeeper game clock', () => {
70
70
  });
71
71
 
72
72
  it('undoes a sin-bin event and its timer together', () => {
73
- const withSinBin = addSinBin(createInitialState(), ' Player <x> ', 600);
73
+ const withSinBin = addSinBin(createInitialState(), ' Player <x> ', 600, 'away');
74
74
  expect(withSinBin.sinBin).toHaveLength(1);
75
75
  expect(withSinBin.sinBin[0]?.player).toBe('Player <x>');
76
+ expect(withSinBin.sinBin[0]?.team).toBe('away');
77
+ expect(withSinBin.history[0]?.team).toBe('away');
76
78
  const undone = undoLast(withSinBin);
77
79
  expect(undone.sinBin).toEqual([]);
78
80
  expect(undone.history).toEqual([]);
@@ -99,4 +101,13 @@ describe('rugbyScoreKeeper game clock', () => {
99
101
  const homeTry = scoreTry(state, 'home');
100
102
  expect(losingBonus(homeTry.home, homeTry.away, 'away')).toBe(true);
101
103
  });
104
+
105
+ it('only reduces sin-bin time by clock time actually played before halftime', () => {
106
+ let state = addSinBin(createInitialState(), 'Number 4', 600, 'home');
107
+ state = startMatch({ ...state, elapsed: 2390 });
108
+ state = tickClock(state, 120);
109
+ expect(state.elapsed).toBe(2400);
110
+ expect(state.clockRunning).toBe(false);
111
+ expect(state.sinBin[0]?.remaining).toBe(590);
112
+ });
102
113
  });
@@ -21,6 +21,7 @@ export interface TeamScore {
21
21
 
22
22
  export interface SinBinEntry {
23
23
  id: number;
24
+ team: TeamKey;
24
25
  player: string;
25
26
  remaining: number;
26
27
  total: number;
@@ -105,13 +106,13 @@ export function scoreDropGoal(state: MatchState, team: TeamKey): MatchState {
105
106
  return s;
106
107
  }
107
108
 
108
- export function addSinBin(state: MatchState, player: string, duration: number): MatchState {
109
+ export function addSinBin(state: MatchState, player: string, duration: number, team: TeamKey = 'home'): MatchState {
109
110
  const cleanPlayer = player.trim();
110
111
  if (state.matchEnded || !cleanPlayer || !Number.isFinite(duration) || duration <= 0) return state;
111
112
  const s = structuredClone(state);
112
113
  const id = Math.max(Date.now(), ...s.sinBin.map((entry) => entry.id + 1));
113
- s.sinBin.push({ id, player: cleanPlayer, remaining: duration, total: duration });
114
- pushEvent(s, 'sinbin', 'home', { label: `${cleanPlayer} SIN BIN`, player: cleanPlayer, sinBinId: id });
114
+ s.sinBin.push({ id, team, player: cleanPlayer, remaining: duration, total: duration });
115
+ pushEvent(s, 'sinbin', team, { label: `${cleanPlayer} SIN BIN`, player: cleanPlayer, sinBinId: id });
115
116
  return s;
116
117
  }
117
118
 
@@ -127,17 +128,13 @@ export function tickSinBin(state: MatchState, delta: number): MatchState {
127
128
  export function tickClock(state: MatchState, delta: number): MatchState {
128
129
  const s = structuredClone(state);
129
130
  if (!s.clockRunning || s.matchEnded || !Number.isFinite(delta) || delta <= 0) return s;
130
- s.elapsed += delta;
131
- if (s.elapsed >= 2400 && s.half === 1) {
132
- s.elapsed = 2400;
133
- s.clockRunning = false;
134
- }
135
- if (s.elapsed >= 4800) {
136
- s.elapsed = 4800;
137
- s.clockRunning = false;
138
- s.matchEnded = true;
139
- }
140
- return tickSinBin(s, delta);
131
+ const limit = s.half === 1 ? 2400 : 4800;
132
+ const nextElapsed = Math.min(s.elapsed + delta, limit);
133
+ const appliedDelta = nextElapsed - s.elapsed;
134
+ s.elapsed = nextElapsed;
135
+ s.clockRunning = s.elapsed < limit;
136
+ s.matchEnded = s.elapsed === 4800;
137
+ return tickSinBin(s, appliedDelta);
141
138
  }
142
139
 
143
140
  export function startMatch(state: MatchState): MatchState {