@jjlmoya/utils-sports 1.47.0 → 1.49.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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -0
  4. package/src/tests/spanish_leakage.test.ts +25 -3
  5. package/src/tool/vo2MaxCalculator/bibliography.astro +6 -0
  6. package/src/tool/vo2MaxCalculator/bibliography.ts +18 -0
  7. package/src/tool/vo2MaxCalculator/component.astro +175 -0
  8. package/src/tool/vo2MaxCalculator/content-factory.ts +105 -0
  9. package/src/tool/vo2MaxCalculator/controller.ts +159 -0
  10. package/src/tool/vo2MaxCalculator/dom-views.ts +80 -0
  11. package/src/tool/vo2MaxCalculator/entry.ts +30 -0
  12. package/src/tool/vo2MaxCalculator/evaluator.ts +19 -0
  13. package/src/tool/vo2MaxCalculator/i18n/de.ts +20 -0
  14. package/src/tool/vo2MaxCalculator/i18n/en.ts +240 -0
  15. package/src/tool/vo2MaxCalculator/i18n/es.ts +49 -0
  16. package/src/tool/vo2MaxCalculator/i18n/fr.ts +13 -0
  17. package/src/tool/vo2MaxCalculator/i18n/id.ts +13 -0
  18. package/src/tool/vo2MaxCalculator/i18n/it.ts +13 -0
  19. package/src/tool/vo2MaxCalculator/i18n/ja.ts +13 -0
  20. package/src/tool/vo2MaxCalculator/i18n/ko.ts +13 -0
  21. package/src/tool/vo2MaxCalculator/i18n/nl.ts +13 -0
  22. package/src/tool/vo2MaxCalculator/i18n/pl.ts +13 -0
  23. package/src/tool/vo2MaxCalculator/i18n/pt.ts +13 -0
  24. package/src/tool/vo2MaxCalculator/i18n/ru.ts +13 -0
  25. package/src/tool/vo2MaxCalculator/i18n/sv.ts +13 -0
  26. package/src/tool/vo2MaxCalculator/i18n/tr.ts +13 -0
  27. package/src/tool/vo2MaxCalculator/i18n/zh.ts +13 -0
  28. package/src/tool/vo2MaxCalculator/index.ts +10 -0
  29. package/src/tool/vo2MaxCalculator/logic.test.ts +48 -0
  30. package/src/tool/vo2MaxCalculator/logic.ts +155 -0
  31. package/src/tool/vo2MaxCalculator/seo.astro +14 -0
  32. package/src/tool/vo2MaxCalculator/storage.ts +19 -0
  33. package/src/tool/vo2MaxCalculator/ui.ts +41 -0
  34. package/src/tool/vo2MaxCalculator/vo2-max-calculator.css +616 -0
  35. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.47.0",
3
+ "version": "1.49.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -11,6 +11,7 @@ import { rugbyScoreKeeper } from '../tool/rugbyScoreKeeper/entry';
11
11
  import { baseballScoreKeeper } from '../tool/baseballScoreKeeper/entry';
12
12
  import { penaltyShootout } from '../tool/penaltyShootout/entry';
13
13
  import { swimCssCalculator } from '../tool/swimCssCalculator/entry';
14
+ import { vo2MaxCalculator } from '../tool/vo2MaxCalculator/entry';
14
15
 
