@jjlmoya/utils-sports 1.59.0 → 1.60.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 (29) hide show
  1. package/package.json +3 -2
  2. package/src/tests/mfe_assets_contract.test.ts +48 -0
  3. package/src/tests/qa-test-helpers.ts +32 -0
  4. package/src/tests/qa_bibliography_links.test.ts +50 -0
  5. package/src/tests/qa_claim_evidence.test.ts +69 -0
  6. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  7. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  8. package/src/tool/rugbyScoreKeeper/component.astro +9 -8
  9. package/src/tool/rugbyScoreKeeper/i18n/de.ts +2 -0
  10. package/src/tool/rugbyScoreKeeper/i18n/en.ts +2 -0
  11. package/src/tool/rugbyScoreKeeper/i18n/es.ts +2 -0
  12. package/src/tool/rugbyScoreKeeper/i18n/fr.ts +2 -0
  13. package/src/tool/rugbyScoreKeeper/i18n/id.ts +2 -0
  14. package/src/tool/rugbyScoreKeeper/i18n/it.ts +2 -0
  15. package/src/tool/rugbyScoreKeeper/i18n/ja.ts +2 -0
  16. package/src/tool/rugbyScoreKeeper/i18n/ko.ts +2 -0
  17. package/src/tool/rugbyScoreKeeper/i18n/nl.ts +2 -0
  18. package/src/tool/rugbyScoreKeeper/i18n/pl.ts +2 -0
  19. package/src/tool/rugbyScoreKeeper/i18n/pt.ts +2 -0
  20. package/src/tool/rugbyScoreKeeper/i18n/ru.ts +10 -8
  21. package/src/tool/rugbyScoreKeeper/i18n/sv.ts +2 -0
  22. package/src/tool/rugbyScoreKeeper/i18n/tr.ts +2 -0
  23. package/src/tool/rugbyScoreKeeper/i18n/zh.ts +2 -0
  24. package/src/tool/rugbyScoreKeeper/logic.test.ts +102 -0
  25. package/src/tool/rugbyScoreKeeper/logic.ts +43 -17
  26. package/src/tool/rugbyScoreKeeper/rugby-scorekeeper.css +12 -0
  27. package/src/tool/rugbyScoreKeeper/ui-helpers.ts +20 -0
  28. package/src/tool/rugbyScoreKeeper/ui-init.ts +61 -56
  29. package/src/tool/rugbyScoreKeeper/ui.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -27,7 +27,8 @@
27
27
  "lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
- "test": "vitest run --reporter=verbose --testTimeout=30000",
30
+ "test": "vitest run --exclude src/tests/qa_*.test.ts --reporter=verbose --testTimeout=30000",
31
+ "qa:audit": "vitest run src/tests/qa_*.test.ts --reporter=verbose",
31
32
  "preversion": "npm run lint && npm run test && npm run build",
32
33
  "postversion": "git push && git push --tags",
33
34
  "patch": "npm version patch",
