@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.
- package/package.json +3 -2
- package/src/tests/mfe_assets_contract.test.ts +48 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +50 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +100 -0
- package/src/tool/rugbyScoreKeeper/component.astro +9 -8
- package/src/tool/rugbyScoreKeeper/i18n/de.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/en.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/es.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/fr.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/id.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/it.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/ja.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/ko.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/nl.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/pl.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/pt.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/ru.ts +10 -8
- package/src/tool/rugbyScoreKeeper/i18n/sv.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/tr.ts +2 -0
- package/src/tool/rugbyScoreKeeper/i18n/zh.ts +2 -0
- package/src/tool/rugbyScoreKeeper/logic.test.ts +102 -0
- package/src/tool/rugbyScoreKeeper/logic.ts +43 -17
- package/src/tool/rugbyScoreKeeper/rugby-scorekeeper.css +12 -0
- package/src/tool/rugbyScoreKeeper/ui-helpers.ts +20 -0
- package/src/tool/rugbyScoreKeeper/ui-init.ts +61 -56
- 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.
|
|
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
|
|
72
|
-
<option value="300">5
|
|
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>
|
|
@@ -13,7 +13,7 @@ const faqData = [
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
question: 'Что происходит, когда игрок получает жёлтую карточку в регби?',
|
|
16
|
-
answer: '
|
|
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
|
-
|
|
26
|
+
name: 'Записывайте игровые события',
|
|
27
27
|
text: 'Нажимайте кнопки Попытка, Реализация, Штрафной или Дроп-гол для записи очков. После нажатия Попытки кнопка Реализации подсвечивается автоматически.',
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
name: 'Управляйте штрафными удалениями',
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
eventLog: 'Журнал событий',
|
|
213
213
|
eventEmpty: 'Событий пока нет',
|
|
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,
|
|
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
|
-
|
|
105
|
-
|
|
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 === '
|
|
147
|
-
|
|
148
|
-
else
|
|
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
|
|
174
|
-
const
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
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
|
-
|
|
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():
|
|
12
|
+
function getUI(): RugbyScoreKeeperUI {
|
|
20
13
|
const app = document.getElementById('rg-app') as HTMLElement;
|
|
21
|
-
return JSON.parse(app?.dataset.rgUi ?? '{}') as
|
|
14
|
+
return JSON.parse(app?.dataset.rgUi ?? '{}') as RugbyScoreKeeperUI;
|
|
22
15
|
}
|
|
23
16
|
|
|
24
|
-
function q<T extends
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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[
|
|
108
|
-
if (tds[2]) tds[2].textContent = String(s.away[
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
252
|
+
const clockId: ClockId = { v: undefined };
|
|
248
253
|
|
|
249
254
|
wireScoreButtons(ctx);
|
|
250
255
|
onBtn('rg-btn-clock', () => handleClock(ctx, clockId, ui));
|