@jjlmoya/utils-sports 1.49.0 → 1.50.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -0
  4. package/src/index.ts +3 -0
  5. package/src/tests/seo_translation_completeness.test.ts +69 -0
  6. package/src/tool/roundRobinLeagueManager/bibliography.astro +6 -0
  7. package/src/tool/roundRobinLeagueManager/bibliography.ts +12 -0
  8. package/src/tool/roundRobinLeagueManager/client-io.ts +35 -0
  9. package/src/tool/roundRobinLeagueManager/component.astro +195 -0
  10. package/src/tool/roundRobinLeagueManager/controller.ts +234 -0
  11. package/src/tool/roundRobinLeagueManager/dom-views.ts +246 -0
  12. package/src/tool/roundRobinLeagueManager/draft-form.ts +62 -0
  13. package/src/tool/roundRobinLeagueManager/entry.ts +30 -0
  14. package/src/tool/roundRobinLeagueManager/evaluator.ts +28 -0
  15. package/src/tool/roundRobinLeagueManager/i18n/de.ts +263 -0
  16. package/src/tool/roundRobinLeagueManager/i18n/en.ts +263 -0
  17. package/src/tool/roundRobinLeagueManager/i18n/es.ts +263 -0
  18. package/src/tool/roundRobinLeagueManager/i18n/fr.ts +263 -0
  19. package/src/tool/roundRobinLeagueManager/i18n/id.ts +263 -0
  20. package/src/tool/roundRobinLeagueManager/i18n/it.ts +263 -0
  21. package/src/tool/roundRobinLeagueManager/i18n/ja.ts +264 -0
  22. package/src/tool/roundRobinLeagueManager/i18n/ko.ts +263 -0
  23. package/src/tool/roundRobinLeagueManager/i18n/nl.ts +263 -0
  24. package/src/tool/roundRobinLeagueManager/i18n/pl.ts +263 -0
  25. package/src/tool/roundRobinLeagueManager/i18n/pt.ts +263 -0
  26. package/src/tool/roundRobinLeagueManager/i18n/ru.ts +263 -0
  27. package/src/tool/roundRobinLeagueManager/i18n/sv.ts +263 -0
  28. package/src/tool/roundRobinLeagueManager/i18n/tr.ts +263 -0
  29. package/src/tool/roundRobinLeagueManager/i18n/zh.ts +263 -0
  30. package/src/tool/roundRobinLeagueManager/index.ts +11 -0
  31. package/src/tool/roundRobinLeagueManager/logic.test.ts +152 -0
  32. package/src/tool/roundRobinLeagueManager/logic.ts +244 -0
  33. package/src/tool/roundRobinLeagueManager/round-robin-league-manager.css +1124 -0
  34. package/src/tool/roundRobinLeagueManager/seo.astro +15 -0
  35. package/src/tool/roundRobinLeagueManager/sharing.ts +45 -0
  36. package/src/tool/roundRobinLeagueManager/storage.ts +43 -0
  37. package/src/tool/roundRobinLeagueManager/ui.ts +90 -0
  38. package/src/tool/roundRobinLeagueManager/validation.ts +57 -0
  39. package/src/tool/runningPacePredictor/i18n/fr.ts +9 -9
  40. package/src/tool/runningPacePredictor/i18n/id.ts +9 -9
  41. package/src/tool/runningPacePredictor/i18n/it.ts +10 -10
  42. package/src/tool/runningPacePredictor/i18n/nl.ts +7 -7
  43. package/src/tool/runningPacePredictor/i18n/pl.ts +10 -10
  44. package/src/tool/runningPacePredictor/i18n/pt.ts +10 -10
  45. package/src/tool/runningPacePredictor/i18n/ru.ts +10 -10
  46. package/src/tool/runningPacePredictor/i18n/sv.ts +10 -10
  47. package/src/tool/runningPacePredictor/i18n/tr.ts +9 -9
  48. package/src/tool/swimCssCalculator/i18n/de.ts +7 -7
  49. package/src/tool/swimCssCalculator/i18n/es.ts +7 -7
  50. package/src/tool/swimCssCalculator/i18n/fr.ts +7 -7
  51. package/src/tool/swimCssCalculator/i18n/id.ts +7 -7
  52. package/src/tool/swimCssCalculator/i18n/it.ts +7 -7
  53. package/src/tool/swimCssCalculator/i18n/nl.ts +7 -7
  54. package/src/tool/swimCssCalculator/i18n/pl.ts +7 -7
  55. package/src/tool/swimCssCalculator/i18n/pt.ts +7 -7
  56. package/src/tool/swimCssCalculator/i18n/ru.ts +7 -7
  57. package/src/tool/swimCssCalculator/i18n/sv.ts +7 -7
  58. package/src/tool/swimCssCalculator/i18n/tr.ts +7 -7
  59. package/src/tool/swimCssCalculator/i18n/zh.ts +7 -7
  60. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.49.0",