@@ -0,0 +1,48 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
6
+
7
+ const categoryImageMatch = CATEGORY_OG_IMAGE.match(
8
+ /^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
9
+ );
10
+
11
+ if (!categoryImageMatch) {
12
+ throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
13
+ }
14
+
15
+ const [, imageUrlRoot, categoryImage, assetVersion] = categoryImageMatch;
16
+ const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
17
+ const categorySlug = basename(categoryImage, '.webp');
18
+
19
+ describe('MFE asset contract', () => {
20
+ it('has one non-empty English-slug OG image per category and registered tool', async () => {
21
+ const expectedSlugs = new Set([categorySlug]);
22
+
23
+ for (const { entry } of ALL_TOOLS) {
24
+ const englishLoader = entry.i18n.en;
25
+ if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
26
+
27
+ const englishContent = await englishLoader();
28
+ expectedSlugs.add(englishContent.slug);
29
+ }
30
+
31
+ const actualSlugs = new Set(
32
+ readdirSync(assetRoot)
33
+ .filter((filename) => filename.endsWith('.webp'))
34
+ .map((filename) => filename.slice(0, -'.webp'.length)),
35
+ );
36
+
37
+ expect(actualSlugs).toEqual(expectedSlugs);
38
+
39
+ for (const slug of expectedSlugs) {
40
+ const imagePath = join(assetRoot, `${slug}.webp`);
41
+ expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
42
+ expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
43
+ expect(getUtilityOgImage(slug)).toBe(
44
+ `${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
45
+ );
46
+ }
47
+ }, 30000);
48
+ });
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ export const repositoryRoot = process.cwd();
5
+ export const toolRoot = join(repositoryRoot, 'src', 'tool');
6
+
7
+ export function listToolDirectories(): string[] {
8
+ return readdirSync(toolRoot)
9
+ .map((name) => join(toolRoot, name))
10
+ .filter((path) => statSync(path).isDirectory());
11
+ }
12
+
13
+ export function findFiles(directory: string, extensions: string[]): string[] {
14
+ const files: string[] = [];
15
+
16
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
17
+ const path = join(directory, entry.name);
18
+ if (entry.isDirectory()) files.push(...findFiles(path, extensions));
19
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
20
+ }
21
+
22
+ return files;
23
+ }
24
+
25
+ export function read(path: string): string {
26
+ return readFileSync(path, 'utf8');
27
+ }
28
+
29
+ export function displayPath(path: string): string {
30
+ return relative(repositoryRoot, path).replace(/\\/g, '/');
31
+ }
32
+
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
4
+
5
+ interface LinkFailure {
6
+ tool: string;
7
+ message: string;
8
+ }
9
+
10
+ function validateUrl(tool: string, rawUrl: string, seen: Set<string>): LinkFailure[] {
11
+ let url: URL;
12
+ try {
13
+ url = new URL(rawUrl);
14
+ } catch {
15
+ return [{ tool, message: `invalid URL: ${rawUrl}` }];
16
+ }
17
+
18
+ const failures: LinkFailure[] = [];
19
+ if (url.protocol !== 'https:') failures.push({ tool, message: `non-HTTPS URL: ${rawUrl}` });
20
+ if (url.pathname === '/' && !url.search && !url.hash) {
21
+ failures.push({ tool, message: `generic homepage, cite the exact document: ${rawUrl}` });
22
+ }
23
+ if (seen.has(url.href)) failures.push({ tool, message: `duplicate source URL: ${rawUrl}` });
24
+ seen.add(url.href);
25
+ return failures;
26
+ }
27
+
28
+ describe('QA: bibliography links are specific and usable', () => {
29
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
30
+ const failures: LinkFailure[] = [];
31
+
32
+ for (const tool of ALL_TOOLS) {
33
+ const loader = tool.entry.i18n.en;
34
+ if (!loader) {
35
+ failures.push({ tool: tool.entry.id, message: 'English locale loader is missing' });
36
+ continue;
37
+ }
38
+
39
+ const content = (await loader()) as ToolLocaleContent;
40
+ const seen = new Set<string>();
41
+
42
+ for (const entry of content.bibliography ?? []) {
43
+ failures.push(...validateUrl(tool.entry.id, entry.url, seen));
44
+ }
45
+ }
46
+
47
+ const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
48
+ expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
49
+ });
50
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { displayPath, listToolDirectories, read } from './qa-test-helpers';
5
+
6
+ const evidenceClaims = [
7
+ /\bofficial algorithm\b/i,
8
+ /\balgoritmo oficial\b/i,
9
+ /\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
10
+ /\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
11
+ /\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
12
+ /\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
13
+ /\b(?:guarantees|ensures)\b/i,
14
+ /\bgarantiza\b/i,
15
+ /\bofficial (?:data|rate|calculation)\b/i,
16
+ /\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
17
+ ];
18
+
19
+ const requiredEvidenceFields = [
20
+ 'reviewedAt',
21
+ 'methodology',
22
+ 'sources',
23
+ 'referenceCases',
24
+ 'limitations',
25
+ ];
26
+
27
+ describe('QA: strong factual claims are traceable', () => {
28
+ it('requires machine-readable validation evidence beside tools making strong claims', () => {
29
+ const failures: string[] = [];
30
+
31
+ for (const directory of listToolDirectories()) {
32
+ const localePaths = ['en', 'es']
33
+ .map((locale) => join(directory, 'i18n', `${locale}.ts`))
34
+ .filter(existsSync);
35
+ const claims = localePaths.flatMap((path) => {
36
+ const source = read(path);
37
+ return evidenceClaims
38
+ .filter((pattern) => pattern.test(source))
39
+ .map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
40
+ });
41
+ if (claims.length === 0) continue;
42
+
43
+ const evidencePath = join(directory, 'validation.ts');
44
+ if (!existsSync(evidencePath)) {
45
+ failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
46
+ continue;
47
+ }
48
+
49
+ const evidence = read(evidencePath);
50
+ const missingFields = requiredEvidenceFields.filter(
51
+ (field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
52
+ );
53
+ if (missingFields.length > 0) {
54
+ failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
55
+ }
56
+ }
57
+
58
+ expect(
59
+ failures,
60
+ [
61
+ 'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
62
+ 'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
63
+ 'or weaken/remove the unsupported claim.',
64
+ ...failures,
65
+ ].join('\n'),
66
+ ).toEqual([]);
67
+ });
68
+ });
69
+
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ function hasDedicatedLogicTest(toolDirectory: string): boolean {
13
+ const localCandidates = [
14
+ join(toolDirectory, 'logic.test.ts'),
15
+ join(toolDirectory, 'logic.spec.ts'),
16
+ join(toolDirectory, '__tests__', 'logic.test.ts'),
17
+ join(toolDirectory, '__tests__', 'logic.spec.ts'),
18
+ ];
19
+ if (localCandidates.some(existsSync)) return true;
20
+
21
+ const toolName = basename(toolDirectory);
22
+ const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
23
+ return centralTests.some((testPath) => {
24
+ const source = read(testPath).replace(/\\/g, '/');
25
+ return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
26
+ });
27
+ }
28
+
29
+ describe('QA: calculation logic has reference tests', () => {
30
+ it('gives every public calculator logic module its own behavioral test suite', () => {
31
+ const failures = listToolDirectories()
32
+ .filter((directory) => existsSync(join(directory, 'logic.ts')))
33
+ .filter((directory) => !hasDedicatedLogicTest(directory))
34
+ .map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
35
+
36
+ expect(
37
+ failures,
38
+ [
39
+ 'Add behavioral tests through each logic module public API.',
40
+ 'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
41
+ ...failures,
42
+ ].join('\n'),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ interface VisibleLiteral {
13
+ line: number;
14
+ value: string;
15
+ }
16
+
17
+ const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
18
+ const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
19
+
20
+ function visibleWords(value: string): string {
21
+ return value
22
+ .replace(/\$\{[^}]*\}/g, ' ')
23
+ .replace(/<[^>]+>/g, ' ')
24
+ .replace(/&[a-z]+;/gi, ' ')
25
+ .replace(/[\d\p{P}\p{S}_]+/gu, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ function isTechnicalLiteral(value: string): boolean {
31
+ return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
32
+ || /^data-[a-z0-9-]+$/i.test(value);
33
+ }
34
+
35
+ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
36
+ const failures: VisibleLiteral[] = [];
37
+
38
+ source.split(/\r?\n/).forEach((line, index) => {
39
+ if (!domTextAssignment.test(line)) return;
40
+
41
+ const assignment = line.slice(line.search(domTextAssignment));
42
+ for (const match of assignment.matchAll(quotedLiteral)) {
43
+ const literal = match[2];
44
+ if (!literal || isTechnicalLiteral(literal)) continue;
45
+ const words = visibleWords(literal);
46
+ if (words && /\p{L}/u.test(words)) {
47
+ failures.push({ line: index + 1, value: literal });
48
+ }
49
+ }
50
+ });
51
+
52
+ return failures;
53
+ }
54
+
55
+ function runtimeSources(toolDirectory: string): string {
56
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
57
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
58
+ .filter((path) => basename(path) !== 'ui.ts')
59
+ .map(read)
60
+ .join('\n');
61
+ }
62
+
63
+ describe('QA: runtime copy is localized', () => {
64
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
65
+
66
+ it('does not write user-facing string literals directly into the DOM', () => {
67
+ const failures = componentFiles.flatMap((path) =>
68
+ hardcodedVisibleLiterals(read(path)).map(
69
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
70
+ ),
71
+ );
72
+
73
+ expect(
74
+ failures,
75
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it('uses every string declared by each UI locale contract', () => {
80
+ const failures: string[] = [];
81
+
82
+ for (const directory of listToolDirectories()) {
83
+ const uiPath = join(directory, 'ui.ts');
84
+ if (!existsSync(uiPath)) continue;
85
+
86
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
87
+ const runtime = runtimeSources(directory);
88
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
89
+
90
+ if (unused.length > 0) {
91
+ failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
92
+ }
93
+ }
94
+
95
+ expect(
96
+ failures,
97
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
98
+ ).toEqual([]);
99
+ });
100
+ });
@@ -18,7 +18,7 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
18
18
  <div class="rg-strip">
19
19
  <div class="rg-team">
20
20
  <input type="text" class="rg-name" id="rg-name-home" value={t.home} aria-label={t.home} />
21
- <div class="rg-score" id="rg-score-home">0</div>
21
+ <div class="rg-score" id="rg-score-home" aria-live="polite">0</div>
22
22
  <div class="rg-bonus" id="rg-bonus-home"></div>
23
23
  </div>
24
24
  <div class="rg-divider">
@@ -27,7 +27,7 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
27
27
  </div>
28
28
  <div class="rg-team">
29
29
  <input type="text" class="rg-name" id="rg-name-away" value={t.away} aria-label={t.away} />
30
- <div class="rg-score" id="rg-score-away">0</div>
30
+ <div class="rg-score" id="rg-score-away" aria-live="polite">0</div>
31
31
  <div class="rg-bonus" id="rg-bonus-away"></div>
32
32
  </div>
33
33
  </div>
@@ -54,10 +54,10 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
54
54
  <div class="rg-clock-ring">
55
55
  <svg class="rg-clock-svg" viewBox="0 0 120 120">
56
56
  <circle class="rg-clock-bg" cx="60" cy="60" r="54" />
57
- <circle class="rg-clock-fill" id="rg-clock-fill" cx="60" cy="60" r="54" />
57
+ <circle class="rg-clock-fill" id="rg-clock-fill" cx="60" cy="60" r="54" aria-hidden="true" />
58
58
  </svg>
59
59
  <div class="rg-clock-inner">
60
- <div class="rg-clock-time" id="rg-clock-time">00:00</div>
60
+ <div class="rg-clock-time" id="rg-clock-time" aria-live="polite">00:00</div>
61
61
  <div class="rg-clock-label">{t.half} <span id="rg-half-label">1</span></div>
62
62
  </div>
63
63
  </div>
@@ -68,8 +68,8 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
68
68
  <div class="rg-sinbin-form">
69
69
  <input type="text" class="rg-input" id="rg-sinbin-input" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
70
70
  <select class="rg-select" id="rg-sinbin-duration" aria-label={t.sinBin + " duration"}>
71
- <option value="600">10 min</option>
72
- <option value="300">5 min</option>
71
+ <option value="600">10 {t.minutesShort}</option>
72
+ <option value="300">5 {t.minutesShort}</option>
73
73
  </select>
74
74
  <button class="rg-btn rg-btn-yellow" id="rg-btn-sinbin">{t.sinBinAdd}</button>
75
75
  </div>
@@ -110,12 +110,13 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
110
110
  </div>
111
111
  </div>
112
112
 
113
- <div id="rg-banner" class="rg-banner" style="display:none">
113
+ <div id="rg-banner" class="rg-banner" role="status" aria-live="polite" style="display:none">
114
114
  <div class="rg-banner-text" id="rg-banner-text"></div>
115
115
  </div>
116
116
 
117
- <div class="rg-reset-modal" id="rg-modal">
117
+ <div class="rg-reset-modal" id="rg-modal" role="dialog" aria-modal="true" aria-labelledby="rg-modal-title">
118
118
  <div class="rg-modal-content">
119
+ <h2 class="rg-modal-title" id="rg-modal-title">{t.resetMatch}</h2>
119
120
  <div class="rg-modal-text">{t.resetConfirm}</div>
120
121
  <div class="rg-modal-btns">
121
122
  <button class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Letztes Rückgängig',
215
215
  timeOff: 'Zeit Gestoppt',
216
216
  timeOn: 'Zeit Läuft',
217
+ fullTime: 'Spielende',
218
+ minutesShort: 'Min.',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Undo Last',
215
215
  timeOff: 'Time Off',
216
216
  timeOn: 'Time On',
217
+ fullTime: 'Full time',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Deshacer Último',
215
215
  timeOff: 'Tiempo Detenido',
216
216
  timeOn: 'Tiempo en Marcha',
217
+ fullTime: 'Fin del partido',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Annuler le Dernier',
215
215
  timeOff: 'Temps Arrêté',
216
216
  timeOn: 'Temps en Marche',
217
+ fullTime: 'Fin du match',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Batalkan terakhir',
215
215
  timeOff: 'Waktu Berhenti',
216
216
  timeOn: 'Waktu Berjalan',
217
+ fullTime: 'Pertandingan Selesai',
218
+ minutesShort: 'mnt',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Annulla Ultimo',
215
215
  timeOff: 'Tempo Fermo',
216
216
  timeOn: 'Tempo in Gioco',
217
+ fullTime: 'Fine partita',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: '最後を元に戻す',
215
215
  timeOff: 'タイムオフ',
216
216
  timeOn: 'タイムオン',
217
+ fullTime: '試合終了',
218
+ minutesShort: '分',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: '마지막 실행 취소',
215
215
  timeOff: '타임 오프',
216
216
  timeOn: '타임 온',
217
+ fullTime: '경기 종료',
218
+ minutesShort: '분',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Ongedaan Maken Laatste',
215
215
  timeOff: 'Tijd Stil',
216
216
  timeOn: 'Tijd Loopt',
217
+ fullTime: 'Einde wedstrijd',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Cofnij ostatnie',
215
215
  timeOff: 'Czas zatrzymany',
216
216
  timeOn: 'Czas włączony',
217
+ fullTime: 'Koniec meczu',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Desfazer Último',
215
215
  timeOff: 'Tempo Parado',
216
216
  timeOn: 'Tempo em Jogo',
217
+ fullTime: 'Fim de jogo',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -13,7 +13,7 @@ const faqData = [
13
13
  },
14
14
  {
15
15
  question: 'Что происходит, когда игрок получает жёлтую карточку в регби?',
16
- answer: '�-ёлтая карточка означает временное удаление на скамейку штрафников. Игрок должен покинуть поле на 10 минут игрового времени. Его команда играет в меньшинстве в этот период. Таймер скамейки штрафников отсчитывает время только когда часы матча запущены.',
16
+ answer: 'Жёлтая карточка означает временное удаление на скамейку штрафников. Игрок должен покинуть поле на 10 минут игрового времени. Его команда играет в меньшинстве в этот период. Таймер скамейки штрафников отсчитывает время только когда часы матча запущены.',
17
17
  },
18
18
  {
19
19
  question: 'Сколько длится матч по регби-юнион?',
@@ -23,12 +23,12 @@ const faqData = [
23
23
 
24
24
  const howToData = [
25
25
  {
26
- name: '�-аписывайте игровые события',
26
+ name: 'Записывайте игровые события',
27
27
  text: 'Нажимайте кнопки Попытка, Реализация, Штрафной или Дроп-гол для записи очков. После нажатия Попытки кнопка Реализации подсвечивается автоматически.',
28
28
  },
29
29
  {
30
30
  name: 'Управляйте штрафными удалениями',
31
- text: 'Нажмите �-ёлтая карточка, чтобы отправить игрока на скамейку штрафников. Введите его имя или номер, и начнётся автоматический 10-минутный отсчёт.',
31
+ text: 'Нажмите "Жёлтая карточка", чтобы отправить игрока на скамейку штрафников. Введите его имя или номер, и начнётся автоматический 10-минутный отсчёт.',
32
32
  },
33
33
  {
34
34
  name: 'Контролируйте время матча',
@@ -92,7 +92,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
92
92
  },
93
93
  {
94
94
  type: 'paragraph',
95
- html: 'Управляйте подсчётом очков в матче по регби с помощью нашего интерактивного цифрового табло. �-аписывайте попытки, реализации, штрафные и дроп-голы в реальном времени. Система управления скамейкой штрафников автоматически отсчитывает время удалений, а часы матча идеально синхронизируют обе половины. Судите ли вы местный клубный матч или тренируете молодёжную команду, этот инструмент автоматически обработает все детали подсчёта и хронометража.',
95
+ html: 'Управляйте подсчётом очков в матче по регби с помощью нашего интерактивного цифрового табло. Записывайте попытки, реализации, штрафные и дроп-голы в реальном времени. Система управления скамейкой штрафников автоматически отсчитывает время удалений, а часы матча идеально синхронизируют обе половины. Судите ли вы местный клубный матч или тренируете молодёжную команду, этот инструмент автоматически обработает все детали подсчёта и хронометража.',
96
96
  },
97
97
  {
98
98
  type: 'title',
@@ -109,7 +109,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
109
109
  items: [
110
110
  {
111
111
  title: 'Попытка',
112
- description: '�-анести мяч в зачётную зону на 5 очков.',
112
+ description: 'Занести мяч в зачётную зону на 5 очков.',
113
113
  icon: 'mdi:rugby',
114
114
  points: ['Начисляется 5 очков', 'Даёт право на реализацию', 'Требуется занос мяча'],
115
115
  },
@@ -162,7 +162,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
162
162
  },
163
163
  {
164
164
  type: 'title',
165
- text: '�-ачем использовать цифровой счётчик регби',
165
+ text: 'Зачем использовать цифровой счётчик регби',
166
166
  level: 2,
167
167
  },
168
168
  {
@@ -182,7 +182,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
182
182
  dropGoal: 'Дроп-гол +3',
183
183
  conversionYes: 'Да',
184
184
  conversionNo: 'Нет',
185
- sinBin: '�-ёлтая карточка',
185
+ sinBin: 'Жёлтая карточка',
186
186
  sinBinTitle: 'Скамейка штрафников',
187
187
  sinBinPlayer: 'Имя или номер игрока:',
188
188
  sinBinAdd: 'Начать отстранение',
@@ -209,10 +209,12 @@ export const content: RugbyScoreKeeperLocaleContent = {
209
209
  totalPoints: 'Всего',
210
210
  fullscreen: 'Полный экран',
211
211
  toggleSound: 'Вкл/Выкл звук',
212
- eventLog: '�-урнал событий',
212
+ eventLog: 'Журнал событий',
213
213
  eventEmpty: 'Событий пока нет',
214
214
  undoBtn: 'Отменить последнее',
215
215
  timeOff: 'Время выкл',
216
216
  timeOn: 'Время вкл',
217
+ fullTime: 'Матч окончен',
218
+ minutesShort: 'мин',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Ångra senaste',
215
215
  timeOff: 'Tid stoppad',
216
216
  timeOn: 'Tid igång',
217
+ fullTime: 'Matchen är slut',
218
+ minutesShort: 'min',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: 'Son işlemi geri al',
215
215
  timeOff: 'Süre durdu',
216
216
  timeOn: 'Süre başladı',
217
+ fullTime: 'Maç bitti',
218
+ minutesShort: 'dk',
217
219
  },
218
220
  };
@@ -214,5 +214,7 @@ export const content: RugbyScoreKeeperLocaleContent = {
214
214
  undoBtn: '撤销上一步',
215
215
  timeOff: '时间停',
216
216
  timeOn: '时间开',
217
+ fullTime: '比赛结束',
218
+ minutesShort: '分钟',
217
219
  },
218
220
  };
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ addSinBin,
4
+ createInitialState,
5
+ scorePenalty,
6
+ scoreTry,
7
+ startMatch,
8
+ startSecondHalf,
9
+ teamTotal,
10
+ tickClock,
11
+ tickSinBin,
12
+ toggleClock,
13
+ undoLast,
14
+ losingBonus,
15
+ } from './logic';
16
+
17
+ describe('rugbyScoreKeeper game clock', () => {
18
+ it('starts the second half without losing the score, statistics, history, or sin-bin timers', () => {
19
+ let state = createInitialState();
20
+ state = scoreTry(state, 'home');
21
+ state = scorePenalty(state, 'away');
22
+ state = addSinBin(state, 'Number 8', 600);
23
+ state = startMatch({ ...state, elapsed: 2399 });
24
+
25
+ state = tickClock(state, 1);
26
+
27
+ expect(state.half).toBe(1);
28
+ expect(state.elapsed).toBe(2400);
29
+ expect(state.clockRunning).toBe(false);
30
+ expect(teamTotal(state.home)).toBe(5);
31
+ expect(teamTotal(state.away)).toBe(3);
32
+ expect(state.history).toHaveLength(3);
33
+ expect(state.sinBin[0]?.remaining).toBe(599);
34
+
35
+ const halftimeState = structuredClone(state);
36
+ state = startSecondHalf(state);
37
+
38
+ expect(state).toEqual({
39
+ ...halftimeState,
40
+ half: 2,
41
+ clockRunning: true,
42
+ });
43
+
44
+ state = tickClock(state, 1);
45
+ expect(state.elapsed).toBe(2401);
46
+ expect(state.clockRunning).toBe(true);
47
+ expect(teamTotal(state.home)).toBe(5);
48
+ expect(teamTotal(state.away)).toBe(3);
49
+ });
50
+
51
+ it('does not start the second half before halftime', () => {
52
+ const state = startMatch({ ...createInitialState(), elapsed: 2399 });
53
+
54
+ expect(startSecondHalf(state)).toBe(state);
55
+ });
56
+
57
+ it('ends the match at 80 minutes after the second half', () => {
58
+ let state = startSecondHalf({
59
+ ...createInitialState(),
60
+ matchStarted: true,
61
+ elapsed: 2400,
62
+ });
63
+ state = { ...state, elapsed: 4799 };
64
+
65
+ state = tickClock(state, 1);
66
+
67
+ expect(state.elapsed).toBe(4800);
68
+ expect(state.clockRunning).toBe(false);
69
+ expect(state.matchEnded).toBe(true);
70
+ });
71
+
72
+ it('undoes a sin-bin event and its timer together', () => {
73
+ const withSinBin = addSinBin(createInitialState(), ' Player <x> ', 600);
74
+ expect(withSinBin.sinBin).toHaveLength(1);
75
+ expect(withSinBin.sinBin[0]?.player).toBe('Player <x>');
76
+ const undone = undoLast(withSinBin);
77
+ expect(undone.sinBin).toEqual([]);
78
+ expect(undone.history).toEqual([]);
79
+ });
80
+
81
+ it('rejects invalid timer input and keeps match state immutable', () => {
82
+ const state = createInitialState();
83
+ expect(addSinBin(state, ' ', 600)).toBe(state);
84
+ expect(addSinBin(state, 'Player', 0)).toBe(state);
85
+ expect(addSinBin(state, 'Player', Number.NaN)).toBe(state);
86
+ expect(tickSinBin(state, 0)).not.toBe(state);
87
+ expect(tickSinBin(state, -1).sinBin).toEqual([]);
88
+ });
89
+
90
+ it('does not score or toggle after full time', () => {
91
+ const ended = { ...createInitialState(), matchEnded: true };
92
+ expect(scoreTry(ended, 'home')).toBe(ended);
93
+ expect(toggleClock(ended)).toBe(ended);
94
+ });
95
+
96
+ it('does not award a losing bonus for a tie', () => {
97
+ const state = createInitialState();
98
+ expect(losingBonus(state.home, state.away, 'home')).toBe(false);
99
+ const homeTry = scoreTry(state, 'home');
100
+ expect(losingBonus(homeTry.home, homeTry.away, 'away')).toBe(true);
101
+ });
102
+ });
@@ -7,6 +7,8 @@ export interface HistoryEvent {
7
7
  team: TeamKey;
8
8
  label: string;
9
9
  minute: string;
10
+ player?: string;
11
+ sinBinId?: number;
10
12
  }
11
13
 
12
14
  export interface TeamScore {
@@ -62,52 +64,60 @@ function fmtMin(elapsed: number): string {
62
64
  return `Min ${Math.floor(elapsed / 60)}`;
63
65
  }
64
66
 
65
- function pushEvent(s: MatchState, type: EventType, team: TeamKey, label: string) {
66
- s.history.push({ type, team, label, minute: fmtMin(s.elapsed) });
67
+ function pushEvent(s: MatchState, type: EventType, team: TeamKey, details: { label?: string; player?: string; sinBinId?: number } = {}) {
68
+ s.history.push({ type, team, label: details.label ?? type, minute: fmtMin(s.elapsed), ...details });
67
69
  }
68
70
 
69
71
  export function scoreTry(state: MatchState, team: TeamKey): MatchState {
72
+ if (state.matchEnded) return state;
70
73
  const s = structuredClone(state);
71
74
  if (team === 'home') s.home.tries++; else s.away.tries++;
72
- pushEvent(s, 'try', team, 'TRY');
75
+ pushEvent(s, 'try', team, { label: 'TRY' });
73
76
  return s;
74
77
  }
75
78
 
76
79
  export function scoreConversion(state: MatchState, team: TeamKey, success: boolean): MatchState {
80
+ if (state.matchEnded) return state;
77
81
  const s = structuredClone(state);
78
82
  if (success) {
79
83
  if (team === 'home') s.home.conversions++; else s.away.conversions++;
80
- pushEvent(s, 'conv', team, 'CONV +2');
84
+ pushEvent(s, 'conv', team, { label: 'CONV +2' });
81
85
  } else {
82
86
  if (team === 'home') s.home.missedConversions++; else s.away.missedConversions++;
83
- pushEvent(s, 'missed-conv', team, 'CONV miss');
87
+ pushEvent(s, 'missed-conv', team, { label: 'CONV miss' });
84
88
  }
85
89
  return s;
86
90
  }
87
91
 
88
92
  export function scorePenalty(state: MatchState, team: TeamKey): MatchState {
93
+ if (state.matchEnded) return state;
89
94
  const s = structuredClone(state);
90
95
  if (team === 'home') s.home.penalties++; else s.away.penalties++;
91
- pushEvent(s, 'pen', team, 'PEN +3');
96
+ pushEvent(s, 'pen', team, { label: 'PEN +3' });
92
97
  return s;
93
98
  }
94
99
 
95
100
  export function scoreDropGoal(state: MatchState, team: TeamKey): MatchState {
101
+ if (state.matchEnded) return state;
96
102
  const s = structuredClone(state);
97
103
  if (team === 'home') s.home.dropGoals++; else s.away.dropGoals++;
98
- pushEvent(s, 'drop', team, 'DG +3');
104
+ pushEvent(s, 'drop', team, { label: 'DG +3' });
99
105
  return s;
100
106
  }
101
107
 
102
108
  export function addSinBin(state: MatchState, player: string, duration: number): MatchState {
109
+ const cleanPlayer = player.trim();
110
+ if (state.matchEnded || !cleanPlayer || !Number.isFinite(duration) || duration <= 0) return state;
103
111
  const s = structuredClone(state);
104
- s.sinBin.push({ id: Date.now(), player, remaining: duration, total: duration });
105
- pushEvent(s, 'sinbin', 'home', `${player} SIN BIN`);
112
+ const id = Math.max(Date.now(), ...s.sinBin.map((entry) => entry.id + 1));
113
+ s.sinBin.push({ id, player: cleanPlayer, remaining: duration, total: duration });
114
+ pushEvent(s, 'sinbin', 'home', { label: `${cleanPlayer} SIN BIN`, player: cleanPlayer, sinBinId: id });
106
115
  return s;
107
116
  }
108
117
 
109
118
  export function tickSinBin(state: MatchState, delta: number): MatchState {
110
119
  const s = structuredClone(state);
120
+ if (!Number.isFinite(delta) || delta <= 0) return s;
111
121
  s.sinBin = s.sinBin
112
122
  .map((e) => ({ ...e, remaining: Math.max(0, e.remaining - delta) }))
113
123
  .filter((e) => e.remaining > 0);
@@ -116,7 +126,7 @@ export function tickSinBin(state: MatchState, delta: number): MatchState {
116
126
 
117
127
  export function tickClock(state: MatchState, delta: number): MatchState {
118
128
  const s = structuredClone(state);
119
- if (!s.clockRunning || s.matchEnded) return s;
129
+ if (!s.clockRunning || s.matchEnded || !Number.isFinite(delta) || delta <= 0) return s;
120
130
  s.elapsed += delta;
121
131
  if (s.elapsed >= 2400 && s.half === 1) {
122
132
  s.elapsed = 2400;
@@ -131,23 +141,36 @@ export function tickClock(state: MatchState, delta: number): MatchState {
131
141
  }
132
142
 
133
143
  export function startMatch(state: MatchState): MatchState {
144
+ if (state.matchEnded) return state;
134
145
  return { ...state, clockRunning: true, matchStarted: true };
135
146
  }
136
147
 
148
+ export function startSecondHalf(state: MatchState): MatchState {
149
+ if (state.half !== 1 || state.elapsed < 2400 || state.matchEnded) return state;
150
+ return { ...state, half: 2, clockRunning: true };
151
+ }
152
+
137
153
  export function toggleClock(state: MatchState): MatchState {
154
+ if (state.matchEnded) return state;
138
155
  return { ...state, clockRunning: !state.clockRunning };
139
156
  }
140
157
 
158
+ function undoScore(ts: TeamScore, type: EventType) {
159
+ if (type === 'try') ts.tries = Math.max(0, ts.tries - 1);
160
+ else if (type === 'conv') ts.conversions = Math.max(0, ts.conversions - 1);
161
+ else if (type === 'missed-conv') ts.missedConversions = Math.max(0, ts.missedConversions - 1);
162
+ else if (type === 'pen') ts.penalties = Math.max(0, ts.penalties - 1);
163
+ else if (type === 'drop') ts.dropGoals = Math.max(0, ts.dropGoals - 1);
164
+ }
165
+
141
166
  export function undoLast(state: MatchState): MatchState {
142
167
  const s = structuredClone(state);
143
168
  const ev = s.history.pop();
144
169
  if (!ev) return s;
145
170
  const ts = ev.team === 'home' ? s.home : s.away;
146
- if (ev.type === 'try') ts.tries = Math.max(0, ts.tries - 1);
147
- else if (ev.type === 'conv') ts.conversions = Math.max(0, ts.conversions - 1);
148
- else if (ev.type === 'missed-conv') ts.missedConversions = Math.max(0, ts.missedConversions - 1);
149
- else if (ev.type === 'pen') ts.penalties = Math.max(0, ts.penalties - 1);
150
- else if (ev.type === 'drop') ts.dropGoals = Math.max(0, ts.dropGoals - 1);
171
+ if (ev.type === 'sinbin' && ev.sinBinId !== undefined) {
172
+ s.sinBin = s.sinBin.filter((entry) => entry.id !== ev.sinBinId);
173
+ } else undoScore(ts, ev.type);
151
174
  return s;
152
175
  }
153
176
 
@@ -170,7 +193,10 @@ export function bonusPoints(ts: TeamScore): string[] {
170
193
  }
171
194
 
172
195
  export function losingBonus(home: TeamScore, away: TeamScore, team: TeamKey): boolean {
173
- const diff = Math.abs(teamTotal(home) - teamTotal(away));
174
- const losing = teamTotal(home) < teamTotal(away) ? 'home' : 'away';
196
+ const homeTotal = teamTotal(home);
197
+ const awayTotal = teamTotal(away);
198
+ if (homeTotal === awayTotal) return false;
199
+ const diff = Math.abs(homeTotal - awayTotal);
200
+ const losing = homeTotal < awayTotal ? 'home' : 'away';
175
201
  return losing === team && diff <= 7;
176
202
  }
@@ -623,3 +623,15 @@
623
623
  0%, 100% { opacity: 1; }
624
624
  50% { opacity: 0.5; }
625
625
  }
626
+
627
+ @media (max-width: 560px) {
628
+ .rg-app { padding: 8px; }
629
+ .rg-main-card { padding: 8px; }
630
+ .rg-strip { padding: 10px 8px; }
631
+ .rg-divider { padding: 0 4px; }
632
+ .rg-mid-row, .rg-bottom-row { grid-template-columns: 1fr; }
633
+ .rg-sinbin-form { flex-wrap: wrap; }
634
+ .rg-sinbin-form .rg-input { flex-basis: 100%; }
635
+ .rg-sinbin-form .rg-btn-yellow { flex: 1; }
636
+ .rg-btn { min-height: 44px; }
637
+ }
@@ -0,0 +1,20 @@
1
+ import type { HistoryEvent } from './logic';
2
+ import type { RugbyScoreKeeperUI } from './ui';
3
+
4
+ export function escapeHtml(value: string): string {
5
+ return value.replace(/[&<>"']/g, (char) => ({
6
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
7
+ }[char] ?? char));
8
+ }
9
+
10
+ export function eventLabel(event: HistoryEvent, ui: RugbyScoreKeeperUI): string {
11
+ switch (event.type) {
12
+ case 'try': return `${ui.tryScored} +5`;
13
+ case 'conv': return `${ui.conversionSuccess} +2`;
14
+ case 'missed-conv': return ui.conversionMiss;
15
+ case 'pen': return `${ui.penaltyScored} +3`;
16
+ case 'drop': return `${ui.dropGoalScored} +3`;
17
+ case 'sinbin': return event.player ? `${ui.sinBin}: ${event.player}` : ui.sinBin;
18
+ default: return event.label;
19
+ }
20
+ }
@@ -1,39 +1,38 @@
1
1
  import type { TeamKey, MatchState, TeamScore } from './logic';
2
+ import type { RugbyScoreKeeperUI } from './ui';
3
+ import { escapeHtml, eventLabel } from './ui-helpers';
2
4
  import {
3
5
  createInitialState, scoreTry, scoreConversion, scorePenalty, scoreDropGoal,
4
- addSinBin, tickClock, startMatch, toggleClock, teamTotal,
6
+ addSinBin, tickClock, startMatch, startSecondHalf, toggleClock, teamTotal,
5
7
  formatTime, formatSinBinTime, undoLast, bonusPoints, losingBonus,
6
8
  } from './logic';
7
9
 
8
- interface RGUI {
9
- home: string; away: string; tryLabel: string; conversion: string; penalty: string;
10
- dropGoal: string; sinBin: string; sinBinPlayer: string; sinBinAdd: string;
11
- sinBinEmpty: string; matchClock: string; half: string; half1: string;
12
- half2: string; startMatch: string; resetMatch: string; resetConfirm: string;
13
- cancel: string; confirm: string; scoringSummary: string; tryScored: string;
14
- conversionSuccess: string; penaltyScored: string; dropGoalScored: string;
15
- totalPoints: string; fullscreen: string; toggleSound: string;
16
- eventLog: string; eventEmpty: string; undoBtn: string; timeOff: string; timeOn: string;
17
- }
10
+ type ClockId = { v: ReturnType<typeof setInterval> | undefined };
18
11
 
19
- function getUI(): RGUI {
12
+ function getUI(): RugbyScoreKeeperUI {
20
13
  const app = document.getElementById('rg-app') as HTMLElement;
21
- return JSON.parse(app?.dataset.rgUi ?? '{}') as RGUI;
14
+ return JSON.parse(app?.dataset.rgUi ?? '{}') as RugbyScoreKeeperUI;
22
15
  }
23
16
 
24
- function q<T extends HTMLElement = HTMLElement>(id: string): T | null {
17
+ function q<T extends Element = HTMLElement>(id: string): T | null {
25
18
  return document.getElementById(id) as T | null;
26
19
  }
27
20
 
21
+ function renderHalf(s: MatchState) {
22
+ const label = q('rg-half-label');
23
+ const badge = q('rg-half-badge');
24
+ if (label) label.textContent = String(s.half);
25
+ if (badge) badge.textContent = s.half === 1 ? getUI().half1 : getUI().half2;
26
+ }
27
+
28
28
  function renderScoreboard(s: MatchState) {
29
29
  const sh = q('rg-score-home');
30
30
  const sa = q('rg-score-away');
31
31
  const ct = q('rg-clock-time');
32
- const hl = q('rg-half-label');
33
32
  if (sh) sh.textContent = String(teamTotal(s.home));
34
33
  if (sa) sa.textContent = String(teamTotal(s.away));
35
34
  if (ct) ct.textContent = formatTime(s.elapsed);
36
- if (hl) hl.textContent = String(s.half);
35
+ renderHalf(s);
37
36
  const cf = q<SVGPathElement>('rg-clock-fill');
38
37
  if (cf) {
39
38
  const max = s.half === 1 ? 2400 : 4800;
@@ -58,9 +57,10 @@ function renderHistory(s: MatchState) {
58
57
  return;
59
58
  }
60
59
  if (undo) undo.removeAttribute('disabled');
60
+ const ui = getUI();
61
61
  list.innerHTML = s.history.map((e) => {
62
62
  const cls = e.team === 'home' ? 'rg-ev-home' : 'rg-ev-away';
63
- return `<div class="rg-history-event ${cls}"><span class="rg-ev-min">${e.minute}</span><span class="rg-ev-label">${e.label}</span></div>`;
63
+ return `<div class="rg-history-event ${cls}"><span class="rg-ev-min">${escapeHtml(e.minute)}</span><span class="rg-ev-label">${escapeHtml(eventLabel(e, ui))}</span></div>`;
64
64
  }).join('');
65
65
  requestAnimationFrame(() => { list.scrollTo(0, 1e9); });
66
66
  }
@@ -87,12 +87,13 @@ function renderSinBin(s: MatchState) {
87
87
  sl.innerHTML = `<div class="rg-sinbin-empty">${getUI().sinBinEmpty}</div>`;
88
88
  return;
89
89
  }
90
+ const ui = getUI();
90
91
  sl.innerHTML = s.sinBin.map((e) => {
91
- const pct = (e.remaining / e.total) * 100;
92
+ const pct = Math.max(0, Math.min(100, (e.remaining / e.total) * 100));
92
93
  let cls = 'rg-sinbin-safe';
93
94
  if (pct < 25) cls = 'rg-sinbin-critical';
94
95
  else if (pct < 60) cls = 'rg-sinbin-warn';
95
- return `<div class="rg-sinbin-card ${cls}"><div class="rg-sinbin-player">${e.player}</div><div class="rg-sinbin-time">${formatSinBinTime(e.remaining)}</div><div class="rg-sinbin-bar"><div class="rg-sinbin-fill" style="width:${pct}%"></div></div><div class="rg-sinbin-return">${pct <= 0 ? getUI().sinBinEmpty : ''}</div></div>`;
96
+ return `<div class="rg-sinbin-card ${cls}"><div class="rg-sinbin-player">${escapeHtml(e.player)}</div><div class="rg-sinbin-time">${formatSinBinTime(e.remaining)}</div><div class="rg-sinbin-bar"><div class="rg-sinbin-fill" style="width:${pct}%"></div></div><div class="rg-sinbin-return">${pct <= 0 ? ui.sinBinEmpty : ''}</div></div>`;
96
97
  }).join('');
97
98
  }
98
99
 
@@ -102,10 +103,11 @@ function renderSummary(s: MatchState) {
102
103
  if (!sb) return;
103
104
  const bodyRows = sb.querySelectorAll('tr');
104
105
  bodyRows.forEach((row, i) => {
105
- if (i >= rows.length) return;
106
+ const key = rows[i];
107
+ if (!key) return;
106
108
  const tds = row.querySelectorAll('td');
107
- if (tds[1]) tds[1].textContent = String(s.home[rows[i]]);
108
- if (tds[2]) tds[2].textContent = String(s.away[rows[i]]);
109
+ if (tds[1]) tds[1].textContent = String(s.home[key]);
110
+ if (tds[2]) tds[2].textContent = String(s.away[key]);
109
111
  });
110
112
  const hTotal = teamTotal(s.home);
111
113
  const aTotal = teamTotal(s.away);
@@ -115,11 +117,11 @@ function renderSummary(s: MatchState) {
115
117
  if (ta) { ta.textContent = String(aTotal); }
116
118
  }
117
119
 
118
- function showBanner(html: string, isPeak: boolean) {
120
+ function showBanner(message: string, isPeak: boolean) {
119
121
  const bn = q('rg-banner');
120
122
  const bt = q('rg-banner-text');
121
123
  if (!bn || !bt) return;
122
- bt.innerHTML = html;
124
+ bt.textContent = message;
123
125
  bn.className = `rg-banner ${isPeak ? 'rg-banner-peak' : 'rg-banner-warn'}`;
124
126
  bn.style.display = 'flex';
125
127
  bn.classList.remove('rg-banner-hide');
@@ -131,8 +133,8 @@ function showBanner(html: string, isPeak: boolean) {
131
133
  }
132
134
 
133
135
  function toggleConv(team: TeamKey | null) {
134
- const ch = q(`rg-conv-home`);
135
- const ca = q(`rg-conv-away`);
136
+ const ch = q<HTMLButtonElement>(`rg-conv-home`);
137
+ const ca = q<HTMLButtonElement>(`rg-conv-away`);
136
138
  if (ch) ch.disabled = team !== 'home';
137
139
  if (ca) ca.disabled = team !== 'away';
138
140
  }
@@ -149,33 +151,25 @@ function teamName(team: TeamKey): string {
149
151
  }
150
152
 
151
153
  function handleScoreClick(ctx: { state: MatchState; convTeam: TeamKey | null }, team: TeamKey, action: string) {
154
+ if (ctx.state.matchEnded) return;
152
155
  const name = teamName(team);
156
+ const ui = getUI();
153
157
  if (action === 'try') {
154
- ctx.state = scoreTry(ctx.state, team);
155
- ctx.convTeam = team;
156
- toggleConv(team);
157
- showBanner(`TRY! ${name} +5`, true);
158
- renderScoreboard(ctx.state);
158
+ ctx.state = scoreTry(ctx.state, team); ctx.convTeam = team;
159
+ toggleConv(team); showBanner(`${ui.tryScored} ${name} +5`, true); renderScoreboard(ctx.state);
159
160
  return;
160
161
  }
161
162
  if (action === 'conv' && ctx.convTeam) {
162
- ctx.state = scoreConversion(ctx.state, ctx.convTeam, true);
163
- toggleConv(null);
164
- ctx.convTeam = null;
165
- showBanner(`CONVERSION! +2`, true);
166
- renderScoreboard(ctx.state);
163
+ ctx.state = scoreConversion(ctx.state, ctx.convTeam, true); toggleConv(null); ctx.convTeam = null;
164
+ showBanner(`${ui.conversionSuccess} +2`, true); renderScoreboard(ctx.state);
167
165
  return;
168
166
  }
169
167
  if (action === 'pen') {
170
- ctx.state = scorePenalty(ctx.state, team);
171
- showBanner(`PENALTY! ${name} +3`, true);
172
- renderScoreboard(ctx.state);
168
+ ctx.state = scorePenalty(ctx.state, team); showBanner(`${ui.penaltyScored} ${name} +3`, true); renderScoreboard(ctx.state);
173
169
  return;
174
170
  }
175
171
  if (action === 'drop') {
176
- ctx.state = scoreDropGoal(ctx.state, team);
177
- showBanner(`DROP GOAL! ${name} +3`, true);
178
- renderScoreboard(ctx.state);
172
+ ctx.state = scoreDropGoal(ctx.state, team); showBanner(`${ui.dropGoalScored} ${name} +3`, true); renderScoreboard(ctx.state);
179
173
  }
180
174
  }
181
175
 
@@ -188,22 +182,33 @@ function wireScoreButtons(ctx: { state: MatchState; convTeam: TeamKey | null })
188
182
  });
189
183
  }
190
184
 
191
- function handleClock(ctx: { state: MatchState }, clockId: { v: number }, ui: RGUI) {
185
+ function runClock(ctx: { state: MatchState }, clockId: ClockId, btn: HTMLElement, ui: RugbyScoreKeeperUI) {
186
+ clockId.v = setInterval(() => {
187
+ ctx.state = tickClock(ctx.state, 1);
188
+ renderScoreboard(ctx.state);
189
+ if (ctx.state.half === 1 && ctx.state.elapsed === 2400 && !ctx.state.clockRunning) {
190
+ btn.textContent = ui.half2;
191
+ }
192
+ if (ctx.state.matchEnded) {
193
+ if (clockId.v) clearInterval(clockId.v);
194
+ btn.textContent = ui.startMatch;
195
+ showBanner(ui.fullTime, false);
196
+ }
197
+ }, 1000);
198
+ }
199
+
200
+ function handleClock(ctx: { state: MatchState }, clockId: ClockId, ui: RugbyScoreKeeperUI) {
192
201
  const btn = q('rg-btn-clock');
193
202
  if (!btn) return;
194
203
  if (!ctx.state.matchStarted) {
195
204
  ctx.state = startMatch(ctx.state);
196
205
  btn.textContent = ui.timeOff;
197
206
  renderScoreboard(ctx.state);
198
- clockId.v = setInterval(() => {
199
- ctx.state = tickClock(ctx.state, 1);
200
- renderScoreboard(ctx.state);
201
- if (ctx.state.matchEnded) {
202
- clearInterval(clockId.v);
203
- btn.textContent = ui.startMatch;
204
- showBanner('FULL TIME! Match is over', false);
205
- }
206
- }, 1000);
207
+ runClock(ctx, clockId, btn, ui);
208
+ } else if (ctx.state.half === 1 && ctx.state.elapsed >= 2400) {
209
+ ctx.state = startSecondHalf(ctx.state);
210
+ btn.textContent = ui.timeOff;
211
+ renderScoreboard(ctx.state);
207
212
  } else if (ctx.state.clockRunning) {
208
213
  ctx.state = toggleClock(ctx.state);
209
214
  btn.textContent = ui.timeOn;
@@ -218,7 +223,7 @@ function handleSinBin(ctx: { state: MatchState }) {
218
223
  const dur = q<HTMLSelectElement>('rg-sinbin-duration');
219
224
  if (!inp || !dur || !inp.value.trim()) return;
220
225
  ctx.state = addSinBin(ctx.state, inp.value.trim(), Number(dur.value));
221
- showBanner(`SIN BIN ${dur.value === '600' ? '10min' : '5min'}`, false);
226
+ showBanner(`${getUI().sinBin} ${formatSinBinTime(Number(dur.value))}`, false);
222
227
  inp.value = '';
223
228
  renderScoreboard(ctx.state);
224
229
  }
@@ -230,8 +235,8 @@ function handleUndo(ctx: { state: MatchState; convTeam: TeamKey | null }) {
230
235
  renderScoreboard(ctx.state);
231
236
  }
232
237
 
233
- function confirmReset(ctx: { state: MatchState; convTeam: TeamKey | null }, clockId: { v: number }, ui: RGUI) {
234
- clearInterval(clockId.v);
238
+ function confirmReset(ctx: { state: MatchState; convTeam: TeamKey | null }, clockId: ClockId, ui: RugbyScoreKeeperUI) {
239
+ if (clockId.v) clearInterval(clockId.v);
235
240
  ctx.state = createInitialState();
236
241
  ctx.convTeam = null;
237
242
  toggleConv(null);
@@ -244,7 +249,7 @@ function confirmReset(ctx: { state: MatchState; convTeam: TeamKey | null }, cloc
244
249
  export function initRugbyScorekeeper() {
245
250
  const ui = getUI();
246
251
  const ctx = { state: createInitialState(), convTeam: null as TeamKey | null };
247
- const clockId = { v: 0 };
252
+ const clockId: ClockId = { v: undefined };
248
253
 
249
254
  wireScoreButtons(ctx);
250
255
  onBtn('rg-btn-clock', () => handleClock(ctx, clockId, ui));
@@ -42,4 +42,6 @@ export interface RugbyScoreKeeperUI {
42
42
  undoBtn: string;
43
43
  timeOff: string;
44
44
  timeOn: string;
45
+ fullTime: string;
46
+ minutesShort: string;
45
47
  }