@jjlmoya/utils-health 1.45.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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +2 -0
  5. package/src/tests/i18n_coverage.test.ts +7 -3
  6. package/src/tests/locale_completeness.test.ts +1 -1
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/bmrCalorieDeficitPlanner/bibliography.astro +14 -0
  9. package/src/tool/bmrCalorieDeficitPlanner/bibliography.ts +16 -0
  10. package/src/tool/bmrCalorieDeficitPlanner/bmr-calorie-deficit-planner.css +160 -0
  11. package/src/tool/bmrCalorieDeficitPlanner/component.astro +76 -0
  12. package/src/tool/bmrCalorieDeficitPlanner/controller.ts +134 -0
  13. package/src/tool/bmrCalorieDeficitPlanner/dom-views.ts +82 -0
  14. package/src/tool/bmrCalorieDeficitPlanner/entry.ts +27 -0
  15. package/src/tool/bmrCalorieDeficitPlanner/evaluator.ts +30 -0
  16. package/src/tool/bmrCalorieDeficitPlanner/i18n/de.ts +45 -0
  17. package/src/tool/bmrCalorieDeficitPlanner/i18n/en.ts +56 -0
  18. package/src/tool/bmrCalorieDeficitPlanner/i18n/es.ts +45 -0
  19. package/src/tool/bmrCalorieDeficitPlanner/i18n/fr.ts +45 -0
  20. package/src/tool/bmrCalorieDeficitPlanner/i18n/id.ts +45 -0
  21. package/src/tool/bmrCalorieDeficitPlanner/i18n/it.ts +45 -0
  22. package/src/tool/bmrCalorieDeficitPlanner/i18n/ja.ts +45 -0
  23. package/src/tool/bmrCalorieDeficitPlanner/i18n/ko.ts +45 -0
  24. package/src/tool/bmrCalorieDeficitPlanner/i18n/nl.ts +45 -0
  25. package/src/tool/bmrCalorieDeficitPlanner/i18n/pl.ts +45 -0
  26. package/src/tool/bmrCalorieDeficitPlanner/i18n/pt.ts +45 -0
  27. package/src/tool/bmrCalorieDeficitPlanner/i18n/ru.ts +45 -0
  28. package/src/tool/bmrCalorieDeficitPlanner/i18n/sv.ts +45 -0
  29. package/src/tool/bmrCalorieDeficitPlanner/i18n/tr.ts +45 -0
  30. package/src/tool/bmrCalorieDeficitPlanner/i18n/zh.ts +45 -0
  31. package/src/tool/bmrCalorieDeficitPlanner/index.ts +12 -0
  32. package/src/tool/bmrCalorieDeficitPlanner/logic.test.ts +46 -0
  33. package/src/tool/bmrCalorieDeficitPlanner/logic.ts +84 -0
  34. package/src/tool/bmrCalorieDeficitPlanner/seo.astro +14 -0
  35. package/src/tool/bmrCalorieDeficitPlanner/storage.ts +28 -0
  36. package/src/tool/bmrCalorieDeficitPlanner/ui.ts +61 -0
  37. package/src/tool/targetHeartRateZonePlanner/bibliography.astro +14 -0
  38. package/src/tool/targetHeartRateZonePlanner/bibliography.ts +22 -0
  39. package/src/tool/targetHeartRateZonePlanner/component.astro +66 -0
  40. package/src/tool/targetHeartRateZonePlanner/controller.ts +59 -0
  41. package/src/tool/targetHeartRateZonePlanner/dom-views.ts +69 -0
  42. package/src/tool/targetHeartRateZonePlanner/entry.ts +27 -0
  43. package/src/tool/targetHeartRateZonePlanner/export-tcx.test.ts +17 -0
  44. package/src/tool/targetHeartRateZonePlanner/export-tcx.ts +36 -0
  45. package/src/tool/targetHeartRateZonePlanner/i18n/de.ts +50 -0
  46. package/src/tool/targetHeartRateZonePlanner/i18n/en.ts +61 -0
  47. package/src/tool/targetHeartRateZonePlanner/i18n/es.ts +50 -0
  48. package/src/tool/targetHeartRateZonePlanner/i18n/fr.ts +50 -0
  49. package/src/tool/targetHeartRateZonePlanner/i18n/id.ts +50 -0
  50. package/src/tool/targetHeartRateZonePlanner/i18n/it.ts +50 -0
  51. package/src/tool/targetHeartRateZonePlanner/i18n/ja.ts +50 -0
  52. package/src/tool/targetHeartRateZonePlanner/i18n/ko.ts +50 -0
  53. package/src/tool/targetHeartRateZonePlanner/i18n/nl.ts +50 -0
  54. package/src/tool/targetHeartRateZonePlanner/i18n/pl.ts +50 -0
  55. package/src/tool/targetHeartRateZonePlanner/i18n/pt.ts +50 -0
  56. package/src/tool/targetHeartRateZonePlanner/i18n/ru.ts +50 -0
  57. package/src/tool/targetHeartRateZonePlanner/i18n/sv.ts +50 -0
  58. package/src/tool/targetHeartRateZonePlanner/i18n/tr.ts +50 -0
  59. package/src/tool/targetHeartRateZonePlanner/i18n/zh.ts +50 -0
  60. package/src/tool/targetHeartRateZonePlanner/index.ts +12 -0
  61. package/src/tool/targetHeartRateZonePlanner/logic.test.ts +50 -0
  62. package/src/tool/targetHeartRateZonePlanner/logic.ts +104 -0
  63. package/src/tool/targetHeartRateZonePlanner/seo.astro +14 -0
  64. package/src/tool/targetHeartRateZonePlanner/target-heart-rate-zone-planner.css +586 -0
  65. package/src/tool/targetHeartRateZonePlanner/ui.ts +56 -0
  66. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-health",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -34,10 +34,12 @@ import { anionGapCalculator } from '../tool/anionGapCalculator/entry';
34
34
  import { creatinineClearanceCockcroftGault } from '../tool/creatinineClearanceCockcroftGault/entry';
35
35
  import { correctedCalciumCalculator } from '../tool/correctedCalciumCalculator/entry';
36
36
  import { egfrCalculator } from '../tool/egfrCalculator/entry';
37
+ import { bmrCalorieDeficitPlanner } from '../tool/bmrCalorieDeficitPlanner/entry';
38
+ import { targetHeartRateZonePlanner } from '../tool/targetHeartRateZonePlanner/entry';
37
39
 
