@jjlmoya/utils-health 1.45.0 → 1.46.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 (37) 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/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/tools.ts +2 -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.46.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -34,10 +34,11 @@ 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';
37
38
 
38
39
  export const healthCategory: HealthCategoryEntry = {
39
40
  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],
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],
41
42
  i18n: {
42
43
  en: () => import('./i18n/en').then((m) => m.content),
43
44
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -31,6 +31,7 @@ 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';
34
35
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
35
36
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
36
37
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -73,6 +74,7 @@ import { creatinineClearanceCockcroftGault } from './tool/creatinineClearanceCoc
73
74
  import { correctedCalciumCalculator } from './tool/correctedCalciumCalculator/entry';
74
75
  import { egfrCalculator } from './tool/egfrCalculator/entry';
75
76
  import { sleepDebtRecoveryPlanner } from './tool/sleepDebtRecoveryPlanner/entry';
77
+ import { bmrCalorieDeficitPlanner } from './tool/bmrCalorieDeficitPlanner/entry';
76
78
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
77
79
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
78
80
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -81,4 +83,4 @@ import { tinnitusReliever } from './tool/tinnitusReliever/entry';
81
83
  import { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
82
84
  import { ubeCalculator } from './tool/ubeCalculator/entry';
83
85
  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];
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];
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ 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';
42
43
 
43
44
  export { healthCategory } from './category';
44
45
  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(42);
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 42 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(42);
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
+ }