@jjlmoya/utils-sports 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +62 -0
  2. package/src/category/i18n/en.ts +108 -0
  3. package/src/category/i18n/es.ts +108 -0
  4. package/src/category/i18n/fr.ts +95 -0
  5. package/src/category/index.ts +21 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +55 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +22 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/gymTracker/bibliography.astro +15 -0
  25. package/src/tool/gymTracker/component.astro +835 -0
  26. package/src/tool/gymTracker/exercises.ts +28 -0
  27. package/src/tool/gymTracker/i18n/en.ts +225 -0
  28. package/src/tool/gymTracker/i18n/es.ts +225 -0
  29. package/src/tool/gymTracker/i18n/fr.ts +225 -0
  30. package/src/tool/gymTracker/index.ts +34 -0
  31. package/src/tool/gymTracker/logic.ts +169 -0
  32. package/src/tool/gymTracker/seo.astro +15 -0
  33. package/src/tool/gymTracker/storage.ts +43 -0
  34. package/src/tool/gymTracker/timer.ts +126 -0
  35. package/src/tool/gymTracker/types.ts +11 -0
  36. package/src/tool/gymTracker/ui-utils.ts +59 -0
  37. package/src/tool/gymTracker/ui.ts +27 -0
  38. package/src/tool/reactionTester/bibliography.astro +2 -0
  39. package/src/tool/reactionTester/component.astro +1074 -0
  40. package/src/tool/reactionTester/i18n/en.ts +144 -0
  41. package/src/tool/reactionTester/i18n/es.ts +144 -0
  42. package/src/tool/reactionTester/i18n/fr.ts +144 -0
  43. package/src/tool/reactionTester/index.ts +34 -0
  44. package/src/tool/reactionTester/seo.astro +12 -0
  45. package/src/tool/reactionTester/ui.ts +43 -0
  46. package/src/tool/scoreKeeper/bibliography.astro +14 -0
  47. package/src/tool/scoreKeeper/component.astro +858 -0
  48. package/src/tool/scoreKeeper/i18n/en.ts +207 -0
  49. package/src/tool/scoreKeeper/i18n/es.ts +207 -0
  50. package/src/tool/scoreKeeper/i18n/fr.ts +207 -0
  51. package/src/tool/scoreKeeper/index.ts +35 -0
  52. package/src/tool/scoreKeeper/logic.ts +275 -0
  53. package/src/tool/scoreKeeper/seo.astro +15 -0
  54. package/src/tool/scoreKeeper/sports.ts +70 -0
  55. package/src/tool/scoreKeeper/ui.ts +19 -0
  56. package/src/tool/tournamentBracket/bibliography.astro +10 -0
  57. package/src/tool/tournamentBracket/component.astro +1092 -0
  58. package/src/tool/tournamentBracket/i18n/en.ts +160 -0
  59. package/src/tool/tournamentBracket/i18n/es.ts +178 -0
  60. package/src/tool/tournamentBracket/i18n/fr.ts +160 -0
  61. package/src/tool/tournamentBracket/index.ts +34 -0
  62. package/src/tool/tournamentBracket/logic/active.controller.ts +106 -0
  63. package/src/tool/tournamentBracket/logic/generator.ts +71 -0
  64. package/src/tool/tournamentBracket/logic/manager.ts +165 -0
  65. package/src/tool/tournamentBracket/logic/setup.controller.ts +84 -0
  66. package/src/tool/tournamentBracket/logic/sharing.ts +81 -0
  67. package/src/tool/tournamentBracket/logic/storage.ts +56 -0
  68. package/src/tool/tournamentBracket/models.ts +34 -0
  69. package/src/tool/tournamentBracket/seo.astro +12 -0
  70. package/src/tool/tournamentBracket/tournament.controller.ts +65 -0
  71. package/src/tool/tournamentBracket/tournament.renderer.ts +45 -0
  72. package/src/tool/tournamentBracket/ui/bracket-desktop.ts +143 -0
  73. package/src/tool/tournamentBracket/ui/bracket-mobile.ts +82 -0
  74. package/src/tool/tournamentBracket/ui/mediator.ts +96 -0
  75. package/src/tool/tournamentBracket/ui/navigator.ts +84 -0
  76. package/src/tool/tournamentBracket/ui/setup.ts +120 -0
  77. package/src/tool/tournamentBracket/ui.ts +42 -0
  78. package/src/tools.ts +13 -0
  79. package/src/types.ts +72 -0
