@jjlmoya/utils-sports 1.19.0 → 1.20.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 +1 -1
- package/src/entries.ts +4 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/tennisScoreKeeper/bibliography.astro +6 -0
- package/src/tool/tennisScoreKeeper/bibliography.ts +8 -0
- package/src/tool/tennisScoreKeeper/component.astro +164 -0
- package/src/tool/tennisScoreKeeper/entry.ts +30 -0
- package/src/tool/tennisScoreKeeper/events.ts +136 -0
- package/src/tool/tennisScoreKeeper/game-logic.ts +248 -0
- package/src/tool/tennisScoreKeeper/i18n/de.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/en.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/es.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/fr.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/id.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/it.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/ja.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/ko.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/nl.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/pl.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/pt.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/ru.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/sv.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/tr.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/zh.ts +195 -0
- package/src/tool/tennisScoreKeeper/index.ts +9 -0
- package/src/tool/tennisScoreKeeper/logic.ts +205 -0
- package/src/tool/tennisScoreKeeper/render.ts +256 -0
- package/src/tool/tennisScoreKeeper/seo.astro +15 -0
- package/src/tool/tennisScoreKeeper/tennis-scorekeeper.css +827 -0
- package/src/tool/tennisScoreKeeper/ui.ts +27 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/entries.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { scoreKeeper } from './tool/scoreKeeper/entry';
|
|
|
12
12
|
export type { ScoreKeeperLocaleContent } from './tool/scoreKeeper/entry';
|
|
13
13
|
export { tournamentBracket } from './tool/tournamentBracket/entry';
|
|
14
14
|
export type { TournamentBracketLocaleContent } from './tool/tournamentBracket/entry';
|
|
15
|
+
export { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
|
|
16
|
+
export type { TennisScoreKeeperLocaleContent } from './tool/tennisScoreKeeper/entry';
|
|
15
17
|
export { sportsCategory } from './category';
|
|
16
18
|
import { basketScoreKeeper } from './tool/basketScoreKeeper/entry';
|
|
17
19
|
import { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
|
|
@@ -20,4 +22,5 @@ import { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
|
|
|
20
22
|
import { reactionTester } from './tool/reactionTester/entry';
|
|
21
23
|
import { scoreKeeper } from './tool/scoreKeeper/entry';
|
|
22
24
|
import { tournamentBracket } from './tool/tournamentBracket/entry';
|
|
23
|
-
|
|
25
|
+
import { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
|
|
26
|
+
export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper];
|
|
@@ -4,8 +4,8 @@ import { sportsCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 8 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(8);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('sportsCategory should be defined', () => {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { KnownLocale } from '../../types';
|
|
3
|
+
import type { TennisScoreKeeperUI } from './ui';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
locale?: KnownLocale;
|
|
7
|
+
ui?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { ui } = Astro.props;
|
|
11
|
+
const t = (ui ?? {}) as TennisScoreKeeperUI;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="tn-console" id="tn-card" data-tn-ui={JSON.stringify(t)}>
|
|
15
|
+
<div class="tn-header-panel">
|
|
16
|
+
<div class="tn-header-actions">
|
|
17
|
+
<button class="tn-action-btn" data-tn-reset title={t.reset}>
|
|
18
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
19
|
+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
|
20
|
+
<path d="M16 3h5v5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
|
21
|
+
<path d="M8 21H3v-5"/>
|
|
22
|
+
</svg>
|
|
23
|
+
</button>
|
|
24
|
+
<button class="tn-action-btn" data-tn-fs title={t.fullscreen}>
|
|
25
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
26
|
+
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
|
27
|
+
</svg>
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="tn-3d-arena">
|
|
33
|
+
<div class="tn-court" id="tn-interactive-court">
|
|
34
|
+
<div class="tn-court-markings">
|
|
35
|
+
<svg viewBox="0 0 100 150" preserveAspectRatio="none">
|
|
36
|
+
<rect x="5" y="5" width="90" height="140" fill="none" stroke="white" stroke-width="1.5"/>
|
|
37
|
+
<line x1="5" y1="75" x2="95" y2="75" stroke="white" stroke-width="2.5"/>
|
|
38
|
+
<line x1="18" y1="5" x2="18" y2="145" stroke="white" stroke-width="1"/>
|
|
39
|
+
<line x1="82" y1="5" x2="82" y2="145" stroke="white" stroke-width="1"/>
|
|
40
|
+
<line x1="50" y1="30" x2="50" y2="120" stroke="white" stroke-width="1.2"/>
|
|
41
|
+
<line x1="18" y1="30" x2="82" y2="30" stroke="white" stroke-width="1.2"/>
|
|
42
|
+
<line x1="18" y1="120" x2="82" y2="120" stroke="white" stroke-width="1.2"/>
|
|
43
|
+
<line x1="50" y1="5" x2="50" y2="8" stroke="white" stroke-width="1.5"/>
|
|
44
|
+
<line x1="50" y1="142" x2="50" y2="145" stroke="white" stroke-width="1.5"/>
|
|
45
|
+
</svg>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="tn-net">
|
|
49
|
+
<div class="tn-net-mesh"></div>
|
|
50
|
+
<div class="tn-net-strap"></div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="tn-ripple-field" id="tn-ripples"></div>
|
|
54
|
+
|
|
55
|
+
<div class="tn-court-half tn-court-half-a" id="tn-court-half-a">
|
|
56
|
+
<div class="tn-player-hud">
|
|
57
|
+
<div class="tn-player-name-section">
|
|
58
|
+
<svg class="tn-court-racquet" id="tn-racquet-a" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
59
|
+
<ellipse cx="12" cy="8" rx="5" ry="6" />
|
|
60
|
+
<path d="M9 8h6M10 5h4M10 11h4M12 2v12M10 3v10M14 3v10" stroke-width="0.8" opacity="0.6"/>
|
|
61
|
+
<path d="M9.5 13.5L12 16.5L14.5 13.5M11 16.5h2v6.5h-2z" />
|
|
62
|
+
</svg>
|
|
63
|
+
<input type="text" value={t.playerA} class="tn-player-input" id="tn-name-a" list="tn-names-a" />
|
|
64
|
+
<datalist id="tn-names-a"></datalist>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="tn-hud-sets" id="tn-sets-a"></div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="tn-court-score-box">
|
|
70
|
+
<span class="tn-court-score" id="tn-score-val-a">0</span>
|
|
71
|
+
<span class="tn-court-games" id="tn-games-a">{t.game}: 0</span>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="tn-court-half tn-court-half-b" id="tn-court-half-b">
|
|
76
|
+
<div class="tn-court-score-box">
|
|
77
|
+
<span class="tn-court-games" id="tn-games-b">{t.game}: 0</span>
|
|
78
|
+
<span class="tn-court-score" id="tn-score-val-b">0</span>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="tn-player-hud">
|
|
82
|
+
<div class="tn-player-name-section">
|
|
83
|
+
<svg class="tn-court-racquet" id="tn-racquet-b" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
84
|
+
<ellipse cx="12" cy="8" rx="5" ry="6" />
|
|
85
|
+
<path d="M9 8h6M10 5h4M10 11h4M12 2v12M10 3v10M14 3v10" stroke-width="0.8" opacity="0.6"/>
|
|
86
|
+
<path d="M9.5 13.5L12 16.5L14.5 13.5M11 16.5h2v6.5h-2z" />
|
|
87
|
+
</svg>
|
|
88
|
+
<input type="text" value={t.playerB} class="tn-player-input" id="tn-name-b" list="tn-names-b" />
|
|
89
|
+
<datalist id="tn-names-b"></datalist>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="tn-hud-sets" id="tn-sets-b"></div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="tn-status-banner" id="tn-status"></div>
|
|
98
|
+
|
|
99
|
+
<div class="tn-console-controls">
|
|
100
|
+
<div class="tn-control-group">
|
|
101
|
+
<button class="tn-control-btn tn-btn-mode tn-mode-active" data-mode-bo3>{t.bo3}</button>
|
|
102
|
+
<button class="tn-control-btn tn-btn-mode" data-mode-bo5>{t.bo5}</button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="tn-control-group">
|
|
106
|
+
<button class="tn-control-btn tn-btn-minus" data-minus-a title="Undo Player A">− A</button>
|
|
107
|
+
<button class="tn-control-btn tn-btn-minus" data-minus-b title="Undo Player B">− B</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="tn-history-grid" id="tn-history">
|
|
112
|
+
<div class="tn-history-row" id="tn-row-a">
|
|
113
|
+
<div class="tn-serving-indicator-col" id="tn-hist-serve-a"></div>
|
|
114
|
+
<div class="tn-history-name" id="tn-hist-name-a">{t.playerA}</div>
|
|
115
|
+
<div class="tn-history-sets" id="tn-set-list-a"></div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="tn-history-row" id="tn-row-b">
|
|
118
|
+
<div class="tn-serving-indicator-col" id="tn-hist-serve-b"></div>
|
|
119
|
+
<div class="tn-history-name" id="tn-hist-name-b">{t.playerB}</div>
|
|
120
|
+
<div class="tn-history-sets" id="tn-set-list-b"></div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div id="tn-swap" class="tn-swap-overlay">
|
|
125
|
+
<div class="tn-swap-toast" data-tn-swap-btn>
|
|
126
|
+
<svg class="tn-swap-toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
127
|
+
<path d="M17 17H3m14 0l-4-4m4 4l-4 4M7 7h14M7 7l4-4M7 7l4 4"/>
|
|
128
|
+
</svg>
|
|
129
|
+
<div>
|
|
130
|
+
<div class="tn-toast-title">{t.changeSide}</div>
|
|
131
|
+
<div class="tn-toast-sub">{t.swapHint}</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div id="tn-winner" class="tn-celebration-screen">
|
|
137
|
+
<div class="tn-celebration-box">
|
|
138
|
+
<svg class="tn-celebration-cup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
139
|
+
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6m12 5h1.5a2.5 2.5 0 0 0 0-5H18M6 4h12M12 18v3m-4 0h8m-4-17v14a4 4 0 0 1-4-4"/>
|
|
140
|
+
</svg>
|
|
141
|
+
<div class="tn-celebration-label">{t.winnerLabel}</div>
|
|
142
|
+
<div id="tn-winner-team" class="tn-celebration-name">Player 1</div>
|
|
143
|
+
<div id="tn-winner-score" class="tn-celebration-score">0 - 0</div>
|
|
144
|
+
<button class="tn-celebration-btn" data-close-winner>×</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div id="tn-confetti" class="tn-confetti-field"></div>
|
|
149
|
+
|
|
150
|
+
<div id="tn-modal" class="tn-inline-modal">
|
|
151
|
+
<div class="tn-inline-modal-box">
|
|
152
|
+
<p class="tn-inline-modal-text">{t.resetConfirm}</p>
|
|
153
|
+
<div class="tn-inline-modal-btns">
|
|
154
|
+
<button id="tn-modal-cancel" class="tn-inline-modal-btn tn-inline-btn-cancel">{t.cancel}</button>
|
|
155
|
+
<button id="tn-modal-confirm" class="tn-inline-modal-btn tn-inline-btn-reset">{t.reset}</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<script>
|
|
162
|
+
import { initTennisScoreKeeper } from './logic';
|
|
163
|
+
initTennisScoreKeeper();
|
|
164
|
+
</script>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SportsToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { TennisScoreKeeperUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { TennisScoreKeeperUI };
|
|
5
|
+
export type TennisScoreKeeperLocaleContent = ToolLocaleContent<TennisScoreKeeperUI>;
|
|
6
|
+
|
|
7
|
+
export const tennisScoreKeeper: SportsToolEntry<TennisScoreKeeperUI> = {
|
|
8
|
+
id: 'tennis-scorekeeper',
|
|
9
|
+
icons: {
|
|
10
|
+
bg: 'mdi:tennis',
|
|
11
|
+
fg: 'mdi:scoreboard-outline',
|
|
12
|
+
},
|
|
13
|
+
i18n: {
|
|
14
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
15
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
16
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
17
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
18
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
19
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
20
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
21
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
22
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
23
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
24
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
25
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
26
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
27
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
28
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { TennisScoreKeeperUI } from './ui';
|
|
2
|
+
import { type PlayerSide, type MatchFormat } from './game-logic';
|
|
3
|
+
import { el } from './render';
|
|
4
|
+
|
|
5
|
+
export interface TennisAPI {
|
|
6
|
+
addPoint: (player: PlayerSide, e?: MouseEvent) => void;
|
|
7
|
+
minusPoint: (player: PlayerSide) => void;
|
|
8
|
+
setMode: (mode: MatchFormat) => void;
|
|
9
|
+
reset: () => void;
|
|
10
|
+
resetAll: () => void;
|
|
11
|
+
confirmReset: () => void;
|
|
12
|
+
cancelReset: () => void;
|
|
13
|
+
toggleFullscreen: () => void;
|
|
14
|
+
confirmSwap: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NAMES_KEY = 'tn_names';
|
|
18
|
+
const HISTORY_KEY = 'tn_history';
|
|
19
|
+
|
|
20
|
+
export function saveNames(a: string, b: string): void {
|
|
21
|
+
localStorage.setItem(NAMES_KEY, JSON.stringify({ a, b }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadNames(): { a: string; b: string } {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(localStorage.getItem(NAMES_KEY) || '{"a":"Player 1","b":"Player 2"}');
|
|
27
|
+
} catch {
|
|
28
|
+
return { a: 'Player 1', b: 'Player 2' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAllKnownNames(): string[] {
|
|
33
|
+
const h: Record<string, { w: number; l: number }> = JSON.parse(localStorage.getItem(HISTORY_KEY) || '{}');
|
|
34
|
+
return Object.keys(h).sort();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function recordMatchOutcome(winner: string, loser: string): void {
|
|
38
|
+
const w = winner.trim();
|
|
39
|
+
const l = loser.trim();
|
|
40
|
+
const h: Record<string, { w: number; l: number }> = JSON.parse(localStorage.getItem(HISTORY_KEY) || '{}');
|
|
41
|
+
if (w) { h[w] = h[w] || { w: 0, l: 0 }; h[w].w += 1; }
|
|
42
|
+
if (l) { h[l] = h[l] || { w: 0, l: 0 }; h[l].l += 1; }
|
|
43
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(h));
|
|
44
|
+
|
|
45
|
+
const names = getAllKnownNames();
|
|
46
|
+
['tn-names-a', 'tn-names-b'].forEach((id) => {
|
|
47
|
+
const dl = document.getElementById(id) as HTMLDataListElement;
|
|
48
|
+
if (dl) dl.innerHTML = names.map((n) => `<option value="${n}">`).join('');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isModeButton(target: HTMLElement, api: TennisAPI): boolean {
|
|
53
|
+
if (target.closest('[data-mode-bo3]')) { api.setMode('bo3'); return true; }
|
|
54
|
+
if (target.closest('[data-mode-bo5]')) { api.setMode('bo5'); return true; }
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isCourtClick(target: HTMLElement, e: MouseEvent, api: TennisAPI): boolean {
|
|
59
|
+
const halfA = target.closest('#tn-court-half-a');
|
|
60
|
+
if (halfA && !target.closest('.tn-player-input') && !target.closest('.tn-hud-sets')) { api.addPoint('a', e); return true; }
|
|
61
|
+
const halfB = target.closest('#tn-court-half-b');
|
|
62
|
+
if (halfB && !target.closest('.tn-player-input') && !target.closest('.tn-hud-sets')) { api.addPoint('b', e); return true; }
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isMinusButton(target: HTMLElement, api: TennisAPI): boolean {
|
|
67
|
+
if (target.closest('[data-minus-a]')) { api.minusPoint('a'); return true; }
|
|
68
|
+
if (target.closest('[data-minus-b]')) { api.minusPoint('b'); return true; }
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isSpecialButton(target: HTMLElement, api: TennisAPI): boolean {
|
|
73
|
+
if (target.closest('[data-close-winner]')) { el('tn-winner')?.classList.remove('tn-winner-active'); api.resetAll(); return true; }
|
|
74
|
+
if (target.closest('[data-tn-swap-btn]')) { api.confirmSwap(); return true; }
|
|
75
|
+
if (target.closest('#tn-modal-cancel')) { api.cancelReset(); return true; }
|
|
76
|
+
if (target.closest('#tn-modal-confirm')) { api.confirmReset(); return true; }
|
|
77
|
+
if (target.closest('[data-tn-reset]')) { api.reset(); return true; }
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setupClickEvents(api: TennisAPI): void {
|
|
82
|
+
document.addEventListener('click', (e) => {
|
|
83
|
+
const target = e.target as HTMLElement;
|
|
84
|
+
if (isSpecialButton(target, api)) return;
|
|
85
|
+
if (isModeButton(target, api)) return;
|
|
86
|
+
if (isCourtClick(target, e, api)) return;
|
|
87
|
+
isMinusButton(target, api);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function setupFullscreen(card: HTMLElement, api: TennisAPI): void {
|
|
92
|
+
const fsBtn = card.querySelector('[data-tn-fs]');
|
|
93
|
+
fsBtn?.addEventListener('click', (e) => {
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
api.toggleFullscreen();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
document.addEventListener('fullscreenchange', () => {
|
|
99
|
+
const isFs = !!document.fullscreenElement;
|
|
100
|
+
card.classList.toggle('tn-fullscreen-on', isFs);
|
|
101
|
+
if (!isFs) card.classList.remove('tn-fullscreen-fallback');
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function setupNameInputs(score: Record<string, unknown>, save: () => void): void {
|
|
106
|
+
const na = document.getElementById('tn-name-a') as HTMLInputElement;
|
|
107
|
+
const nb = document.getElementById('tn-name-b') as HTMLInputElement;
|
|
108
|
+
[na, nb].forEach((inp) => {
|
|
109
|
+
if (!inp) return;
|
|
110
|
+
inp.addEventListener('input', () => {
|
|
111
|
+
const a = (document.getElementById('tn-name-a') as HTMLInputElement)?.value || 'Player 1';
|
|
112
|
+
const b = (document.getElementById('tn-name-b') as HTMLInputElement)?.value || 'Player 2';
|
|
113
|
+
saveNames(a, b);
|
|
114
|
+
save();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function populateDatalists(): void {
|
|
120
|
+
const names = getAllKnownNames();
|
|
121
|
+
['tn-names-a', 'tn-names-b'].forEach((id) => {
|
|
122
|
+
const dl = document.getElementById(id) as HTMLDataListElement;
|
|
123
|
+
if (!dl) return;
|
|
124
|
+
dl.innerHTML = names.map((n) => `<option value="${n}">`).join('');
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function setupUI(t: TennisScoreKeeperUI): void {
|
|
129
|
+
const saved = loadNames();
|
|
130
|
+
t.playerA = saved.a;
|
|
131
|
+
t.playerB = saved.b;
|
|
132
|
+
const na = document.getElementById('tn-name-a') as HTMLInputElement;
|
|
133
|
+
const nb = document.getElementById('tn-name-b') as HTMLInputElement;
|
|
134
|
+
if (na) na.value = saved.a;
|
|
135
|
+
if (nb) nb.value = saved.b;
|
|
136
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
export type PlayerSide = 'a' | 'b';
|
|
2
|
+
export type MatchFormat = 'bo3' | 'bo5';
|
|
3
|
+
|
|
4
|
+
export interface SetScore {
|
|
5
|
+
gamesA: number;
|
|
6
|
+
gamesB: number;
|
|
7
|
+
tiebreakPointsA?: number;
|
|
8
|
+
tiebreakPointsB?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MatchScore {
|
|
12
|
+
currentGamePointsA: number;
|
|
13
|
+
currentGamePointsB: number;
|
|
14
|
+
setHistory: SetScore[];
|
|
15
|
+
gamesWonInCurrentSetA: number;
|
|
16
|
+
gamesWonInCurrentSetB: number;
|
|
17
|
+
setsWonA: number;
|
|
18
|
+
setsWonB: number;
|
|
19
|
+
servingPlayer: PlayerSide;
|
|
20
|
+
format: MatchFormat;
|
|
21
|
+
areSidesSwapped: boolean;
|
|
22
|
+
inTiebreak: boolean;
|
|
23
|
+
tiebreakPointsA: number;
|
|
24
|
+
tiebreakPointsB: number;
|
|
25
|
+
firstServerOfSet: PlayerSide;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createInitialScore(): MatchScore {
|
|
29
|
+
return {
|
|
30
|
+
currentGamePointsA: 0,
|
|
31
|
+
currentGamePointsB: 0,
|
|
32
|
+
setHistory: [],
|
|
33
|
+
gamesWonInCurrentSetA: 0,
|
|
34
|
+
gamesWonInCurrentSetB: 0,
|
|
35
|
+
setsWonA: 0,
|
|
36
|
+
setsWonB: 0,
|
|
37
|
+
servingPlayer: 'a',
|
|
38
|
+
format: 'bo3',
|
|
39
|
+
areSidesSwapped: false,
|
|
40
|
+
inTiebreak: false,
|
|
41
|
+
tiebreakPointsA: 0,
|
|
42
|
+
tiebreakPointsB: 0,
|
|
43
|
+
firstServerOfSet: 'a',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setsNeededForMatchWin(format: MatchFormat): number {
|
|
48
|
+
return format === 'bo3' ? 2 : 3;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function standardPointLabel(points: number, opponentPoints: number): string {
|
|
52
|
+
if (points <= 1) return points === 0 ? '0' : '15';
|
|
53
|
+
if (points === 2) return '30';
|
|
54
|
+
if (points === 3 || points === opponentPoints) return '40';
|
|
55
|
+
if (points - opponentPoints === 1) return 'Ad';
|
|
56
|
+
return '0';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getPointLabel(points: number, opponentPoints: number, inTiebreak: boolean): string {
|
|
60
|
+
return inTiebreak ? String(points) : standardPointLabel(points, opponentPoints);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checkMatchOver(score: MatchScore): PlayerSide | null {
|
|
64
|
+
const need = setsNeededForMatchWin(score.format);
|
|
65
|
+
if (score.setsWonA >= need) return 'a';
|
|
66
|
+
if (score.setsWonB >= need) return 'b';
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function tiebreakSetWinner(score: MatchScore): PlayerSide | null {
|
|
71
|
+
if (score.tiebreakPointsA >= 7 && score.tiebreakPointsA - score.tiebreakPointsB >= 2) return 'a';
|
|
72
|
+
if (score.tiebreakPointsB >= 7 && score.tiebreakPointsB - score.tiebreakPointsA >= 2) return 'b';
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function regularSetWinner(a: number, b: number): PlayerSide | null {
|
|
77
|
+
if (a >= 6 && a - b >= 2) return 'a';
|
|
78
|
+
if (b >= 6 && b - a >= 2) return 'b';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function checkSetOver(score: MatchScore): PlayerSide | null {
|
|
83
|
+
if (score.inTiebreak) return tiebreakSetWinner(score);
|
|
84
|
+
return regularSetWinner(score.gamesWonInCurrentSetA, score.gamesWonInCurrentSetB);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function checkGameOver(score: MatchScore): PlayerSide | null {
|
|
88
|
+
if (score.inTiebreak) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const a = score.currentGamePointsA;
|
|
92
|
+
const b = score.currentGamePointsB;
|
|
93
|
+
if (a >= 4 && a - b >= 2) return 'a';
|
|
94
|
+
if (b >= 4 && b - a >= 2) return 'b';
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tiebreakPointWinner(a: number, b: number): PlayerSide | null {
|
|
99
|
+
if (a >= 6 && a > b) return 'a';
|
|
100
|
+
if (b >= 6 && b > a) return 'b';
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function gamePointWinner(a: number, b: number): PlayerSide | null {
|
|
105
|
+
if (a >= 3 && a > b) return 'a';
|
|
106
|
+
if (b >= 3 && b > a) return 'b';
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function checkPointWinnerOpportunity(score: MatchScore): PlayerSide | null {
|
|
111
|
+
if (checkMatchOver(score)) return null;
|
|
112
|
+
return score.inTiebreak
|
|
113
|
+
? tiebreakPointWinner(score.tiebreakPointsA, score.tiebreakPointsB)
|
|
114
|
+
: gamePointWinner(score.currentGamePointsA, score.currentGamePointsB);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isSetPointForSide(gamesLeader: number, gamesTrailer: number): boolean {
|
|
118
|
+
return (gamesLeader === 5 && gamesTrailer <= 4) || (gamesLeader === 6 && gamesTrailer === 5);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isSetPoint(score: MatchScore): PlayerSide | null {
|
|
122
|
+
const opp = checkPointWinnerOpportunity(score);
|
|
123
|
+
if (!opp) return null;
|
|
124
|
+
if (score.inTiebreak) return opp;
|
|
125
|
+
const gA = score.gamesWonInCurrentSetA;
|
|
126
|
+
const gB = score.gamesWonInCurrentSetB;
|
|
127
|
+
if (opp === 'a' && isSetPointForSide(gA, gB)) return 'a';
|
|
128
|
+
if (opp === 'b' && isSetPointForSide(gB, gA)) return 'b';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isMatchPoint(score: MatchScore): PlayerSide | null {
|
|
133
|
+
const sp = isSetPoint(score);
|
|
134
|
+
if (!sp) return null;
|
|
135
|
+
const need = setsNeededForMatchWin(score.format);
|
|
136
|
+
if (sp === 'a' && score.setsWonA === need - 1) return 'a';
|
|
137
|
+
if (sp === 'b' && score.setsWonB === need - 1) return 'b';
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getTiebreakServer(firstServer: PlayerSide, totalPoints: number): PlayerSide {
|
|
142
|
+
if (totalPoints % 2 === 1) return oppositeSide(firstServer);
|
|
143
|
+
return ((totalPoints / 2) % 2 === 1) ? oppositeSide(firstServer) : firstServer;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function oppositeSide(side: PlayerSide): PlayerSide {
|
|
147
|
+
return side === 'a' ? 'b' : 'a';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function awardTiebreakPoint(next: MatchScore, side: PlayerSide): void {
|
|
151
|
+
if (side === 'a') next.tiebreakPointsA += 1;
|
|
152
|
+
else next.tiebreakPointsB += 1;
|
|
153
|
+
next.servingPlayer = getTiebreakServer(next.firstServerOfSet, next.tiebreakPointsA + next.tiebreakPointsB);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function awardRegularPoint(next: MatchScore, side: PlayerSide): void {
|
|
157
|
+
if (side === 'a') next.currentGamePointsA += 1;
|
|
158
|
+
else next.currentGamePointsB += 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function awardPointToPlayer(score: MatchScore, side: PlayerSide): MatchScore {
|
|
162
|
+
if (checkMatchOver(score)) return score;
|
|
163
|
+
const next = { ...score };
|
|
164
|
+
if (next.inTiebreak) awardTiebreakPoint(next, side);
|
|
165
|
+
else awardRegularPoint(next, side);
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function undoLastTiebreakPoint(next: MatchScore, side: PlayerSide): boolean {
|
|
170
|
+
if (side === 'a' && next.tiebreakPointsA > 0) next.tiebreakPointsA -= 1;
|
|
171
|
+
else if (side === 'b' && next.tiebreakPointsB > 0) next.tiebreakPointsB -= 1;
|
|
172
|
+
else return false;
|
|
173
|
+
next.servingPlayer = getTiebreakServer(next.firstServerOfSet, next.tiebreakPointsA + next.tiebreakPointsB);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function undoLastRegularPoint(next: MatchScore, side: PlayerSide): boolean {
|
|
178
|
+
if (side === 'a' && next.currentGamePointsA > 0) next.currentGamePointsA -= 1;
|
|
179
|
+
else if (side === 'b' && next.currentGamePointsB > 0) next.currentGamePointsB -= 1;
|
|
180
|
+
else return false;
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function undoLastPoint(score: MatchScore, side: PlayerSide): MatchScore {
|
|
185
|
+
if (checkMatchOver(score)) return score;
|
|
186
|
+
const next = { ...score };
|
|
187
|
+
const ok = next.inTiebreak
|
|
188
|
+
? undoLastTiebreakPoint(next, side)
|
|
189
|
+
: undoLastRegularPoint(next, side);
|
|
190
|
+
return ok ? next : score;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function concludeGame(score: MatchScore, gameWinner: PlayerSide): MatchScore {
|
|
194
|
+
const next = { ...score };
|
|
195
|
+
if (gameWinner === 'a') {
|
|
196
|
+
next.gamesWonInCurrentSetA += 1;
|
|
197
|
+
} else {
|
|
198
|
+
next.gamesWonInCurrentSetB += 1;
|
|
199
|
+
}
|
|
200
|
+
next.currentGamePointsA = 0;
|
|
201
|
+
next.currentGamePointsB = 0;
|
|
202
|
+
next.servingPlayer = next.servingPlayer === 'a' ? 'b' : 'a';
|
|
203
|
+
if (next.gamesWonInCurrentSetA === 6 && next.gamesWonInCurrentSetB === 6) {
|
|
204
|
+
next.inTiebreak = true;
|
|
205
|
+
next.tiebreakPointsA = 0;
|
|
206
|
+
next.tiebreakPointsB = 0;
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function makeSetHistoryItem(next: MatchScore, setWinner: PlayerSide): SetScore {
|
|
212
|
+
const item: SetScore = {
|
|
213
|
+
gamesA: next.gamesWonInCurrentSetA,
|
|
214
|
+
gamesB: next.gamesWonInCurrentSetB,
|
|
215
|
+
};
|
|
216
|
+
if (next.inTiebreak) {
|
|
217
|
+
item.tiebreakPointsA = next.tiebreakPointsA;
|
|
218
|
+
item.tiebreakPointsB = next.tiebreakPointsB;
|
|
219
|
+
item.gamesA = setWinner === 'a' ? 7 : 6;
|
|
220
|
+
item.gamesB = setWinner === 'a' ? 6 : 7;
|
|
221
|
+
}
|
|
222
|
+
return item;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resetSetState(next: MatchScore): void {
|
|
226
|
+
next.gamesWonInCurrentSetA = 0;
|
|
227
|
+
next.gamesWonInCurrentSetB = 0;
|
|
228
|
+
next.currentGamePointsA = 0;
|
|
229
|
+
next.currentGamePointsB = 0;
|
|
230
|
+
next.inTiebreak = false;
|
|
231
|
+
next.tiebreakPointsA = 0;
|
|
232
|
+
next.tiebreakPointsB = 0;
|
|
233
|
+
next.firstServerOfSet = next.firstServerOfSet === 'a' ? 'b' : 'a';
|
|
234
|
+
next.servingPlayer = next.firstServerOfSet;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function concludeSet(score: MatchScore, setWinner: PlayerSide): MatchScore {
|
|
238
|
+
const next = { ...score };
|
|
239
|
+
next.setHistory = [...next.setHistory, makeSetHistoryItem(next, setWinner)];
|
|
240
|
+
if (setWinner === 'a') next.setsWonA += 1;
|
|
241
|
+
else next.setsWonB += 1;
|
|
242
|
+
resetSetState(next);
|
|
243
|
+
return next;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function createCleanMatch(score: MatchScore): MatchScore {
|
|
247
|
+
return { ...createInitialScore(), format: score.format };
|
|
248
|
+
}
|