@jjlmoya/utils-sports 1.48.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 (34) 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/tool/vo2MaxCalculator/bibliography.astro +6 -0
  5. package/src/tool/vo2MaxCalculator/bibliography.ts +18 -0
  6. package/src/tool/vo2MaxCalculator/component.astro +175 -0
  7. package/src/tool/vo2MaxCalculator/content-factory.ts +105 -0
  8. package/src/tool/vo2MaxCalculator/controller.ts +159 -0
  9. package/src/tool/vo2MaxCalculator/dom-views.ts +80 -0
  10. package/src/tool/vo2MaxCalculator/entry.ts +30 -0
  11. package/src/tool/vo2MaxCalculator/evaluator.ts +19 -0
  12. package/src/tool/vo2MaxCalculator/i18n/de.ts +20 -0
  13. package/src/tool/vo2MaxCalculator/i18n/en.ts +240 -0
  14. package/src/tool/vo2MaxCalculator/i18n/es.ts +49 -0
  15. package/src/tool/vo2MaxCalculator/i18n/fr.ts +13 -0
  16. package/src/tool/vo2MaxCalculator/i18n/id.ts +13 -0
  17. package/src/tool/vo2MaxCalculator/i18n/it.ts +13 -0
  18. package/src/tool/vo2MaxCalculator/i18n/ja.ts +13 -0
  19. package/src/tool/vo2MaxCalculator/i18n/ko.ts +13 -0
  20. package/src/tool/vo2MaxCalculator/i18n/nl.ts +13 -0
  21. package/src/tool/vo2MaxCalculator/i18n/pl.ts +13 -0
  22. package/src/tool/vo2MaxCalculator/i18n/pt.ts +13 -0
  23. package/src/tool/vo2MaxCalculator/i18n/ru.ts +13 -0
  24. package/src/tool/vo2MaxCalculator/i18n/sv.ts +13 -0
  25. package/src/tool/vo2MaxCalculator/i18n/tr.ts +13 -0
  26. package/src/tool/vo2MaxCalculator/i18n/zh.ts +13 -0
  27. package/src/tool/vo2MaxCalculator/index.ts +10 -0
  28. package/src/tool/vo2MaxCalculator/logic.test.ts +48 -0
  29. package/src/tool/vo2MaxCalculator/logic.ts +155 -0
  30. package/src/tool/vo2MaxCalculator/seo.astro +14 -0
  31. package/src/tool/vo2MaxCalculator/storage.ts +19 -0
  32. package/src/tool/vo2MaxCalculator/ui.ts +41 -0
  33. package/src/tool/vo2MaxCalculator/vo2-max-calculator.css +616 -0
  34. 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.48.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
  ];
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ import type { SportsToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { Vo2MaxCalculatorUI } from './ui';
3
+
4
+ export type { Vo2MaxCalculatorUI };
5
+ export type Vo2MaxCalculatorLocaleContent = ToolLocaleContent<Vo2MaxCalculatorUI>;
6
+
7
+ export const vo2MaxCalculator: SportsToolEntry<Vo2MaxCalculatorUI> = {
8
+ id: 'vo2-max-calculator',
9
+ icons: {
10
+ bg: 'mdi:heart-pulse',
11
+ fg: 'mdi:lungs',
12
+ },
13
+ i18n: {
14
+ de: () => import('./i18n/de').then((module) => module.content),
15
+ en: () => import('./i18n/en').then((module) => module.content),
16
+ es: () => import('./i18n/es').then((module) => module.content),
17
+ fr: () => import('./i18n/fr').then((module) => module.content),
18
+ id: () => import('./i18n/id').then((module) => module.content),
19
+ it: () => import('./i18n/it').then((module) => module.content),
20
+ ja: () => import('./i18n/ja').then((module) => module.content),
21
+ ko: () => import('./i18n/ko').then((module) => module.content),
22
+ nl: () => import('./i18n/nl').then((module) => module.content),
23
+ pl: () => import('./i18n/pl').then((module) => module.content),
24
+ pt: () => import('./i18n/pt').then((module) => module.content),
25
+ ru: () => import('./i18n/ru').then((module) => module.content),
26
+ sv: () => import('./i18n/sv').then((module) => module.content),
27
+ tr: () => import('./i18n/tr').then((module) => module.content),
28
+ zh: () => import('./i18n/zh').then((module) => module.content),
29
+ },
30
+ };
@@ -0,0 +1,19 @@
1
+ import type { Vo2Input, Vo2Result } from './logic';
2
+
3
+ export type EvaluationTone = 'neutral' | 'caution' | 'strong';
4
+
5
+ export interface Vo2Evaluation {
6
+ tone: EvaluationTone;
7
+ methodNote: 'cooper12' | 'cooper15' | 'rockport' | 'shuttle20';
8
+ hasReference: boolean;
9
+ }
10
+
11
+ export function evaluateVo2Result(input: Vo2Input, result: Vo2Result | null): Vo2Evaluation {
12
+ if (!result) return { tone: 'caution', methodNote: input.method, hasReference: false };
13
+ const strong = result.position >= 75;
14
+ const caution = result.position < 25;
15
+ let tone: EvaluationTone = 'neutral';
16
+ if (strong) tone = 'strong';
17
+ if (caution) tone = 'caution';
18
+ return { tone, methodNote: input.method, hasReference: true };
19
+ }