@jjlmoya/utils-health 1.46.0 → 1.47.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/targetHeartRateZonePlanner/bibliography.astro +14 -0
  8. package/src/tool/targetHeartRateZonePlanner/bibliography.ts +22 -0
  9. package/src/tool/targetHeartRateZonePlanner/component.astro +66 -0
  10. package/src/tool/targetHeartRateZonePlanner/controller.ts +59 -0
  11. package/src/tool/targetHeartRateZonePlanner/dom-views.ts +69 -0
  12. package/src/tool/targetHeartRateZonePlanner/entry.ts +27 -0
  13. package/src/tool/targetHeartRateZonePlanner/export-tcx.test.ts +17 -0
  14. package/src/tool/targetHeartRateZonePlanner/export-tcx.ts +36 -0
  15. package/src/tool/targetHeartRateZonePlanner/i18n/de.ts +50 -0
  16. package/src/tool/targetHeartRateZonePlanner/i18n/en.ts +61 -0
  17. package/src/tool/targetHeartRateZonePlanner/i18n/es.ts +50 -0
  18. package/src/tool/targetHeartRateZonePlanner/i18n/fr.ts +50 -0
  19. package/src/tool/targetHeartRateZonePlanner/i18n/id.ts +50 -0
  20. package/src/tool/targetHeartRateZonePlanner/i18n/it.ts +50 -0
  21. package/src/tool/targetHeartRateZonePlanner/i18n/ja.ts +50 -0
  22. package/src/tool/targetHeartRateZonePlanner/i18n/ko.ts +50 -0
  23. package/src/tool/targetHeartRateZonePlanner/i18n/nl.ts +50 -0
  24. package/src/tool/targetHeartRateZonePlanner/i18n/pl.ts +50 -0
  25. package/src/tool/targetHeartRateZonePlanner/i18n/pt.ts +50 -0
  26. package/src/tool/targetHeartRateZonePlanner/i18n/ru.ts +50 -0
  27. package/src/tool/targetHeartRateZonePlanner/i18n/sv.ts +50 -0
  28. package/src/tool/targetHeartRateZonePlanner/i18n/tr.ts +50 -0
  29. package/src/tool/targetHeartRateZonePlanner/i18n/zh.ts +50 -0
  30. package/src/tool/targetHeartRateZonePlanner/index.ts +12 -0
  31. package/src/tool/targetHeartRateZonePlanner/logic.test.ts +50 -0
  32. package/src/tool/targetHeartRateZonePlanner/logic.ts +104 -0
  33. package/src/tool/targetHeartRateZonePlanner/seo.astro +14 -0
  34. package/src/tool/targetHeartRateZonePlanner/target-heart-rate-zone-planner.css +586 -0
  35. package/src/tool/targetHeartRateZonePlanner/ui.ts +56 -0
  36. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-health",
3
- "version": "1.46.0",
3
+ "version": "1.47.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -35,10 +35,11 @@ import { creatinineClearanceCockcroftGault } from '../tool/creatinineClearanceCo
35
35
  import { correctedCalciumCalculator } from '../tool/correctedCalciumCalculator/entry';
36
36
  import { egfrCalculator } from '../tool/egfrCalculator/entry';
37
37
  import { bmrCalorieDeficitPlanner } from '../tool/bmrCalorieDeficitPlanner/entry';
38
+ import { targetHeartRateZonePlanner } from '../tool/targetHeartRateZonePlanner/entry';
38
39
 
39
40
  export const healthCategory: HealthCategoryEntry = {
40
41
  icon: 'mdi:heart-pulse',
41
- tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, estimatedBloodVolumeCalculator, anionGapCalculator, creatinineClearanceCockcroftGault, correctedCalciumCalculator, egfrCalculator, targetHeightEstimator, daltonismSimulator, tinnitusReliever, parametricNoiseSynthesizer, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale, bmrCalorieDeficitPlanner],
42
+ tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, estimatedBloodVolumeCalculator, anionGapCalculator, creatinineClearanceCockcroftGault, correctedCalciumCalculator, egfrCalculator, targetHeightEstimator, daltonismSimulator, tinnitusReliever, parametricNoiseSynthesizer, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale, bmrCalorieDeficitPlanner, targetHeartRateZonePlanner],
42
43
  i18n: {
43
44
  en: () => import('./i18n/en').then((m) => m.content),
44
45
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -32,6 +32,7 @@ export { correctedCalciumCalculator } from './tool/correctedCalciumCalculator/en
32
32
  export { egfrCalculator } from './tool/egfrCalculator/entry';
33
33
  export { sleepDebtRecoveryPlanner } from './tool/sleepDebtRecoveryPlanner/entry';
34
34
  export { bmrCalorieDeficitPlanner } from './tool/bmrCalorieDeficitPlanner/entry';
35
+ export { targetHeartRateZonePlanner } from './tool/targetHeartRateZonePlanner/entry';
35
36
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
36
37
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
37
38
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -75,6 +76,7 @@ import { correctedCalciumCalculator } from './tool/correctedCalciumCalculator/en
75
76
  import { egfrCalculator } from './tool/egfrCalculator/entry';
76
77
  import { sleepDebtRecoveryPlanner } from './tool/sleepDebtRecoveryPlanner/entry';
77
78
  import { bmrCalorieDeficitPlanner } from './tool/bmrCalorieDeficitPlanner/entry';
79
+ import { targetHeartRateZonePlanner } from './tool/targetHeartRateZonePlanner/entry';
78
80
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
79
81
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
80
82
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -83,4 +85,4 @@ import { tinnitusReliever } from './tool/tinnitusReliever/entry';
83
85
  import { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
84
86
  import { ubeCalculator } from './tool/ubeCalculator/entry';
85
87
  import { waterPurifier } from './tool/waterPurifier/entry';
86
- export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, estimatedBloodVolumeCalculator, anionGapCalculator, creatinineClearanceCockcroftGault, correctedCalciumCalculator, egfrCalculator, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, parametricNoiseSynthesizer, ubeCalculator, waterPurifier, sleepDebtRecoveryPlanner, bmrCalorieDeficitPlanner];
88
+ export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, estimatedBloodVolumeCalculator, anionGapCalculator, creatinineClearanceCockcroftGault, correctedCalciumCalculator, egfrCalculator, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, parametricNoiseSynthesizer, ubeCalculator, waterPurifier, sleepDebtRecoveryPlanner, bmrCalorieDeficitPlanner, targetHeartRateZonePlanner];
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export { CORRECTED_CALCIUM_CALCULATOR_TOOL } from './tool/correctedCalciumCalcul
40
40
  export { EGFR_CALCULATOR_TOOL } from './tool/egfrCalculator';
