@jjlmoya/utils-health 1.23.0 → 1.24.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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/macronutrientCalculator/bibliography.astro +14 -0
  8. package/src/tool/macronutrientCalculator/bibliography.ts +28 -0
  9. package/src/tool/macronutrientCalculator/component.astro +310 -0
  10. package/src/tool/macronutrientCalculator/entry.ts +27 -0
  11. package/src/tool/macronutrientCalculator/i18n/de.ts +415 -0
  12. package/src/tool/macronutrientCalculator/i18n/en.ts +415 -0
  13. package/src/tool/macronutrientCalculator/i18n/es.ts +415 -0
  14. package/src/tool/macronutrientCalculator/i18n/fr.ts +415 -0
  15. package/src/tool/macronutrientCalculator/i18n/id.ts +415 -0
  16. package/src/tool/macronutrientCalculator/i18n/it.ts +415 -0
  17. package/src/tool/macronutrientCalculator/i18n/ja.ts +415 -0
  18. package/src/tool/macronutrientCalculator/i18n/ko.ts +419 -0
  19. package/src/tool/macronutrientCalculator/i18n/nl.ts +415 -0
  20. package/src/tool/macronutrientCalculator/i18n/pl.ts +415 -0
  21. package/src/tool/macronutrientCalculator/i18n/pt.ts +415 -0
  22. package/src/tool/macronutrientCalculator/i18n/ru.ts +415 -0
  23. package/src/tool/macronutrientCalculator/i18n/sv.ts +415 -0
  24. package/src/tool/macronutrientCalculator/i18n/tr.ts +415 -0
  25. package/src/tool/macronutrientCalculator/i18n/zh.ts +415 -0
  26. package/src/tool/macronutrientCalculator/index.ts +12 -0
  27. package/src/tool/macronutrientCalculator/logic.ts +62 -0
  28. package/src/tool/macronutrientCalculator/macronutrient-calculator.css +492 -0
  29. package/src/tool/macronutrientCalculator/seo.astro +15 -0
  30. package/src/tool/macronutrientCalculator/ui.ts +39 -0
  31. 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.23.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,6 +10,7 @@ import { breathingVisualizer } from '../tool/breathingVisualizer/entry';
10
10
  import { caffeineTracker } from '../tool/caffeineTracker/entry';
11
11
  import { caloricDeficitCalculator } from '../tool/caloricDeficitCalculator/entry';
12
12
  import { dailyProteinCalculator } from '../tool/dailyProteinCalculator/entry';
13
+ import { macronutrientCalculator } from '../tool/macronutrientCalculator/entry';
13
14
  import { waterPurifier } from '../tool/waterPurifier/entry';
14
15
  import { digestionStopwatch } from '../tool/digestionStopwatch/entry';
15
16
  import { binauralTuner } from '../tool/binauralTuner/entry';
@@ -21,7 +22,7 @@ import { metabolicAgeCalculator } from '../tool/metabolicAgeCalculator/entry';
21
22
 
22
23
  export const healthCategory: HealthCategoryEntry = {
23
24
  icon: 'mdi:heart-pulse',
24
- tools: [bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
25
+ tools: [bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
25
26
  i18n: {
26
27
  en: () => import('./i18n/en').then((m) => m.content),
27
28
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -15,6 +15,7 @@ export { epworthSleepinessScale } from './tool/epworthSleepinessScale/entry';
15
15
  export { hydrationCalculator } from './tool/hydrationCalculator/entry';
16
16
  export { leanBodyMassCalculator } from './tool/leanBodyMassCalculator/entry';
17
17
  export { metabolicAgeCalculator } from './tool/metabolicAgeCalculator/entry';
18
+ export { macronutrientCalculator } from './tool/macronutrientCalculator/entry';
18
19
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
19
20
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
20
21
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -40,6 +41,7 @@ import { epworthSleepinessScale } from './tool/epworthSleepinessScale/entry';
40
41
  import { hydrationCalculator } from './tool/hydrationCalculator/entry';
41
42
  import { leanBodyMassCalculator } from './tool/leanBodyMassCalculator/entry';
42
43
  import { metabolicAgeCalculator } from './tool/metabolicAgeCalculator/entry';
44
+ import { macronutrientCalculator } from './tool/macronutrientCalculator/entry';
43
45
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
44
46
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
45
47
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -47,4 +49,4 @@ import { screenDecompressionTime } from './tool/screenDecompressionTime/entry';
47
49
  import { tinnitusReliever } from './tool/tinnitusReliever/entry';
48
50
  import { ubeCalculator } from './tool/ubeCalculator/entry';
49
51
  import { waterPurifier } from './tool/waterPurifier/entry';
50
- export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
52
+ export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export * from './tool/dailyProteinCalculator';
22
22
  export * from './tool/leanBodyMassCalculator';
23
23
  export * from './tool/metabolicAgeCalculator';
24
24
  export * from './tool/bodyFatPercentageCalculator';
25
+ export * from './tool/macronutrientCalculator';
25
26
 
26
27
  export { healthCategory } from './category';
27
28
  export const healthCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all 24 tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(24);
39
+ expect(ALL_TOOLS.length).toBe(25);
40
40
  });
41
41
  });