38
40
  export const healthCategory: HealthCategoryEntry = {
39
41
  icon: 'mdi:heart-pulse',
40
- 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],
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],
41
43
  i18n: {
42
44
  en: () => import('./i18n/en').then((m) => m.content),
43
45
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -31,6 +31,8 @@ export { creatinineClearanceCockcroftGault } from './tool/creatinineClearanceCoc
31
31
  export { correctedCalciumCalculator } from './tool/correctedCalciumCalculator/entry';
32
32
  export { egfrCalculator } from './tool/egfrCalculator/entry';
33
33
  export { sleepDebtRecoveryPlanner } from './tool/sleepDebtRecoveryPlanner/entry';
34
+ export { bmrCalorieDeficitPlanner } from './tool/bmrCalorieDeficitPlanner/entry';
35
+ export { targetHeartRateZonePlanner } from './tool/targetHeartRateZonePlanner/entry';
34
36
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
35
37
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
36
38
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -73,6 +75,8 @@ import { creatinineClearanceCockcroftGault } from './tool/creatinineClearanceCoc
73
75
  import { correctedCalciumCalculator } from './tool/correctedCalciumCalculator/entry';
74
76
  import { egfrCalculator } from './tool/egfrCalculator/entry';
75
77
  import { sleepDebtRecoveryPlanner } from './tool/sleepDebtRecoveryPlanner/entry';
78
+ import { bmrCalorieDeficitPlanner } from './tool/bmrCalorieDeficitPlanner/entry';
79
+ import { targetHeartRateZonePlanner } from './tool/targetHeartRateZonePlanner/entry';
76
80
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
77
81
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
78
82
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -81,4 +85,4 @@ import { tinnitusReliever } from './tool/tinnitusReliever/entry';
81
85
  import { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
82
86
  import { ubeCalculator } from './tool/ubeCalculator/entry';
83
87
  import { waterPurifier } from './tool/waterPurifier/entry';
84
- 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];
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
@@ -39,6 +39,8 @@ export { CREATININE_CLEARANCE_COCKCROFT_GAULT_TOOL } from './tool/creatinineClea
39
39
  export { CORRECTED_CALCIUM_CALCULATOR_TOOL } from './tool/correctedCalciumCalculator';
40
40
  export { EGFR_CALCULATOR_TOOL } from './tool/egfrCalculator';
41
41
  export { SLEEP_DEBT_RECOVERY_PLANNER_TOOL } from './tool/sleepDebtRecoveryPlanner';
42
+ export { BMR_CALORIE_DEFICIT_PLANNER_TOOL } from './tool/bmrCalorieDeficitPlanner';
43
+ export { TARGET_HEART_RATE_ZONE_PLANNER_TOOL } from './tool/targetHeartRateZonePlanner';
42
44
 
43
45
  export { healthCategory } from './category';
44
46
  export const healthCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -5,6 +5,8 @@ const EXPECTED_LOCALES = [
5
5
  'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
6
  ];
7
7
 
8
+ const ENGLISH_ONLY_PHASE_TWO_TOOLS = new Set<string>();
9
+
8
10
  describe('I18n Coverage Validation', () => {
9
11
  it('all tools should be registered', () => {
10
12
  expect(ALL_TOOLS.length).toBeGreaterThan(0);
@@ -12,9 +14,10 @@ describe('I18n Coverage Validation', () => {
12
14
 
13
15
  ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
14
16
  describe(`Tool: ${entry.id}`, () => {
15
- it('should have all 15 required locales', () => {
17
+ it('should have all required locales for its implementation phase', () => {
16
18
  const registeredLocales = Object.keys(entry.i18n);
17
- EXPECTED_LOCALES.forEach((locale) => {
19
+ const expectedLocales = ENGLISH_ONLY_PHASE_TWO_TOOLS.has(entry.id) ? ['en'] : EXPECTED_LOCALES;
20
+ expectedLocales.forEach((locale) => {
18
21
  expect(
19
22
  registeredLocales,
20
23
  `Tool "${entry.id}" is missing locale "${locale}"`,
@@ -23,7 +26,8 @@ describe('I18n Coverage Validation', () => {
23
26
  });
24
27
 
25
28
  it('all locale loaders should be functions', () => {
26
- EXPECTED_LOCALES.forEach((locale) => {
29
+ const expectedLocales = ENGLISH_ONLY_PHASE_TWO_TOOLS.has(entry.id) ? ['en'] : EXPECTED_LOCALES;
30
+ expectedLocales.forEach((locale) => {
27
31
  const loader = entry.i18n[locale as keyof typeof entry.i18n];
28
32
  expect(
29
33
  typeof loader,
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(41);
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 41 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(41);
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 { bmrCalorieDeficitPlanner } 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 bmrCalorieDeficitPlanner.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Mifflin MD et al. A new predictive equation for resting energy expenditure in healthy individuals',
6
+ url: 'https://pubmed.ncbi.nlm.nih.gov/2305711/',
7
+ },
8
+ {
9
+ name: 'Deutsche Gesellschaft für Ernährung. FAQ Energiezufuhr',
10
+ url: 'https://www.dge.de/gesunde-ernaehrung/faq/energiezufuhr/',
11
+ },
12
+ {
13
+ name: 'NHS. Calories and weight loss',
14
+ url: 'https://www.nhs.uk/better-health/lose-weight/calorie-counting/',
15
+ },
16
+ ];
@@ -0,0 +1,160 @@
1
+ .bmr-planner {
2
+ --bmr-paper: #f5f0e7;
3
+ --bmr-panel: #fffdf8;
4
+ --bmr-ink: #13252a;
5
+ --bmr-muted: #687778;
6
+ --bmr-line: #d8d8cf;
7
+ --bmr-teal: #147f78;
8
+ --bmr-teal-deep: #0d4f55;
9
+ --bmr-teal-soft: #d8ece6;
10
+ --bmr-coral: #ed795d;
11
+ --bmr-coral-soft: #f5ddd4;
12
+ --bmr-shadow: 0 1.5rem 3rem rgba(17, 41, 43, 0.15);
13
+
14
+ display: grid;
15
+ grid-template-columns: minmax(20rem, 0.9fr) minmax(0, 1.25fr);
16
+ max-width: 68rem;
17
+ margin: 2rem auto;
18
+ overflow: hidden;
19
+ color: var(--bmr-ink);
20
+ background: var(--bmr-panel);
21
+ border: 1px solid rgba(19, 37, 42, 0.14);
22
+ border-radius: 1.5rem;
23
+ box-shadow: var(--bmr-shadow);
24
+ }
25
+
26
+ .theme-dark .bmr-planner {
27
+ --bmr-paper: #1c2728;
28
+ --bmr-panel: #222f30;
29
+ --bmr-ink: #f4f1e8;
30
+ --bmr-muted: #b1c0bc;
31
+ --bmr-line: #465655;
32
+ --bmr-teal: #72d0c2;
33
+ --bmr-teal-deep: #123f45;
34
+ --bmr-teal-soft: #294b49;
35
+ --bmr-coral: #f29476;
36
+ --bmr-coral-soft: #51352e;
37
+ --bmr-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.3);
38
+ }
39
+
40
+ .bmr-planner *, .bmr-planner *::before, .bmr-planner *::after { box-sizing: border-box; }
41
+ .bmr-planner h2, .bmr-planner h3, .bmr-planner p { margin: 0; }
42
+ .bmr-planner h2 { font-size: clamp(1.35rem, 2vw, 1.65rem); letter-spacing: -0.045em; }
43
+ .bmr-planner h3 { font-size: 0.95rem; letter-spacing: -0.02em; }
44
+ .bmr-planner button { font: inherit; }
45
+ .bmr-planner button:focus-visible { outline: 3px solid var(--bmr-coral); outline-offset: 3px; }
46
+ .bmr-planner__controls, .bmr-planner__result { min-width: 0; padding: 2rem; }
47
+ .bmr-planner__controls { background: var(--bmr-panel); }
48
+ .bmr-planner__result { position: relative; overflow: hidden; color: #f8f4eb; background: var(--bmr-teal-deep); }
49
+ .bmr-planner__result::before, .bmr-planner__result::after { position: absolute; z-index: 0; display: block; content: ''; pointer-events: none; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 50%; }
50
+ .bmr-planner__result::before { width: 26rem; height: 26rem; top: -13rem; right: -10rem; }
51
+ .bmr-planner__result::after { width: 15rem; height: 15rem; top: -7.5rem; right: -4.5rem; border-color: rgba(237, 121, 93, 0.5); }
52
+ .bmr-planner__result > * { position: relative; z-index: 1; }
53
+ .bmr-planner__toolbar, .bmr-planner__result-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
54
+ .bmr-planner__section-heading, .bmr-planner__result-head > div { display: grid; gap: 0.25rem; }
55
+ .bmr-planner__eyebrow, .bmr-planner__result-label, .bmr-planner__scene-label, .bmr-planner__guidance > div > span { font-size: 0.65rem; font-weight: 850; letter-spacing: 0.12em; line-height: 1.2; text-transform: uppercase; }
56
+ .bmr-planner__eyebrow { color: var(--bmr-muted); }
57
+ .bmr-planner__result .bmr-planner__eyebrow, .bmr-planner__result-label, .bmr-planner__scene-label, .bmr-planner__guidance > div > span { color: #a9d3ca; }
58
+ .bmr-planner__unit-switch { display: inline-flex; padding: 0.22rem; border: 1px solid var(--bmr-line); border-radius: 0.65rem; background: var(--bmr-paper); }
59
+ .bmr-planner__choice-group, .bmr-planner__chips { display: flex; flex-wrap: wrap; gap: 0.45rem; }
60
+ .bmr-planner__choice, .bmr-planner__chip, .bmr-planner__presets button { border: 1px solid var(--bmr-line); color: var(--bmr-ink); background: transparent; border-radius: 0.55rem; cursor: pointer; transition: border-color 140ms ease, background 140ms ease, color 140ms ease, transform 140ms ease; }
61
+ .bmr-planner__choice:hover, .bmr-planner__chip:hover, .bmr-planner__presets button:hover { border-color: var(--bmr-teal); transform: translateY(-1px); }
62
+ .bmr-planner__choice { min-height: 2.1rem; padding: 0.4rem 0.7rem; font-size: 0.75rem; }
63
+ .bmr-planner__unit-switch .bmr-planner__choice { border: 0; border-radius: 0.42rem; }
64
+ .bmr-planner__choice--active, .bmr-planner__chip.bmr-planner__choice--active { color: var(--bmr-panel); background: var(--bmr-teal); border-color: var(--bmr-teal); }
65
+ .bmr-planner__profile-block { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; margin-top: 2rem; padding: 1rem; border: 1px solid var(--bmr-line); border-radius: 1rem; background: var(--bmr-paper); }
66
+ .bmr-planner__field { display: grid; align-content: start; gap: 0.5rem; min-width: 0; }
67
+ .bmr-planner__field-label { color: var(--bmr-muted); font-size: 0.68rem; font-weight: 800; }
68
+ .bmr-planner__field--sex { grid-column: 1 / -1; }
69
+ .bmr-planner__field--sex .bmr-planner__choice-group { flex-wrap: nowrap; }
70
+ .bmr-planner__field--sex .bmr-planner__choice { flex: 1; min-width: 0; padding-right: 0.4rem; padding-left: 0.4rem; white-space: nowrap; }
71
+ .bmr-planner__input-wrap { display: flex; align-items: center; gap: 0.3rem; min-width: 0; padding: 0.8rem 0.75rem; border: 1px solid var(--bmr-line); border-radius: 0.65rem; background: var(--bmr-panel); }
72
+ .bmr-planner__input-wrap:focus-within { border-color: var(--bmr-teal); box-shadow: 0 0 0 3px var(--bmr-teal-soft); }
73
+ .bmr-planner input { flex: 1 1 0; width: 1px; min-width: 0; border: 0; outline: 0; padding: 0; color: var(--bmr-ink); background: transparent; font-size: 1.1rem; font-weight: 700; }
74
+ .bmr-planner__input-wrap span { color: var(--bmr-muted); font-size: 0.7rem; }
75
+ .bmr-planner__planning-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25rem; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--bmr-line); }
76
+ .bmr-planner__subsection { display: grid; gap: 0.75rem; min-width: 0; }
77
+ .bmr-planner__subsection-head { display: flex; align-items: center; gap: 0.5rem; }
78
+ .bmr-planner__section-index { display: grid; width: 1.45rem; height: 1.45rem; place-items: center; color: var(--bmr-panel); background: var(--bmr-ink); border-radius: 50%; font-size: 0.58rem; font-weight: 850; }
79
+ .bmr-planner__chip { min-height: 2.25rem; padding: 0.55rem 0.7rem; font-size: 0.73rem; }
80
+ .bmr-planner__presets { display: flex; align-items: center; flex-wrap: wrap; gap: 0.45rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px dashed var(--bmr-line); color: var(--bmr-muted); font-size: 0.68rem; }
81
+ .bmr-planner__presets button { padding: 0.38rem 0.55rem; font-size: 0.67rem; }
82
+ .bmr-planner__result-head h2 { color: #fffaf0; font-size: clamp(1.45rem, 2.5vw, 1.8rem); }
83
+ .bmr-planner__plan-state { display: grid; align-content: start; justify-items: end; gap: 0.3rem; min-width: 7rem; padding-top: 0.15rem; text-align: right; }
84
+ .bmr-planner__plan-state-label { color: #a9d3ca; font-size: 0.62rem; font-weight: 850; letter-spacing: 0.1em; text-transform: uppercase; }
85
+ .bmr-planner__plan-state strong { display: inline-flex; align-items: center; gap: 0.4rem; color: #fffaf0; font-size: 0.82rem; }
86
+ .bmr-planner__plan-state strong::before { width: 0.55rem; height: 0.55rem; content: ''; background: #b7ddd3; border-radius: 50%; box-shadow: 0 0 0 0.2rem rgba(183, 221, 211, 0.14); }
87
+ .bmr-planner__result[data-goal="gentleLoss"] .bmr-planner__plan-state strong::before { background: #f8c58d; box-shadow: 0 0 0 0.2rem rgba(248, 197, 141, 0.15); }
88
+ .bmr-planner__result[data-goal="steadyLoss"] .bmr-planner__plan-state strong::before { background: var(--bmr-coral); box-shadow: 0 0 0 0.2rem rgba(237, 121, 93, 0.16); }
89
+ .bmr-planner__target-value { display: flex; align-items: baseline; gap: 0.45rem; margin-top: 1.1rem; }
90
+ .bmr-planner__target-value strong { color: #fffaf0; font-size: clamp(3.9rem, 7vw, 5.8rem); font-weight: 850; letter-spacing: -0.09em; line-height: 0.82; }
91
+ .bmr-planner__target-value small { color: #c7e0d9; font-size: 0.78rem; font-weight: 800; }
92
+ .bmr-planner__result-label { display: block; margin-top: 0.55rem; color: #c7e0d9; letter-spacing: 0.08em; }
93
+ .bmr-planner__badge { flex: 0 0 auto; padding: 0.48rem 0.65rem; color: var(--bmr-teal-deep); background: #b7ddd3; border-radius: 2rem; font-size: 0.67rem; font-weight: 850; }
94
+ .bmr-planner__badge--review { color: #fffaf0; background: var(--bmr-coral); }
95
+ .bmr-planner__result-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(9rem, 0.75fr); gap: 0.75rem; margin-top: 2.1rem; }
96
+ .bmr-planner__scene-wrap { min-width: 0; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 0.95rem; background: rgba(255, 255, 255, 0.08); }
97
+ .bmr-planner__scene-heading, .bmr-planner__energy-scale, .bmr-planner__energy-values, .bmr-planner__energy-target { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
98
+ .bmr-planner__scene { display: grid; gap: 0.5rem; margin-top: 1.4rem; }
99
+ .bmr-planner__energy-scale { color: #b9d1cc; font-size: 0.64rem; font-weight: 700; }
100
+ .bmr-planner__energy-bar { position: relative; display: flex; height: 3.6rem; overflow: visible; margin-top: 0.25rem; background: rgba(255, 255, 255, 0.16); border-radius: 0.55rem; }
101
+ .bmr-planner__energy-bar span { display: block; height: 100%; }
102
+ .bmr-planner__resting { width: 64%; background: var(--bmr-teal); border-radius: 0.55rem 0 0 0.55rem; }
103
+ .bmr-planner__activity { width: 36%; background: var(--bmr-coral); border-radius: 0 0.55rem 0.55rem 0; }
104
+ .bmr-planner__target { position: absolute; top: -0.55rem; left: clamp(0.09rem, var(--target-position, 50%), calc(100% - 0.09rem)); width: 0.18rem; height: 4.7rem; background: #fffaf0; border-radius: 99rem; box-shadow: 0 0 0 0.25rem rgba(255, 250, 240, 0.16); transform: translateX(-50%); }
105
+ .bmr-planner__target::before { position: absolute; top: -0.2rem; left: 50%; width: 0.55rem; height: 0.55rem; content: ''; background: #fffaf0; border-radius: 50%; transform: translateX(-50%); }
106
+ .bmr-planner__energy-values { margin-top: 0.65rem; }
107
+ .bmr-planner__energy-values div { display: grid; gap: 0.15rem; min-width: 0; }
108
+ .bmr-planner__energy-values div:last-child { text-align: right; }
109
+ .bmr-planner__energy-values span, .bmr-planner__energy-target span, .bmr-planner__energy-target small { color: #b9d1cc; font-size: 0.63rem; }
110
+ .bmr-planner__energy-values strong, .bmr-planner__energy-target strong { color: #fffaf0; font-size: 0.75rem; }
111
+ .bmr-planner__energy-target { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; min-width: 0; margin-top: 1.15rem; padding-top: 0.75rem; border-top: 1px dashed rgba(255, 255, 255, 0.3); }
112
+ .bmr-planner__energy-target-name { display: inline-flex; align-items: center; gap: 0.35rem; min-width: 0; }
113
+ .bmr-planner__energy-target-name .bmr-planner__dot { flex: 0 0 auto; }
114
+ .bmr-planner__energy-target-name span { color: #fffaf0; font-weight: 850; }
115
+ .bmr-planner__energy-target-name small { color: #f8b19b; font-size: 0.6rem; font-weight: 750; }
116
+ .bmr-planner__energy-target > strong { flex: 0 0 auto; }
117
+ .bmr-planner__scene-key { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-top: 0.9rem; color: #b9d1cc; font-size: 0.62rem; }
118
+ .bmr-planner__scene-key span { display: inline-flex; align-items: center; gap: 0.3rem; }
119
+ .bmr-planner__dot { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--bmr-teal); }
120
+ .bmr-planner__dot--activity { background: var(--bmr-coral); }
121
+ .bmr-planner__dot--target { background: #fffaf0; }
122
+ .bmr-planner__metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
123
+ .bmr-planner__metric { display: grid; align-content: start; gap: 0.3rem; min-width: 0; padding: 0.75rem; border: 1px solid rgba(255, 255, 255, 0.17); border-radius: 0.7rem; background: rgba(255, 255, 255, 0.07); }
124
+ .bmr-planner__metric span, .bmr-planner__formula span { color: #b9d1cc; font-size: 0.62rem; line-height: 1.25; }
125
+ .bmr-planner__metric strong { color: #fffaf0; font-size: 0.78rem; line-height: 1.25; }
126
+ .bmr-planner__metric--primary { border-color: rgba(183, 221, 211, 0.45); background: rgba(183, 221, 211, 0.13); }
127
+ .bmr-planner__metric--primary strong { color: #b7ddd3; font-size: 0.9rem; }
128
+ .bmr-planner__formula { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.3rem 0.5rem; padding: 0.15rem 0.1rem 0; }
129
+ .bmr-planner__formula strong { color: #fffaf0; font-size: 0.7rem; }
130
+ .bmr-planner__guidance { display: grid; gap: 0.7rem; margin-top: 1rem; padding: 0.95rem 1rem; border-left: 3px solid var(--bmr-coral); background: rgba(237, 121, 93, 0.13); }
131
+ .bmr-planner__guidance p { margin-top: 0.35rem; color: #f8f4eb; font-size: 0.77rem; line-height: 1.45; }
132
+ .bmr-planner__warning { display: none; padding: 0.65rem; border-left: 3px solid var(--bmr-coral); color: #fffaf0; background: rgba(237, 121, 93, 0.22); font-size: 0.73rem; }
133
+ .bmr-planner__warning--visible { display: block; }
134
+ .bmr-planner p.bmr-planner__note, .bmr-planner p.bmr-planner__disclaimer, .bmr-planner p.bmr-planner__source-note { margin-top: 0.8rem; color: #a9c4be; font-size: 0.63rem; line-height: 1.45; }
135
+ .bmr-planner__disclaimer { color: #d1e0da; }
136
+ .bmr-planner__details { margin-top: 1rem; border-top: 1px solid rgba(255, 255, 255, 0.16); }
137
+ .bmr-planner__details summary { padding-top: 0.8rem; color: #d1e0da; cursor: pointer; font-size: 0.68rem; font-weight: 800; }
138
+ .bmr-planner__details[open] summary { color: #fffaf0; }
139
+
140
+ @media (max-width: 900px) {
141
+ .bmr-planner { grid-template-columns: 1fr; max-width: 44rem; }
142
+ .bmr-planner__result-grid { grid-template-columns: minmax(0, 1.25fr) minmax(12rem, 0.75fr); }
143
+ }
144
+
145
+ @media (max-width: 620px) {
146
+ .bmr-planner { margin: 1rem auto; border-radius: 1rem; }
147
+ .bmr-planner__controls, .bmr-planner__result { padding: 1.25rem; }
148
+ .bmr-planner__profile-block { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 1.35rem; padding: 0.75rem; }
149
+ .bmr-planner__field--sex { grid-column: 1 / -1; }
150
+ .bmr-planner__planning-grid, .bmr-planner__result-grid { grid-template-columns: 1fr; }
151
+ .bmr-planner__planning-grid { gap: 1rem; margin-top: 1.2rem; padding-top: 1.2rem; }
152
+ .bmr-planner__subsection + .bmr-planner__subsection { padding-top: 1rem; border-top: 1px solid var(--bmr-line); }
153
+ .bmr-planner__result-head { gap: 1.25rem; }
154
+ .bmr-planner__plan-state { min-width: 0; }
155
+ }
156
+
157
+ @media (max-width: 380px) {
158
+ .bmr-planner__profile-block, .bmr-planner__metrics { grid-template-columns: 1fr; }
159
+ .bmr-planner__field--sex, .bmr-planner__formula { grid-column: auto; }
160
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ import './bmr-calorie-deficit-planner.css';
3
+ import type { BmrCalorieDeficitPlannerUI } from './ui';
4
+
5
+ interface Props {
6
+ locale?: string;
7
+ ui?: Record<string, unknown>;
8
+ }
9
+
10
+ const locale = Astro.props.locale || 'en';
11
+ const t = (Astro.props.ui || {}) as BmrCalorieDeficitPlannerUI;
12
+ ---
13
+
14
+ <div class="bmr-planner" data-ui={JSON.stringify(t)} data-locale={locale}>
15
+ <section class="bmr-planner__controls" aria-labelledby="bmr-controls-title">
16
+ <div class="bmr-planner__toolbar">
17
+ <div class="bmr-planner__section-heading"><span class="bmr-planner__eyebrow">{t.profileKicker}</span><h2 id="bmr-controls-title">{t.profileTitle}</h2></div>
18
+ <div class="bmr-planner__unit-switch" role="group" aria-label={t.unitSystemLabel}>
19
+ <button type="button" class="bmr-planner__choice bmr-planner__choice--active" data-unit="metric">{t.unitMetric}</button>
20
+ <button type="button" class="bmr-planner__choice" data-unit="imperial">{t.unitImperial}</button>
21
+ </div>
22
+ </div>
23
+ <div class="bmr-planner__profile-block">
24
+ <div class="bmr-planner__field bmr-planner__field--sex">
25
+ <span class="bmr-planner__field-label">{t.sexLabel}</span>
26
+ <div class="bmr-planner__choice-group" role="group" aria-label={t.sexLabel}>
27
+ <button type="button" class="bmr-planner__choice" data-sex="female">{t.sexFemale}</button>
28
+ <button type="button" class="bmr-planner__choice bmr-planner__choice--active" data-sex="male">{t.sexMale}</button>
29
+ </div>
30
+ </div>
31
+ <label class="bmr-planner__field" for="bmr-age"><span class="bmr-planner__field-label">{t.ageLabel}</span><span class="bmr-planner__input-wrap"><input id="bmr-age" type="number" min="18" max="80" step="1" value="32" inputmode="numeric" /><span>{t.years}</span></span></label>
32
+ <label class="bmr-planner__field" for="bmr-height"><span class="bmr-planner__field-label">{t.heightLabel}</span><span class="bmr-planner__input-wrap"><input id="bmr-height" type="number" min="140" max="220" step="1" value="175" inputmode="decimal" /><span id="bmr-height-unit">{t.cm}</span></span></label>
33
+ <label class="bmr-planner__field" for="bmr-weight"><span class="bmr-planner__field-label">{t.weightLabel}</span><span class="bmr-planner__input-wrap"><input id="bmr-weight" type="number" min="40" max="200" step="0.1" value="76" inputmode="decimal" /><span id="bmr-weight-unit">{t.kg}</span></span></label>
34
+ </div>
35
+ <div class="bmr-planner__planning-grid">
36
+ <div class="bmr-planner__subsection">
37
+ <div class="bmr-planner__subsection-head"><span class="bmr-planner__section-index">01</span><h3>{t.activityTitle}</h3></div>
38
+ <div class="bmr-planner__chips" role="group" aria-label={t.activityTitle}>
39
+ <button type="button" class="bmr-planner__chip" data-activity="sedentary">{t.activitySedentary}</button>
40
+ <button type="button" class="bmr-planner__chip" data-activity="light">{t.activityLight}</button>
41
+ <button type="button" class="bmr-planner__chip bmr-planner__choice--active" data-activity="moderate">{t.activityModerate}</button>
42
+ <button type="button" class="bmr-planner__chip" data-activity="high">{t.activityHigh}</button>
43
+ </div>
44
+ </div>
45
+ <div class="bmr-planner__subsection">
46
+ <div class="bmr-planner__subsection-head"><span class="bmr-planner__section-index">02</span><h3>{t.goalTitle}</h3></div>
47
+ <div class="bmr-planner__chips" role="group" aria-label={t.goalTitle}>
48
+ <button type="button" class="bmr-planner__chip bmr-planner__choice--active" data-goal="maintain">{t.goalMaintain}</button>
49
+ <button type="button" class="bmr-planner__chip" data-goal="gentleLoss">{t.goalGentleLoss}</button>
50
+ <button type="button" class="bmr-planner__chip" data-goal="steadyLoss">{t.goalSteadyLoss}</button>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ <div class="bmr-planner__presets" aria-labelledby="bmr-presets-title">
55
+ <span id="bmr-presets-title">{t.presetsTitle}</span>
56
+ <button type="button" data-preset="everyday">{t.presetEveryday}</button>
57
+ <button type="button" data-preset="desk">{t.presetDesk}</button>
58
+ <button type="button" data-preset="active">{t.presetActive}</button>
59
+ </div>
60
+ </section>
61
+
62
+ <section class="bmr-planner__result" aria-labelledby="bmr-result-title" data-goal="maintain">
63
+ <div class="bmr-planner__result-head"><div><span class="bmr-planner__eyebrow">{t.resultKicker}</span><h2 id="bmr-result-title">{t.resultTitle}</h2><div class="bmr-planner__target-value"><strong id="bmr-target-calories">2133</strong> <small>{t.kcalPerDay}</small></div></div><div class="bmr-planner__plan-state" aria-live="polite"><span class="bmr-planner__plan-state-label">{t.goalTitle}</span><strong id="bmr-plan-mode">{t.goalMaintain}</strong></div></div>
64
+ <div class="bmr-planner__result-grid">
65
+ <div class="bmr-planner__scene-wrap"><div class="bmr-planner__scene-heading"><span class="bmr-planner__scene-label">{t.energyMapLabel}</span></div><div id="bmr-energy-map" class="bmr-planner__scene" role="img" aria-label={t.energyMapLabel}><div class="bmr-planner__energy-scale"><span>{t.restingEngine}</span><span>{t.maintenanceLine}</span></div><div class="bmr-planner__energy-bar"><span id="bmr-resting-band" class="bmr-planner__resting"></span><span id="bmr-activity-band" class="bmr-planner__activity"></span><span id="bmr-target-marker" class="bmr-planner__target" aria-hidden="true"></span></div><div class="bmr-planner__energy-values"><div><span>{t.restingEngine}</span><strong id="bmr-map-resting-value">1,699 {t.kcalPerDay}</strong></div><div><span>{t.movementAndDigestion}</span><strong id="bmr-map-maintenance-value">2,633 {t.kcalPerDay}</strong></div></div><div class="bmr-planner__energy-target"><div class="bmr-planner__energy-target-name"><i class="bmr-planner__dot bmr-planner__dot--target"></i><span id="bmr-map-goal-label">{t.goalMaintain}</span><small>{t.targetLine}</small></div><strong id="bmr-map-target-value">2,133 {t.kcalPerDay}</strong></div></div><div class="bmr-planner__scene-key"><span><i class="bmr-planner__dot bmr-planner__dot--resting"></i>{t.restingEngine}</span><span><i class="bmr-planner__dot bmr-planner__dot--activity"></i>{t.movementAndDigestion}</span><span><i class="bmr-planner__dot bmr-planner__dot--target"></i>{t.targetLine}</span></div></div>
66
+ <div class="bmr-planner__metrics"><div class="bmr-planner__metric bmr-planner__metric--primary"><span>{t.maintenanceLabel}</span><strong id="bmr-maintenance">2,633 {t.kcalPerDay}</strong></div><div class="bmr-planner__metric"><span>{t.bmrLabel}</span><strong id="bmr-bmr">1,699 {t.kcalPerDay}</strong></div><div class="bmr-planner__metric"><span>{t.weeklyChangeLabel}</span><strong><span id="bmr-weekly-change">-0.5</span> {t.kgPerWeek}</strong></div><div class="bmr-planner__metric"><span>{t.targetRangeLabel}</span><strong id="bmr-target-range">1,870 - 2,396 {t.kcalPerDay}</strong></div><div class="bmr-planner__formula"><span>{t.formulaLabel}</span><strong>{t.formulaText}</strong><span>{t.activityFactorLabel} <b id="bmr-activity-factor">1.55</b></span></div></div>
67
+ </div>
68
+ <div class="bmr-planner__guidance"><div><span>{t.guidanceTitle}</span><p id="bmr-guidance">{t.guidanceSteady}</p></div><p class="bmr-planner__warning" id="bmr-low-target-warning"></p></div>
69
+ <details class="bmr-planner__details"><summary>{t.detailsTitle}</summary><p class="bmr-planner__note">{t.sensitivityNote}</p><p class="bmr-planner__disclaimer">{t.disclaimer}</p><p class="bmr-planner__source-note">{t.sourceNote}</p></details>
70
+ </section>
71
+ </div>
72
+
73
+ <script>
74
+ import { initBmrCalorieDeficitPlanner } from './controller';
75
+ document.querySelectorAll('.bmr-planner').forEach((root) => initBmrCalorieDeficitPlanner(root));
76
+ </script>
@@ -0,0 +1,134 @@
1
+ import { calculateBmrPlanner, type ActivityLevel, type BmrPlannerInput, type Goal, type Sex } from './logic';
2
+ import { evaluatePlanner } from './evaluator';
3
+ import { loadBmrPlannerState, saveBmrPlannerState } from './storage';
4
+ import { renderBmrPlanner } from './dom-views';
5
+ import type { BmrCalorieDeficitPlannerUI } from './ui';
6
+
7
+ type UnitSystem = 'metric' | 'imperial';
8
+
9
+ interface PlannerContext {
10
+ root: Element;
11
+ ui: BmrCalorieDeficitPlannerUI;
12
+ locale: string;
13
+ unit: UnitSystem;
14
+ input: BmrPlannerInput;
15
+ }
16
+
17
+ const DEFAULT_INPUT: BmrPlannerInput = { age: 32, heightCm: 175, weightKg: 76, sex: 'male', activity: 'moderate', goal: 'steadyLoss' };
18
+
19
+ function getNumber(root: Element, selector: string, fallback: number): number {
20
+ const value = Number(root.querySelector<HTMLInputElement>(selector)?.value);
21
+ return Number.isFinite(value) && value > 0 ? value : fallback;
22
+ }
23
+
24
+ function displayHeight(input: BmrPlannerInput, unit: UnitSystem): number {
25
+ return unit === 'metric' ? input.heightCm : input.heightCm / 2.54;
26
+ }
27
+
28
+ function displayWeight(input: BmrPlannerInput, unit: UnitSystem): number {
29
+ return unit === 'metric' ? input.weightKg : input.weightKg * 2.2046226218;
30
+ }
31
+
32
+ interface InputBounds {
33
+ metricMin: string;
34
+ metricMax: string;
35
+ imperialMin: string;
36
+ imperialMax: string;
37
+ }
38
+
39
+ function setBounds(input: HTMLInputElement | null, unit: UnitSystem, bounds: InputBounds): void {
40
+ if (!input) return;
41
+ input.min = unit === 'metric' ? bounds.metricMin : bounds.imperialMin;
42
+ input.max = unit === 'metric' ? bounds.metricMax : bounds.imperialMax;
43
+ input.step = '0.1';
44
+ }
45
+
46
+ function setDisplayValues(context: PlannerContext): void {
47
+ const { root, input, unit } = context;
48
+ const height = root.querySelector<HTMLInputElement>('#bmr-height');
49
+ const weight = root.querySelector<HTMLInputElement>('#bmr-weight');
50
+ setBounds(height, unit, { metricMin: '140', metricMax: '220', imperialMin: '55', imperialMax: '87' });
51
+ setBounds(weight, unit, { metricMin: '40', metricMax: '200', imperialMin: '88', imperialMax: '441' });
52
+ if (height) height.value = String(Math.round(displayHeight(input, unit) * 10) / 10);
53
+ if (weight) weight.value = String(Math.round(displayWeight(input, unit) * 10) / 10);
54
+ const age = root.querySelector<HTMLInputElement>('#bmr-age');
55
+ if (age) age.value = String(input.age);
56
+ }
57
+
58
+ function readDisplayedValues(context: PlannerContext): void {
59
+ const { root, input, unit } = context;
60
+ input.age = getNumber(root, '#bmr-age', input.age);
61
+ const height = getNumber(root, '#bmr-height', displayHeight(input, unit));
62
+ const weight = getNumber(root, '#bmr-weight', displayWeight(input, unit));
63
+ input.heightCm = unit === 'metric' ? height : height * 2.54;
64
+ input.weightKg = unit === 'metric' ? weight : weight / 2.2046226218;
65
+ }
66
+
67
+ function render(context: PlannerContext): void {
68
+ const result = calculateBmrPlanner(context.input);
69
+ const evaluation = evaluatePlanner(result, context.input.goal);
70
+ renderBmrPlanner({ root: context.root, input: context.input, result, evaluation, ui: context.ui, locale: context.locale }, context.unit);
71
+ saveBmrPlannerState({ ...context.input, unit: context.unit });
72
+ }
73
+
74
+ function applyPreset(context: PlannerContext, preset: string): void {
75
+ const presets: Record<string, Partial<BmrPlannerInput>> = {
76
+ everyday: { age: 32, heightCm: 175, weightKg: 76, sex: 'male', activity: 'moderate', goal: 'steadyLoss' },
77
+ desk: { age: 38, heightCm: 168, weightKg: 72, sex: 'female', activity: 'sedentary', goal: 'gentleLoss' },
78
+ active: { age: 29, heightCm: 182, weightKg: 82, sex: 'male', activity: 'high', goal: 'maintain' },
79
+ };
80
+ const selected = presets[preset];
81
+ if (!selected) return;
82
+ context.input = { ...context.input, ...selected };
83
+ setDisplayValues(context);
84
+ render(context);
85
+ }
86
+
87
+ function switchUnit(context: PlannerContext, nextUnit: UnitSystem): void {
88
+ if (context.unit === nextUnit) return;
89
+ readDisplayedValues(context);
90
+ context.unit = nextUnit;
91
+ setDisplayValues(context);
92
+ render(context);
93
+ }
94
+
95
+ function attachChoiceEvents(context: PlannerContext): void {
96
+ context.root.querySelectorAll<HTMLButtonElement>('[data-sex]').forEach((button) => button.addEventListener('click', () => {
97
+ context.input.sex = button.dataset.sex as Sex;
98
+ render(context);
99
+ }));
100
+ context.root.querySelectorAll<HTMLButtonElement>('[data-activity]').forEach((button) => button.addEventListener('click', () => {
101
+ context.input.activity = button.dataset.activity as ActivityLevel;
102
+ render(context);
103
+ }));
104
+ context.root.querySelectorAll<HTMLButtonElement>('[data-goal]').forEach((button) => button.addEventListener('click', () => {
105
+ context.input.goal = button.dataset.goal as Goal;
106
+ render(context);
107
+ }));
108
+ context.root.querySelectorAll<HTMLButtonElement>('[data-unit]').forEach((button) => button.addEventListener('click', () => switchUnit(context, button.dataset.unit as UnitSystem)));
109
+ context.root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => applyPreset(context, button.dataset.preset || '')));
110
+ }
111
+
112
+ function restore(context: PlannerContext): void {
113
+ const saved = loadBmrPlannerState();
114
+ context.unit = saved.unit === 'imperial' ? 'imperial' : 'metric';
115
+ context.input = {
116
+ age: saved.age || DEFAULT_INPUT.age,
117
+ heightCm: saved.heightCm || DEFAULT_INPUT.heightCm,
118
+ weightKg: saved.weightKg || DEFAULT_INPUT.weightKg,
119
+ sex: saved.sex || DEFAULT_INPUT.sex,
120
+ activity: saved.activity || DEFAULT_INPUT.activity,
121
+ goal: saved.goal || DEFAULT_INPUT.goal,
122
+ };
123
+ setDisplayValues(context);
124
+ }
125
+
126
+ export function initBmrCalorieDeficitPlanner(root: Element): void {
127
+ const element = root as HTMLElement;
128
+ const ui = JSON.parse(element.dataset.ui || '{}') as BmrCalorieDeficitPlannerUI;
129
+ const context: PlannerContext = { root, ui, locale: element.dataset.locale || 'en', unit: 'metric', input: DEFAULT_INPUT };
130
+ restore(context);
131
+ attachChoiceEvents(context);
132
+ ['#bmr-age', '#bmr-height', '#bmr-weight'].forEach((selector) => root.querySelector(selector)?.addEventListener('input', () => { readDisplayedValues(context); render(context); }));
133
+ render(context);
134
+ }
@@ -0,0 +1,82 @@
1
+ import type { BmrPlannerResult, BmrPlannerInput } from './logic';
2
+ import type { PlannerEvaluation } from './evaluator';
3
+ import type { BmrCalorieDeficitPlannerUI } from './ui';
4
+
5
+ interface RenderContext {
6
+ root: Element;
7
+ input: BmrPlannerInput;
8
+ result: BmrPlannerResult;
9
+ evaluation: PlannerEvaluation;
10
+ ui: BmrCalorieDeficitPlannerUI;
11
+ locale: string;
12
+ }
13
+
14
+ function format(value: number, locale: string, fractionDigits = 0): string {
15
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits }).format(value);
16
+ }
17
+
18
+ function setText(root: Element, selector: string, value: string): void {
19
+ const element = root.querySelector<HTMLElement>(selector);
20
+ if (element) element.textContent = value;
21
+ }
22
+
23
+ function setAttribute(root: Element, selector: string, name: string, value: string): void {
24
+ const element = root.querySelector(selector);
25
+ if (element) element.setAttribute(name, value);
26
+ }
27
+
28
+ function setStyle(root: Element, selector: string, property: string, value: string): void {
29
+ const element = root.querySelector<HTMLElement>(selector);
30
+ if (element) element.style.setProperty(property, value);
31
+ }
32
+
33
+ function updateButtons(root: Element, attribute: string, value: string, className: string): void {
34
+ root.querySelectorAll<HTMLButtonElement>(`[${attribute}]`).forEach((button) => {
35
+ const active = button.dataset[attribute.slice(5)] === value;
36
+ button.classList.toggle(className, active);
37
+ button.setAttribute('aria-pressed', String(active));
38
+ });
39
+ }
40
+
41
+ function updateInputLabels(root: Element, unit: 'metric' | 'imperial', ui: BmrCalorieDeficitPlannerUI): void {
42
+ setText(root, '#bmr-height-unit', unit === 'metric' ? ui.cm : ui.in);
43
+ setText(root, '#bmr-weight-unit', unit === 'metric' ? ui.kg : ui.lb);
44
+ }
45
+
46
+ function updateScene(root: Element, result: BmrPlannerResult, ui: BmrCalorieDeficitPlannerUI, locale: string): void {
47
+ const restingPercent = Math.max(0, Math.min(100, result.bmr / result.maintenance * 100));
48
+ const targetPercent = Math.max(0, Math.min(100, result.targetCalories / result.maintenance * 100));
49
+ setStyle(root, '#bmr-resting-band', 'width', `${restingPercent}%`);
50
+ setStyle(root, '#bmr-activity-band', 'width', `${100 - restingPercent}%`);
51
+ setStyle(root, '#bmr-target-marker', '--target-position', `${targetPercent}%`);
52
+ setText(root, '#bmr-map-resting-value', `${format(result.bmr, locale)} ${ui.kcalPerDay}`);
53
+ setText(root, '#bmr-map-maintenance-value', `${format(result.maintenance, locale)} ${ui.kcalPerDay}`);
54
+ setText(root, '#bmr-map-target-value', `${format(result.targetCalories, locale)} ${ui.kcalPerDay}`);
55
+ setAttribute(root, '#bmr-energy-map', 'aria-label', `${ui.energyMapLabel}: ${format(result.targetCalories, locale)} ${ui.kcalPerDay}`);
56
+ }
57
+
58
+ export function renderBmrPlanner(context: RenderContext, unit: 'metric' | 'imperial'): void {
59
+ const { root, input, result, evaluation, ui, locale } = context;
60
+ const goalLabels: Record<string, string> = { maintain: ui.goalMaintain, gentleLoss: ui.goalGentleLoss, steadyLoss: ui.goalSteadyLoss };
61
+ const goalLabel = goalLabels[input.goal];
62
+ setAttribute(root, '.bmr-planner__result', 'data-goal', input.goal);
63
+ setText(root, '#bmr-plan-mode', goalLabel);
64
+ setText(root, '#bmr-map-goal-label', goalLabel);
65
+ setText(root, '#bmr-target-calories', format(result.targetCalories, locale));
66
+ setText(root, '#bmr-maintenance', `${format(result.maintenance, locale)} ${ui.kcalPerDay}`);
67
+ setText(root, '#bmr-bmr', `${format(result.bmr, locale)} ${ui.kcalPerDay}`);
68
+ setText(root, '#bmr-weekly-change', result.estimatedWeeklyLossKg === 0 ? '0' : `-${format(result.estimatedWeeklyLossKg, locale, 1)}`);
69
+ setText(root, '#bmr-target-range', `${format(result.targetRange.low, locale)} - ${format(result.targetRange.high, locale)} ${ui.kcalPerDay}`);
70
+ setText(root, '#bmr-activity-factor', result.activityFactor.toFixed(3).replace(/0$/, ''));
71
+ setText(root, '#bmr-status', ui[evaluation.badgeKey]);
72
+ setText(root, '#bmr-guidance', ui[evaluation.guidanceKey]);
73
+ setText(root, '#bmr-low-target-warning', evaluation.needsReview ? ui.lowTargetWarning : '');
74
+ root.querySelector('#bmr-low-target-warning')?.classList.toggle('bmr-planner__warning--visible', evaluation.needsReview);
75
+ root.querySelector('#bmr-status')?.classList.toggle('bmr-planner__badge--review', evaluation.needsReview);
76
+ updateButtons(root, 'data-sex', input.sex, 'bmr-planner__choice--active');
77
+ updateButtons(root, 'data-activity', input.activity, 'bmr-planner__choice--active');
78
+ updateButtons(root, 'data-goal', input.goal, 'bmr-planner__choice--active');
79
+ updateButtons(root, 'data-unit', unit, 'bmr-planner__choice--active');
80
+ updateInputLabels(root, unit, ui);
81
+ updateScene(root, result, ui, locale);
82
+ }