3
+ "version": "1.50.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -12,6 +12,7 @@ import { baseballScoreKeeper } from '../tool/baseballScoreKeeper/entry';
12
12
  import { penaltyShootout } from '../tool/penaltyShootout/entry';
13
13
  import { swimCssCalculator } from '../tool/swimCssCalculator/entry';
14
14
  import { vo2MaxCalculator } from '../tool/vo2MaxCalculator/entry';
15
+ import { roundRobinLeagueManager } from '../tool/roundRobinLeagueManager/entry';
15
16
 
16
17
  export const sportsCategory: SportsCategoryEntry = {
17
18
  icon: 'mdi:soccer',
@@ -29,6 +30,7 @@ export const sportsCategory: SportsCategoryEntry = {
29
30
  penaltyShootout,
30
31
  swimCssCalculator,
31
32
  vo2MaxCalculator,
33
+ roundRobinLeagueManager,
32
34
  ] as unknown as SportsToolEntry<Record<string, string>>[],
33
35
  i18n: {
34
36
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -40,6 +40,8 @@ export { volleyballScoreKeeper } from './tool/volleyballScoreKeeper/entry';
40
40
  export type { VolleyballScoreKeeperLocaleContent } from './tool/volleyballScoreKeeper/entry';
41
41
  export { vo2MaxCalculator } from './tool/vo2MaxCalculator/entry';
42
42
  export type { Vo2MaxCalculatorLocaleContent } from './tool/vo2MaxCalculator/entry';
43
+ export { roundRobinLeagueManager } from './tool/roundRobinLeagueManager/entry';
44
+ export type { RoundRobinLeagueManagerLocaleContent } from './tool/roundRobinLeagueManager/entry';
43
45
 
44
46
  export { sportsCategory } from './category';
45
47
 
@@ -64,6 +66,7 @@ import { runningPacePredictor } from './tool/runningPacePredictor/entry';
64
66
  import { swimCssCalculator } from './tool/swimCssCalculator/entry';
65
67
  import { volleyballScoreKeeper } from './tool/volleyballScoreKeeper/entry';
66
68
  import { vo2MaxCalculator } from './tool/vo2MaxCalculator/entry';
69
+ import { roundRobinLeagueManager } from './tool/roundRobinLeagueManager/entry';
67
70
 
68
71
  export const ALL_ENTRIES = [
69
72
  basketScoreKeeper,
@@ -87,4 +90,5 @@ export const ALL_ENTRIES = [
87
90
  swimCssCalculator,
88
91
  volleyballScoreKeeper,
89
92
  vo2MaxCalculator,
93
+ roundRobinLeagueManager,
90
94
  ];
package/src/index.ts CHANGED
@@ -37,3 +37,6 @@ export type { PenaltyShootoutUI, PenaltyShootoutLocaleContent } from './tool/pen
37
37
 
38
38
  export { VOLLEYBALL_SCOREKEEPER_TOOL, volleyballScoreKeeper } from './tool/volleyballScoreKeeper/index';
39
39
  export type { VolleyballScoreKeeperUI, VolleyballScoreKeeperLocaleContent } from './tool/volleyballScoreKeeper/index';
40
+
41
+ export { ROUND_ROBIN_LEAGUE_MANAGER_TOOL, roundRobinLeagueManager } from './tool/roundRobinLeagueManager/index';
42
+ export type { RoundRobinLeagueManagerUI, RoundRobinLeagueManagerLocaleContent } from './tool/roundRobinLeagueManager/index';
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'FIDE Handbook: Berger Tables for Round Robin Tournaments',
6
+ url: 'https://handbook.fide.com/files/handbook/C02Standards.pdf',
7
+ },
8
+ {
9
+ name: 'Premier League Handbook: League Competition Rules',
10
+ url: 'https://resources.premierleague.pulselive.com/premierleague/document/2025/07/24/99839920-d274-42aa-a2ac-e5612b4f6c61/PL_Handbook_25-26_Digital_24.07.pdf',
11
+ },
12
+ ];
@@ -0,0 +1,35 @@
1
+ import { createLeagueShareUrl } from './sharing';
2
+ import { isLeagueRecord, type LeagueRecord } from './logic';
3
+
4
+ export function createClientId(): string {
5
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
6
+ return `league-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
7
+ }
8
+
9
+ export async function copyLeagueLink(record: LeagueRecord): Promise<boolean> {
10
+ try {
11
+ await navigator.clipboard.writeText(createLeagueShareUrl(window.location.href, record));
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function downloadLeagueBackup(record: LeagueRecord): void {
19
+ const blob = new Blob([JSON.stringify(record, null, 2)], { type: 'application/json' });
20
+ const url = URL.createObjectURL(blob);
21
+ const anchor = document.createElement('a');
22
+ anchor.href = url;
23
+ anchor.download = `${record.config.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'league'}.json`;
24
+ anchor.click();
25
+ URL.revokeObjectURL(url);
26
+ }
27
+
28
+ export async function readLeagueBackup(file: File): Promise<LeagueRecord | null> {
29
+ try {
30
+ const parsed: unknown = JSON.parse(await file.text());
31
+ return isLeagueRecord(parsed) ? parsed : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
@@ -0,0 +1,195 @@
1
+ ---
2
+ import './round-robin-league-manager.css';
3
+ import type { RoundRobinLeagueManagerUI } from './ui';
4
+
5
+ interface Props {
6
+ ui: RoundRobinLeagueManagerUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const uiJson = JSON.stringify(ui).replace(/</g, '\\u003c');
11
+ ---
12
+
13
+ <div class="rlm-tool" data-round-robin-league-manager data-active-tab="matches">
14
+ <script is:inline type="application/json" data-role="ui-json" set:html={uiJson}></script>
15
+
16
+ <section class="rlm-library" data-view="library">
17
+ <header class="rlm-library-header">
18
+ <div>
19
+ <span class="rlm-eyebrow">{ui.libraryEyebrow}</span>
20
+ <div class="rlm-title-line">
21
+ <h2>{ui.myLeagues}</h2>
22
+ <span class="rlm-count" data-role="league-count">0</span>
23
+ </div>
24
+ <p>{ui.libraryIntro}</p>
25
+ </div>
26
+ <label class="rlm-import-button">
27
+ {ui.importLeague}
28
+ <input type="file" accept="application/json" data-role="import-file" />
29
+ </label>
30
+ </header>
31
+
32
+ <p class="rlm-notice" data-role="notice" aria-live="polite"></p>
33
+
34
+ <div class="rlm-library-grid">
35
+ <div class="rlm-league-stack">
36
+ <div class="rlm-empty-library" data-role="library-empty">
37
+ <strong>{ui.noLeagues}</strong>
38
+ <span>{ui.noLeaguesHint}</span>
39
+ </div>
40
+ <div class="rlm-league-list" data-role="league-list"></div>
41
+ </div>
42
+
43
+ <form class="rlm-create-card" data-role="create-form">
44
+ <div class="rlm-card-heading">
45
+ <span class="rlm-card-number">+</span>
46
+ <div>
47
+ <h3>{ui.newLeague}</h3>
48
+ <button type="button" class="rlm-example" data-action="load-example">{ui.loadExample}</button>
49
+ </div>
50
+ </div>
51
+
52
+ <label class="rlm-field">
53
+ <span>{ui.leagueNameLabel}</span>
54
+ <input type="text" maxlength="60" placeholder={ui.leagueNamePlaceholder} data-role="league-name" />
55
+ </label>
56
+
57
+ <label class="rlm-field">
58
+ <span>{ui.teamsLabel}</span>
59
+ <small>{ui.teamsHint}</small>
60
+ <textarea rows="7" maxlength="1024" placeholder={ui.teamsPlaceholder} data-role="teams"></textarea>
61
+ </label>
62
+
63
+ <fieldset class="rlm-fieldset">
64
+ <legend>{ui.formatLabel}</legend>
65
+ <div class="rlm-choice-row">
66
+ <button type="button" data-format="single" aria-pressed="true">
67
+ <strong>{ui.singleLeg}</strong>
68
+ <span>{ui.singleLegHint}</span>
69
+ </button>
70
+ <button type="button" data-format="double" aria-pressed="false">
71
+ <strong>{ui.doubleLeg}</strong>
72
+ <span>{ui.doubleLegHint}</span>
73
+ </button>
74
+ </div>
75
+ </fieldset>
76
+
77
+ <fieldset class="rlm-fieldset">
78
+ <legend>{ui.scoringLabel}</legend>
79
+ <div class="rlm-points-row">
80
+ <label><span>{ui.winPoints}</span><input type="number" min="0" max="20" value="3" data-role="draft-win" /></label>
81
+ <label><span>{ui.drawPoints}</span><input type="number" min="0" max="20" value="1" data-role="draft-draw" /></label>
82
+ <label><span>{ui.lossPoints}</span><input type="number" min="0" max="20" value="0" data-role="draft-loss" /></label>
83
+ </div>
84
+ </fieldset>
85
+
86
+ <p class="rlm-draft-status" data-role="draft-status" aria-live="polite"></p>
87
+ <button type="submit" class="rlm-primary" data-role="create-league" disabled>{ui.createLeague}</button>
88
+ <p class="rlm-import-hint">{ui.importHint}</p>
89
+ </form>
90
+ </div>
91
+ </section>
92
+
93
+ <section class="rlm-manager" data-view="manager" hidden>
94
+ <header class="rlm-manager-header">
95
+ <div class="rlm-manager-nav">
96
+ <button type="button" class="rlm-back" data-action="show-library">{ui.backToLeagues}</button>
97
+ <button type="button" class="rlm-back" data-action="new-league">+ {ui.newLeagueAction}</button>
98
+ </div>
99
+ <div class="rlm-league-identity">
100
+ <div class="rlm-league-mark" aria-hidden="true"><span></span><span></span><span></span></div>
101
+ <div>
102
+ <h2 data-role="active-name"></h2>
103
+ <p data-role="active-meta"></p>
104
+ </div>
105
+ </div>
106
+ <div class="rlm-manager-actions">
107
+ <span class="rlm-saved">{ui.allChangesSaved}</span>
108
+ <button type="button" data-action="download-active">{ui.downloadLeague}</button>
109
+ <button type="button" class="rlm-share" data-action="share-active">{ui.shareLeague}</button>
110
+ </div>
111
+ </header>
112
+
113
+ <div class="rlm-progress-strip">
114
+ <span>{ui.progressLabel}</span>
115
+ <div><i data-role="progress-bar"></i></div>
116
+ <strong data-role="progress-text"></strong>
117
+ </div>
118
+
119
+ <nav class="rlm-tabs" aria-label={ui.myLeagues}>
120
+ <button type="button" data-action="set-tab" data-tab="matches" aria-pressed="true">{ui.matchesTab}</button>
121
+ <button type="button" data-action="set-tab" data-tab="standings" aria-pressed="false">{ui.standingsTab}</button>
122
+ <button type="button" data-action="set-tab" data-tab="settings" aria-pressed="false">{ui.settingsTab}</button>
123
+ </nav>
124
+
125
+ <div class="rlm-panel rlm-overview" data-panel="matches">
126
+ <section class="rlm-fixture-panel">
127
+ <header class="rlm-panel-heading">
128
+ <div>
129
+ <span>{ui.fixtures}</span>
130
+ <h3 data-role="round-title"></h3>
131
+ </div>
132
+ <div class="rlm-round-arrows">
133
+ <button type="button" data-action="previous-round" aria-label={ui.previousRound}></button>
134
+ <button type="button" data-action="next-round" aria-label={ui.nextRound}></button>
135
+ </div>
136
+ </header>
137
+ <nav class="rlm-round-list" data-role="round-list" aria-label={ui.matchday}></nav>
138
+ <div class="rlm-match-list" data-role="match-list"></div>
139
+ <p class="rlm-bye" data-role="bye" hidden></p>
140
+ </section>
141
+
142
+ <aside class="rlm-standing-panel">
143
+ <header class="rlm-panel-heading">
144
+ <div>
145
+ <span>{ui.standings}</span>
146
+ <h3>{ui.standingsTab}</h3>
147
+ </div>
148
+ <button type="button" class="rlm-text-action" data-action="set-tab" data-tab="standings">{ui.standingsTab}</button>
149
+ </header>
150
+ <div class="rlm-table-scroll is-compact" data-role="standings-table"></div>
151
+ </aside>
152
+ </div>
153
+
154
+ <section class="rlm-panel rlm-full-table" data-panel="standings" hidden>
155
+ <header class="rlm-section-header">
156
+ <div><span>{ui.standings}</span><h3>{ui.standingsTab}</h3></div>
157
+ <p>{ui.standingsHint}</p>
158
+ </header>
159
+ <div class="rlm-table-scroll" data-role="standings-table"></div>
160
+ </section>
161
+
162
+ <section class="rlm-panel rlm-settings" data-panel="settings" hidden>
163
+ <header class="rlm-section-header">
164
+ <div><span>{ui.settingsTab}</span><h3>{ui.leagueSettings}</h3></div>
165
+ <p>{ui.settingsHint}</p>
166
+ </header>
167
+ <div class="rlm-settings-grid">
168
+ <label class="rlm-field"><span>{ui.leagueNameLabel}</span><input type="text" maxlength="60" data-role="settings-name" /></label>
169
+ <fieldset class="rlm-fieldset">
170
+ <legend>{ui.scoringLabel}</legend>
171
+ <div class="rlm-points-row">
172
+ <label><span>{ui.winPoints}</span><input type="number" min="0" max="20" data-role="settings-win" /></label>
173
+ <label><span>{ui.drawPoints}</span><input type="number" min="0" max="20" data-role="settings-draw" /></label>
174
+ <label><span>{ui.lossPoints}</span><input type="number" min="0" max="20" data-role="settings-loss" /></label>
175
+ </div>
176
+ </fieldset>
177
+ </div>
178
+ <button type="button" class="rlm-primary is-small" data-action="save-settings">{ui.saveSettings}</button>
179
+ <div class="rlm-danger-zone">
180
+ <div><strong>{ui.dangerZone}</strong><span>{ui.confirmDelete}</span></div>
181
+ <button type="button" data-action="delete-active">{ui.deleteLeague}</button>
182
+ </div>
183
+ </section>
184
+
185
+ <p class="rlm-notice" data-role="notice" aria-live="polite"></p>
186
+ </section>
187
+ </div>
188
+
189
+ <script>
190
+ import { initRoundRobinLeagueManager } from './controller';
191
+
192
+ document.querySelectorAll<HTMLElement>('[data-round-robin-league-manager]').forEach((root) => {
193
+ initRoundRobinLeagueManager(root);
194
+ });
195
+ </script>
@@ -0,0 +1,234 @@
1
+ import { renderLibrary, renderLiveData, renderManager, renderMatchStatus, renderSettings } from './dom-views';
2
+ import { copyLeagueLink, createClientId, downloadLeagueBackup, readLeagueBackup } from './client-io';
3
+ import { pointsValue, readDraft, setFormat, syncDraft } from './draft-form';
4
+ import {
5
+ createLeagueRecord,
6
+ generateSchedule,
7
+ parseScore,
8
+ type LeagueLibrary,
9
+ type LeagueRecord,
10
+ type LeagueSchedule,
11
+ } from './logic';
12
+ import { importSharedLeague } from './sharing';
13
+ import { loadLeagueLibrary, saveLeagueLibrary } from './storage';
14
+ import type { RoundRobinLeagueManagerUI } from './ui';
15
+
16
+ interface AppState {
17
+ library: LeagueLibrary;
18
+ active: LeagueRecord | null;
19
+ schedule: LeagueSchedule | null;
20
+ }
21
+
22
+ type ClickHandler = (button: HTMLButtonElement) => void;
23
+
24
+ function query<T extends Element>(root: HTMLElement, selector: string): T {
25
+ const node = root.querySelector<T>(selector);
26
+ if (!node) throw new Error(`Missing element: ${selector}`);
27
+ return node;
28
+ }
29
+
30
+ function readUI(root: HTMLElement): RoundRobinLeagueManagerUI {
31
+ const script = query<HTMLScriptElement>(root, '[data-role="ui-json"]');
32
+ return JSON.parse(script.textContent ?? '{}') as RoundRobinLeagueManagerUI;
33
+ }
34
+
35
+ function upsertActive(state: AppState): void {
36
+ if (!state.active) return;
37
+ const index = state.library.leagues.findIndex((league) => league.id === state.active?.id);
38
+ if (index >= 0) state.library.leagues[index] = state.active;
39
+ else state.library.leagues.push(state.active);
40
+ state.library.activeLeagueId = state.active.id;
41
+ saveLeagueLibrary(state.library);
42
+ }
43
+
44
+ function setNotice(root: HTMLElement, message: string): void {
45
+ root.querySelectorAll<HTMLElement>('[data-role="notice"]').forEach((notice) => {
46
+ notice.textContent = message;
47
+ });
48
+ }
49
+
50
+ function showLibrary(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI): void {
51
+ query<HTMLElement>(root, '[data-view="library"]').hidden = false;
52
+ query<HTMLElement>(root, '[data-view="manager"]').hidden = true;
53
+ state.active = null;
54
+ state.schedule = null;
55
+ renderLibrary(root, state.library, ui);
56
+ }
57
+
58
+ function startNewLeague(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI): void {
59
+ showLibrary(root, state, ui);
60
+ query<HTMLInputElement>(root, '[data-role="league-name"]').focus();
61
+ }
62
+
63
+ function openLeague(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI, id: string): void {
64
+ const record = state.library.leagues.find((league) => league.id === id);
65
+ if (!record) return;
66
+ state.active = record;
67
+ state.schedule = generateSchedule(record.config);
68
+ record.selectedRound = Math.min(record.selectedRound, state.schedule.rounds.length - 1);
69
+ state.library.activeLeagueId = record.id;
70
+ saveLeagueLibrary(state.library);
71
+ query<HTMLElement>(root, '[data-view="library"]').hidden = true;
72
+ query<HTMLElement>(root, '[data-view="manager"]').hidden = false;
73
+ setTab(root, 'matches');
74
+ renderManager(root, record, state.schedule, ui);
75
+ renderSettings(root, record);
76
+ }
77
+
78
+ function createLeague(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI): void {
79
+ if (!syncDraft(root, ui)) return;
80
+ const now = new Date().toISOString();
81
+ const record = createLeagueRecord(readDraft(root), createClientId(), now);
82
+ state.library.leagues.push(record);
83
+ state.library.activeLeagueId = record.id;
84
+ saveLeagueLibrary(state.library);
85
+ query<HTMLFormElement>(root, '[data-role="create-form"]').reset();
86
+ setFormat(root, 'single');
87
+ openLeague(root, state, ui, record.id);
88
+ }
89
+
90
+ function setTab(root: HTMLElement, tab: string): void {
91
+ root.dataset.activeTab = tab;
92
+ root.querySelectorAll<HTMLButtonElement>('[data-action="set-tab"]').forEach((button) => {
93
+ button.setAttribute('aria-pressed', String(button.dataset.tab === tab));
94
+ });
95
+ root.querySelectorAll<HTMLElement>('[data-panel]').forEach((panel) => {
96
+ panel.hidden = panel.dataset.panel !== tab;
97
+ });
98
+ }
99
+
100
+ function selectRound(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI, index: number): void {
101
+ if (!state.active || !state.schedule) return;
102
+ const bounded = Math.max(0, Math.min(index, state.schedule.rounds.length - 1));
103
+ state.active.selectedRound = bounded;
104
+ state.active.updatedAt = new Date().toISOString();
105
+ upsertActive(state);
106
+ renderManager(root, state.active, state.schedule, ui);
107
+ }
108
+
109
+ function changeRound(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI, delta: number): void {
110
+ if (!state.active) return;
111
+ selectRound(root, state, ui, state.active.selectedRound + delta);
112
+ }
113
+
114
+ async function shareRecord(root: HTMLElement, record: LeagueRecord, ui: RoundRobinLeagueManagerUI): Promise<void> {
115
+ const copied = await copyLeagueLink(record);
116
+ setNotice(root, copied ? ui.copiedLeague : ui.copyFailed);
117
+ }
118
+
119
+ function deleteLeague(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI, id: string): void {
120
+ if (!window.confirm(ui.confirmDelete)) return;
121
+ state.library.leagues = state.library.leagues.filter((league) => league.id !== id);
122
+ if (state.library.activeLeagueId === id) state.library.activeLeagueId = null;
123
+ saveLeagueLibrary(state.library);
124
+ showLibrary(root, state, ui);
125
+ }
126
+
127
+ function updateResult(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI, input: HTMLInputElement): void {
128
+ if (!state.active || !state.schedule) return;
129
+ const matchId = input.dataset.matchId;
130
+ const side = input.dataset.side as 'home' | 'away' | undefined;
131
+ if (!matchId || !side) return;
132
+ const current = state.active.results[matchId] ?? { home: null, away: null };
133
+ state.active.results[matchId] = { ...current, [side]: parseScore(input.value) };
134
+ state.active.updatedAt = new Date().toISOString();
135
+ upsertActive(state);
136
+ renderMatchStatus(input, state.active.results[matchId], ui);
137
+ renderLiveData(root, state.active, state.schedule, ui);
138
+ }
139
+
140
+ function saveSettings(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI): void {
141
+ if (!state.active || !state.schedule) return;
142
+ const name = query<HTMLInputElement>(root, '[data-role="settings-name"]').value.trim();
143
+ if (!name) return;
144
+ state.active.config.name = name;
145
+ state.active.config.scoring = {
146
+ win: pointsValue(root, 'settings-win', state.active.config.scoring.win),
147
+ draw: pointsValue(root, 'settings-draw', state.active.config.scoring.draw),
148
+ loss: pointsValue(root, 'settings-loss', state.active.config.scoring.loss),
149
+ };
150
+ state.active.updatedAt = new Date().toISOString();
151
+ upsertActive(state);
152
+ renderManager(root, state.active, state.schedule, ui);
153
+ setNotice(root, ui.settingsSaved);
154
+ }
155
+
156
+ function loadExample(root: HTMLElement, ui: RoundRobinLeagueManagerUI): void {
157
+ query<HTMLInputElement>(root, '[data-role="league-name"]').value = ui.demoLeagueName;
158
+ query<HTMLTextAreaElement>(root, '[data-role="teams"]').value = ui.demoTeams;
159
+ syncDraft(root, ui);
160
+ }
161
+
162
+ async function importFile(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI, file: File): Promise<void> {
163
+ const parsed = await readLeagueBackup(file);
164
+ if (parsed) {
165
+ const now = new Date().toISOString();
166
+ const record = { ...parsed, id: createClientId(), createdAt: now, updatedAt: now };
167
+ state.library.leagues.push(record);
168
+ saveLeagueLibrary(state.library);
169
+ openLeague(root, state, ui, record.id);
170
+ setNotice(root, ui.importedLeague);
171
+ } else {
172
+ showLibrary(root, state, ui);
173
+ setNotice(root, ui.importFailed);
174
+ }
175
+ }
176
+
177
+ function handlers(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI): Record<string, ClickHandler> {
178
+ return {
179
+ 'show-library': () => showLibrary(root, state, ui),
180
+ 'new-league': () => startNewLeague(root, state, ui),
181
+ 'load-example': () => loadExample(root, ui),
182
+ 'open-league': (button) => openLeague(root, state, ui, button.dataset.leagueId ?? ''),
183
+ 'select-round': (button) => selectRound(root, state, ui, Number(button.dataset.round)),
184
+ 'previous-round': () => changeRound(root, state, ui, -1),
185
+ 'next-round': () => changeRound(root, state, ui, 1),
186
+ 'set-tab': (button) => setTab(root, button.dataset.tab ?? 'matches'),
187
+ 'share-active': () => { if (state.active) void shareRecord(root, state.active, ui); },
188
+ 'download-active': () => {
189
+ if (!state.active) return;
190
+ downloadLeagueBackup(state.active);
191
+ setNotice(root, ui.downloadedLeague);
192
+ },
193
+ 'delete-active': () => { if (state.active) deleteLeague(root, state, ui, state.active.id); },
194
+ 'delete-library-league': (button) => deleteLeague(root, state, ui, button.dataset.leagueId ?? ''),
195
+ 'share-library-league': (button) => { const record = state.library.leagues.find((league) => league.id === button.dataset.leagueId); if (record) void shareRecord(root, record, ui); },
196
+ 'save-settings': () => saveSettings(root, state, ui),
197
+ };
198
+ }
199
+
200
+ function bindEvents(root: HTMLElement, state: AppState, ui: RoundRobinLeagueManagerUI): void {
201
+ const clickHandlers = handlers(root, state, ui);
202
+ root.addEventListener('click', (event) => {
203
+ const button = (event.target as Element).closest<HTMLButtonElement>('[data-action]');
204
+ if (button?.dataset.action) clickHandlers[button.dataset.action]?.(button);
205
+ });
206
+ root.addEventListener('input', (event) => {
207
+ const input = event.target as HTMLInputElement;
208
+ if (input.dataset.matchId) updateResult(root, state, ui, input);
209
+ else if (input.closest('[data-role="create-form"]')) syncDraft(root, ui);
210
+ });
211
+ root.querySelectorAll<HTMLButtonElement>('[data-format]').forEach((button) => button.addEventListener('click', () => {
212
+ setFormat(root, button.dataset.format === 'double' ? 'double' : 'single');
213
+ syncDraft(root, ui);
214
+ }));
215
+ }
216
+
217
+ export function initRoundRobinLeagueManager(root: HTMLElement): void {
218
+ const ui = readUI(root);
219
+ const state: AppState = { library: loadLeagueLibrary(), active: null, schedule: null };
220
+ bindEvents(root, state, ui);
221
+ query<HTMLFormElement>(root, '[data-role="create-form"]').addEventListener('submit', (event) => {
222
+ event.preventDefault();
223
+ createLeague(root, state, ui);
224
+ });
225
+ query<HTMLInputElement>(root, '[data-role="import-file"]').addEventListener('change', (event) => {
226
+ const file = (event.target as HTMLInputElement).files?.[0];
227
+ if (file) void importFile(root, state, ui, file);
228
+ });
229
+ const initialId = importSharedLeague(state.library, window.location.href, new Date().toISOString());
230
+ saveLeagueLibrary(state.library);
231
+ renderLibrary(root, state.library, ui);
232
+ if (initialId) openLeague(root, state, ui, initialId);
233
+ else syncDraft(root, ui);
234
+ }