@@ -0,0 +1,275 @@
1
+ import { SPORTS, SPORT_NAME_KEYS } from './sports';
2
+ import type { SportConfig, SportId } from './sports';
3
+ import type { ScoreKeeperUI } from './ui';
4
+
5
+ const g = (id: string) => document.getElementById(id);
6
+ class GameManager {
7
+ currentSport: SportConfig;
8
+ t: ScoreKeeperUI;
9
+ scoreA = 0; scoreB = 0;
10
+ setsA = 0; setsB = 0;
11
+ gamesA = 0; gamesB = 0;
12
+ isTieBreak = false;
13
+ server: 'A' | 'B' | null = null;
14
+ pointsSinceServeChange = 0; hasShownWin = false;
15
+ ui = {
16
+ select: g('sport-select') as HTMLSelectElement,
17
+ scoreA: g('score-a'), scoreB: g('score-b'),
18
+ touchLayerA: g('touch-layer-a'), touchLayerB: g('touch-layer-b'),
19
+ statsA: g('stats-a'), statsB: g('stats-b'),
20
+ setsA: g('sets-a'), setsB: g('sets-b'),
21
+ gamesA: g('games-a'), gamesB: g('games-b'),
22
+ serveA: g('serve-a'), serveB: g('serve-b'),
23
+ modal: g('winner-modal'), winnerName: g('winner-name'),
24
+ };
25
+
26
+ constructor() {
27
+ const root = g('scoreboard-app');
28
+ this.t = JSON.parse(root?.dataset.skUi ?? '{}') as ScoreKeeperUI;
29
+ this.currentSport = SPORTS.simple;
30
+ this.populateSportSelect();
31
+ this.bindEvents();
32
+ this.setSport('simple');
33
+ (window as unknown as Record<string, unknown>).game = this;
34
+ }
35
+
36
+ private populateSportSelect() {
37
+ Object.entries(SPORTS).forEach(([id, s]) => {
38
+ const o = document.createElement('option');
39
+ o.value = s.id;
40
+ o.text = this.t[SPORT_NAME_KEYS[id as SportId]] || id;
41
+ this.ui.select.appendChild(o);
42
+ });
43
+ }
44
+
45
+ private bindEvents() {
46
+ this.ui.select.addEventListener('change', (e) => {
47
+ this.setSport((e.target as HTMLSelectElement).value as SportId);
48
+ });
49
+ g('btn-reset')?.addEventListener('click', () => {
50
+ if (confirm(this.t.resetConfirm)) this.reset();
51
+ });
52
+ g('btn-swap')?.addEventListener('click', () => this.swap());
53
+ g('btn-modal-reset')?.addEventListener('click', () => {
54
+ this.reset(); this.ui.modal?.classList.add('sk-hidden');
55
+ });
56
+ g('btn-modal-continue')?.addEventListener('click', () => {
57
+ this.ui.modal?.classList.add('sk-hidden');
58
+ });
59
+ }
60
+
61
+ setSport(id: string) {
62
+ this.currentSport = SPORTS[id as SportId] || SPORTS.simple;
63
+ this.reset();
64
+ this.updateLayout();
65
+ }
66
+
67
+ private updateLayout() {
68
+ this.setupTouchLayer(this.ui.touchLayerA, 'A');
69
+ this.setupTouchLayer(this.ui.touchLayerB, 'B');
70
+ this.toggleStats(this.currentSport.hasSets ?? this.currentSport.hasGames ?? false);
71
+ }
72
+
73
+ private setupTouchLayer(layer: HTMLElement | null, team: 'A' | 'B') {
74
+ if (!layer) return;
75
+ layer.innerHTML = '';
76
+ const incs = this.currentSport.increments;
77
+ if (incs.length === 1) {
78
+ layer.className = 'sk-touch-layer';
79
+ layer.appendChild(this.buildTouchBtn(team, incs[0]!, false));
80
+ } else {
81
+ layer.className = 'sk-touch-layer sk-touch-layer-multi';
82
+ incs.forEach((val) => layer.appendChild(this.buildTouchBtn(team, val, true)));
83
+ }
84
+ }
85
+
86
+ private buildTouchBtn(team: 'A' | 'B', val: number, multi: boolean): HTMLButtonElement {
87
+ const btn = document.createElement('button');
88
+ if (multi) {
89
+ btn.className = 'sk-plus-btn'; btn.innerText = `+${val}`;
90
+ btn.onclick = (e) => { e.stopPropagation(); this.addScore(team, val); };
91
+ } else {
92
+ btn.className = 'sk-touch-btn'; btn.ariaLabel = `+${val}`;
93
+ btn.onclick = () => this.addScore(team, val);
94
+ }
95
+ return btn;
96
+ }
97
+
98
+ private toggleStats(show: boolean) {
99
+ this.ui.statsA?.classList.toggle('sk-hidden', !show);
100
+ this.ui.statsB?.classList.toggle('sk-hidden', !show);
101
+ }
102
+ private findNumericWinner(): 'A' | 'B' | null {
103
+ const max = this.currentSport.maxScore!;
104
+ const by = this.currentSport.winBy ?? 1;
105
+ if (this.scoreA >= max && this.scoreA - this.scoreB >= by) return 'A';
106
+ if (this.scoreB >= max && this.scoreB - this.scoreA >= by) return 'B';
107
+ return null;
108
+ }
109
+ private checkWinCondition() {
110
+ if (this.hasShownWin || !this.currentSport.maxScore) return;
111
+ const w = this.findNumericWinner();
112
+ if (w) this.triggerWin(w);
113
+ }
114
+
115
+ private triggerWin(winner: 'A' | 'B') {
116
+ this.hasShownWin = true;
117
+ const inputs = document.querySelectorAll('input[type="text"]');
118
+ let name = winner === 'A' ? this.t.playerA : this.t.playerB;
119
+ const el = inputs.length === 2 ? inputs[winner === 'A' ? 0 : 1] as HTMLInputElement : null;
120
+ if (el?.value.trim()) name = el.value;
121
+ if (this.ui.winnerName) this.ui.winnerName.innerText = name;
122
+ this.ui.modal?.classList.remove('sk-hidden');
123
+ }
124
+
125
+ reset() {
126
+ this.scoreA = 0; this.scoreB = 0;
127
+ this.setsA = 0; this.setsB = 0;
128
+ this.gamesA = 0; this.gamesB = 0;
129
+ this.isTieBreak = false;
130
+ this.pointsSinceServeChange = 0; this.hasShownWin = false;
131
+ this.ui.modal?.classList.add('sk-hidden');
132
+ this.render();
133
+ }
134
+
135
+ addScore(team: 'A' | 'B', val: number) {
136
+ if (this.currentSport.type === 'tennis') {
137
+ this.handleTennisPoint(team);
138
+ } else {
139
+ this.addNumericScore(team, val);
140
+ }
141
+ this.render();
142
+ }
143
+
144
+ private addNumericScore(team: 'A' | 'B', val: number) {
145
+ if (team === 'A') this.scoreA += val; else this.scoreB += val;
146
+ this.animate(team === 'A' ? this.ui.scoreA : this.ui.scoreB);
147
+ this.checkWinCondition();
148
+ if (this.currentSport.serviceRotationPoints && this.server) this.tickServiceRotation();
149
+ }
150
+
151
+ private tickServiceRotation() {
152
+ this.pointsSinceServeChange++;
153
+ let limit = this.currentSport.serviceRotationPoints!;
154
+ if (this.currentSport.id === 'pingpong' && this.scoreA >= 10 && this.scoreB >= 10) limit = 1;
155
+ if (this.pointsSinceServeChange >= limit) this.swapServe();
156
+ }
157
+
158
+ private handleTennisPoint(winner: 'A' | 'B') {
159
+ this.animate(winner === 'A' ? this.ui.scoreA : this.ui.scoreB);
160
+ if (this.isTieBreak) { this.handleTieBreakPoint(winner); }
161
+ else { this.handleNormalGamePoint(winner); }
162
+ }
163
+
164
+ private handleTieBreakPoint(winner: 'A' | 'B') {
165
+ if (winner === 'A') this.scoreA++; else this.scoreB++;
166
+ if ((this.scoreA + this.scoreB) % 2 === 1) this.swapServe();
167
+ const wScore = winner === 'A' ? this.scoreA : this.scoreB;
168
+ const lScore = winner === 'A' ? this.scoreB : this.scoreA;
169
+ if (wScore >= 7 && wScore >= lScore + 2) this.winSet(winner);
170
+ }
171
+
172
+ private handleNormalGamePoint(winner: 'A' | 'B') {
173
+ const wv = winner === 'A' ? this.scoreA : this.scoreB;
174
+ const lv = winner === 'A' ? this.scoreB : this.scoreA;
175
+ if (lv >= 4) { this.scoreA = 3; this.scoreB = 3; return; }
176
+ if (wv === 4 || (wv === 3 && lv < 3)) { this.winGame(winner); return; }
177
+ if (winner === 'A') this.scoreA++; else this.scoreB++;
178
+ }
179
+
180
+ private tryWinSet(team: 'A' | 'B', w: number, l: number): boolean {
181
+ if ((w === 6 && l <= 4) || (w === 7 && l === 5)) {
182
+ if (team === 'A') this.gamesA = w; else this.gamesB = w;
183
+ this.winSet(team); return true;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ private winGame(winner: 'A' | 'B') {
189
+ const gA = winner === 'A' ? this.gamesA + 1 : this.gamesA;
190
+ const gB = winner === 'B' ? this.gamesB + 1 : this.gamesB;
191
+ this.scoreA = 0; this.scoreB = 0; this.swapServe();
192
+ if (this.tryWinSet('A', gA, gB) || this.tryWinSet('B', gB, gA)) return;
193
+ if (gA === 6 && gB === 6) {
194
+ this.gamesA = 6; this.gamesB = 6; this.isTieBreak = true; this.render(); return;
195
+ }
196
+ this.gamesA = gA; this.gamesB = gB;
197
+ }
198
+
199
+ winSet(winner: 'A' | 'B') {
200
+ if (winner === 'A') this.setsA++; else this.setsB++;
201
+ this.gamesA = 0; this.gamesB = 0; this.scoreA = 0; this.scoreB = 0;
202
+ this.isTieBreak = false; this.swapServe();
203
+ }
204
+ private swapServe() {
205
+ if (!this.server) return;
206
+ this.server = this.server === 'A' ? 'B' : 'A';
207
+ this.pointsSinceServeChange = 0;
208
+ }
209
+
210
+ adjScore(team: 'A' | 'B', val: number) {
211
+ if (team === 'A') this.scoreA = Math.max(0, this.scoreA + val);
212
+ else this.scoreB = Math.max(0, this.scoreB + val);
213
+ this.render();
214
+ }
215
+ adjSet(team: 'A' | 'B', val: number) {
216
+ if (team === 'A') this.setsA = Math.max(0, this.setsA + val);
217
+ else this.setsB = Math.max(0, this.setsB + val);
218
+ this.render();
219
+ }
220
+ adjGame(team: 'A' | 'B', val: number) {
221
+ if (team === 'A') this.gamesA = Math.max(0, this.gamesA + val);
222
+ else this.gamesB = Math.max(0, this.gamesB + val);
223
+ this.render();
224
+ }
225
+ setServe(team: 'A' | 'B') {
226
+ this.server = team; this.pointsSinceServeChange = 0; this.render();
227
+ }
228
+
229
+ swap() {
230
+ [this.scoreA, this.scoreB] = [this.scoreB, this.scoreA];
231
+ [this.setsA, this.setsB] = [this.setsB, this.setsA];
232
+ [this.gamesA, this.gamesB] = [this.gamesB, this.gamesA];
233
+ if (this.server) this.server = this.server === 'A' ? 'B' : 'A';
234
+ const inputs = document.querySelectorAll('input[type="text"]');
235
+ if (inputs.length === 2) {
236
+ const [i1, i2] = [inputs[0] as HTMLInputElement, inputs[1] as HTMLInputElement];
237
+ [i1.value, i2.value] = [i2.value, i1.value];
238
+ }
239
+ this.render();
240
+ }
241
+
242
+ private animate(el: HTMLElement | null) {
243
+ if (!el) return;
244
+ el.classList.add('sk-score-pop');
245
+ setTimeout(() => el.classList.remove('sk-score-pop'), 100);
246
+ }
247
+ private getTennisLabel(val: number): string | number {
248
+ if (this.isTieBreak) return val;
249
+ return (['0', '15', '30', '40', 'AD'] as const)[val] ?? val;
250
+ }
251
+
252
+ private renderScores() {
253
+ const isTennis = this.currentSport.type === 'tennis';
254
+ const lA = isTennis ? String(this.getTennisLabel(this.scoreA)) : String(this.scoreA);
255
+ const lB = isTennis ? String(this.getTennisLabel(this.scoreB)) : String(this.scoreB);
256
+ if (this.ui.scoreA) this.ui.scoreA.innerText = lA;
257
+ if (this.ui.scoreB) this.ui.scoreB.innerText = lB;
258
+ const tiebreak = isTennis && this.isTieBreak;
259
+ this.ui.scoreA?.classList.toggle('sk-score-tiebreak', tiebreak);
260
+ this.ui.scoreB?.classList.toggle('sk-score-tiebreak', tiebreak);
261
+ }
262
+
263
+ render() {
264
+ this.renderScores();
265
+ if (this.ui.setsA) this.ui.setsA.innerText = String(this.setsA);
266
+ if (this.ui.setsB) this.ui.setsB.innerText = String(this.setsB);
267
+ if (this.ui.gamesA) this.ui.gamesA.innerText = String(this.gamesA);
268
+ if (this.ui.gamesB) this.ui.gamesB.innerText = String(this.gamesB);
269
+ this.ui.serveA?.classList.toggle('sk-serve-hidden', this.server !== 'A');
270
+ this.ui.serveB?.classList.toggle('sk-serve-hidden', this.server !== 'B');
271
+ }
272
+ }
273
+ export function initScoreKeeper() {
274
+ document.addEventListener('DOMContentLoaded', () => new GameManager());
275
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { scoreKeeper } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await scoreKeeper.i18n[locale]?.();
12
+ if (!content) return;
13
+ ---
14
+
15
+ <SEORenderer content={{ locale: locale as string, sections: content.seo }} />
@@ -0,0 +1,70 @@
1
+ export type SportId = 'simple' | 'tennis' | 'padel' | 'pingpong' | 'volleyball' | 'basket';
2
+
3
+ export type ScoreType = 'numeric' | 'tennis';
4
+
5
+ export interface SportConfig {
6
+ id: SportId;
7
+ type: ScoreType;
8
+ maxScore?: number;
9
+ winBy?: number;
10
+ hasSets?: boolean;
11
+ hasGames?: boolean;
12
+ serviceRotationPoints?: number;
13
+ increments: number[];
14
+ }
15
+
16
+ export const SPORT_NAME_KEYS: Record<SportId, string> = {
17
+ simple: 'sportSimple',
18
+ tennis: 'sportTennis',
19
+ padel: 'sportPadel',
20
+ pingpong: 'sportPingpong',
21
+ volleyball: 'sportVolleyball',
22
+ basket: 'sportBasket',
23
+ };
24
+
25
+ export const SPORTS: Record<SportId, SportConfig> = {
26
+ simple: {
27
+ id: 'simple',
28
+ type: 'numeric',
29
+ increments: [1],
30
+ serviceRotationPoints: 0,
31
+ },
32
+ tennis: {
33
+ id: 'tennis',
34
+ type: 'tennis',
35
+ hasSets: true,
36
+ hasGames: true,
37
+ increments: [1],
38
+ serviceRotationPoints: 0,
39
+ },
40
+ padel: {
41
+ id: 'padel',
42
+ type: 'tennis',
43
+ hasSets: true,
44
+ hasGames: true,
45
+ increments: [1],
46
+ serviceRotationPoints: 0,
47
+ },
48
+ pingpong: {
49
+ id: 'pingpong',
50
+ type: 'numeric',
51
+ maxScore: 11,
52
+ winBy: 2,
53
+ increments: [1],
54
+ serviceRotationPoints: 2,
55
+ },
56
+ volleyball: {
57
+ id: 'volleyball',
58
+ type: 'numeric',
59
+ maxScore: 25,
60
+ winBy: 2,
61
+ increments: [1],
62
+ serviceRotationPoints: 1,
63
+ },
64
+ basket: {
65
+ id: 'basket',
66
+ type: 'numeric',
67
+ increments: [1, 2, 3],
68
+ serviceRotationPoints: 0,
69
+ },
70
+ };
@@ -0,0 +1,19 @@
1
+ export interface ScoreKeeperUI extends Record<string, string> {
2
+ playerA: string;
3
+ playerB: string;
4
+ swapSides: string;
5
+ reset: string;
6
+ serve: string;
7
+ sets: string;
8
+ games: string;
9
+ victory: string;
10
+ newGame: string;
11
+ continueGame: string;
12
+ resetConfirm: string;
13
+ sportSimple: string;
14
+ sportTennis: string;
15
+ sportPadel: string;
16
+ sportPingpong: string;
17
+ sportVolleyball: string;
18
+ sportBasket: string;
19
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ import { tournamentBracket } from './index';
3
+ import type { KnownLocale } from '../../types';
4
+
5
+ interface Props { locale: KnownLocale }
6
+ const { locale } = Astro.props;
7
+ const content = await tournamentBracket.i18n[locale]?.();
8
+ ---
9
+
10
+ {content && content.bibliography.length > 0 && null}