@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,143 @@
1
+ import type { TournamentData, Match } from '../models';
2
+ import type { TournamentBracketUI } from '../ui';
3
+
4
+ const COL_W = 260;
5
+ const COL_GAP = 80;
6
+ const CARD_H = 100;
7
+ const UNIT_H = 110;
8
+
9
+ const TROPHY = `<svg xmlns="http://www.w3.org/2000/svg" class="tb-trophy-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M20.2,2H19.5H18C17.1,2 16,3 16,4H8C8,3 6.9,2 6,2H4.5H3.8C2.8,2 2,2.8 2,3.8V4.5C2,8.9 5.6,12.5 10,12.5V15H7V17H17V15H14V12.5C18.4,12.5 22,8.9 22,4.5V3.8C22,2.8 21.2,2 20.2,2M4,4.5V3.8C4,3.6 4.2,3.5 4.5,3.5H6C6.5,3.5 7,4 7,4.5V9.5C4.2,9.5 4,4.5 4,4.5M20,4.5C20,4.5 19.8,9.5 17,9.5V4.5C17,4 17.5,3.5 18,3.5H19.5C19.8,3.5 20,3.6 20,3.8V4.5Z"/></svg>`;
10
+
11
+ export class DesktopBracketRenderer {
12
+ container: HTMLElement | null;
13
+ private ui: TournamentBracketUI;
14
+
15
+ constructor(ui: TournamentBracketUI) {
16
+ this.ui = ui;
17
+ this.container = document.querySelector('.desktop-bracket-container');
18
+ }
19
+
20
+ public render(data: TournamentData) {
21
+ if (!this.container) return;
22
+ this.container.innerHTML = '';
23
+ const totalW = data.rounds.length * (COL_W + COL_GAP) + 120;
24
+ const totalH = (data.rounds[0]?.matches.length ?? 0) * UNIT_H + 200;
25
+ const wrapper = document.createElement('div');
26
+ wrapper.className = 'tb-bracket-wrapper';
27
+ wrapper.style.cssText = `position:relative;width:${totalW}px;height:${totalH}px;min-width:100%;min-height:100%`;
28
+ data.rounds.forEach((round, rIndex) => wrapper.appendChild(this.buildColumn(round, rIndex, data.scoreEnabled)));
29
+ wrapper.appendChild(this.buildConnectors(data, totalW, totalH));
30
+ this.container.appendChild(wrapper);
31
+ this.enableDragScroll(this.container);
32
+ }
33
+
34
+ private buildColumn(round: { name: string; matches: Match[] }, rIndex: number, scoreEnabled?: boolean): HTMLElement {
35
+ const col = document.createElement('div');
36
+ col.style.cssText = `position:absolute;left:${rIndex * (COL_W + COL_GAP) + 60}px;top:40px;width:${COL_W}px`;
37
+ const header = document.createElement('div');
38
+ header.className = 'tb-round-header';
39
+ header.textContent = round.name;
40
+ col.appendChild(header);
41
+ round.matches.forEach((match, mIndex) => col.appendChild(this.buildMatchEl(match, rIndex, mIndex, scoreEnabled)));
42
+ return col;
43
+ }
44
+
45
+ private buildMatchEl(match: Match, rIndex: number, mIndex: number, scoreEnabled?: boolean): HTMLElement {
46
+ const spacing = UNIT_H * Math.pow(2, rIndex);
47
+ const topPos = mIndex * spacing + (spacing / 2 - CARD_H / 2) + 40;
48
+ const el = document.createElement('div');
49
+ el.style.cssText = `position:absolute;top:${topPos}px;width:100%`;
50
+ el.innerHTML = this.getMatchCardHTML(match, scoreEnabled);
51
+ return el;
52
+ }
53
+
54
+ private buildConnectors(data: TournamentData, totalW: number, totalH: number): SVGSVGElement {
55
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
56
+ svg.setAttribute('class', 'tb-connectors');
57
+ svg.style.cssText = `position:absolute;inset:0;width:${totalW}px;height:${totalH}px;z-index:0;pointer-events:none`;
58
+ data.rounds.forEach((round, rIndex) => {
59
+ if (rIndex === data.rounds.length - 1) return;
60
+ round.matches.forEach((_, mIndex) => svg.appendChild(this.buildPath(rIndex, mIndex)));
61
+ });
62
+ return svg;
63
+ }
64
+
65
+ private buildPath(rIndex: number, mIndex: number): SVGPathElement {
66
+ const spacing = UNIT_H * Math.pow(2, rIndex);
67
+ const nextSpacing = UNIT_H * Math.pow(2, rIndex + 1);
68
+ const myY = mIndex * spacing + (spacing / 2 - CARD_H / 2) + 40 + CARD_H / 2;
69
+ const nextY = Math.floor(mIndex / 2) * nextSpacing + (nextSpacing / 2 - CARD_H / 2) + 40 + CARD_H / 2;
70
+ const myX = rIndex * (COL_W + COL_GAP) + COL_W + 66;
71
+ const nextX = (rIndex + 1) * (COL_W + COL_GAP) + 54;
72
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
73
+ const midX = myX + COL_GAP / 2 - 6;
74
+ path.setAttribute('d', `M ${myX} ${myY} L ${midX} ${myY} L ${midX} ${nextY} L ${nextX} ${nextY}`);
75
+ path.setAttribute('stroke', '#cbd5e1');
76
+ path.setAttribute('stroke-width', '2');
77
+ path.setAttribute('fill', 'none');
78
+ return path;
79
+ }
80
+
81
+ private isDefinitiveBye(match: Match): boolean {
82
+ return !!(match.isBye || (match.winner && !match.player1) || (match.winner && !match.player2));
83
+ }
84
+
85
+ private getMatchCardHTML(match: Match, scoreEnabled?: boolean): string {
86
+ if (this.isDefinitiveBye(match)) return this.getByeCardHTML(match);
87
+ return this.getRegularDesktopCard(match, scoreEnabled);
88
+ }
89
+
90
+ private getByeCardHTML(match: Match): string {
91
+ const player = match.player1 ?? match.player2;
92
+ return `<div class="tb-desktop-card tb-desktop-card-bye"><div class="tb-connector-dot-right"></div><div class="tb-bye-label">${this.ui.byeLabel}</div><div class="tb-bye-name">${player?.name ?? this.ui.waiting}</div></div>`;
93
+ }
94
+
95
+ private isWinner(match: Match, p: { id: string } | null): boolean {
96
+ return !!(match.winner?.id && match.winner.id === p?.id);
97
+ }
98
+
99
+ private buildScoreInput(matchId: string, side: number, val: string | number): string {
100
+ return `<input type="number" class="tb-score-input" data-match-id="${matchId}" data-player="${side}" value="${val}" onclick="event.stopPropagation()">`;
101
+ }
102
+
103
+ private getScoreInput(match: Match, side: 1 | 2, scoreEnabled?: boolean): string {
104
+ if (!scoreEnabled || !match.player1 || !match.player2) return '';
105
+ const raw = side === 1 ? match.score1 : match.score2;
106
+ return this.buildScoreInput(match.id, side, raw != null ? raw : '');
107
+ }
108
+
109
+ private renderBtn(matchId: string, p: { id: string; name: string } | null, isWinner: boolean): string {
110
+ const cls = isWinner ? 'tb-match-btn tb-match-btn-winner' : 'tb-match-btn';
111
+ const label = p ? 'tb-player-label' : 'tb-player-empty-label';
112
+ const name = p ? p.name : '...';
113
+ const wid = p ? p.id : '';
114
+ const dis = p ? '' : 'disabled';
115
+ return `<button class="${cls}" data-match-id="${matchId}" data-winner-id="${wid}" ${dis}><span class="${label}">${name}</span>${isWinner ? TROPHY : ''}</button>`;
116
+ }
117
+
118
+ private buildDesktopPlayerRow(match: Match, side: 1 | 2, scoreEnabled?: boolean): string {
119
+ const p = side === 1 ? match.player1 : match.player2;
120
+ const btn = this.renderBtn(match.id, p, this.isWinner(match, p));
121
+ const input = this.getScoreInput(match, side, scoreEnabled);
122
+ return `<div class="tb-match-row">${btn}${input}</div>`;
123
+ }
124
+
125
+ private getRegularDesktopCard(match: Match, scoreEnabled?: boolean): string {
126
+ const row1 = this.buildDesktopPlayerRow(match, 1, scoreEnabled);
127
+ const row2 = this.buildDesktopPlayerRow(match, 2, scoreEnabled);
128
+ return `<div class="tb-desktop-card"><div class="tb-connector-dot-left"></div><div class="tb-connector-dot-right"></div>${row1}<div class="tb-match-divider"></div>${row2}</div>`;
129
+ }
130
+
131
+ private enableDragScroll(el: HTMLElement) {
132
+ let pos = { top: 0, left: 0, x: 0, y: 0 };
133
+ const onMove = (e: MouseEvent) => { el.scrollLeft = pos.left - (e.clientX - pos.x); el.scrollTop = pos.top - (e.clientY - pos.y); };
134
+ const onUp = () => { el.classList.remove('tb-cursor-grabbing'); el.classList.add('tb-cursor-grab'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
135
+ el.onmousedown = (e: MouseEvent) => {
136
+ el.classList.add('tb-cursor-grabbing');
137
+ el.classList.remove('tb-cursor-grab');
138
+ pos = { left: el.scrollLeft, top: el.scrollTop, x: e.clientX, y: e.clientY };
139
+ document.addEventListener('mousemove', onMove);
140
+ document.addEventListener('mouseup', onUp);
141
+ };
142
+ }
143
+ }
@@ -0,0 +1,82 @@
1
+ import type { TournamentData, Match } from '../models';
2
+ import type { TournamentBracketUI } from '../ui';
3
+
4
+ export class MobileBracketRenderer {
5
+ container: HTMLElement | null;
6
+ private ui: TournamentBracketUI;
7
+
8
+ constructor(ui: TournamentBracketUI) {
9
+ this.ui = ui;
10
+ this.container = document.querySelector('.bracket-mobile');
11
+ }
12
+
13
+ public render(data: TournamentData, activeRound: number, onTabClick: (i: number) => void) {
14
+ if (!this.container) return;
15
+ this.container.innerHTML = this.buildTabs(data, activeRound) + this.buildRounds(data, activeRound);
16
+ this.container.querySelectorAll('.tb-round-tab').forEach((tab) => {
17
+ tab.addEventListener('click', () => onTabClick(parseInt((tab as HTMLElement).dataset.roundIndex ?? '0')));
18
+ });
19
+ }
20
+
21
+ private buildTabs(data: TournamentData, activeRound: number): string {
22
+ const tabs = data.rounds.map((round, i) => `<button class="tb-round-tab${i === activeRound ? ' tb-round-tab-active' : ''}" data-round-index="${i}">${round.name}</button>`).join('');
23
+ return `<div class="tb-tabs">${tabs}</div>`;
24
+ }
25
+
26
+ private buildRounds(data: TournamentData, activeRound: number): string {
27
+ const rounds = data.rounds.map((round, i) => {
28
+ const active = i === activeRound;
29
+ const cards = round.matches.length === 0
30
+ ? `<div class="tb-round-empty">${this.ui.emptyRound}</div>`
31
+ : round.matches.map((m) => this.getMatchCardHTML(m)).join('');
32
+ return `<div class="tb-round-content${active ? ' tb-round-active' : ''}">${cards}</div>`;
33
+ }).join('');
34
+ return `<div class="tb-rounds">${rounds}</div>`;
35
+ }
36
+
37
+ private isDefinitiveBye(match: Match): boolean {
38
+ return !!(match.isBye || (match.winner && !match.player1) || (match.winner && !match.player2));
39
+ }
40
+
41
+ private getMatchCardHTML(match: Match): string {
42
+ if (this.isDefinitiveBye(match)) {
43
+ const player = match.player1 ?? match.player2;
44
+ return `<div class="tb-match-card tb-match-card-bye"><span class="tb-bye-label">${this.ui.byeLabel}</span><div class="tb-bye-name">${player?.name ?? this.ui.waiting}</div></div>`;
45
+ }
46
+ return this.getRegularCardHTML(match);
47
+ }
48
+
49
+ private buildScoreInput(matchId: string, player: number, value: string | number, disabled: boolean): string {
50
+ return `<input type="number" class="tb-score-input" data-match-id="${matchId}" data-player="${player}" value="${value}" min="0" placeholder="-" ${disabled ? 'disabled' : ''}>`;
51
+ }
52
+
53
+ private isWinner(match: Match, p: { id: string } | null): boolean {
54
+ return !!(match.winner?.id && match.winner.id === p?.id);
55
+ }
56
+
57
+ private renderPlayerBtn(matchId: string, p: { id: string; name: string } | null, isWinner: boolean): string {
58
+ const cls = isWinner ? 'tb-match-btn tb-match-btn-winner' : 'tb-match-btn';
59
+ const label = p ? 'tb-player-label' : 'tb-player-empty-label';
60
+ const trophy = isWinner ? '<span class="tb-winner-icon"></span>' : '';
61
+ const name = p ? p.name : '...';
62
+ const wid = p ? p.id : '';
63
+ const dis = p ? '' : 'disabled';
64
+ return `<button class="${cls}" data-match-id="${matchId}" data-winner-id="${wid}" ${dis}><span class="${label}">${name}</span>${trophy}</button>`;
65
+ }
66
+
67
+ private buildPlayerBtn(match: Match, side: 1 | 2): string {
68
+ const p = side === 1 ? match.player1 : match.player2;
69
+ return this.renderPlayerBtn(match.id, p, this.isWinner(match, p));
70
+ }
71
+
72
+ private getRegularCardHTML(match: Match): string {
73
+ const hasWinner = !!match.winner?.id;
74
+ const s1 = match.score1 != null ? match.score1 : '';
75
+ const s2 = match.score2 != null ? match.score2 : '';
76
+ const input1 = this.buildScoreInput(match.id, 1, s1, !match.player1 || hasWinner);
77
+ const input2 = this.buildScoreInput(match.id, 2, s2, !match.player2 || hasWinner);
78
+ const btn1 = this.buildPlayerBtn(match, 1);
79
+ const btn2 = this.buildPlayerBtn(match, 2);
80
+ return `<div class="tb-match-card"><div class="tb-match-row">${btn1}${input1}</div><div class="tb-match-divider"></div><div class="tb-match-row">${btn2}${input2}</div></div>`;
81
+ }
82
+ }
@@ -0,0 +1,96 @@
1
+ import type { TournamentBracketUI } from '../ui';
2
+
3
+ const g = (id: string) => document.getElementById(id);
4
+
5
+ export class TournamentUIMediator {
6
+ setupView: HTMLElement | null;
7
+ bracketView: HTMLElement | null;
8
+ activeControls: HTMLElement | null;
9
+ inputName: HTMLInputElement | null;
10
+ inputPlayer: HTMLInputElement | null;
11
+ btnAdd: HTMLButtonElement | null;
12
+ btnGenerate: HTMLButtonElement | null;
13
+ btnReset: HTMLButtonElement | null;
14
+ btnClearPlayers: HTMLButtonElement | null;
15
+ btnNextMatch: HTMLButtonElement | null;
16
+ titleDisplay: HTMLElement | null;
17
+ dateDisplay: HTMLElement | null;
18
+ private ui: TournamentBracketUI;
19
+
20
+ constructor(ui: TournamentBracketUI) {
21
+ this.ui = ui;
22
+ this.setupView = g('setup-view');
23
+ this.bracketView = g('bracket-view');
24
+ this.activeControls = g('active-controls');
25
+ this.inputName = g('tournament-name-input') as HTMLInputElement;
26
+ this.inputPlayer = g('new-player-input') as HTMLInputElement;
27
+ this.btnAdd = g('add-player-btn') as HTMLButtonElement;
28
+ this.btnGenerate = g('generate-btn') as HTMLButtonElement;
29
+ this.btnReset = g('reset-btn') as HTMLButtonElement;
30
+ this.btnClearPlayers = g('clear-players-btn') as HTMLButtonElement;
31
+ this.btnNextMatch = g('next-match-btn') as HTMLButtonElement;
32
+ this.titleDisplay = g('tournament-title-display');
33
+ this.dateDisplay = g('tournament-date-display');
34
+ }
35
+
36
+ setVisibility(state: 'SETUP' | 'ACTIVE') {
37
+ const isActive = state === 'ACTIVE';
38
+ this.setupView?.classList.toggle('tb-hidden', isActive);
39
+ this.bracketView?.classList.toggle('tb-hidden', !isActive);
40
+ this.activeControls?.classList.toggle('tb-hidden', !isActive);
41
+ }
42
+
43
+ updateHeader(name: string, date: string) {
44
+ if (this.titleDisplay && !this.titleDisplay.querySelector('input')) {
45
+ this.titleDisplay.innerHTML = `${name} <span class="tb-edit-icon" title="Editar"></span>`;
46
+ }
47
+ if (this.dateDisplay) this.dateDisplay.textContent = date;
48
+ }
49
+
50
+ enableTitleEditing(onSave: (n: string) => void) {
51
+ if (!this.titleDisplay) return;
52
+ this.titleDisplay.addEventListener('click', () => this.openTitleInput(onSave));
53
+ }
54
+
55
+ private openTitleInput(onSave: (n: string) => void) {
56
+ if (!this.titleDisplay || this.titleDisplay.querySelector('input')) return;
57
+ const raw = this.titleDisplay.textContent?.trim() ?? '';
58
+ const input = document.createElement('input');
59
+ input.type = 'text';
60
+ input.value = raw;
61
+ input.className = 'tb-title-input';
62
+ this.titleDisplay.innerHTML = '';
63
+ this.titleDisplay.appendChild(input);
64
+ input.focus();
65
+ const finish = () => onSave(input.value.trim() || raw);
66
+ input.onblur = finish;
67
+ input.onkeydown = (e) => { if (e.key === 'Enter') input.blur(); };
68
+ input.onclick = (e) => e.stopPropagation();
69
+ }
70
+
71
+ getPlayerInput(): string { return this.inputPlayer?.value ?? ''; }
72
+ clearPlayerInput() { if (this.inputPlayer) { this.inputPlayer.value = ''; this.inputPlayer.focus(); } }
73
+ getTournamentName(): string {
74
+ return this.inputName?.value.trim() || `${this.ui.defaultName} ${new Date().toLocaleDateString()}`;
75
+ }
76
+
77
+ showVictoryToast() {
78
+ const toast = document.createElement('div');
79
+ toast.className = 'tb-toast tb-toast-victory';
80
+ toast.textContent = this.ui.toastFinished;
81
+ document.body.appendChild(toast);
82
+ setTimeout(() => toast.remove(), 3000);
83
+ }
84
+
85
+ showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
86
+ const toast = document.createElement('div');
87
+ toast.className = `tb-toast tb-toast-${type}`;
88
+ toast.textContent = message;
89
+ document.body.appendChild(toast);
90
+ setTimeout(() => {
91
+ toast.style.opacity = '0';
92
+ toast.style.transition = 'opacity 300ms';
93
+ setTimeout(() => toast.remove(), 300);
94
+ }, 3000);
95
+ }
96
+ }
@@ -0,0 +1,84 @@
1
+ import type { TournamentManager } from '../logic/manager';
2
+
3
+ export class TournamentNavigator {
4
+ static findNextPlayableMatch(manager: TournamentManager) {
5
+ for (const round of manager.rounds) {
6
+ const m = round.matches.find((x) => !x.winner && x.player1 && x.player2);
7
+ if (m) return m;
8
+ }
9
+ return null;
10
+ }
11
+
12
+ static isTournamentUnfinished(manager: TournamentManager): boolean {
13
+ return manager.rounds.some((r) => r.matches.some((m) => !m.winner));
14
+ }
15
+
16
+ static scrollToMatch(
17
+ matchId: string,
18
+ manager: TournamentManager,
19
+ activeRound: number,
20
+ callbacks: { onMobileRoundChange: (i: number) => void; onShowToast: () => void }
21
+ ) {
22
+ const desktop = document.querySelector('.desktop-bracket-container') as HTMLElement;
23
+ if (desktop?.offsetParent !== null) {
24
+ this.handleDesktopScroll(desktop, matchId);
25
+ } else {
26
+ this.handleMobileScroll(matchId, manager, activeRound, callbacks.onMobileRoundChange);
27
+ }
28
+ }
29
+
30
+ private static handleDesktopScroll(container: HTMLElement, matchId: string) {
31
+ const btn = container.querySelector(`button[data-match-id="${matchId}"]`) as HTMLElement;
32
+ if (!btn) return;
33
+ const card = (btn.closest('div[style*="position: absolute"]') as HTMLElement) ?? btn;
34
+ this.scrollDesktopToCard(container, card);
35
+ this.highlightElement(card);
36
+ }
37
+
38
+ private static scrollDesktopToCard(container: HTMLElement, card: HTMLElement) {
39
+ if (card.style.position === 'absolute') {
40
+ const top = parseInt(card.style.top ?? '0');
41
+ const left = parseInt(card.parentElement?.style.left ?? '0');
42
+ container.scrollTo({
43
+ left: Math.max(0, left - container.clientWidth / 2 + card.offsetWidth / 2),
44
+ top: Math.max(0, top - container.clientHeight / 2 + card.offsetHeight / 2),
45
+ behavior: 'smooth',
46
+ });
47
+ } else {
48
+ const cr = card.getBoundingClientRect();
49
+ const co = container.getBoundingClientRect();
50
+ container.scrollTo({
51
+ left: container.scrollLeft + (cr.left + cr.width / 2) - (co.left + co.width / 2),
52
+ top: container.scrollTop + (cr.top + cr.height / 2) - (co.top + co.height / 2),
53
+ behavior: 'smooth',
54
+ });
55
+ }
56
+ }
57
+
58
+ private static handleMobileScroll(
59
+ matchId: string, manager: TournamentManager, activeRound: number, onChange: (i: number) => void
60
+ ) {
61
+ const rIdx = manager.rounds.findIndex((r) => r.matches.some((m) => m.id === matchId));
62
+ if (rIdx !== -1 && rIdx !== activeRound) {
63
+ onChange(rIdx);
64
+ setTimeout(() => {
65
+ const btn = document.querySelector(`.bracket-mobile button[data-match-id="${matchId}"]`) as HTMLElement;
66
+ if (btn) this.scrollMobileIntoView(btn);
67
+ }, 100);
68
+ return;
69
+ }
70
+ const btn = document.querySelector(`.bracket-mobile button[data-match-id="${matchId}"]`) as HTMLElement;
71
+ if (btn) this.scrollMobileIntoView(btn);
72
+ }
73
+
74
+ private static scrollMobileIntoView(btn: HTMLElement) {
75
+ const card = (btn.closest('.tb-match-card') as HTMLElement) ?? btn;
76
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
77
+ this.highlightElement(card);
78
+ }
79
+
80
+ private static highlightElement(el: HTMLElement) {
81
+ el.classList.add('tb-highlight');
82
+ setTimeout(() => el.classList.remove('tb-highlight'), 2000);
83
+ }
84
+ }
@@ -0,0 +1,120 @@
1
+ import type { TournamentBracketUI } from '../ui';
2
+
3
+ const CROWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" class="tb-icon-xs" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16L3 5L8.5 10L12 4L15.5 10L21 5L19 16H5M19 19C19 19.6 18.6 20 18 20H6C5.4 20 5 19.6 5 19V18H19V19Z"/></svg>`;
4
+ const TRASH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" class="tb-icon-sm" viewBox="0 0 24 24" fill="currentColor"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>`;
5
+ const CLOCK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" class="tb-icon-xs" viewBox="0 0 24 24" fill="currentColor"><path d="M12 20C7.6 20 4 16.4 4 12S7.6 4 12 4S20 7.6 20 12S16.4 20 12 20M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22S22 17.5 22 12S17.5 2 12 2M16.2 16.2L11 11V7H12.5V10.2L17 14.9L16.2 16.2Z"/></svg>`;
6
+
7
+ export class SetupRenderer {
8
+ listPlayers: HTMLElement | null;
9
+ countPlayers: HTMLElement | null;
10
+ btnGenerate: HTMLButtonElement | null;
11
+ shuffleWrapper: HTMLElement | null;
12
+ historyContainer: HTMLElement | null;
13
+ private ui: TournamentBracketUI;
14
+
15
+ constructor(ui: TournamentBracketUI) {
16
+ this.ui = ui;
17
+ this.listPlayers = document.getElementById('player-list');
18
+ this.countPlayers = document.getElementById('player-count');
19
+ this.btnGenerate = document.getElementById('generate-btn') as HTMLButtonElement;
20
+ this.shuffleWrapper = document.getElementById('shuffle-wrapper');
21
+ this.historyContainer = document.getElementById('history-container');
22
+ }
23
+
24
+ public updatePlayerList(players: string[], onRemove: (i: number) => void) {
25
+ if (!this.listPlayers || !this.countPlayers || !this.btnGenerate) return;
26
+ this.countPlayers.textContent = String(players.length);
27
+ const clearBtn = document.getElementById('clear-players-btn');
28
+ if (players.length === 0) {
29
+ this.listPlayers.innerHTML = `<li class="tb-player-empty">${this.ui.emptyList}</li>`;
30
+ this.setGenerateBtn(false, 0);
31
+ clearBtn?.classList.add('tb-hidden');
32
+ return;
33
+ }
34
+ clearBtn?.classList.remove('tb-hidden');
35
+ this.listPlayers.innerHTML = '';
36
+ const frag = document.createDocumentFragment();
37
+ players.forEach((player, i) => frag.appendChild(this.buildPlayerItem(player, i, onRemove)));
38
+ this.listPlayers.appendChild(frag);
39
+ this.setGenerateBtn(players.length >= 2, players.length);
40
+ }
41
+
42
+ private buildPlayerItem(player: string, i: number, onRemove: (i: number) => void): HTMLElement {
43
+ const li = document.createElement('li');
44
+ li.className = 'tb-player-item';
45
+ const span = document.createElement('span');
46
+ span.textContent = player;
47
+ span.className = 'tb-player-name';
48
+ const btn = document.createElement('button');
49
+ btn.innerHTML = TRASH_ICON;
50
+ btn.className = 'tb-player-remove';
51
+ btn.onclick = () => onRemove(i);
52
+ li.appendChild(span);
53
+ li.appendChild(btn);
54
+ return li;
55
+ }
56
+
57
+ private setGenerateBtn(enabled: boolean, count: number) {
58
+ if (!this.btnGenerate) return;
59
+ this.btnGenerate.disabled = !enabled;
60
+ this.btnGenerate.classList.toggle('tb-btn-disabled', !enabled);
61
+ this.btnGenerate.textContent = enabled ? `${this.ui.generateBtn} (${count})` : this.ui.generateBtn;
62
+ }
63
+
64
+ public renderShuffleControl(isEnabled: boolean, onToggle: (v: boolean) => void) {
65
+ if (!this.shuffleWrapper) return;
66
+ if (!this.shuffleWrapper.querySelector('#shuffle-check')) {
67
+ this.shuffleWrapper.appendChild(this.buildToggleGroup());
68
+ this.shuffleWrapper.querySelector('#shuffle-check')?.addEventListener('change', (e) => {
69
+ onToggle((e.target as HTMLInputElement).checked);
70
+ });
71
+ }
72
+ const check = this.shuffleWrapper.querySelector('#shuffle-check') as HTMLInputElement;
73
+ if (check) check.checked = isEnabled;
74
+ }
75
+
76
+ private buildToggleGroup(): HTMLElement {
77
+ const div = document.createElement('div');
78
+ div.className = 'tb-toggle-group';
79
+ div.innerHTML = `
80
+ <label class="tb-toggle-label"><div class="tb-toggle"><input type="checkbox" id="shuffle-check" class="tb-toggle-input"><div class="tb-toggle-track"></div><div class="tb-toggle-thumb"></div></div><span class="tb-toggle-text">${this.ui.shuffleLabel}</span></label>
81
+ <label class="tb-toggle-label"><div class="tb-toggle"><input type="checkbox" id="score-check" class="tb-toggle-input"><div class="tb-toggle-track"></div><div class="tb-toggle-thumb"></div></div><span class="tb-toggle-text">${this.ui.scoreLabel}</span></label>
82
+ `;
83
+ return div;
84
+ }
85
+
86
+ public renderScoreControl(isEnabled: boolean, onToggle: (v: boolean) => void) {
87
+ const check = this.shuffleWrapper?.querySelector('#score-check') as HTMLInputElement;
88
+ if (check) { check.checked = isEnabled; check.onchange = (e) => onToggle((e.target as HTMLInputElement).checked); }
89
+ }
90
+
91
+ public renderHistoryList(history: TournamentData[], onLoad: (id: string) => void, onDelete: (id: string) => void) {
92
+ if (!this.historyContainer) return;
93
+ if (history.length === 0) {
94
+ this.historyContainer.innerHTML = `<div class="tb-history-empty">${this.ui.noOldTournaments}</div>`;
95
+ return;
96
+ }
97
+ this.historyContainer.innerHTML = '';
98
+ const frag = document.createDocumentFragment();
99
+ [...history].sort((a, b) => b.createdAt - a.createdAt).forEach((item) => {
100
+ frag.appendChild(this.buildHistoryItem(item, onLoad, onDelete));
101
+ });
102
+ this.historyContainer.appendChild(frag);
103
+ }
104
+
105
+ private buildHistoryItem(item: TournamentData, onLoad: (id: string) => void, onDelete: (id: string) => void): HTMLElement {
106
+ const el = document.createElement('div');
107
+ el.className = 'tb-history-item';
108
+ const date = new Date(item.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
109
+ const finished = item.status === 'FINISHED';
110
+ let statusHtml = '';
111
+ if (finished && item.winner) statusHtml = `<span class="tb-history-badge-winner">${CROWN_ICON} ${(item.winner as { name: string }).name}</span>`;
112
+ else if (!finished) statusHtml = `<span class="tb-history-badge-active">${CLOCK_ICON}</span>`;
113
+ el.innerHTML = `<button class="tb-history-load" data-id="${item.id}"><span class="tb-history-name">${item.name}</span><span class="tb-history-date">${date} ${statusHtml}</span></button><button class="tb-history-delete" data-id="${item.id}" title="Borrar">${TRASH_ICON}</button>`;
114
+ el.querySelector('.tb-history-load')?.addEventListener('click', () => onLoad(item.id));
115
+ el.querySelector('.tb-history-delete')?.addEventListener('click', (e) => { e.stopPropagation(); onDelete(item.id); });
116
+ return el;
117
+ }
118
+ }
119
+
120
+ interface TournamentData { id: string; name: string; createdAt: number; status: string; winner?: unknown; }
@@ -0,0 +1,42 @@
1
+ export interface TournamentBracketUI extends Record<string, string> {
2
+ tournamentInProgress: string;
3
+ nextMatch: string;
4
+ share: string;
5
+ backNew: string;
6
+ back: string;
7
+ newTournament: string;
8
+ setupSubtitle: string;
9
+ tournamentNameLabel: string;
10
+ tournamentNamePlaceholder: string;
11
+ addPlayersLabel: string;
12
+ addPlayerPlaceholder: string;
13
+ playersLabel: string;
14
+ clearAll: string;
15
+ emptyList: string;
16
+ howItWorks: string;
17
+ howItWorksText: string;
18
+ historyLabel: string;
19
+ noHistory: string;
20
+ noOldTournaments: string;
21
+ generateBtn: string;
22
+ shuffleLabel: string;
23
+ scoreLabel: string;
24
+ dragHint: string;
25
+ roundFinal: string;
26
+ roundSemifinal: string;
27
+ roundQuarter: string;
28
+ roundPrefix: string;
29
+ byeLabel: string;
30
+ waiting: string;
31
+ emptyRound: string;
32
+ confirmClearPlayers: string;
33
+ alertMinPlayers: string;
34
+ alertLoadFailed: string;
35
+ confirmDeleteTournament: string;
36
+ toastShareLimit: string;
37
+ toastShareError: string;
38
+ toastShareCopied: string;
39
+ toastShareFailed: string;
40
+ toastFinished: string;
41
+ defaultName: string;
42
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { ToolDefinition } from './types';
2
+ import { SCORE_KEEPER_TOOL } from './tool/scoreKeeper/index';
3
+ import { TOURNAMENT_BRACKET_TOOL } from './tool/tournamentBracket/index';
4
+ import { GYM_TRACKER_TOOL } from './tool/gymTracker/index';
5
+ import { REACTION_TESTER_TOOL } from './tool/reactionTester/index';
6
+
7
+ export const ALL_TOOLS: ToolDefinition[] = [
8
+ SCORE_KEEPER_TOOL,
9
+ TOURNAMENT_BRACKET_TOOL,
10
+ GYM_TRACKER_TOOL,
11
+ REACTION_TESTER_TOOL,
12
+ ];
13
+
package/src/types.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { SEOSection } from '@jjlmoya/utils-shared';
2
+ import type { WithContext, Thing } from 'schema-dts';
3
+
4
+ export type { SEOSection };
5
+
6
+ export type KnownLocale =
7
+ | 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
8
+ | 'fr' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
9
+ | 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
10
+
11
+ export interface FAQItem {
12
+ question: string;
13
+ answer: string;
14
+ }
15
+
16
+ export interface BibliographyEntry {
17
+ name: string;
18
+ url: string;
19
+ }
20
+
21
+ export interface HowToStep {
22
+ name: string;
23
+ text: string;
24
+ }
25
+
26
+ export interface ToolLocaleContent<TUI extends Record<string, string> = Record<string, string>> {
27
+ slug: string;
28
+ title: string;
29
+ description: string;
30
+ ui: TUI;
31
+ seo: SEOSection[];
32
+ faqTitle?: string;
33
+ faq: FAQItem[];
34
+ bibliographyTitle?: string;
35
+ bibliography: BibliographyEntry[];
36
+ howTo: HowToStep[];
37
+ schemas: WithContext<Thing>[];
38
+ }
39
+
40
+ export interface CategoryLocaleContent {
41
+ slug: string;
42
+ title: string;
43
+ description: string;
44
+ seo: SEOSection[];
45
+ }
46
+
47
+ export type LocaleLoader<T> = () => Promise<T>;
48
+
49
+ export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
50
+
51
+ export interface SportsToolEntry<TUI extends Record<string, string> = Record<string, string>> {
52
+ id: string;
53
+ icons: {
54
+ bg: string;
55
+ fg: string;
56
+ };
57
+ i18n: LocaleMap<ToolLocaleContent<TUI>>;
58
+ }
59
+
60
+ export interface SportsCategoryEntry {
61
+ icon: string;
62
+ tools: SportsToolEntry<Record<string, string>>[];
63
+ i18n: LocaleMap<CategoryLocaleContent>;
64
+ }
65
+
66
+ export interface ToolDefinition {
67
+ entry: SportsToolEntry<Record<string, string>>;
68
+ Component: unknown;
69
+ SEOComponent: unknown;
70
+ BibliographyComponent: unknown;
71
+ }
72
+