@@ -5,7 +5,7 @@ import { healthCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have 24 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(24);
8
+ expect(ALL_TOOLS.length).toBe(25);
9
9
  });
10
10
 
11
11
  it('healthCategory should be defined', () => {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { macronutrientCalculator } 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 macronutrientCalculator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <Bibliography links={content.bibliography} />}
@@ -0,0 +1,28 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Manore MM: Dietary recommendations and athletic performance',
6
+ url: 'https://doi.org/10.1080/02640410400023221',
7
+ },
8
+ {
9
+ name: 'Thomas, Erdman, Burke: Nutrition and Athletic Performance',
10
+ url: 'https://doi.org/10.1249/MSS.0000000000000852',
11
+ },
12
+ {
13
+ name: 'Institute of Medicine: Dietary Reference Intakes for macronutrients',
14
+ url: 'https://nap.nationalacademies.org/catalog/10490/dietary-reference-intakes-for-energy-carbohydrate-fiber-fat-fatty-acids-cholesterol-protein-and-amino-acids',
15
+ },
16
+ {
17
+ name: 'International Society of Sports Nutrition position stand: protein and exercise',
18
+ url: 'https://doi.org/10.1186/s12970-017-0177-8',
19
+ },
20
+ {
21
+ name: 'Academy of Nutrition and Dietetics: Nutrition and Athletic Performance',
22
+ url: 'https://www.jandonline.org/article/S2212-2672(15)01802-X/fulltext',
23
+ },
24
+ {
25
+ name: 'Wikipedia: Macronutrient',
26
+ url: 'https://en.wikipedia.org/wiki/Macronutrient',
27
+ },
28
+ ];
@@ -0,0 +1,310 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { MacronutrientCalculatorUI } from './ui';
4
+
5
+ interface Props {
6
+ locale?: string;
7
+ ui?: Record<string, unknown>;
8
+ }
9
+
10
+ const t = (Astro.props.ui || {}) as MacronutrientCalculatorUI;
11
+ ---
12
+
13
+ <div class="macrocalc" data-ui={JSON.stringify(t)}>
14
+ <div class="macrocalc__shell">
15
+ <div class="macrocalc__studio">
16
+ <section class="macrocalc__panel macrocalc__panel--controls">
17
+ <label class="macrocalc__calories">
18
+ <span>{t.caloriesLabel}</span>
19
+ <input id="macrocalc-calories" type="number" min="800" max="8000" step="25" value="2400" inputmode="numeric" />
20
+ <em>{t.caloriesUnit}</em>
21
+ </label>
22
+
23
+ <div class="macrocalc__preset-grid" role="group" aria-label={t.dietStyleLabel}>
24
+ <button type="button" class="macrocalc__preset macrocalc__preset--active" data-style="balanced">{t.balancedLabel}</button>
25
+ <button type="button" class="macrocalc__preset" data-style="keto">{t.ketoLabel}</button>
26
+ <button type="button" class="macrocalc__preset" data-style="lowCarb">{t.lowCarbLabel}</button>
27
+ <button type="button" class="macrocalc__preset" data-style="highProtein">{t.highProteinLabel}</button>
28
+ <button type="button" class="macrocalc__preset" data-style="custom">{t.customLabel}</button>
29
+ </div>
30
+
31
+ <p id="macrocalc-hint" class="macrocalc__hint">{t.presetHint}</p>
32
+
33
+ <div class="macrocalc__sliders" aria-label={t.customLabel}>
34
+ <label class="macrocalc__slider macrocalc__slider--carbs">
35
+ <span><strong>{t.carbsLabel}</strong><em id="macrocalc-carbs-percent">40%</em></span>
36
+ <input id="macrocalc-carbs" type="range" min="0" max="100" value="40" />
37
+ </label>
38
+ <label class="macrocalc__slider macrocalc__slider--protein">
39
+ <span><strong>{t.proteinLabel}</strong><em id="macrocalc-protein-percent">30%</em></span>
40
+ <input id="macrocalc-protein" type="range" min="0" max="100" value="30" />
41
+ </label>
42
+ <label class="macrocalc__slider macrocalc__slider--fat">
43
+ <span><strong>{t.fatLabel}</strong><em id="macrocalc-fat-percent">30%</em></span>
44
+ <input id="macrocalc-fat" type="range" min="0" max="100" value="30" />
45
+ </label>
46
+ </div>
47
+ </section>
48
+
49
+ <section class="macrocalc__panel macrocalc__panel--chart">
50
+ <div class="macrocalc__donut-wrap">
51
+ <div class="macrocalc__donut" aria-label={t.chartLabel} role="img">
52
+ <div class="macrocalc__donut-core">
53
+ <span class="macrocalc__core-row macrocalc__core-row--carbs">
54
+ <strong id="macrocalc-carbs-core">40%</strong>
55
+ <small>{t.carbsShort}</small>
56
+ </span>
57
+ <span class="macrocalc__core-row macrocalc__core-row--protein">
58
+ <strong id="macrocalc-protein-core">30%</strong>
59
+ <small>{t.proteinShort}</small>
60
+ </span>
61
+ <span class="macrocalc__core-row macrocalc__core-row--fat">
62
+ <strong id="macrocalc-fat-core">30%</strong>
63
+ <small>{t.fatShort}</small>
64
+ </span>
65
+ </div>
66
+ </div>
67
+ </div>
68
+
69
+ <div class="macrocalc__stack" aria-hidden="true">
70
+ <span class="macrocalc__stack-carbs"></span>
71
+ <span class="macrocalc__stack-protein"></span>
72
+ <span class="macrocalc__stack-fat"></span>
73
+ </div>
74
+
75
+ <div class="macrocalc__unit" role="group" aria-label={t.unitSystemLabel}>
76
+ <button type="button" class="macrocalc__unit-btn macrocalc__unit-btn--active" data-unit="metric">{t.unitMetric}</button>
77
+ <button type="button" class="macrocalc__unit-btn" data-unit="us">{t.unitUs}</button>
78
+ </div>
79
+
80
+ <div class="macrocalc__cards">
81
+ <article class="macrocalc__card macrocalc__card--carbs">
82
+ <span>{t.carbsShort}</span>
83
+ <strong id="macrocalc-carbs-grams">240 g</strong>
84
+ <em id="macrocalc-carbs-kcal">960 kcal</em>
85
+ </article>
86
+ <article class="macrocalc__card macrocalc__card--protein">
87
+ <span>{t.proteinShort}</span>
88
+ <strong id="macrocalc-protein-grams">180 g</strong>
89
+ <em id="macrocalc-protein-kcal">720 kcal</em>
90
+ </article>
91
+ <article class="macrocalc__card macrocalc__card--fat">
92
+ <span>{t.fatShort}</span>
93
+ <strong id="macrocalc-fat-grams">80 g</strong>
94
+ <em id="macrocalc-fat-kcal">720 kcal</em>
95
+ </article>
96
+ </div>
97
+ </section>
98
+ </div>
99
+
100
+ <div class="macrocalc__table-wrap">
101
+ <table class="macrocalc__table">
102
+ <thead>
103
+ <tr>
104
+ <th>{t.dietStyleLabel}</th>
105
+ <th>{t.percentageColumn}</th>
106
+ <th>{t.caloriesColumn}</th>
107
+ <th id="macrocalc-mass-heading">{t.gramColumn}</th>
108
+ </tr>
109
+ </thead>
110
+ <tbody>
111
+ <tr>
112
+ <th>{t.carbsLabel}</th>
113
+ <td id="macrocalc-carbs-table-pct">40%</td>
114
+ <td id="macrocalc-carbs-table-kcal">960 kcal</td>
115
+ <td id="macrocalc-carbs-table-grams">240 g</td>
116
+ </tr>
117
+ <tr>
118
+ <th>{t.proteinLabel}</th>
119
+ <td id="macrocalc-protein-table-pct">30%</td>
120
+ <td id="macrocalc-protein-table-kcal">720 kcal</td>
121
+ <td id="macrocalc-protein-table-grams">180 g</td>
122
+ </tr>
123
+ <tr>
124
+ <th>{t.fatLabel}</th>
125
+ <td id="macrocalc-fat-table-pct">30%</td>
126
+ <td id="macrocalc-fat-table-kcal">720 kcal</td>
127
+ <td id="macrocalc-fat-table-grams">80 g</td>
128
+ </tr>
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+
133
+ <div class="macrocalc__notes">
134
+ <div class="macrocalc__note">
135
+ <Icon name="mdi:fire" />
136
+ <p><strong>{t.energyRuleLabel}</strong> {t.energyRuleText}</p>
137
+ </div>
138
+ <div class="macrocalc__note">
139
+ <Icon name="mdi:scale-balance" />
140
+ <p><strong>{t.weighingLabel}</strong> {t.weighingText}</p>
141
+ </div>
142
+ <div class="macrocalc__note">
143
+ <Icon name="mdi:calendar-edit" />
144
+ <p><strong>{t.planningLabel}</strong> {t.planningText}</p>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <script>
151
+ type MacroKey = 'carbs' | 'protein' | 'fat';
152
+ type DietStyle = 'balanced' | 'keto' | 'lowCarb' | 'highProtein' | 'custom';
153
+
154
+ interface Percentages {
155
+ carbs: number;
156
+ protein: number;
157
+ fat: number;
158
+ }
159
+
160
+ interface MacroElements {
161
+ calories: HTMLInputElement;
162
+ hint: HTMLElement;
163
+ donut: HTMLElement;
164
+ shell: HTMLElement;
165
+ presets: NodeListOf<HTMLButtonElement>;
166
+ unitButtons: NodeListOf<HTMLButtonElement>;
167
+ sliders: Record<MacroKey, HTMLInputElement>;
168
+ percentages: Record<MacroKey, HTMLElement>;
169
+ }
170
+
171
+ interface MacroState {
172
+ massUnit: 'metric' | 'us';
173
+ current: Percentages;
174
+ }
175
+
176
+ interface RenderMacroContext {
177
+ container: Element;
178
+ ui: Record<string, string>;
179
+ elements: MacroElements;
180
+ state: MacroState;
181
+ kcal: number;
182
+ }
183
+
184
+ const PRESETS: Record<Exclude<DietStyle, 'custom'>, Percentages> = {
185
+ balanced: { carbs: 40, protein: 30, fat: 30 },
186
+ keto: { carbs: 5, protein: 25, fat: 70 },
187
+ lowCarb: { carbs: 20, protein: 40, fat: 40 },
188
+ highProtein: { carbs: 40, protein: 40, fat: 20 },
189
+ };
190
+
191
+ const KCAL_PER_GRAM: Record<MacroKey, number> = { carbs: 4, protein: 4, fat: 9 };
192
+
193
+ function query<T extends HTMLElement>(container: Element, selector: string): T {
194
+ const el = container.querySelector<T>(selector);
195
+ if (!el) throw new Error(`Missing ${selector}`);
196
+ return el;
197
+ }
198
+
199
+ function round(value: number): number {
200
+ return Math.round(value * 10) / 10;
201
+ }
202
+
203
+ function linkedPercentages(current: Percentages, changed: MacroKey, nextValue: number): Percentages {
204
+ const next = Math.max(0, Math.min(100, Math.round(nextValue)));
205
+ const others = (['carbs', 'protein', 'fat'] as MacroKey[]).filter((key) => key !== changed);
206
+ const remaining = 100 - next;
207
+ const otherTotal = current[others[0]] + current[others[1]];
208
+
209
+ if (otherTotal === 0) {
210
+ return { ...current, [changed]: next, [others[0]]: Math.floor(remaining / 2), [others[1]]: Math.ceil(remaining / 2) };
211
+ }
212
+
213
+ const first = Math.round((current[others[0]] / otherTotal) * remaining);
214
+ return { ...current, [changed]: next, [others[0]]: first, [others[1]]: remaining - first };
215
+ }
216
+
217
+ function getMacroElements(container: Element): MacroElements {
218
+ return {
219
+ calories: query<HTMLInputElement>(container, '#macrocalc-calories'),
220
+ hint: query<HTMLElement>(container, '#macrocalc-hint'),
221
+ donut: query<HTMLElement>(container, '.macrocalc__donut'),
222
+ shell: query<HTMLElement>(container, '.macrocalc__shell'),
223
+ presets: container.querySelectorAll<HTMLButtonElement>('.macrocalc__preset'),
224
+ unitButtons: container.querySelectorAll<HTMLButtonElement>('.macrocalc__unit-btn'),
225
+ sliders: {
226
+ carbs: query<HTMLInputElement>(container, '#macrocalc-carbs'),
227
+ protein: query<HTMLInputElement>(container, '#macrocalc-protein'),
228
+ fat: query<HTMLInputElement>(container, '#macrocalc-fat'),
229
+ },
230
+ percentages: {
231
+ carbs: query<HTMLElement>(container, '#macrocalc-carbs-percent'),
232
+ protein: query<HTMLElement>(container, '#macrocalc-protein-percent'),
233
+ fat: query<HTMLElement>(container, '#macrocalc-fat-percent'),
234
+ },
235
+ };
236
+ }
237
+
238
+ function renderMacro(container: Element, ui: Record<string, string>, elements: MacroElements, state: MacroState) {
239
+ const kcal = Math.max(0, Number(elements.calories.value) || 0);
240
+ const carbsDeg = state.current.carbs * 3.6;
241
+ const proteinDeg = (state.current.carbs + state.current.protein) * 3.6;
242
+ elements.donut.style.setProperty('--macro-carbs-deg', `${carbsDeg}deg`);
243
+ elements.donut.style.setProperty('--macro-protein-deg', `${proteinDeg}deg`);
244
+ elements.shell.style.setProperty('--macro-carbs-width', `${state.current.carbs}%`);
245
+ elements.shell.style.setProperty('--macro-protein-width', `${state.current.protein}%`);
246
+ elements.shell.style.setProperty('--macro-fat-width', `${state.current.fat}%`);
247
+
248
+ (['carbs', 'protein', 'fat'] as MacroKey[]).forEach((key) => {
249
+ renderMacroRow({ container, ui, elements, state, kcal }, key);
250
+ });
251
+ query<HTMLElement>(container, '#macrocalc-mass-heading').textContent = state.massUnit === 'metric' ? ui.gramColumn : ui.ounceColumn;
252
+ elements.shell.classList.remove('macrocalc__shell--pulse');
253
+ window.requestAnimationFrame(() => elements.shell.classList.add('macrocalc__shell--pulse'));
254
+ }
255
+
256
+ function renderMacroRow(ctx: RenderMacroContext, key: MacroKey) {
257
+ ctx.elements.sliders[key].value = String(ctx.state.current[key]);
258
+ ctx.elements.percentages[key].textContent = `${ctx.state.current[key]}${ctx.ui.percentageUnit}`;
259
+ query<HTMLElement>(ctx.container, `#macrocalc-${key}-core`).textContent = `${ctx.state.current[key]}${ctx.ui.percentageUnit}`;
260
+ const macroKcal = Math.round(ctx.kcal * (ctx.state.current[key] / 100));
261
+ const grams = round(macroKcal / KCAL_PER_GRAM[key]);
262
+ const massValue = ctx.state.massUnit === 'metric' ? grams : round(grams / 28.349523125);
263
+ const massUnitLabel = ctx.state.massUnit === 'metric' ? ctx.ui.gramsUnit : ctx.ui.ouncesUnit;
264
+ query<HTMLElement>(ctx.container, `#macrocalc-${key}-grams`).textContent = `${massValue} ${massUnitLabel}`;
265
+ query<HTMLElement>(ctx.container, `#macrocalc-${key}-kcal`).textContent = `${macroKcal} ${ctx.ui.kcalUnit}`;
266
+ query<HTMLElement>(ctx.container, `#macrocalc-${key}-table-pct`).textContent = `${ctx.state.current[key]}${ctx.ui.percentageUnit}`;
267
+ query<HTMLElement>(ctx.container, `#macrocalc-${key}-table-kcal`).textContent = `${macroKcal} ${ctx.ui.kcalUnit}`;
268
+ query<HTMLElement>(ctx.container, `#macrocalc-${key}-table-grams`).textContent = `${massValue} ${massUnitLabel}`;
269
+ }
270
+
271
+ function setupMacroListeners(container: Element, ui: Record<string, string>, elements: MacroElements, state: MacroState) {
272
+ function setPreset(nextStyle: DietStyle) {
273
+ if (nextStyle !== 'custom') state.current = { ...PRESETS[nextStyle] };
274
+ elements.presets.forEach((button) => button.classList.toggle('macrocalc__preset--active', button.dataset.style === nextStyle));
275
+ elements.shell.dataset.style = nextStyle;
276
+ elements.hint.textContent = nextStyle === 'custom' ? ui.customHint : ui.presetHint;
277
+ renderMacro(container, ui, elements, state);
278
+ }
279
+
280
+ elements.presets.forEach((button) => {
281
+ button.addEventListener('click', () => setPreset(button.dataset.style as DietStyle));
282
+ });
283
+
284
+ (['carbs', 'protein', 'fat'] as MacroKey[]).forEach((key) => {
285
+ elements.sliders[key].addEventListener('input', () => {
286
+ state.current = linkedPercentages(state.current, key, Number(elements.sliders[key].value));
287
+ setPreset('custom');
288
+ });
289
+ });
290
+
291
+ elements.calories.addEventListener('input', () => renderMacro(container, ui, elements, state));
292
+ elements.unitButtons.forEach((button) => {
293
+ button.addEventListener('click', () => {
294
+ state.massUnit = button.dataset.unit === 'us' ? 'us' : 'metric';
295
+ elements.unitButtons.forEach((item) => item.classList.toggle('macrocalc__unit-btn--active', item === button));
296
+ renderMacro(container, ui, elements, state);
297
+ });
298
+ });
299
+ }
300
+
301
+ function initMacroCalculator(container: Element) {
302
+ const ui = JSON.parse((container as HTMLElement).dataset.ui || '{}') as Record<string, string>;
303
+ const elements = getMacroElements(container);
304
+ const state: MacroState = { massUnit: 'metric', current: { ...PRESETS.balanced } };
305
+ setupMacroListeners(container, ui, elements, state);
306
+ renderMacro(container, ui, elements, state);
307
+ }
308
+
309
+ document.querySelectorAll('.macrocalc').forEach(initMacroCalculator);
310
+ </script>
@@ -0,0 +1,27 @@
1
+ import type { HealthToolEntry } from '../../types';
2
+ import type { MacronutrientCalculatorUI } from './ui';
3
+
4
+ export const macronutrientCalculator: HealthToolEntry<MacronutrientCalculatorUI> = {
5
+ id: 'macronutrientCalculator',
6
+ icons: {
7
+ bg: 'mdi:chart-pie',
8
+ fg: 'mdi:nutrition',
9
+ },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((m) => m.content),
12
+ en: () => import('./i18n/en').then((m) => m.content),
13
+ es: () => import('./i18n/es').then((m) => m.content),
14
+ fr: () => import('./i18n/fr').then((m) => m.content),
15
+ id: () => import('./i18n/id').then((m) => m.content),
16
+ it: () => import('./i18n/it').then((m) => m.content),
17
+ ja: () => import('./i18n/ja').then((m) => m.content),
18
+ ko: () => import('./i18n/ko').then((m) => m.content),
19
+ nl: () => import('./i18n/nl').then((m) => m.content),
20
+ pl: () => import('./i18n/pl').then((m) => m.content),
21
+ pt: () => import('./i18n/pt').then((m) => m.content),
22
+ ru: () => import('./i18n/ru').then((m) => m.content),
23
+ sv: () => import('./i18n/sv').then((m) => m.content),
24
+ tr: () => import('./i18n/tr').then((m) => m.content),
25
+ zh: () => import('./i18n/zh').then((m) => m.content),
26
+ },
27
+ };