15
16
  export const sportsCategory: SportsCategoryEntry = {
16
17
  icon: 'mdi:soccer',
@@ -27,6 +28,7 @@ export const sportsCategory: SportsCategoryEntry = {
27
28
  baseballScoreKeeper,
28
29
  penaltyShootout,
29
30
  swimCssCalculator,
31
+ vo2MaxCalculator,
30
32
  ] as unknown as SportsToolEntry<Record<string, string>>[],
31
33
  i18n: {
32
34
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -38,6 +38,8 @@ export { swimCssCalculator } from './tool/swimCssCalculator/entry';
38
38
  export type { SwimCssCalculatorLocaleContent } from './tool/swimCssCalculator/entry';
39
39
  export { volleyballScoreKeeper } from './tool/volleyballScoreKeeper/entry';
40
40
  export type { VolleyballScoreKeeperLocaleContent } from './tool/volleyballScoreKeeper/entry';
41
+ export { vo2MaxCalculator } from './tool/vo2MaxCalculator/entry';
42
+ export type { Vo2MaxCalculatorLocaleContent } from './tool/vo2MaxCalculator/entry';
41
43
 
42
44
  export { sportsCategory } from './category';
43
45
 
@@ -61,6 +63,7 @@ import { penaltyShootout } from './tool/penaltyShootout/entry';
61
63
  import { runningPacePredictor } from './tool/runningPacePredictor/entry';
62
64
  import { swimCssCalculator } from './tool/swimCssCalculator/entry';
63
65
  import { volleyballScoreKeeper } from './tool/volleyballScoreKeeper/entry';
66
+ import { vo2MaxCalculator } from './tool/vo2MaxCalculator/entry';
64
67
 
65
68
  export const ALL_ENTRIES = [
66
69
  basketScoreKeeper,
@@ -83,4 +86,5 @@ export const ALL_ENTRIES = [
83
86
  runningPacePredictor,
84
87
  swimCssCalculator,
85
88
  volleyballScoreKeeper,
89
+ vo2MaxCalculator,
86
90
  ];
@@ -28,6 +28,8 @@ const TRANSLATABLE_KEYS = [
28
28
  'schemas',
29
29
  ] as const;
30
30
 
31
+ const COPY_THRESHOLD = 0.9;
32
+
31
33
  const SPANISH_MARKERS = [
32
34
  ['sangre', /\bsangre\b/gi],
33
35
  ['molino', /\bmolino\b/gi],
@@ -65,12 +67,16 @@ function normalize(value: string): string {
65
67
  }
66
68
 
67
69
  function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
68
71
  return [
69
72
  'data:image/svg+xml;base64',
70
73
  'background-image: url',
71
74
  '.layout-playground {',
72
75
  'const samplerate =',
73
- ].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
74
80
  }
75
81
 
76
82
  function collectString(value: string, output: string[]): void {
@@ -111,10 +117,26 @@ function findSpanishMarkers(text: string[]): string[] {
111
117
  );
112
118
  }
113
119
 
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
114
132
  function findCopiedFragments(spanish: string[], translated: string[]): string[] {
115
- const corpus = translated.join(' ');
116
133
  return spanish
117
- .filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
118
140
  .sort((a, b) => b.length - a.length)
119
141
  .slice(0, 3);
120
142
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,18 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'Field-based fitness tests in adults: validity and limitations',
6
+ url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC8397016/',
7
+ },
8
+ {
9
+ name: 'Kline et al.: Rockport one-mile walk equation',
10
+ url: 'https://pubmed.ncbi.nlm.nih.gov/3600239/',
11
+ },
12
+ {
13
+ name: 'Kaminsky et al.: FRIEND age and sex reference standards',
14
+ url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC4919021/',
15
+ },
16
+ ];
17
+
18
+ export const bibliography = bibliographyEntries;
@@ -0,0 +1,175 @@
1
+ ---
2
+ import type { Vo2MaxCalculatorUI } from './ui';
3
+ import './vo2-max-calculator.css';
4
+
5
+ interface Props {
6
+ ui: Vo2MaxCalculatorUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div
13
+ class="vo2-tool"
14
+ id="vo2-app"
15
+ data-note-cooper12={ui.methodNoteCooper12}
16
+ data-note-cooper15={ui.methodNoteCooper15}
17
+ data-note-rockport={ui.methodNoteRockport}
18
+ data-note-shuttle20={ui.methodNoteShuttle}
19
+ data-band-below10={ui.bandBelow10}
20
+ data-band-10to25={ui.band10to25}
21
+ data-band-25to50={ui.band25to50}
22
+ data-band-50to75={ui.band50to75}
23
+ data-band-75to90={ui.band75to90}
24
+ data-band-above90={ui.bandAbove90}
25
+ data-invalid-notice={ui.invalidNotice}
26
+ >
27
+ <form class="vo2-card vo2-form" id="vo2-form" novalidate>
28
+ <section class="vo2-result-card" id="vo2-result-card" aria-live="polite">
29
+ <div class="vo2-result-copy">
30
+ <p class="vo2-kicker">{ui.resultTitle}</p>
31
+ <div class="vo2-number-row">
32
+ <strong id="vo2-value">--</strong>
33
+ <span>{ui.estimateUnit}</span>
34
+ </div>
35
+ <p class="vo2-method-note" id="vo2-method-note">{ui.methodNoteCooper12}</p>
36
+ <p class="vo2-percentile" id="vo2-percentile">{ui.referenceDescription}</p>
37
+ </div>
38
+ <div class="vo2-constellation" aria-hidden="true">
39
+ <span class="vo2-orbit vo2-orbit-one"></span>
40
+ <span class="vo2-orbit vo2-orbit-two"></span>
41
+ <span class="vo2-orbit vo2-orbit-three"></span>
42
+ <span class="vo2-star vo2-star-one"></span>
43
+ <span class="vo2-star vo2-star-two"></span>
44
+ <span class="vo2-star vo2-star-three"></span>
45
+ <span class="vo2-star vo2-star-four"></span>
46
+ <span class="vo2-core"></span>
47
+ </div>
48
+ </section>
49
+
50
+ <fieldset class="vo2-method-panel" aria-label={ui.methodLabel}>
51
+ <div class="vo2-method-grid">
52
+ <button type="button" class="vo2-choice is-active" data-method-option="cooper12">{ui.methodCooper12}</button>
53
+ <button type="button" class="vo2-choice" data-method-option="cooper15">{ui.methodCooper15}</button>
54
+ <button type="button" class="vo2-choice" data-method-option="rockport">{ui.methodRockport}</button>
55
+ <button type="button" class="vo2-choice" data-method-option="shuttle20">{ui.methodShuttle}</button>
56
+ </div>
57
+ </fieldset>
58
+
59
+ <div class="vo2-input-layout">
60
+ <fieldset class="vo2-test-panel" aria-label={ui.performanceLegend}>
61
+ <div class="vo2-field" id="vo2-distance-field">
62
+ <div class="vo2-field-header">
63
+ <label for="vo2-performance">{ui.distanceLabel}</label>
64
+ <div class="vo2-unit-switch" data-unit-switch>
65
+ <button type="button" class="vo2-unit is-active" data-unit="metric">{ui.metricUnit}</button>
66
+ <button type="button" class="vo2-unit" data-unit="imperial">{ui.imperialUnit}</button>
67
+ </div>
68
+ </div>
69
+ <div class="vo2-input-with-unit">
70
+ <input id="vo2-performance" type="number" min="1" step="1" value="2400" inputmode="decimal" />
71
+ <span class="vo2-unit-label" data-distance-unit>m</span>
72
+ </div>
73
+ <small>{ui.distanceHint}</small>
74
+ </div>
75
+
76
+ <div class="vo2-field" id="vo2-time-field" hidden>
77
+ <label>{ui.timeLabel}</label>
78
+ <div class="vo2-time-grid">
79
+ <input id="vo2-minutes" type="number" min="0" max="180" value="12" inputmode="numeric" aria-label={ui.minutesLabel} />
80
+ <span>:</span>
81
+ <input id="vo2-seconds" type="number" min="0" max="59" value="0" inputmode="numeric" aria-label={ui.secondsLabel} />
82
+ </div>
83
+ <small>{ui.minutesLabel} and {ui.secondsLabel}</small>
84
+ </div>
85
+
86
+ <div class="vo2-field" id="vo2-stage-field" hidden>
87
+ <label for="vo2-stage">{ui.stageLabel}</label>
88
+ <input id="vo2-stage" type="number" min="1" max="21" step="1" value="8" inputmode="numeric" />
89
+ <small>{ui.stageHint}</small>
90
+ </div>
91
+
92
+ <div class="vo2-field" id="vo2-weight-field" hidden>
93
+ <div class="vo2-field-header">
94
+ <label for="vo2-weight">{ui.weightLabel}</label>
95
+ <div class="vo2-unit-switch" data-unit-switch>
96
+ <button type="button" class="vo2-unit is-active" data-unit="metric">{ui.metricUnit}</button>
97
+ <button type="button" class="vo2-unit" data-unit="imperial">{ui.imperialUnit}</button>
98
+ </div>
99
+ </div>
100
+ <div class="vo2-input-with-unit">
101
+ <input id="vo2-weight" type="number" min="20" max="400" step="0.1" value="75" inputmode="decimal" />
102
+ <span class="vo2-unit-label" data-weight-unit>kg</span>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="vo2-field" id="vo2-heart-rate-field" hidden>
107
+ <label for="vo2-heart-rate">{ui.heartRateLabel}</label>
108
+ <div class="vo2-input-with-unit">
109
+ <input id="vo2-heart-rate" type="number" min="50" max="230" step="1" value="150" inputmode="numeric" />
110
+ <span class="vo2-unit-label">bpm</span>
111
+ </div>
112
+ </div>
113
+ </fieldset>
114
+
115
+ <fieldset class="vo2-context-panel" aria-label={ui.referenceTitle}>
116
+ <div class="vo2-field">
117
+ <label for="vo2-age">{ui.ageLabel}</label>
118
+ <input id="vo2-age" type="number" min="20" max="79" step="1" value="35" inputmode="numeric" />
119
+ </div>
120
+ <div class="vo2-field">
121
+ <span class="vo2-label">{ui.sexLabel}</span>
122
+ <div class="vo2-sex-grid">
123
+ <button type="button" class="vo2-choice is-active" data-sex="male">{ui.maleLabel}</button>
124
+ <button type="button" class="vo2-choice" data-sex="female">{ui.femaleLabel}</button>
125
+ </div>
126
+ </div>
127
+ </fieldset>
128
+ </div>
129
+
130
+ <div class="vo2-reference-panel" id="vo2-reference-chart" role="img" aria-label={ui.referenceDescription}>
131
+ <div class="vo2-reference-scale">
132
+ <span class="vo2-reference-line"></span>
133
+ <span class="vo2-reference-marker"></span>
134
+ <div class="vo2-reference-segments">
135
+ <span data-reference-band="below10">{ui.bandBelow10}</span>
136
+ <span data-reference-band="10to25">{ui.band10to25}</span>
137
+ <span data-reference-band="25to50">{ui.band25to50}</span>
138
+ <span data-reference-band="50to75">{ui.band50to75}</span>
139
+ <span data-reference-band="75to90">{ui.band75to90}</span>
140
+ <span data-reference-band="above90">{ui.bandAbove90}</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <div class="vo2-actions">
146
+ <button type="button" class="vo2-secondary" id="vo2-reset">{ui.resetButton}</button>
147
+ </div>
148
+ <p class="vo2-safety">{ui.safetyNotice}</p>
149
+ </form>
150
+ </div>
151
+
152
+ <script>
153
+ import { initVo2Calculator } from './controller';
154
+
155
+ const root = document.getElementById('vo2-app');
156
+ if (root) {
157
+ initVo2Calculator({
158
+ methodNote: {
159
+ cooper12: root.dataset.noteCooper12 ?? '',
160
+ cooper15: root.dataset.noteCooper15 ?? '',
161
+ rockport: root.dataset.noteRockport ?? '',
162
+ shuttle20: root.dataset.noteShuttle20 ?? '',
163
+ },
164
+ bandLabels: {
165
+ below10: root.dataset.bandBelow10 ?? '',
166
+ '10to25': root.dataset.band10to25 ?? '',
167
+ '25to50': root.dataset.band25to50 ?? '',
168
+ '50to75': root.dataset.band50to75 ?? '',
169
+ '75to90': root.dataset.band75to90 ?? '',
170
+ above90: root.dataset.bandAbove90 ?? '',
171
+ },
172
+ invalidNotice: root.dataset.invalidNotice ?? '',
173
+ });
174
+ }
175
+ </script>
@@ -0,0 +1,105 @@
1
+ import type { SEOSection } from '@jjlmoya/utils-shared';
2
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
3
+ import type { ToolLocaleContent } from '../../types';
4
+ import { bibliographyEntries } from './bibliography';
5
+ import type { Vo2MaxCalculatorUI } from './ui';
6
+
7
+ interface SeoCopy {
8
+ introTitle: string;
9
+ introParagraph: string;
10
+ summaryTitle: string;
11
+ summaryItems: string[];
12
+ stats: { value: string; label: string }[];
13
+ protocolTitle: string;
14
+ comparative: { title: string; description: string }[];
15
+ rockportTitle: string;
16
+ diagnosticTitle: string;
17
+ diagnosticHtml: string;
18
+ ageTitle: string;
19
+ tableHeaders: string[];
20
+ tableRows: string[][];
21
+ shuttleTitle: string;
22
+ shuttleParagraph: string;
23
+ listItems: string[];
24
+ tipTitle: string;
25
+ tipHtml: string;
26
+ }
27
+
28
+ export interface Vo2LocaleCopy {
29
+ locale: string;
30
+ slug: string;
31
+ title: string;
32
+ description: string;
33
+ faqTitle: string;
34
+ bibliographyTitle: string;
35
+ diagnosticBadge: string;
36
+ ui: Vo2MaxCalculatorUI;
37
+ faq: { question: string; answer: string }[];
38
+ howTo: { name: string; text: string }[];
39
+ seo: SeoCopy;
40
+ }
41
+
42
+ function createSeo(copy: SeoCopy, diagnosticBadge: string): SEOSection[] {
43
+ return [
44
+ { type: 'title', text: copy.introTitle, level: 2 },
45
+ { type: 'paragraph', html: copy.introParagraph },
46
+ { type: 'summary', title: copy.summaryTitle, items: copy.summaryItems },
47
+ { type: 'stats', items: copy.stats },
48
+ { type: 'title', text: copy.protocolTitle, level: 2 },
49
+ { type: 'comparative', columns: 2, items: copy.comparative },
50
+ { type: 'title', text: copy.rockportTitle, level: 2 },
51
+ { type: 'diagnostic', variant: 'info', title: copy.diagnosticTitle, badge: diagnosticBadge, html: copy.diagnosticHtml },
52
+ { type: 'title', text: copy.ageTitle, level: 2 },
53
+ { type: 'table', headers: copy.tableHeaders, rows: copy.tableRows },
54
+ { type: 'title', text: copy.shuttleTitle, level: 2 },
55
+ { type: 'paragraph', html: copy.shuttleParagraph },
56
+ { type: 'list', items: copy.listItems },
57
+ { type: 'tip', title: copy.tipTitle, html: copy.tipHtml },
58
+ ];
59
+ }
60
+
61
+ function createSchemas(copy: Vo2LocaleCopy): WithContext<FAQPage | HowTo | SoftwareApplication>[] {
62
+ const faqSchema: WithContext<FAQPage> = {
63
+ '@context': 'https://schema.org',
64
+ '@type': 'FAQPage',
65
+ mainEntity: copy.faq.map((item) => ({
66
+ '@type': 'Question',
67
+ name: item.question,
68
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
69
+ })),
70
+ };
71
+ const howToSchema: WithContext<HowTo> = {
72
+ '@context': 'https://schema.org',
73
+ '@type': 'HowTo',
74
+ name: copy.title,
75
+ description: copy.description,
76
+ step: copy.howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
77
+ };
78
+ const appSchema: WithContext<SoftwareApplication> = {
79
+ '@context': 'https://schema.org',
80
+ '@type': 'SoftwareApplication',
81
+ name: copy.title,
82
+ description: copy.description,
83
+ applicationCategory: 'SportsApplication',
84
+ operatingSystem: 'All',
85
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
86
+ inLanguage: copy.locale,
87
+ };
88
+ return [appSchema, faqSchema, howToSchema];
89
+ }
90
+
91
+ export function createVo2Content(copy: Vo2LocaleCopy): ToolLocaleContent<Vo2MaxCalculatorUI> {
92
+ return {
93
+ slug: copy.slug,
94
+ title: copy.title,
95
+ description: copy.description,
96
+ faqTitle: copy.faqTitle,
97
+ faq: copy.faq,
98
+ howTo: copy.howTo,
99
+ schemas: createSchemas(copy),
100
+ bibliographyTitle: copy.bibliographyTitle,
101
+ bibliography: bibliographyEntries,
102
+ seo: createSeo(copy.seo, copy.diagnosticBadge),
103
+ ui: copy.ui,
104
+ };
105
+ }
@@ -0,0 +1,159 @@
1
+ import { calculateVo2Max, defaultVo2Input, type Sex, type UnitSystem, type Vo2Input, type Vo2Method } from './logic';
2
+ import { renderVo2Result, setMethodFields } from './dom-views';
3
+ import { loadVo2State, saveVo2State } from './storage';
4
+
5
+ interface ControllerLabels {
6
+ methodNote: Record<Vo2Method, string>;
7
+ bandLabels: Record<string, string>;
8
+ invalidNotice: string;
9
+ }
10
+
11
+ function numberValue(id: string): number {
12
+ const element = document.getElementById(id) as HTMLInputElement | null;
13
+ return Number(element?.value ?? 0);
14
+ }
15
+
16
+ function selectedSex(): Sex {
17
+ return document.querySelector<HTMLButtonElement>('[data-sex].is-active')?.dataset.sex === 'female' ? 'female' : 'male';
18
+ }
19
+
20
+ function selectedUnit(): UnitSystem {
21
+ return document.querySelector<HTMLButtonElement>('[data-unit].is-active')?.dataset.unit === 'imperial' ? 'imperial' : 'metric';
22
+ }
23
+
24
+ function readInput(): Vo2Input {
25
+ const method = (document.querySelector<HTMLButtonElement>('[data-method-option].is-active')?.dataset.methodOption ?? 'cooper12') as Vo2Method;
26
+ const performanceId = method === 'shuttle20' ? 'vo2-stage' : 'vo2-performance';
27
+ return {
28
+ method,
29
+ unit: selectedUnit(),
30
+ performance: numberValue(performanceId),
31
+ minutes: numberValue('vo2-minutes'),
32
+ seconds: numberValue('vo2-seconds'),
33
+ age: numberValue('vo2-age'),
34
+ sex: selectedSex(),
35
+ weight: numberValue('vo2-weight'),
36
+ heartRate: numberValue('vo2-heart-rate'),
37
+ };
38
+ }
39
+
40
+ function applyNumericValues(input: Vo2Input): void {
41
+ const values: Record<string, number> = {
42
+ 'vo2-performance': input.performance,
43
+ 'vo2-stage': input.performance,
44
+ 'vo2-minutes': input.minutes,
45
+ 'vo2-seconds': input.seconds,
46
+ 'vo2-age': input.age,
47
+ 'vo2-weight': input.weight,
48
+ 'vo2-heart-rate': input.heartRate,
49
+ };
50
+ Object.entries(values).forEach(([id, value]) => {
51
+ const element = document.getElementById(id) as HTMLInputElement | null;
52
+ if (element) element.value = String(value);
53
+ });
54
+ }
55
+
56
+ function applyChoiceValues(input: Vo2Input): void {
57
+ document.querySelectorAll<HTMLButtonElement>('[data-method-option]').forEach((button) => {
58
+ button.classList.toggle('is-active', button.dataset.methodOption === input.method);
59
+ });
60
+ document.querySelectorAll<HTMLButtonElement>('[data-sex]').forEach((button) => {
61
+ button.classList.toggle('is-active', button.dataset.sex === input.sex);
62
+ });
63
+ document.querySelectorAll<HTMLButtonElement>('[data-unit]').forEach((button) => {
64
+ button.classList.toggle('is-active', button.dataset.unit === input.unit);
65
+ });
66
+ setMethodFields(input.method);
67
+ document.querySelectorAll<HTMLElement>('[data-distance-unit]').forEach((element) => {
68
+ element.textContent = input.unit === 'metric' ? 'm' : 'mi';
69
+ });
70
+ document.querySelectorAll<HTMLElement>('[data-weight-unit]').forEach((element) => {
71
+ element.textContent = input.unit === 'metric' ? 'kg' : 'lb';
72
+ });
73
+ }
74
+
75
+ function applyInput(input: Vo2Input): void {
76
+ applyNumericValues(input);
77
+ applyChoiceValues(input);
78
+ }
79
+
80
+ function normalizeMethodValue(method: Vo2Method): void {
81
+ if (method !== 'shuttle20') return;
82
+ const stage = document.getElementById('vo2-stage') as HTMLInputElement | null;
83
+ if (stage && (Number(stage.value) < 1 || Number(stage.value) > 21)) stage.value = '8';
84
+ }
85
+
86
+ function refresh(labels: ControllerLabels): void {
87
+ const method = (document.querySelector<HTMLButtonElement>('[data-method-option].is-active')?.dataset.methodOption ?? 'cooper12') as Vo2Method;
88
+ normalizeMethodValue(method);
89
+ const input = readInput();
90
+ setMethodFields(input.method);
91
+ const result = calculateVo2Max(input);
92
+ saveVo2State(input);
93
+ renderVo2Result(input, result, labels);
94
+ }
95
+
96
+ function bindChoice(selector: string, onChange: () => void): void {
97
+ document.querySelectorAll<HTMLButtonElement>(selector).forEach((button) => {
98
+ button.addEventListener('click', () => {
99
+ document.querySelectorAll<HTMLButtonElement>(selector).forEach((item) => item.classList.remove('is-active'));
100
+ button.classList.add('is-active');
101
+ onChange();
102
+ });
103
+ });
104
+ }
105
+
106
+ function convertField(id: string, factor: number): void {
107
+ const element = document.getElementById(id) as HTMLInputElement | null;
108
+ if (element) element.value = String(Number(element.value) * factor);
109
+ }
110
+
111
+ function convertUnitFields(method: Vo2Method, next: UnitSystem): void {
112
+ if (method === 'cooper12') convertField('vo2-performance', next === 'metric' ? 1609.344 : 1 / 1609.344);
113
+ if (method === 'rockport') convertField('vo2-weight', next === 'metric' ? 1 / 2.2046226218 : 2.2046226218);
114
+ }
115
+
116
+ function handleUnitChoice(button: HTMLButtonElement, onChange: () => void): void {
117
+ const previous = selectedUnit();
118
+ const next = button.dataset.unit === 'imperial' ? 'imperial' : 'metric';
119
+ const method = (document.querySelector<HTMLButtonElement>('[data-method-option].is-active')?.dataset.methodOption ?? 'cooper12') as Vo2Method;
120
+ if (previous === next) return;
121
+ document.querySelectorAll<HTMLButtonElement>('[data-unit]').forEach((item) => item.classList.remove('is-active'));
122
+ button.classList.add('is-active');
123
+ convertUnitFields(method, next);
124
+ onChange();
125
+ }
126
+
127
+ function bindUnitChoice(onChange: () => void): void {
128
+ document.querySelectorAll<HTMLButtonElement>('[data-unit]').forEach((button) => {
129
+ button.addEventListener('click', () => handleUnitChoice(button, onChange));
130
+ });
131
+ }
132
+
133
+ function bindInputs(onChange: () => void): void {
134
+ document.querySelectorAll<HTMLInputElement>('#vo2-form input').forEach((input) => input.addEventListener('input', onChange));
135
+ }
136
+
137
+ function init(labels: ControllerLabels): void {
138
+ const saved = loadVo2State();
139
+ applyInput(saved);
140
+ const update = () => refresh(labels);
141
+ bindChoice('[data-method-option]', update);
142
+ bindChoice('[data-sex]', update);
143
+ bindUnitChoice(update);
144
+ bindInputs(update);
145
+ document.getElementById('vo2-reset')?.addEventListener('click', () => {
146
+ const initial = defaultVo2Input();
147
+ applyInput(initial);
148
+ refresh(labels);
149
+ });
150
+ refresh(labels);
151
+ }
152
+
153
+ export function initVo2Calculator(labels: ControllerLabels): void {
154
+ if (document.readyState === 'loading') {
155
+ document.addEventListener('DOMContentLoaded', () => init(labels), { once: true });
156
+ return;
157
+ }
158
+ init(labels);
159
+ }
@@ -0,0 +1,80 @@
1
+ import { evaluateVo2Result } from './evaluator';
2
+ import type { Vo2Input, Vo2Result, Vo2Method } from './logic';
3
+
4
+ interface DomLabels {
5
+ methodNote: Record<Vo2Method, string>;
6
+ bandLabels: Record<string, string>;
7
+ invalidNotice: string;
8
+ }
9
+
10
+ function setText(id: string, value: string): void {
11
+ const element = document.getElementById(id);
12
+ if (element) element.textContent = value;
13
+ }
14
+
15
+ function setVisible(id: string, visible: boolean): void {
16
+ const element = document.getElementById(id);
17
+ if (element) element.hidden = !visible;
18
+ }
19
+
20
+ function updateMethodFields(method: Vo2Method): void {
21
+ setVisible('vo2-distance-field', method === 'cooper12');
22
+ setVisible('vo2-time-field', method === 'cooper15' || method === 'rockport');
23
+ setVisible('vo2-stage-field', method === 'shuttle20');
24
+ setVisible('vo2-weight-field', method === 'rockport');
25
+ setVisible('vo2-heart-rate-field', method === 'rockport');
26
+ document.querySelectorAll<HTMLElement>('[data-method-option]').forEach((button) => {
27
+ button.classList.toggle('is-active', button.dataset.methodOption === method);
28
+ });
29
+ }
30
+
31
+ function motionDuration(position: number): string {
32
+ if (position >= 75) return '0.85s';
33
+ if (position <= 25) return '2.2s';
34
+ return '1.45s';
35
+ }
36
+
37
+ function renderReferenceChart(result: Vo2Result, labels: DomLabels): void {
38
+ const chart = document.getElementById('vo2-reference-chart');
39
+ if (!chart) return;
40
+ chart.style.setProperty('--vo2-position', `${result.position}%`);
41
+ chart.style.setProperty('--vo2-motion-duration', motionDuration(result.position));
42
+ chart.dataset.band = result.referenceBand.id;
43
+ chart.classList.remove('is-empty');
44
+ chart.classList.remove('is-updating');
45
+ requestAnimationFrame(() => chart.classList.add('is-updating'));
46
+ document.querySelectorAll<HTMLElement>('[data-reference-band]').forEach((segment) => {
47
+ const active = segment.dataset.referenceBand === result.referenceBand.id;
48
+ segment.classList.toggle('is-active', active);
49
+ const key = segment.dataset.referenceBand ?? '';
50
+ segment.textContent = labels.bandLabels[key] ?? '';
51
+ });
52
+ setText('vo2-percentile', labels.bandLabels[result.referenceBand.label] ?? '');
53
+ }
54
+
55
+ function renderEmpty(labels: DomLabels): void {
56
+ setText('vo2-value', '--');
57
+ setText('vo2-percentile', labels.invalidNotice);
58
+ const chart = document.getElementById('vo2-reference-chart');
59
+ chart?.classList.remove('is-updating');
60
+ chart?.classList.add('is-empty');
61
+ document.querySelectorAll<HTMLElement>('[data-reference-band]').forEach((segment) => segment.classList.remove('is-active'));
62
+ }
63
+
64
+ export function renderVo2Result(input: Vo2Input, result: Vo2Result | null, labels: DomLabels): void {
65
+ const evaluation = evaluateVo2Result(input, result);
66
+ const resultCard = document.getElementById('vo2-result-card');
67
+ resultCard?.classList.toggle('is-caution', evaluation.tone === 'caution');
68
+ resultCard?.classList.toggle('is-strong', evaluation.tone === 'strong');
69
+ if (!result) {
70
+ renderEmpty(labels);
71
+ return;
72
+ }
73
+ setText('vo2-value', result.estimate.toFixed(1));
74
+ setText('vo2-method-note', labels.methodNote[evaluation.methodNote]);
75
+ renderReferenceChart(result, labels);
76
+ }
77
+
78
+ export function setMethodFields(method: Vo2Method): void {
79
+ updateMethodFields(method);
80
+ }