41
41
  export { SLEEP_DEBT_RECOVERY_PLANNER_TOOL } from './tool/sleepDebtRecoveryPlanner';
42
42
  export { BMR_CALORIE_DEFICIT_PLANNER_TOOL } from './tool/bmrCalorieDeficitPlanner';
43
+ export { TARGET_HEART_RATE_ZONE_PLANNER_TOOL } from './tool/targetHeartRateZonePlanner';
43
44
 
44
45
  export { healthCategory } from './category';
45
46
  export const healthCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(42);
39
+ expect(ALL_TOOLS.length).toBe(43);
40
40
  });
41
41
  });
@@ -4,8 +4,8 @@ import { healthCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 42 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(42);
7
+ it('should have 43 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(43);
9
9
  });
10
10
 
11
11
  it('healthCategory should be defined', () => {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { targetHeartRateZonePlanner } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await targetHeartRateZonePlanner.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,22 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'American College of Sports Medicine. Exercising Your Way to Lowering Your Blood Pressure',
6
+ url: 'https://www.acsm.org/wp-content/uploads/2025/02/Exercising-Your-Way-to-Lowering-Your-Blood-Pressure-handout.pdf',
7
+ },
8
+ {
9
+ name: 'American Heart Association. Exercise and Physical Activity',
10
+ url: 'https://www.heart.org/en/healthy-living/exercise-and-physical-activity/',
11
+ },
12
+ {
13
+ name: 'Ministerio de Sanidad de España. Kilómetros de salud: calculando la zona de actividad',
14
+ url: 'https://vsf-iwsold-pro-portal.sanidad.gob.es/eu/areas/promocionPrevencion/actividadFisica/entornoEscolar/docs/2CICLOESO_UNIDAD_KILOMETROS_SALUDABLES.pdf',
15
+ },
16
+ ];
17
+
18
+ export const sourceTraceability = [
19
+ { country: 'United States', language: 'English', claim: 'Gellish maximum heart rate equation, heart rate reserve limits, RPE caveat and medical clearance', url: bibliography[0].url },
20
+ { country: 'United States', language: 'English', claim: 'Warm-up, gradual progression and target heart rate values as general guidance', url: bibliography[1].url },
21
+ { country: 'Spain', language: 'Spanish', claim: 'Heart rate reserve and Karvonen target heart rate method', url: bibliography[2].url },
22
+ ];
@@ -0,0 +1,66 @@
1
+ ---
2
+ import type { TargetHeartRateZonePlannerUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: TargetHeartRateZonePlannerUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ const initialUi = JSON.stringify(ui);
10
+ ---
11
+
12
+ <div class="pulse-planner" data-pulse-planner>
13
+ <div class="pulse-controls">
14
+ <div class="pulse-input-row">
15
+ <label class="pulse-field">
16
+ <span>{ui.ageLabel}</span>
17
+ <input data-input="age" type="number" inputmode="numeric" min="18" max="90" value="35" />
18
+ <small>{ui.ageUnit}</small>
19
+ </label>
20
+ <label class="pulse-field">
21
+ <span>{ui.restingHeartRateLabel}</span>
22
+ <input data-input="restingHeartRate" type="number" inputmode="numeric" min="35" max="110" value="60" />
23
+ <small>{ui.restingHeartRateHint}</small>
24
+ </label>
25
+ <label class="pulse-field">
26
+ <span>{ui.maximumHeartRateLabel}</span>
27
+ <input data-input="maximumHeartRate" type="number" inputmode="numeric" min="100" max="220" />
28
+ <small>{ui.maximumHeartRateHint}</small>
29
+ </label>
30
+ <label class="pulse-field">
31
+ <span>{ui.durationLabel}</span>
32
+ <input data-input="duration" type="number" inputmode="numeric" min="10" max="180" value="45" />
33
+ <small>{ui.durationHint}</small>
34
+ </label>
35
+ </div>
36
+ <fieldset class="pulse-choice">
37
+ <legend>{ui.intensityLabel}</legend>
38
+ <div class="pulse-choice__options">
39
+ <button type="button" data-intensity="easy" aria-pressed="false">{ui.intensityEasy}</button>
40
+ <button type="button" data-intensity="steady" aria-pressed="false">{ui.intensitySteady}</button>
41
+ <button type="button" data-intensity="endurance" aria-pressed="true">{ui.intensityEndurance}</button>
42
+ <button type="button" data-intensity="strong" aria-pressed="false">{ui.intensityStrong}</button>
43
+ </div>
44
+ </fieldset>
45
+ <fieldset class="pulse-choice">
46
+ <legend>{ui.sessionTypeLabel}</legend>
47
+ <div class="pulse-choice__options">
48
+ <button type="button" data-session-type="steady" aria-pressed="true">{ui.sessionTypeSteady}</button>
49
+ <button type="button" data-session-type="intervals" aria-pressed="false">{ui.sessionTypeIntervals}</button>
50
+ <button type="button" data-session-type="hiit" aria-pressed="false">{ui.sessionTypeHiit}</button>
51
+ </div>
52
+ <small class="pulse-choice__hint">{ui.sessionTypeHint}</small>
53
+ </fieldset>
54
+ </div>
55
+ <div class="pulse-scene" data-pulse-scene aria-live="polite"></div>
56
+ <section class="pulse-result" data-pulse-result aria-live="polite"></section>
57
+ </div>
58
+
59
+ <script is:inline type="application/json" data-pulse-ui set:html={initialUi}></script>
60
+ <script>
61
+ import { mountTargetHeartRateZonePlanner } from './controller';
62
+
63
+ const root = document.querySelector('[data-pulse-planner]');
64
+ const data = document.querySelector('[data-pulse-ui]');
65
+ if (root instanceof HTMLElement && data instanceof HTMLScriptElement) mountTargetHeartRateZonePlanner(root, JSON.parse(data.textContent ?? '{}'));
66
+ </script>
@@ -0,0 +1,59 @@
1
+ import { calculatePlan, validateInput, type IntensityKey, type PlannerInput, type SessionTypeKey } from './logic';
2
+ import { renderInvalid, renderPulseScene, renderResult } from './dom-views';
3
+ import { downloadTcx } from './export-tcx';
4
+ import type { TargetHeartRateZonePlannerUI } from './ui';
5
+
6
+ function readNumber(root: HTMLElement, key: string): number {
7
+ const value = (root.querySelector(`[data-input="${key}"]`) as HTMLInputElement)?.value ?? '';
8
+ return value === '' ? NaN : Number(value);
9
+ }
10
+
11
+ function readInput(root: HTMLElement, intensity: IntensityKey, sessionType: SessionTypeKey): PlannerInput {
12
+ const maximumHeartRate = readNumber(root, 'maximumHeartRate');
13
+ const input: PlannerInput = { age: readNumber(root, 'age'), restingHeartRate: readNumber(root, 'restingHeartRate'), intensity, sessionType, duration: readNumber(root, 'duration') };
14
+ if (Number.isFinite(maximumHeartRate)) input.maximumHeartRate = maximumHeartRate;
15
+ return input;
16
+ }
17
+
18
+ function syncButtons(root: HTMLElement, group: string, value: string): void {
19
+ root.querySelectorAll(`[data-${group}]`).forEach((button) => button.setAttribute('aria-pressed', String(button.getAttribute(`data-${group}`) === value)));
20
+ }
21
+
22
+ function bindChoice<T extends string>(root: HTMLElement, group: string, setValue: (value: T) => void, update: () => void): void {
23
+ root.querySelectorAll(`[data-${group}]`).forEach((button) => button.addEventListener('click', () => { setValue(button.getAttribute(`data-${group}`) as T); syncButtons(root, group, button.getAttribute(`data-${group}`) ?? ''); update(); }));
24
+ }
25
+
26
+ function bindLiveControls(root: HTMLElement, update: () => void, ui: TargetHeartRateZonePlannerUI, getPlan: () => ReturnType<typeof calculatePlan> | null): void {
27
+ root.querySelectorAll('[data-input]').forEach((input) => input.addEventListener('input', update));
28
+ root.addEventListener('click', (event) => {
29
+ const target = event.target;
30
+ const plan = getPlan();
31
+ if (target instanceof HTMLElement && target.closest('[data-export-tcx]') && plan) downloadTcx(plan, ui);
32
+ });
33
+ }
34
+
35
+ export function mountTargetHeartRateZonePlanner(root: HTMLElement, ui: TargetHeartRateZonePlannerUI): void {
36
+ let intensity: IntensityKey = 'endurance';
37
+ let sessionType: SessionTypeKey = 'steady';
38
+ let lastPlan: ReturnType<typeof calculatePlan> | null = null;
39
+ const scene = root.querySelector('[data-pulse-scene]') as HTMLElement;
40
+ const result = root.querySelector('[data-pulse-result]') as HTMLElement;
41
+ const update = (): void => {
42
+ const input = readInput(root, intensity, sessionType);
43
+ const errors = validateInput(input);
44
+ if (errors.length > 0) {
45
+ renderInvalid(result, errors, ui);
46
+ return;
47
+ }
48
+ const plan = calculatePlan(input);
49
+ lastPlan = plan;
50
+ renderPulseScene(scene, plan, ui);
51
+ renderResult(result, plan, ui);
52
+ };
53
+ bindChoice(root, 'intensity', (value: IntensityKey) => { intensity = value; }, update);
54
+ bindChoice(root, 'session-type', (value: SessionTypeKey) => { sessionType = value; }, update);
55
+ bindLiveControls(root, update, ui, () => lastPlan);
56
+ syncButtons(root, 'intensity', intensity);
57
+ syncButtons(root, 'session-type', sessionType);
58
+ update();
59
+ }
@@ -0,0 +1,69 @@
1
+ import type { PlannerResult } from './logic';
2
+ import type { TargetHeartRateZonePlannerUI } from './ui';
3
+
4
+ function formatRange(result: PlannerResult, ui: TargetHeartRateZonePlannerUI): string {
5
+ return `${result.targetLow} ${ui.rangeSeparator} ${result.targetHigh} ${ui.bpmUnit}`;
6
+ }
7
+
8
+ function renderTower(result: PlannerResult, ui: TargetHeartRateZonePlannerUI): string {
9
+ const bandBottom = ((result.targetLow - result.restingHeartRate) / result.heartRateReserve) * 100;
10
+ const bandHeight = ((result.targetHigh - result.targetLow) / result.heartRateReserve) * 100;
11
+ const ceilingLabel = result.usesMeasuredMaximum ? ui.sceneCeilingMeasured : ui.sceneCeiling;
12
+ return `<div class="pulse-tower" style="--n-band-bottom:${bandBottom}%;--n-band-height:${bandHeight}%"><div class="pulse-tower__cap"><span>${result.maximumHeartRate}</span><small>${ceilingLabel}</small></div><div class="pulse-tower__shaft"><span class="pulse-tower__band"></span><span class="pulse-tower__beam"></span><span class="pulse-tower__rest"><strong>${result.restingHeartRate}</strong><small>${ui.sceneResting}</small></span></div><div class="pulse-tower__window"><strong>${formatRange(result, ui)}</strong><small>${ui.sceneWindow}</small></div></div>`;
13
+ }
14
+
15
+ function profilePath(result: PlannerResult): string {
16
+ const warmEnd = (result.segments[0].minutes / result.duration) * 640;
17
+ const mainEnd = warmEnd + (result.segments[1].minutes / result.duration) * 640;
18
+ const baseY = 142;
19
+ const targetY = 72;
20
+ const easyY = 110;
21
+ const points = [`M 0 ${baseY}`, `L ${warmEnd.toFixed(1)} ${easyY}`];
22
+ if (result.sessionType === 'steady') {
23
+ points.push(`L ${mainEnd.toFixed(1)} ${targetY}`);
24
+ } else {
25
+ const rounds = result.sessionType === 'hiit' ? 8 : 6;
26
+ const width = (mainEnd - warmEnd) / rounds;
27
+ for (let index = 0; index < rounds; index += 1) {
28
+ const start = warmEnd + width * index;
29
+ const peakY = result.sessionType === 'hiit' ? 42 : 58;
30
+ points.push(`L ${(start + width * 0.28).toFixed(1)} ${peakY}`, `L ${(start + width * 0.55).toFixed(1)} ${targetY}`, `L ${(start + width).toFixed(1)} ${easyY}`);
31
+ }
32
+ }
33
+ points.push('L 640 142');
34
+ return points.join(' ');
35
+ }
36
+
37
+ function renderTimeline(result: PlannerResult, ui: TargetHeartRateZonePlannerUI): string {
38
+ const labels = { warmUp: ui.sceneWarmUp, mainSet: ui.sceneMainSet, coolDown: ui.sceneCoolDown };
39
+ const warmEnd = (result.segments[0].minutes / result.duration) * 640;
40
+ const mainEnd = warmEnd + (result.segments[1].minutes / result.duration) * 640;
41
+ const path = profilePath(result);
42
+ return `<div class="pulse-timeline"><div class="pulse-timeline__line"><svg viewBox="0 0 640 180" role="img" aria-label="${ui.sceneProfile}"><rect class="pulse-profile__target" x="${warmEnd.toFixed(1)}" y="52" width="${(mainEnd - warmEnd).toFixed(1)}" height="40" rx="8" /><path class="pulse-profile__axis" d="M0 142H640" /><path class="pulse-profile__area" d="${path} L 640 142 L 0 142 Z" /><path class="pulse-profile__path" d="${path}" /><text x="0" y="170">${labels.warmUp}</text><text x="${((warmEnd + mainEnd) / 2).toFixed(1)}" y="170" text-anchor="middle">${labels.mainSet}</text><text x="640" y="170" text-anchor="end">${labels.coolDown}</text></svg></div><div class="pulse-timeline__segments">${result.segments.map((segment) => `<div class="pulse-timeline__segment pulse-timeline__segment--${segment.key}" style="--n-segment:${segment.minutes / result.duration}"><span>${labels[segment.key]}</span><strong>${segment.minutes}<small> ${ui.durationUnit}</small></strong></div>`).join('')}</div></div>`;
43
+ }
44
+
45
+ export function renderPulseScene(root: HTMLElement, result: PlannerResult, ui: TargetHeartRateZonePlannerUI): void {
46
+ root.innerHTML = `<div class="pulse-scene__header"><span>${ui.sceneSession}</span><strong>${formatRange(result, ui)}</strong></div><div class="pulse-scene__body">${renderTower(result, ui)}${renderTimeline(result, ui)}</div>`;
47
+ }
48
+
49
+ function renderMetric(label: string, value: string): string {
50
+ return `<div class="pulse-metric"><span>${label}</span><strong>${value}</strong></div>`;
51
+ }
52
+
53
+ function sessionPattern(result: PlannerResult, ui: TargetHeartRateZonePlannerUI): string {
54
+ if (result.sessionType === 'hiit') return ui.hiitPattern;
55
+ if (result.sessionType === 'intervals') return ui.intervalsPattern;
56
+ return ui.steadyPattern;
57
+ }
58
+
59
+ export function renderResult(root: HTMLElement, result: PlannerResult, ui: TargetHeartRateZonePlannerUI): void {
60
+ const [warmUp, mainSet, coolDown] = result.segments;
61
+ const maximumLabel = result.usesMeasuredMaximum ? ui.measuredMaximumLabel : ui.estimatedMaximumLabel;
62
+ const pattern = sessionPattern(result, ui);
63
+ root.innerHTML = `<div class="pulse-result__headline"><span>${ui.resultLabel}</span><strong>${formatRange(result, ui)}</strong><p>${ui.monitorInstruction}</p></div><div class="pulse-result__metrics">${renderMetric(maximumLabel, `${result.maximumHeartRate} ${ui.bpmUnit}`)}${renderMetric(ui.reserveLabel, `${result.heartRateReserve} ${ui.bpmUnit}`)}${renderMetric(ui.durationLabel, `${result.duration} ${ui.durationUnit}`)}</div><div class="pulse-result__outline"><h2>${ui.sessionOutlineLabel}</h2><div class="pulse-result__step"><span>01</span><p><strong>${warmUp.minutes} ${ui.durationUnit} ${ui.sceneWarmUp}</strong>${ui.warmUpInstruction}</p></div><div class="pulse-result__step"><span>02</span><p><strong>${mainSet.minutes} ${ui.durationUnit} ${ui.sceneMainSet}</strong>${ui.mainSetInstruction}<em>${ui.sessionPlanLabel}: ${pattern}</em></p></div><div class="pulse-result__step"><span>03</span><p><strong>${coolDown.minutes} ${ui.durationUnit} ${ui.sceneCoolDown}</strong>${ui.coolDownInstruction}</p></div></div><div class="pulse-result__actions"><button type="button" data-export-tcx>${ui.exportTcxLabel}</button><small>${ui.exportTcxHint}</small></div><p class="pulse-result__note"><strong>${ui.warningLabel}</strong> ${ui.rpeInstruction}</p>`;
64
+ }
65
+
66
+ export function renderInvalid(root: HTMLElement, errors: string[], ui: TargetHeartRateZonePlannerUI): void {
67
+ const messages: Record<string, string> = { age: ui.invalidAge, restingHeartRate: ui.invalidRestingHeartRate, maximumHeartRate: ui.invalidMaximumHeartRate, duration: ui.invalidDuration, impossibleHeartRate: ui.impossibleHeartRate };
68
+ root.innerHTML = `<div class="pulse-invalid"><strong>${ui.warningLabel}</strong><p>${errors.map((error) => messages[error]).join(' ')}</p></div>`;
69
+ }
@@ -0,0 +1,27 @@
1
+ import type { HealthToolEntry } from '../../types';
2
+ import type { TargetHeartRateZonePlannerUI } from './ui';
3
+
4
+ export const targetHeartRateZonePlanner: HealthToolEntry<TargetHeartRateZonePlannerUI> = {
5
+ id: 'targetHeartRateZonePlanner',
6
+ icons: {
7
+ bg: 'mdi:heart-pulse',
8
+ fg: 'mdi:run-fast',
9
+ },
10
+ i18n: {
11
+ en: () => import('./i18n/en').then((module) => module.content),
12
+ de: () => import('./i18n/de').then((module) => module.content),
13
+ es: () => import('./i18n/es').then((module) => module.content),
14
+ fr: () => import('./i18n/fr').then((module) => module.content),
15
+ id: () => import('./i18n/id').then((module) => module.content),
16
+ it: () => import('./i18n/it').then((module) => module.content),
17
+ ja: () => import('./i18n/ja').then((module) => module.content),
18
+ ko: () => import('./i18n/ko').then((module) => module.content),
19
+ nl: () => import('./i18n/nl').then((module) => module.content),
20
+ pl: () => import('./i18n/pl').then((module) => module.content),
21
+ pt: () => import('./i18n/pt').then((module) => module.content),
22
+ ru: () => import('./i18n/ru').then((module) => module.content),
23
+ sv: () => import('./i18n/sv').then((module) => module.content),
24
+ tr: () => import('./i18n/tr').then((module) => module.content),
25
+ zh: () => import('./i18n/zh').then((module) => module.content),
26
+ },
27
+ };
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildTcx } from './export-tcx';
3
+ import { calculatePlan } from './logic';
4
+ import type { TargetHeartRateZonePlannerUI } from './ui';
5
+
6
+ const ui = { sceneSession: 'Pulse session', durationUnit: 'min', sceneWarmUp: 'Warm up', sceneMainSet: 'Main set', sceneCoolDown: 'Cool down' } as TargetHeartRateZonePlannerUI;
7
+
8
+ describe('target heart rate zone planner TCX export', () => {
9
+ it('serializes the three session steps and the target range', () => {
10
+ const result = calculatePlan({ age: 35, restingHeartRate: 60, intensity: 'endurance', sessionType: 'steady', duration: 45 });
11
+ const tcx = buildTcx(result, ui);
12
+ expect(tcx).toContain('<Name>Pulse session - 45 min</Name>');
13
+ expect(tcx).toContain('<Seconds>540</Seconds>');
14
+ expect(tcx).toContain('<Low>134</Low><High>144</High>');
15
+ expect(tcx).toContain('<Seconds>420</Seconds>');
16
+ });
17
+ });
@@ -0,0 +1,36 @@
1
+ import type { PlannerResult } from './logic';
2
+ import type { TargetHeartRateZonePlannerUI } from './ui';
3
+
4
+ function escapeXml(value: string): string {
5
+ return value.replace(/[<>&'\"]/g, (character) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[character] ?? character);
6
+ }
7
+
8
+ interface TcxStep {
9
+ name: string;
10
+ type: string;
11
+ minutes: number;
12
+ }
13
+
14
+ function stepXml(step: TcxStep, result: PlannerResult, target: boolean): string {
15
+ const targetXml = target ? `<Target xsi:type="HeartRate_t"><Low>${result.targetLow}</Low><High>${result.targetHigh}</High></Target>` : '';
16
+ return `<Step xsi:type="Step_t"><Name>${escapeXml(step.name)}</Name><StepType>${step.type}</StepType><Duration xsi:type="Time_t"><Seconds>${step.minutes * 60}</Seconds></Duration>${targetXml}<Intensity>Active</Intensity></Step>`;
17
+ }
18
+
19
+ export function buildTcx(result: PlannerResult, ui: TargetHeartRateZonePlannerUI): string {
20
+ const workoutName = `${ui.sceneSession} - ${result.duration} ${ui.durationUnit}`;
21
+ const steps = [{ name: ui.sceneWarmUp, type: 'Warmup', minutes: result.segments[0].minutes }, { name: ui.sceneMainSet, type: 'Active', minutes: result.segments[1].minutes }, { name: ui.sceneCoolDown, type: 'Cooldown', minutes: result.segments[2].minutes }];
22
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><Workouts><Workout Sport="Other"><Name>${escapeXml(workoutName)}</Name>${stepXml(steps[0], result, false)}${stepXml(steps[1], result, true)}${stepXml(steps[2], result, false)}</Workout></Workouts></TrainingCenterDatabase>`;
23
+ }
24
+
25
+ export function downloadTcx(result: PlannerResult, ui: TargetHeartRateZonePlannerUI): void {
26
+ const blob = new Blob([buildTcx(result, ui)], { type: 'application/vnd.garmin.tcx+xml' });
27
+ const url = URL.createObjectURL(blob);
28
+ const link = document.createElement('a');
29
+ link.href = url;
30
+ link.download = 'target-heart-rate-session.tcx';
31
+ link.style.display = 'none';
32
+ document.body.appendChild(link);
33
+ link.click();
34
+ link.remove();
35
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
36
+ }
@@ -0,0 +1,50 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
3
+ import type { ToolLocaleContent } from '../../../types';
4
+ import type { TargetHeartRateZonePlannerUI } from '../ui';
5
+
6
+ const slug = 'zielherzfrequenz-zonen-planer';
7
+ const title = 'Planer für Zielherzfrequenzzonen';
8
+ const description = 'Plane eine praktische Einheit mit Herzfrequenzreserve aus Alter, Ruhepuls oder gemessener Maximalfrequenz sowie gleichmäßigen, Intervall- und HIIT-Profilen.';
9
+ const faq = [
10
+ { question: 'Was berechnet dieser Planer für Herzfrequenzzonen?', answer: 'Er schätzt einen Trainingsbereich aus Alter, Ruhepuls und gewählter Intensität. Außerdem teilt er die geplante Dauer in Aufwärmen, Hauptteil und Abkühlen. Die Werte sind pädagogische Schätzungen und keine Diagnose oder persönliche Verordnung.' },
11
+ { question: 'Wie funktioniert die Karvonen-Methode hier?', answer: 'Der Planer schätzt die maximale Herzfrequenz mit 207 minus 0,7 mal Alter oder verwendet deinen gemessenen Wert. Die maximale Herzfrequenz minus Ruhepuls ergibt die Herzfrequenzreserve. Auf den Ruhepuls wird dann der gewählte Anteil dieser Reserve addiert.' },
12
+ { question: 'Kann ich eine gemessene maximale Herzfrequenz eingeben?', answer: 'Ja. Lass das optionale Feld leer, um die altersbasierte Schätzung zu nutzen, oder gib einen aktuellen gemessenen Wert aus einem gut verstandenen Belastungstest ein. Das macht die Rechnung persönlicher, aber nicht zu einer medizinischen Verordnung.' },
13
+ { question: 'Welche Intensität ist für Zone 2 am besten?', answer: 'Zonen sind nicht in jeder Sportuhr oder jedem Trainingssystem gleich definiert. Dieses Tool bezeichnet 60 bis 69 Prozent der Herzfrequenzreserve als aerobe Ausdauerzone. Atmung, subjektive Anstrengung und professionelle Hinweise bleiben wichtig.' },
14
+ { question: 'Wie messe ich den Ruhepuls?', answer: 'Miss ihn ruhig und erholt, möglichst immer zur gleichen Zeit vor Koffein und Bewegung. Zähle eine volle Minute oder nutze ein zuverlässiges Messgerät. Ein einzelner ungewöhnlicher Wert kann durch Schlaf, Stress, Krankheit oder Trainingsbelastung entstehen.' },
15
+ { question: 'Kann ich das bei Betablockern oder einer Herzerkrankung verwenden?', answer: 'Verwende den berechneten Bereich dann nicht als persönlichen Zielwert. Medikamente und Erkrankungen können das Verhältnis zwischen Herzfrequenz und Belastung verändern. Bitte einen Arzt oder ein kardiologisches Reha-Team um einen individuellen Plan.' },
16
+ ];
17
+ const howTo = [
18
+ { name: 'Ruhepuls messen', text: 'Miss morgens ruhig und wiederholbar und gib den Wert in Schlägen pro Minute ein.' },
19
+ { name: 'Belastungsbereich wählen', text: 'Wähle eine Intensität, die zum Zweck und zur erwarteten Dauer der Einheit passt.' },
20
+ { name: 'Einheitsprofil wählen', text: 'Wähle gleichmäßiges Training, Tempointervalle oder HIIT passend zur geplanten Einheit.' },
21
+ { name: 'Dauer festlegen', text: 'Gib die Gesamtdauer ein, damit Aufwärmen, Hauptteil und Abkühlen aufgeteilt werden können.' },
22
+ { name: 'Pulsbereich nutzen', text: 'Nutze den Bereich im Hauptteil, trainiere Aufwärmen und Abkühlen leichter und vergleiche die Zahl mit Atmung und Belastungsgefühl.' },
23
+ ];
24
+ const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'HealthApplication', operatingSystem: 'Web', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } };
25
+ const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
26
+ const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: title, description, step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) };
27
+
28
+ export const content: ToolLocaleContent<TargetHeartRateZonePlannerUI> = {
29
+ slug, title, description, faqTitle: 'Fragen zu Zielherzfrequenzzonen', faq, bibliographyTitle: 'Quellen für diese Schätzung', bibliography, howTo, schemas: [appSchema, faqSchema, howToSchema],
30
+ ui: {
31
+ rangeSeparator: 'bis',
32
+ ageLabel: 'Alter', ageUnit: 'Jahre', restingHeartRateLabel: 'Ruhepuls', restingHeartRateHint: 'Ruhige Messung vor der Aktivität', maximumHeartRateLabel: 'Maximale Herzfrequenz', maximumHeartRateHint: 'Optionale Messung', bpmUnit: 'bpm', intensityLabel: 'Belastungsbereich wählen', intensityEasy: 'Leicht 40 bis 49 %', intensitySteady: 'Moderat 50 bis 59 %', intensityEndurance: 'Aerob 60 bis 69 %', intensityStrong: 'Stark 70 bis 79 %', sessionTypeLabel: 'Einheitsprofil', sessionTypeSteady: 'Gleichmäßig', sessionTypeIntervals: 'Tempointervalle', sessionTypeHiit: 'HIIT-Blöcke', sessionTypeHint: 'Das Profil ändert die Form, nicht die Herzfrequenzrechnung.', durationLabel: 'Einheitsdauer', durationUnit: 'Min.', durationHint: 'Vom Aufwärmen bis zum Abkühlen', sceneResting: 'Ruhepuls', sceneCeiling: 'geschätzte Obergrenze', sceneCeilingMeasured: 'gemessene Obergrenze', sceneWindow: 'Zielbereich', scenePulsePath: 'Pulsverlauf durch die Einheit', sceneProfile: 'Belastungsprofil durch die Einheit', sceneSession: 'Dein Pulsleuchtturm', sceneWarmUp: 'Aufwärmen', sceneMainSet: 'Hauptteil', sceneCoolDown: 'Abkühlen', resultLabel: 'Ziel im Hauptteil', targetRangeLabel: 'Zielbereich', estimatedMaximumLabel: 'Geschätzte Maximalfrequenz', measuredMaximumLabel: 'Gemessene Maximalfrequenz', reserveLabel: 'Herzfrequenzreserve', sessionOutlineLabel: 'Einheitsablauf', sessionPlanLabel: 'Einheitsmuster', steadyPattern: 'Halte ein gleichmäßiges Tempo im Zielbereich.', intervalsPattern: 'Wechsle im Hauptteil zwei Minuten nahe am Ziel mit zwei Minuten lockerem Tempo ab.', hiitPattern: 'Wechsle 30 Sekunden hart mit 90 Sekunden locker für kontrollierte intensive Blöcke ab.', exportTcxLabel: 'TCX Einheit exportieren', exportTcxHint: 'Eine Trainingsdatei für kompatible Plattformen.', warmUpInstruction: 'Steigere dich langsam, bis sich die Atmung ruhig und kontrolliert anfühlt.', mainSetInstruction: 'Bleibe im Zielbereich. Atmung und Belastungsgefühl gehen einer Zahl vor, die sich falsch anfühlt.', coolDownInstruction: 'Verringere das Tempo schrittweise, bis die Atmung fast normal ist.', monitorInstruction: 'Nutze bei Bedarf einen Monitor, aber behandle den Bereich als Startpunkt und nicht als Befehl.', rpeInstruction: 'Wenn sich die Belastung viel härter als erwartet anfühlt, werde langsamer. Medikamente, Krankheit, Hitze, Stress und Fitness können Schätzungen verfälschen.', estimateNote: 'Pädagogische Schätzung für Erwachsene', warningLabel: 'Sicherheitshinweis', invalidAge: 'Gib ein Alter von 18 bis 90 Jahren ein.', invalidRestingHeartRate: 'Gib einen Ruhepuls von 35 bis 110 bpm ein.', invalidMaximumHeartRate: 'Gib eine gemessene Maximalfrequenz von 100 bis 220 bpm ein oder lass das Feld leer.', invalidDuration: 'Gib eine Einheitsdauer von 10 bis 180 Minuten ein.', impossibleHeartRate: 'Dieser Ruhepuls lässt zu wenig Abstand zur geschätzten Maximalfrequenz. Prüfe den Wert und hole vor dem Training individuelle Beratung ein.',
33
+ },
34
+ seo: [
35
+ { type: 'summary', title: 'Eine Einheit mit Zielherzfrequenz planen', items: ['Maximalfrequenz aus dem Alter schätzen oder einen Messwert eingeben.', 'Einen passenden Belastungsbereich statt eines einzelnen Pulswerts wählen.', 'Die Einheit gleichmäßig, als Tempointervall oder HIIT gestalten.', 'Puls mit Atmung, Belastungsgefühl und gesundheitlichem Kontext abgleichen.'] },
36
+ { type: 'title', text: 'Was eine Zielherzfrequenzzone aussagt', level: 2 },
37
+ { type: 'paragraph', html: 'Eine Zielherzfrequenzzone ist eine praktische Orientierung für das Tempo. Dieser Planer verbindet Alter und ruhigen Ruhepuls, damit der Startpunkt besser berücksichtigt wird als bei einem einfachen Prozentsatz der Maximalfrequenz. Das Ergebnis gilt für den Hauptteil und misst weder die aerobe Schwelle noch die Sicherheit einer Einheit.' },
38
+ { type: 'title', text: 'So funktioniert die Reserveberechnung', level: 2 },
39
+ { type: 'paragraph', html: 'Zuerst wird die maximale Herzfrequenz mit der Gellish-Gleichung geschätzt: 207 minus 0,7 mal Alter, sofern kein Messwert eingegeben wurde. Danach wird der Ruhepuls abgezogen. Auf den Ruhepuls wird der gewählte Anteil der Reserve addiert. Bei 35 Jahren und 60 bpm entstehen 183 bpm Maximalfrequenz, 123 bpm Reserve und bei 60 bis 69 Prozent etwa 134 bis 144 bpm.' },
40
+ { type: 'table', headers: ['Planerstufe', 'Bedeutung'], rows: [['Ruhepuls', 'Der ruhige Ausgangspuls für die Rechnung.'], ['Maximale Herzfrequenz', 'Eine altersbasierte Schätzung oder dein Messwert.'], ['Herzfrequenzreserve', 'Maximalfrequenz minus Ruhepuls.'], ['Ziel im Hauptteil', 'Ruhepuls plus gewählter Anteil der Reserve.']] },
41
+ { type: 'title', text: 'Den Belastungsbereich auswählen', level: 2 },
42
+ { type: 'paragraph', html: 'Leichte und moderate Bereiche passen zu einem Wiedereinstieg oder einer gut kontrollierbaren Einheit. Der aerobe Bereich eignet sich als Planungsband für längere Belastungen, aber der Begriff Zone 2 wird je nach System unterschiedlich verwendet. Der starke Bereich verlangt einen passenden Trainings- und Gesundheitskontext.' },
43
+ { type: 'list', items: ['Ruhepuls ruhig und möglichst immer zur gleichen Zeit messen.', 'Den niedrigsten Bereich wählen, der zum Ziel der heutigen Einheit passt.', 'Aufwärmen und Abkühlen leichter als den Hauptteil halten.', 'Atmung und Belastungsgefühl genauso beobachten wie den Monitor.', 'Mehrere Einheiten betrachten statt nach einem ungewöhnlichen Wert alles zu ändern.', 'Bei Medikamenten, Symptomen oder Erkrankungen persönliche Beratung einholen.'] },
44
+ { type: 'title', text: 'Aus der Schätzung eine Einheit machen', level: 2 },
45
+ { type: 'paragraph', html: 'Die Dauer erhält drei Aufgaben. Das Aufwärmen steigert die Belastung, der Hauptteil nutzt den Zielbereich und das Abkühlen senkt das Tempo schrittweise. Das gewählte Profil macht daraus eine gleichmäßige, Tempo- oder HIIT-Einheit und zeigt, wann der Zahlenbereich relevant ist.' },
46
+ { type: 'tip', title: 'Den Puls als Gespräch und nicht als Befehl nutzen', html: 'Hitze, Flüssigkeitsmangel, Schlaf, Stress, Krankheit, Höhe, Trainingslast und Medikamente verändern die Herzfrequenz. Wenn die Zahl nicht zum Körpergefühl passt, reduziere die Belastung und kläre den Kontext. Bei starken oder beunruhigenden Symptomen Training abbrechen und dringend Hilfe suchen.' },
47
+ { type: 'title', text: 'Grenzen dieser Schätzung', level: 2 },
48
+ { type: 'paragraph', html: 'Dieses Tool diagnostiziert keine Fitness, erstellt keine sichere medizinische Verordnung und bestimmt keine ventilatorische Schwelle. Die Altersgleichung kann für einzelne Personen deutlich abweichen. Ein Belastungstest oder eine professionelle Einschätzung ist bei hohem Präzisionsbedarf geeigneter.' },
49
+ ],
50
+ };
@@ -0,0 +1,61 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
3
+ import type { ToolLocaleContent } from '../../../types';
4
+ import type { TargetHeartRateZonePlannerUI } from '../ui';
5
+
6
+ const slug = 'target-heart-rate-zone-planner';
7
+ const title = 'Target Heart Rate Zone Planner';
8
+ const description = 'Build a practical heart rate reserve session from an age-based or measured maximum heart rate, with steady, interval and HIIT shapes for adults.';
9
+
10
+ const faq = [
11
+ { question: 'What does this target heart rate planner calculate?', answer: 'It estimates a training band from your age, resting heart rate and selected intensity. It also divides your planned duration into a warm up, main set and cool down. The numbers are educational estimates, not a diagnosis, prescription or proof of a physiological threshold.' },
12
+ { question: 'How does the Karvonen method work here?', answer: 'The planner estimates maximum heart rate with 207 minus 0.7 times age, or uses your measured maximum when you provide one. It subtracts resting heart rate to obtain heart rate reserve, then adds the chosen fraction of that reserve back to resting heart rate. Rounding creates a practical whole beat range.' },
13
+ { question: 'Can I enter a measured maximum heart rate?', answer: 'Yes. Leave the optional maximum field empty to use the age-based estimate, or enter a recent measured maximum from a supervised or well-understood exercise test. A measured value can make the arithmetic more personal, but it does not make the result a medical prescription.' },
14
+ { question: 'What is the best intensity for zone 2 training?', answer: 'Zone names are not standardized across every sport watch or coaching system. This tool labels a 60 to 69 percent heart rate reserve band as aerobic endurance, but that label is not a measured ventilatory threshold. Use breathing, perceived effort and your own professional guidance alongside the estimate.' },
15
+ { question: 'How should I measure resting heart rate?', answer: 'Measure when you are calm and rested, ideally at a consistent time before caffeine, exercise or other activity. Count for a full minute or use a reliable monitor. A single unusual reading can reflect sleep, stress, illness or training load.' },
16
+ { question: 'Can I use this if I take a beta blocker or have a heart condition?', answer: 'Do not treat the calculated band as your personal target in that situation. Medicines and cardiovascular conditions can change the relationship between heart rate and effort. Ask your clinician or cardiac rehabilitation team for an individualized plan, and stop exercise for concerning symptoms.' },
17
+ ];
18
+
19
+ const howTo = [
20
+ { name: 'Measure your resting heart rate', text: 'Take a calm, repeatable morning reading and enter beats per minute.' },
21
+ { name: 'Set your effort band', text: 'Choose an intensity that matches the session you can sustain, not the hardest effort you can imagine.' },
22
+ { name: 'Choose the session shape', text: 'Select a steady session, tempo intervals or HIIT so the profile matches the workout you intend to do.' },
23
+ { name: 'Choose the session duration', text: 'Enter the total session duration so the planner can divide it into a warm up, main set and cool down.' },
24
+ { name: 'Follow the pulse window', text: 'Use the target range for the main set, keep the warm up and cool down easy, and compare the number with breathing and perceived effort.' },
25
+ ];
26
+
27
+ const appSchema: WithContext<SoftwareApplication> = {
28
+ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'HealthApplication', operatingSystem: 'Web', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
29
+ };
30
+
31
+ const faqSchema: WithContext<FAQPage> = {
32
+ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
33
+ };
34
+
35
+ const howToSchema: WithContext<HowTo> = {
36
+ '@context': 'https://schema.org', '@type': 'HowTo', name: title, description, step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
37
+ };
38
+
39
+ export const content: ToolLocaleContent<TargetHeartRateZonePlannerUI> = {
40
+ slug, title, description, faqTitle: 'Questions about target heart rate zones', faq, bibliographyTitle: 'Sources for this estimate', bibliography, howTo, schemas: [appSchema, faqSchema, howToSchema],
41
+ ui: {
42
+ rangeSeparator: 'to',
43
+ ageLabel: 'Age', ageUnit: 'years', restingHeartRateLabel: 'Resting heart rate', restingHeartRateHint: 'Calm reading before activity', maximumHeartRateLabel: 'Maximum heart rate', maximumHeartRateHint: 'Optional measured value', bpmUnit: 'bpm', intensityLabel: 'Choose the effort window', intensityEasy: 'Easy 40 to 49%', intensitySteady: 'Steady 50 to 59%', intensityEndurance: 'Aerobic 60 to 69%', intensityStrong: 'Strong 70 to 79%', sessionTypeLabel: 'Session shape', sessionTypeSteady: 'Steady state', sessionTypeIntervals: 'Tempo intervals', sessionTypeHiit: 'HIIT blocks', sessionTypeHint: 'The shape changes the profile, not the heart rate math.', durationLabel: 'Session duration', durationUnit: 'min', durationHint: 'Warm up to cool down', sceneResting: 'resting', sceneCeiling: 'estimated ceiling', sceneCeilingMeasured: 'measured ceiling', sceneWindow: 'target window', scenePulsePath: 'Pulse path through the session', sceneProfile: 'Training load profile through the session', sceneSession: 'Your pulse lighthouse', sceneWarmUp: 'Warm up', sceneMainSet: 'Main set', sceneCoolDown: 'Cool down', resultLabel: 'Main set target', targetRangeLabel: 'Target range', estimatedMaximumLabel: 'Estimated maximum', measuredMaximumLabel: 'Measured maximum', reserveLabel: 'Heart rate reserve', sessionOutlineLabel: 'Session outline', sessionPlanLabel: 'Session pattern', steadyPattern: 'Keep a smooth pace inside the target window.', intervalsPattern: 'Alternate 2 minutes near the target with 2 minutes easy during the main set.', hiitPattern: 'Alternate 30 seconds hard with 90 seconds easy for controlled high-intensity blocks.', exportTcxLabel: 'Export TCX workout', exportTcxHint: 'A workout file for compatible training platforms.', warmUpInstruction: 'Build gently until breathing feels comfortable and controlled.', mainSetInstruction: 'Settle inside the target window. Let breathing and perceived effort overrule a number that feels wrong.', coolDownInstruction: 'Ease the pace gradually until your breathing is close to normal.', monitorInstruction: 'Use a monitor if you have one, but treat this as a starting band rather than a command.', rpeInstruction: 'If the effort feels much harder than expected, slow down. Medication, illness, heat, stress and fitness can make heart rate estimates misleading.', estimateNote: 'Educational estimate for adults', warningLabel: 'Safety note', invalidAge: 'Enter an age from 18 to 90 years.', invalidRestingHeartRate: 'Enter a resting heart rate from 35 to 110 bpm.', invalidMaximumHeartRate: 'Enter a measured maximum from 100 to 220 bpm, or leave it empty to use the estimate.', invalidDuration: 'Enter a session from 10 to 180 minutes.', impossibleHeartRate: 'This resting value leaves too little room below the estimated maximum. Check the reading and seek individual guidance before using a target.',
44
+ },
45
+ seo: [
46
+ { type: 'summary', title: 'Plan a target heart rate session', items: ['Estimate maximum heart rate from age or enter a measured maximum.', 'Choose an effort band instead of chasing one exact pulse.', 'Shape the session as steady state, tempo intervals or HIIT.', 'Cross check the number with breathing, perceived effort and medical context.'] },
47
+ { type: 'title', text: 'What a target heart rate zone can tell you', level: 2 },
48
+ { type: 'paragraph', html: 'A target heart rate zone is a practical pacing reference. This planner combines age with a calm resting heart rate so the result reflects more of your starting point than a percentage of maximum heart rate alone. The output is a range for the main part of a session, not a measurement of your aerobic threshold or a guarantee that a workout is safe for every person.' },
49
+ { type: 'title', text: 'How the reserve calculation works', level: 2 },
50
+ { type: 'paragraph', html: 'First, the planner estimates maximum heart rate with the Gellish equation: 207 minus 0.7 multiplied by age, unless you enter a measured maximum. It then calculates heart rate reserve as maximum heart rate minus resting heart rate. The target is resting heart rate plus the selected percentage of that reserve. For example, a 35 year old with a resting rate of 60 has an estimated maximum of 183 and a reserve of 123. At 60 to 69 percent reserve, the practical target is about 134 to 144 bpm.' },
51
+ { type: 'table', headers: ['Planner stage', 'What it means'], rows: [['Resting heart rate', 'Your calm starting pulse entered into the calculation.'], ['Maximum heart rate', 'An age-based estimate or your optional measured value.'], ['Heart rate reserve', 'Maximum heart rate minus resting heart rate.'], ['Main set target', 'Resting heart rate plus the selected share of reserve.']] },
52
+ { type: 'title', text: 'How to choose an effort band', level: 2 },
53
+ { type: 'paragraph', html: 'Use the easy and steady windows when you are returning to movement or want a controllable session. The aerobic window is a useful planning band for a sustained session, but "zone 2" means different things depending on whether a system uses heart rate reserve, maximum heart rate or a measured ventilatory threshold. The strong window belongs in a plan that matches your experience and health context.' },
54
+ { type: 'list', items: ['Measure resting heart rate when calm and at a consistent time.', 'Choose the lowest band that matches the purpose of today\'s session.', 'Keep the warm up and cool down easier than the main target.', 'Watch breathing and perceived effort as well as the monitor.', 'Review repeated sessions rather than changing the plan from one unusual reading.', 'Ask a qualified professional for a personal prescription when medication, symptoms or a diagnosed condition is involved.'] },
55
+ { type: 'title', text: 'Turn the estimate into a session', level: 2 },
56
+ { type: 'paragraph', html: 'The duration is divided into three jobs. The warm up raises effort gradually, the main set is where the target window is most relevant, and the cool down brings the pace down progressively. The session shape then gives that main set a smooth, tempo or HIIT profile. That structure makes the output more useful than a lone number because it tells you when the number matters and when it should not.' },
57
+ { type: 'tip', title: 'Use the pulse as a conversation, not a command', html: 'Heart rate can be shifted by heat, dehydration, sleep, stress, illness, altitude, training load and medicines. If the number conflicts with how you feel, reduce the effort and investigate the context. Stop and seek urgent medical help for severe or concerning symptoms.' },
58
+ { type: 'title', text: 'Limits you should keep in view', level: 2 },
59
+ { type: 'paragraph', html: 'This tool cannot diagnose cardiovascular fitness, determine a safe training prescription, identify a ventilatory threshold or account for every medication and condition. The age equation can be wrong for an individual, and the relationship between heart rate and effort changes with activity and environment. A measured exercise test or professional assessment is more specific when precision matters.' },
60
+ ],
61
+ };