@jjlmoya/utils-sports 1.59.0 → 1.61.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 +48 -35
- package/src/tool/rugbyScoreKeeper/i18n/de.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/en.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/es.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/fr.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/id.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/it.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/ja.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/ko.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/nl.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/pl.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/pt.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/ru.ts +14 -8
- package/src/tool/rugbyScoreKeeper/i18n/sv.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/tr.ts +6 -0
- package/src/tool/rugbyScoreKeeper/i18n/zh.ts +6 -0
- package/src/tool/rugbyScoreKeeper/logic.test.ts +113 -0
- package/src/tool/rugbyScoreKeeper/logic.ts +52 -29
- package/src/tool/rugbyScoreKeeper/rugby-scorekeeper.css +175 -27
- package/src/tool/rugbyScoreKeeper/storage.test.ts +66 -0
- package/src/tool/rugbyScoreKeeper/storage.ts +126 -0
- package/src/tool/rugbyScoreKeeper/ui-helpers.ts +14 -0
- package/src/tool/rugbyScoreKeeper/ui-render.ts +149 -0
- package/src/tool/rugbyScoreKeeper/ui-runtime.ts +260 -0
- package/src/tool/rugbyScoreKeeper/ui.ts +6 -0
- package/src/tool/rugbyScoreKeeper/ui-init.ts +0 -259
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-sports",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.61.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
|
+
});
|
|
@@ -8,70 +8,82 @@ interface Props {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const { ui } = Astro.props;
|
|
11
|
-
const t = (ui ?? {}) as RugbyScoreKeeperUI;
|
|
11
|
+
const t = (ui ?? {}) as unknown as RugbyScoreKeeperUI;
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
<div class="rg-app" id="rg-app" data-rg-ui={JSON.stringify(t)}>
|
|
15
15
|
|
|
16
16
|
<div class="rg-main-card">
|
|
17
17
|
|
|
18
|
+
<div class="rg-save-status" id="rg-save-status" role="status">
|
|
19
|
+
<span class="rg-save-dot" aria-hidden="true"></span>
|
|
20
|
+
<span id="rg-save-status-text">{t.savedLocally}</span>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
18
23
|
<div class="rg-strip">
|
|
19
|
-
<div class="rg-team">
|
|
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>
|
|
24
|
+
<div class="rg-team rg-team-home">
|
|
25
|
+
<input type="text" class="rg-name" id="rg-name-home" value={t.home} maxlength="40" aria-label={t.home} />
|
|
26
|
+
<div class="rg-score" id="rg-score-home" aria-live="polite">0</div>
|
|
22
27
|
<div class="rg-bonus" id="rg-bonus-home"></div>
|
|
23
28
|
</div>
|
|
24
29
|
<div class="rg-divider">
|
|
25
30
|
<span class="rg-vs">VS</span>
|
|
26
31
|
<div class="rg-half-badge" id="rg-half-badge">{t.half1}</div>
|
|
27
32
|
</div>
|
|
28
|
-
<div class="rg-team">
|
|
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>
|
|
33
|
+
<div class="rg-team rg-team-away">
|
|
34
|
+
<input type="text" class="rg-name" id="rg-name-away" value={t.away} maxlength="40" aria-label={t.away} />
|
|
35
|
+
<div class="rg-score" id="rg-score-away" aria-live="polite">0</div>
|
|
31
36
|
<div class="rg-bonus" id="rg-bonus-away"></div>
|
|
32
37
|
</div>
|
|
33
38
|
</div>
|
|
34
39
|
|
|
35
40
|
<div class="rg-columns">
|
|
36
41
|
<div class="rg-col" data-team="home">
|
|
37
|
-
<div class="rg-col-label">{t.home}</div>
|
|
38
|
-
<button class="rg-btn rg-btn-try" data-action="try"
|
|
39
|
-
<button class="rg-btn rg-btn-conv" id="rg-conv-home" data-action="conv" disabled
|
|
40
|
-
<button class="rg-btn rg-btn-pen" data-action="pen"
|
|
41
|
-
<button class="rg-btn rg-btn-drop" data-action="drop"
|
|
42
|
+
<div class="rg-col-label" id="rg-col-label-home">{t.home}</div>
|
|
43
|
+
<button type="button" class="rg-btn rg-btn-try" data-action="try">{t.tryLabel}</button>
|
|
44
|
+
<button type="button" class="rg-btn rg-btn-conv" id="rg-conv-home" data-action="conv" disabled>{t.conversion}</button>
|
|
45
|
+
<button type="button" class="rg-btn rg-btn-pen" data-action="pen">{t.penalty}</button>
|
|
46
|
+
<button type="button" class="rg-btn rg-btn-drop" data-action="drop">{t.dropGoal}</button>
|
|
42
47
|
</div>
|
|
43
48
|
<div class="rg-col" data-team="away">
|
|
44
|
-
<div class="rg-col-label">{t.away}</div>
|
|
45
|
-
<button class="rg-btn rg-btn-try" data-action="try"
|
|
46
|
-
<button class="rg-btn rg-btn-conv" id="rg-conv-away" data-action="conv" disabled
|
|
47
|
-
<button class="rg-btn rg-btn-pen" data-action="pen"
|
|
48
|
-
<button class="rg-btn rg-btn-drop" data-action="drop"
|
|
49
|
+
<div class="rg-col-label" id="rg-col-label-away">{t.away}</div>
|
|
50
|
+
<button type="button" class="rg-btn rg-btn-try" data-action="try">{t.tryLabel}</button>
|
|
51
|
+
<button type="button" class="rg-btn rg-btn-conv" id="rg-conv-away" data-action="conv" disabled>{t.conversion}</button>
|
|
52
|
+
<button type="button" class="rg-btn rg-btn-pen" data-action="pen">{t.penalty}</button>
|
|
53
|
+
<button type="button" class="rg-btn rg-btn-drop" data-action="drop">{t.dropGoal}</button>
|
|
49
54
|
</div>
|
|
50
55
|
</div>
|
|
51
56
|
|
|
52
57
|
<div class="rg-mid-row">
|
|
53
58
|
<div class="rg-clock-panel">
|
|
59
|
+
<div class="rg-panel-header">{t.matchClock}</div>
|
|
54
60
|
<div class="rg-clock-ring">
|
|
55
61
|
<svg class="rg-clock-svg" viewBox="0 0 120 120">
|
|
56
62
|
<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" />
|
|
63
|
+
<circle class="rg-clock-fill" id="rg-clock-fill" cx="60" cy="60" r="54" aria-hidden="true" />
|
|
58
64
|
</svg>
|
|
59
65
|
<div class="rg-clock-inner">
|
|
60
|
-
<div class="rg-clock-time" id="rg-clock-time">00:00</div>
|
|
66
|
+
<div class="rg-clock-time" id="rg-clock-time" aria-live="polite">00:00</div>
|
|
61
67
|
<div class="rg-clock-label">{t.half} <span id="rg-half-label">1</span></div>
|
|
62
68
|
</div>
|
|
63
69
|
</div>
|
|
64
|
-
<button class="rg-btn rg-btn-clock" id="rg-btn-clock">{t.startMatch}</button>
|
|
70
|
+
<button type="button" class="rg-btn rg-btn-clock" id="rg-btn-clock">{t.startMatch}</button>
|
|
65
71
|
</div>
|
|
66
72
|
<div class="rg-sinbin-panel">
|
|
67
|
-
<div class="rg-panel-header">{t.
|
|
73
|
+
<div class="rg-panel-header">{t.sinBinTitle}</div>
|
|
68
74
|
<div class="rg-sinbin-form">
|
|
69
|
-
<input type="text" class="rg-input" id="rg-sinbin-input" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
<input type="text" class="rg-input" id="rg-sinbin-input" maxlength="80" placeholder={t.sinBinPlayer} aria-label={t.sinBinPlayer} />
|
|
76
|
+
<div class="rg-choice-row">
|
|
77
|
+
<div class="rg-choice-group" role="group" aria-label={t.teamLabel}>
|
|
78
|
+
<button type="button" class="rg-choice rg-choice-home is-active" data-sinbin-team="home" aria-pressed="true">{t.home}</button>
|
|
79
|
+
<button type="button" class="rg-choice rg-choice-away" data-sinbin-team="away" aria-pressed="false">{t.away}</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="rg-choice-group" role="group" aria-label={t.durationLabel}>
|
|
82
|
+
<button type="button" class="rg-choice is-active" data-sinbin-duration="600" aria-pressed="true">10 {t.minutesShort}</button>
|
|
83
|
+
<button type="button" class="rg-choice" data-sinbin-duration="300" aria-pressed="false">5 {t.minutesShort}</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<button type="button" class="rg-btn rg-btn-yellow" id="rg-btn-sinbin">{t.sinBinAdd}</button>
|
|
75
87
|
</div>
|
|
76
88
|
<div class="rg-sinbin-list" id="rg-sinbin-list">
|
|
77
89
|
<div class="rg-sinbin-empty">{t.sinBinEmpty}</div>
|
|
@@ -85,13 +97,13 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
|
|
|
85
97
|
<div class="rg-history-list" id="rg-history-list">
|
|
86
98
|
<div class="rg-history-empty">{t.eventEmpty}</div>
|
|
87
99
|
</div>
|
|
88
|
-
<button class="rg-btn rg-btn-undo" id="rg-btn-undo" disabled>{t.undoBtn}</button>
|
|
100
|
+
<button type="button" class="rg-btn rg-btn-undo" id="rg-btn-undo" disabled>{t.undoBtn}</button>
|
|
89
101
|
</div>
|
|
90
102
|
<div class="rg-summary">
|
|
91
103
|
<div class="rg-panel-header">{t.scoringSummary}</div>
|
|
92
104
|
<table class="rg-table">
|
|
93
105
|
<thead>
|
|
94
|
-
<tr><th></th><th>{t.home}</th><th>{t.away}</th></tr>
|
|
106
|
+
<tr><th></th><th class="rg-th-home" id="rg-summary-home">{t.home}</th><th class="rg-th-away" id="rg-summary-away">{t.away}</th></tr>
|
|
95
107
|
</thead>
|
|
96
108
|
<tbody id="rg-summary-body">
|
|
97
109
|
<tr><td>{t.tryScored}</td><td>0</td><td>0</td></tr>
|
|
@@ -110,28 +122,29 @@ const t = (ui ?? {}) as RugbyScoreKeeperUI;
|
|
|
110
122
|
</div>
|
|
111
123
|
</div>
|
|
112
124
|
|
|
113
|
-
<div id="rg-banner" class="rg-banner" style="display:none">
|
|
125
|
+
<div id="rg-banner" class="rg-banner" role="status" aria-live="polite" style="display:none">
|
|
114
126
|
<div class="rg-banner-text" id="rg-banner-text"></div>
|
|
115
127
|
</div>
|
|
116
128
|
|
|
117
|
-
<div class="rg-reset-modal" id="rg-modal">
|
|
129
|
+
<div class="rg-reset-modal" id="rg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="rg-modal-title">
|
|
118
130
|
<div class="rg-modal-content">
|
|
131
|
+
<h2 class="rg-modal-title" id="rg-modal-title">{t.resetMatch}</h2>
|
|
119
132
|
<div class="rg-modal-text">{t.resetConfirm}</div>
|
|
120
133
|
<div class="rg-modal-btns">
|
|
121
|
-
<button class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
|
|
122
|
-
<button class="rg-btn rg-btn-modal-confirm" id="rg-modal-confirm">{t.confirm}</button>
|
|
134
|
+
<button type="button" class="rg-btn rg-btn-modal-cancel" id="rg-modal-cancel">{t.cancel}</button>
|
|
135
|
+
<button type="button" class="rg-btn rg-btn-modal-confirm" id="rg-modal-confirm">{t.confirm}</button>
|
|
123
136
|
</div>
|
|
124
137
|
</div>
|
|
125
138
|
</div>
|
|
126
139
|
|
|
127
140
|
<div class="rg-actions">
|
|
128
|
-
<button class="rg-btn rg-btn-reset" id="rg-btn-reset">{t.resetMatch}</button>
|
|
141
|
+
<button type="button" class="rg-btn rg-btn-reset" id="rg-btn-reset">{t.resetMatch}</button>
|
|
129
142
|
</div>
|
|
130
143
|
|
|
131
144
|
</div>
|
|
132
145
|
</div>
|
|
133
146
|
|
|
134
147
|
<script>
|
|
135
|
-
import { initRugbyScorekeeper } from './ui-
|
|
148
|
+
import { initRugbyScorekeeper } from './ui-runtime';
|
|
136
149
|
initRugbyScorekeeper();
|
|
137
150
|
</script>
|
|
@@ -214,5 +214,11 @@ 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.',
|
|
219
|
+
savedLocally: 'Auf diesem Gerät gespeichert',
|
|
220
|
+
saveUnavailable: 'Speichern nicht verfügbar',
|
|
221
|
+
teamLabel: 'Team',
|
|
222
|
+
durationLabel: 'Dauer',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ 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',
|
|
219
|
+
savedLocally: 'Saved on this device',
|
|
220
|
+
saveUnavailable: 'Saving unavailable',
|
|
221
|
+
teamLabel: 'Team',
|
|
222
|
+
durationLabel: 'Duration',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ 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',
|
|
219
|
+
savedLocally: 'Guardado en este dispositivo',
|
|
220
|
+
saveUnavailable: 'No se puede guardar',
|
|
221
|
+
teamLabel: 'Equipo',
|
|
222
|
+
durationLabel: 'Duración',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ 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',
|
|
219
|
+
savedLocally: 'Enregistré sur cet appareil',
|
|
220
|
+
saveUnavailable: 'Enregistrement indisponible',
|
|
221
|
+
teamLabel: 'Équipe',
|
|
222
|
+
durationLabel: 'Durée',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ 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',
|
|
219
|
+
savedLocally: 'Tersimpan di perangkat ini',
|
|
220
|
+
saveUnavailable: 'Penyimpanan tidak tersedia',
|
|
221
|
+
teamLabel: 'Tim',
|
|
222
|
+
durationLabel: 'Durasi',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ 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',
|
|
219
|
+
savedLocally: 'Salvato su questo dispositivo',
|
|
220
|
+
saveUnavailable: 'Salvataggio non disponibile',
|
|
221
|
+
teamLabel: 'Squadra',
|
|
222
|
+
durationLabel: 'Durata',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
|
|
|
214
214
|
undoBtn: '最後を元に戻す',
|
|
215
215
|
timeOff: 'タイムオフ',
|
|
216
216
|
timeOn: 'タイムオン',
|
|
217
|
+
fullTime: '試合終了',
|
|
218
|
+
minutesShort: '分',
|
|
219
|
+
savedLocally: 'この端末に保存済み',
|
|
220
|
+
saveUnavailable: '保存できません',
|
|
221
|
+
teamLabel: 'チーム',
|
|
222
|
+
durationLabel: '時間',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ export const content: RugbyScoreKeeperLocaleContent = {
|
|
|
214
214
|
undoBtn: '마지막 실행 취소',
|
|
215
215
|
timeOff: '타임 오프',
|
|
216
216
|
timeOn: '타임 온',
|
|
217
|
+
fullTime: '경기 종료',
|
|
218
|
+
minutesShort: '분',
|
|
219
|
+
savedLocally: '이 기기에 저장됨',
|
|
220
|
+
saveUnavailable: '저장할 수 없음',
|
|
221
|
+
teamLabel: '팀',
|
|
222
|
+
durationLabel: '시간',
|
|
217
223
|
},
|
|
218
224
|
};
|
|
@@ -214,5 +214,11 @@ 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',
|
|
219
|
+
savedLocally: 'Op dit apparaat opgeslagen',
|
|
220
|
+
saveUnavailable: 'Opslaan niet beschikbaar',
|
|
221
|
+
teamLabel: 'Team',
|
|
222
|
+
durationLabel: 'Duur',
|
|
217
223
|
},
|
|
218
224
|
};
|