@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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +2 -0
- package/src/tests/i18n_coverage.test.ts +7 -3
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/bmrCalorieDeficitPlanner/bibliography.astro +14 -0
- package/src/tool/bmrCalorieDeficitPlanner/bibliography.ts +16 -0
- package/src/tool/bmrCalorieDeficitPlanner/bmr-calorie-deficit-planner.css +160 -0
- package/src/tool/bmrCalorieDeficitPlanner/component.astro +76 -0
- package/src/tool/bmrCalorieDeficitPlanner/controller.ts +134 -0
- package/src/tool/bmrCalorieDeficitPlanner/dom-views.ts +82 -0
- package/src/tool/bmrCalorieDeficitPlanner/entry.ts +27 -0
- package/src/tool/bmrCalorieDeficitPlanner/evaluator.ts +30 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/de.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/en.ts +56 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/es.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/fr.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/id.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/it.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/ja.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/ko.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/nl.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/pl.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/pt.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/ru.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/sv.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/tr.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/i18n/zh.ts +45 -0
- package/src/tool/bmrCalorieDeficitPlanner/index.ts +12 -0
- package/src/tool/bmrCalorieDeficitPlanner/logic.test.ts +46 -0
- package/src/tool/bmrCalorieDeficitPlanner/logic.ts +84 -0
- package/src/tool/bmrCalorieDeficitPlanner/seo.astro +14 -0
- package/src/tool/bmrCalorieDeficitPlanner/storage.ts +28 -0
- package/src/tool/bmrCalorieDeficitPlanner/ui.ts +61 -0
- package/src/tool/targetHeartRateZonePlanner/bibliography.astro +14 -0
- package/src/tool/targetHeartRateZonePlanner/bibliography.ts +22 -0
- package/src/tool/targetHeartRateZonePlanner/component.astro +66 -0
- package/src/tool/targetHeartRateZonePlanner/controller.ts +59 -0
- package/src/tool/targetHeartRateZonePlanner/dom-views.ts +69 -0
- package/src/tool/targetHeartRateZonePlanner/entry.ts +27 -0
- package/src/tool/targetHeartRateZonePlanner/export-tcx.test.ts +17 -0
- package/src/tool/targetHeartRateZonePlanner/export-tcx.ts +36 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/de.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/en.ts +61 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/es.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/fr.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/id.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/it.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/ja.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/ko.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/nl.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/pl.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/pt.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/ru.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/sv.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/tr.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/i18n/zh.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/index.ts +12 -0
- package/src/tool/targetHeartRateZonePlanner/logic.test.ts +50 -0
- package/src/tool/targetHeartRateZonePlanner/logic.ts +104 -0
- package/src/tool/targetHeartRateZonePlanner/seo.astro +14 -0
- package/src/tool/targetHeartRateZonePlanner/target-heart-rate-zone-planner.css +586 -0
- package/src/tool/targetHeartRateZonePlanner/ui.ts +56 -0
- package/src/tools.ts +3 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -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
|
|
17
|
+
it('should have all required locales for its implementation phase', () => {
|
|
16
18
|
const registeredLocales = Object.keys(entry.i18n);
|
|
17
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
+
}
|