forge-openclaw-plugin 0.2.101 → 0.2.102

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 (188) hide show
  1. package/dist/assets/{activity-page-CpjuNSHw.js → activity-page-CgF7K2ww.js} +1 -1
  2. package/dist/assets/ai-surface-workspace-xtB5RFQu.js +1 -0
  3. package/dist/assets/atlas-panel-B3dPHCmZ.js +1 -0
  4. package/dist/assets/{board-BkDRaMp6.js → board-DqHzdCPQ.js} +1 -1
  5. package/dist/assets/{calendar-page-DNNt6lfz.js → calendar-page-C1Wfd2Fy.js} +1 -1
  6. package/dist/assets/{calendar-rules-DNJFNsxi.js → calendar-rules-CaZXtlxt.js} +1 -1
  7. package/dist/assets/calendar-week-toolbar-BZ_-X3Wb.js +1 -0
  8. package/dist/assets/{charts-P7EVhIog.js → charts-FcU0F3XV.js} +8 -8
  9. package/dist/assets/{companion-sync-lab-page-KxEDigM6.js → companion-sync-lab-page-NgeK-O-P.js} +1 -1
  10. package/dist/assets/daily-metrics-dashboard-BMyL0Qr4.js +1 -0
  11. package/dist/assets/{entity-note-count-link-DrhjJZ4i.js → entity-note-count-link-BrS1-O0o.js} +1 -1
  12. package/dist/assets/entity-notes-surface-CBylYDwy.js +1 -0
  13. package/dist/assets/{execution-board-D07gOocB.js → execution-board-CpO2ch6v.js} +1 -1
  14. package/dist/assets/faceted-token-search-D7xPWfOl.js +1 -0
  15. package/dist/assets/flagship-signal-deck-BEFKOhvx.js +1 -0
  16. package/dist/assets/{floating-action-menu-Fs_ZiUMo.js → floating-action-menu-zzC2r0Ob.js} +1 -1
  17. package/dist/assets/{forms-BFlTgZ3W.js → forms-CmLAyGqz.js} +1 -1
  18. package/dist/assets/{goal-detail-page-CqLiNz4f.js → goal-detail-page-JK_Nva8e.js} +1 -1
  19. package/dist/assets/goals-page-yeoJ06Vw.js +1 -0
  20. package/dist/assets/{graph-D6JLqDbD.js → graph-BTa79qum.js} +14 -14
  21. package/dist/assets/{habits-page-BJxagdzx.js → habits-page-Dx5EhkJi.js} +1 -1
  22. package/dist/assets/index-BHIKoiZ6.js +19 -0
  23. package/dist/assets/index-H8R-ABM3.css +1 -0
  24. package/dist/assets/insight-flow-dialog-BtIQXXsS.js +1 -0
  25. package/dist/assets/{insights-page-D6rOa7uk.js → insights-page-CujrosD_.js} +1 -1
  26. package/dist/assets/{kanban-page-XQ7Se6dH.js → kanban-page-Q9NuIz5w.js} +1 -1
  27. package/dist/assets/knowledge-graph-page-DaJmlvvM.js +1 -0
  28. package/dist/assets/{life-force-page-Dy0JTS2G.js → life-force-page-BGDkKfbJ.js} +1 -1
  29. package/dist/assets/{life-force-workspace-OfyB9HJM.js → life-force-workspace-CLVexVnb.js} +1 -1
  30. package/dist/assets/{maps-ClgJoCjz.js → maps-CF1RagUX.js} +1 -1
  31. package/dist/assets/metric-tile-4iMd_WnJ.js +1 -0
  32. package/dist/assets/{motion-BeD44FeG.js → motion-CfdU2F35.js} +1 -1
  33. package/dist/assets/movement-page-ClaoTNuX.js +1 -0
  34. package/dist/assets/note-markdown-CsGQhQXF.js +3 -0
  35. package/dist/assets/note-tags-input-DdZi93tj.js +1 -0
  36. package/dist/assets/notes-page-DYI8s3NN.js +1 -0
  37. package/dist/assets/{open-in-graph-button-IXe9SGth.js → open-in-graph-button-C0TGev7c.js} +1 -1
  38. package/dist/assets/orbit-map-DVXkfQdd.js +1 -0
  39. package/dist/assets/overview-page-BwMlFQKX.js +1 -0
  40. package/dist/assets/page-hero-oFHaAjtL.js +1 -0
  41. package/dist/assets/pill-cluster-7UZd_lHp.js +1 -0
  42. package/dist/assets/{preference-entity-handoff-button-5PzUn42S.js → preference-entity-handoff-button-DOKV9bZc.js} +1 -1
  43. package/dist/assets/preferences-page-BrKEkmfD.js +1 -0
  44. package/dist/assets/{project-collections-xPz2mlRr.js → project-collections-CZagCmeH.js} +1 -1
  45. package/dist/assets/{project-detail-page-BXK5-4xW.js → project-detail-page-DogvVNEn.js} +1 -1
  46. package/dist/assets/{project-management-hierarchy-page-DtRpMABw.js → project-management-hierarchy-page-Dr7hW9PW.js} +1 -1
  47. package/dist/assets/{project-management-section-nav-P3ixzPa-.js → project-management-section-nav-BLdAgTge.js} +1 -1
  48. package/dist/assets/{projects-page-C5ViRuf4.js → projects-page-UKrby_5-.js} +1 -1
  49. package/dist/assets/psyche-behaviors-page-BeVlXvbj.js +5 -0
  50. package/dist/assets/psyche-flashcards-page-CqAWCO4E.js +1 -0
  51. package/dist/assets/psyche-goal-map-page-IyNnbk_W.js +1 -0
  52. package/dist/assets/psyche-graph-IkUQRaDK.js +1 -0
  53. package/dist/assets/{psyche-metrics-page-CuR9oqEy.js → psyche-metrics-page-uai0a7Lx.js} +1 -1
  54. package/dist/assets/psyche-mode-guide-page-C7p-ABiF.js +1 -0
  55. package/dist/assets/psyche-modes-page-CLQ5V3w0.js +1 -0
  56. package/dist/assets/psyche-page-NQBHkkpU.js +1 -0
  57. package/dist/assets/psyche-patterns-page-BkRiNpI_.js +5 -0
  58. package/dist/assets/psyche-questionnaire-builder-page-Du-7HwPC.js +1 -0
  59. package/dist/assets/psyche-questionnaire-detail-page-C5-yf8MF.js +1 -0
  60. package/dist/assets/psyche-questionnaire-run-detail-page-CoSH5O6R.js +1 -0
  61. package/dist/assets/psyche-questionnaire-run-page-ChI7c1wy.js +1 -0
  62. package/dist/assets/psyche-questionnaires-page-xUcJJzbI.js +1 -0
  63. package/dist/assets/psyche-report-detail-page-BPrZCFZS.js +3 -0
  64. package/dist/assets/psyche-reports-page-D2EXJmm6.js +1 -0
  65. package/dist/assets/{psyche-schemas-HFmg37Wj.js → psyche-schemas-RcZYaokx.js} +1 -1
  66. package/dist/assets/psyche-schemas-beliefs-page-h0ooZ1lp.js +9 -0
  67. package/dist/assets/psyche-screen-time-page-_3-4LikV.js +1 -0
  68. package/dist/assets/psyche-self-observation-page-BdbCsgR5.js +1 -0
  69. package/dist/assets/psyche-values-page-DuUVki5e.js +5 -0
  70. package/dist/assets/report-chain-fields-BBMz0sGI.js +1 -0
  71. package/dist/assets/{rewards-page-DmC4R_Ps.js → rewards-page-PZFPa6rR.js} +1 -1
  72. package/dist/assets/{scheduling-rules-editor-D02s70hr.js → scheduling-rules-editor-B9KNKsQz.js} +1 -1
  73. package/dist/assets/schema-badge-D93RcG36.js +1 -0
  74. package/dist/assets/schema-visuals-CvC9a3i6.js +1 -0
  75. package/dist/assets/select-menu-Cdq7foRu.js +1 -0
  76. package/dist/assets/{settings-agents-page-C_v_hMJF.js → settings-agents-page-DPx2F6wf.js} +3 -3
  77. package/dist/assets/{settings-bin-page-DY5bg81n.js → settings-bin-page-COwP3gjo.js} +1 -1
  78. package/dist/assets/settings-calendar-page-C-ghE0YT.js +5 -0
  79. package/dist/assets/{settings-data-page-CHRQFU9H.js → settings-data-page-CBubzKBw.js} +1 -1
  80. package/dist/assets/{settings-logs-page-B04pUwEv.js → settings-logs-page-uOuXMeBm.js} +1 -1
  81. package/dist/assets/{settings-mobile-page-D9kTlYDS.js → settings-mobile-page-C5rfj8_r.js} +1 -1
  82. package/dist/assets/settings-models-page-DijmUWdU.js +1 -0
  83. package/dist/assets/{settings-page-DYDTFlnv.js → settings-page-B4u5TR5g.js} +1 -1
  84. package/dist/assets/{settings-rewards-page-cl4vqqO_.js → settings-rewards-page-COiTwkMH.js} +1 -1
  85. package/dist/assets/{settings-section-nav-DSOuht_F.js → settings-section-nav-Dc4IeVBt.js} +1 -1
  86. package/dist/assets/{settings-users-page-BU79JB_T.js → settings-users-page-DEGa5DNn.js} +1 -1
  87. package/dist/assets/settings-wiki-page-Cvsiz5_e.js +1 -0
  88. package/dist/assets/{sleep-page-D8NbdhyS.js → sleep-page-BnSOwkEU.js} +1 -1
  89. package/dist/assets/{sports-page-CV4Cnzwn.js → sports-page-l1RqXzA_.js} +1 -1
  90. package/dist/assets/{state-B-4sS1xO.js → state-VYvD1QVP.js} +1 -1
  91. package/dist/assets/{strategies-page-C4qvXnql.js → strategies-page-DEnPlpAs.js} +1 -1
  92. package/dist/assets/{strategy-detail-page-DJLo5rfy.js → strategy-detail-page-Ls8bxKeH.js} +1 -1
  93. package/dist/assets/strategy-dialog-DMyRKrWf.js +1 -0
  94. package/dist/assets/surface-Bfz_sLX6.js +1 -0
  95. package/dist/assets/{table-WfAPUppN.js → table-C0VTeqw0.js} +1 -1
  96. package/dist/assets/task-detail-page-CgrYgQLD.js +1 -0
  97. package/dist/assets/{timebox-planning-dialog-DB6FLqmI.js → timebox-planning-dialog-Ww0NGLLo.js} +1 -1
  98. package/dist/assets/today-page-CIuFHMi1.js +1 -0
  99. package/dist/assets/training-load-page-BIwc648i.js +1 -0
  100. package/dist/assets/{ui-C13Nbgas.js → ui-CsEkP2V8.js} +4 -4
  101. package/dist/assets/use-psyche-focus-target-qxT5Oy_z.js +1 -0
  102. package/dist/assets/{vendor-DHkYh85p.js → vendor-kIz9EZnX.js} +237 -222
  103. package/dist/assets/{vitals-page-qre17Nw8.js → vitals-page-Dz1Jt5H8.js} +1 -1
  104. package/dist/assets/{weekly-review-page-Cz4vkRcx.js → weekly-review-page-BFpBe1kI.js} +1 -1
  105. package/dist/assets/weight-loss-page-BgMoBpBt.js +5 -0
  106. package/dist/assets/{wiki-article-markdown-DdiR2TJE.js → wiki-article-markdown-gsPTXTg1.js} +1 -1
  107. package/dist/assets/{wiki-editor-page-DqwoqVFb.js → wiki-editor-page-BpAZHooY.js} +7 -7
  108. package/dist/assets/{wiki-ingest-history-page--evBLbOw.js → wiki-ingest-history-page-C-ig8O22.js} +1 -1
  109. package/dist/assets/{wiki-ingest-modal--ohzFnj2.js → wiki-ingest-modal-BK4eQgqs.js} +1 -1
  110. package/dist/assets/{wiki-page-B_VJFBPA.js → wiki-page-CMTZ60Zt.js} +1 -1
  111. package/dist/assets/workbench-flow-page-BIpWUcLJ.js +5 -0
  112. package/dist/assets/workbench-page-CtCjYSRe.js +1 -0
  113. package/dist/assets/workout-detail-page-DD9IGN6l.js +2 -0
  114. package/dist/index.html +9 -9
  115. package/dist/openclaw/local-runtime.js +41 -14
  116. package/dist/server/server/migrations/067_weight_loss_daily_active_overrides.sql +13 -0
  117. package/dist/server/server/src/app.js +103 -30
  118. package/dist/server/server/src/health-weight-loss.js +457 -55
  119. package/dist/server/server/src/health.js +12 -4
  120. package/dist/server/server/src/movement.js +84 -1
  121. package/dist/server/server/src/openapi.js +123 -18
  122. package/dist/server/server/src/repositories/model-settings.js +12 -9
  123. package/dist/server/server/src/repositories/settings.js +19 -5
  124. package/dist/server/src/components/ui/info-tooltip.js +6 -6
  125. package/dist/server/src/lib/api.js +14 -4
  126. package/dist/server/src/lib/theme-system.js +8 -0
  127. package/openclaw.plugin.json +1 -1
  128. package/package.json +3 -3
  129. package/server/migrations/067_weight_loss_daily_active_overrides.sql +13 -0
  130. package/skills/forge-openclaw/SKILL.md +13 -0
  131. package/skills/forge-openclaw/entity_conversation_playbooks.md +7 -1
  132. package/dist/assets/ai-surface-workspace-DEAFZruS.js +0 -1
  133. package/dist/assets/atlas-panel-CdVNPotj.js +0 -1
  134. package/dist/assets/calendar-week-toolbar-BbPwYeN0.js +0 -1
  135. package/dist/assets/daily-metrics-dashboard-B3cqJgDt.js +0 -1
  136. package/dist/assets/entity-notes-surface-CkcRsKJQ.js +0 -1
  137. package/dist/assets/faceted-token-search-BxRRcM3q.js +0 -1
  138. package/dist/assets/flagship-signal-deck-cmy82b8_.js +0 -1
  139. package/dist/assets/goals-page-BTk7mg_T.js +0 -1
  140. package/dist/assets/index-CF4J4R9L.js +0 -19
  141. package/dist/assets/index-CZbuZQjw.css +0 -1
  142. package/dist/assets/insight-flow-dialog-8f3D0GuC.js +0 -1
  143. package/dist/assets/knowledge-graph-page-BtAg8iv3.js +0 -1
  144. package/dist/assets/metric-tile-DKpo-8xw.js +0 -1
  145. package/dist/assets/movement-page-Bg_T_Stx.js +0 -1
  146. package/dist/assets/note-markdown-N-uxD3Xt.js +0 -3
  147. package/dist/assets/note-tags-input-Cdu7wiw6.js +0 -1
  148. package/dist/assets/notes-page-CKXnF_KU.js +0 -1
  149. package/dist/assets/orbit-map-Dzi6KliQ.js +0 -1
  150. package/dist/assets/overview-page-1miYqaVS.js +0 -1
  151. package/dist/assets/page-hero-DRy5b2MU.js +0 -1
  152. package/dist/assets/pill-cluster-C9QczVJ2.js +0 -1
  153. package/dist/assets/preferences-page-DtNaF5Q3.js +0 -1
  154. package/dist/assets/psyche-behaviors-page-Dco46sC4.js +0 -5
  155. package/dist/assets/psyche-flashcards-page-ZcoEB8gV.js +0 -1
  156. package/dist/assets/psyche-goal-map-page-CLBAQOI0.js +0 -1
  157. package/dist/assets/psyche-graph-k4tX2tdp.js +0 -1
  158. package/dist/assets/psyche-mode-guide-page-hIVXcCnE.js +0 -1
  159. package/dist/assets/psyche-modes-page-0lYtBlhO.js +0 -1
  160. package/dist/assets/psyche-page-VZ9k9ISp.js +0 -1
  161. package/dist/assets/psyche-patterns-page-gx5nmdGq.js +0 -5
  162. package/dist/assets/psyche-questionnaire-builder-page-Bn0TOISd.js +0 -1
  163. package/dist/assets/psyche-questionnaire-detail-page-CmVzSd_s.js +0 -1
  164. package/dist/assets/psyche-questionnaire-run-detail-page-BsMbmXCG.js +0 -1
  165. package/dist/assets/psyche-questionnaire-run-page-CgkRL2vi.js +0 -1
  166. package/dist/assets/psyche-questionnaires-page-D7V8uLXM.js +0 -1
  167. package/dist/assets/psyche-report-detail-page-OlFq57eL.js +0 -3
  168. package/dist/assets/psyche-reports-page-dVUZjna1.js +0 -1
  169. package/dist/assets/psyche-schemas-beliefs-page-BCgc8FUd.js +0 -9
  170. package/dist/assets/psyche-screen-time-page-B_6BT_WN.js +0 -1
  171. package/dist/assets/psyche-self-observation-page-CEG5mluK.js +0 -1
  172. package/dist/assets/psyche-values-page-DRbRfEd6.js +0 -5
  173. package/dist/assets/report-chain-fields-CALCV3V5.js +0 -1
  174. package/dist/assets/schema-badge-BZO-qNhO.js +0 -1
  175. package/dist/assets/schema-visuals-D6nxjbYC.js +0 -1
  176. package/dist/assets/select-menu-fYyreSdQ.js +0 -1
  177. package/dist/assets/settings-calendar-page-D1CzE6cg.js +0 -5
  178. package/dist/assets/settings-models-page-D26270R2.js +0 -1
  179. package/dist/assets/settings-wiki-page-DwAUlyA3.js +0 -1
  180. package/dist/assets/strategy-dialog-D3AuUlVz.js +0 -1
  181. package/dist/assets/task-detail-page-z-9u9rF0.js +0 -1
  182. package/dist/assets/today-page-BKlu6gx5.js +0 -1
  183. package/dist/assets/training-load-page-CyJQqo_3.js +0 -1
  184. package/dist/assets/use-psyche-focus-target-C1C_XjYG.js +0 -1
  185. package/dist/assets/weight-loss-page-BQrnOI0y.js +0 -1
  186. package/dist/assets/workbench-flow-page-Du62mtJU.js +0 -5
  187. package/dist/assets/workbench-page-4MKr3iRm.js +0 -1
  188. package/dist/assets/workout-detail-page-DfUbYYw1.js +0 -2
@@ -74,6 +74,17 @@ export const nutritionTargetUpdateSchema = z.object({
74
74
  bodyGoal: z.string().trim().default(""),
75
75
  notes: z.string().trim().default("")
76
76
  });
77
+ export const nutritionDailyActiveCaloriesUpdateSchema = z.object({
78
+ userId: z.string().trim().min(1).optional(),
79
+ dayKey: z
80
+ .string()
81
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
82
+ .optional(),
83
+ activeCaloriesKcal: z
84
+ .union([z.null(), z.coerce.number().finite().min(0)])
85
+ .optional(),
86
+ notes: z.string().trim().default("")
87
+ });
77
88
  export const nutritionFoodLogCreateSchema = z.object({
78
89
  userId: z.string().trim().min(1).optional(),
79
90
  loggedAt: z.string().datetime().optional(),
@@ -167,7 +178,9 @@ export const nutritionExperimentCreateSchema = z.object({
167
178
  userId: z.string().trim().min(1).optional(),
168
179
  hypothesisId: z.string().trim().min(1).nullable().optional(),
169
180
  title: z.string().trim().min(1),
170
- status: z.enum(["planned", "running", "complete", "paused"]).default("planned"),
181
+ status: z
182
+ .enum(["planned", "running", "complete", "paused"])
183
+ .default("planned"),
171
184
  baselineStart: z.string().trim().nullable().optional(),
172
185
  baselineEnd: z.string().trim().nullable().optional(),
173
186
  interventionStart: z.string().trim().nullable().optional(),
@@ -177,7 +190,9 @@ export const nutritionExperimentCreateSchema = z.object({
177
190
  adherence: z.record(z.string(), z.unknown()).default({}),
178
191
  resultSummary: z.string().trim().default("")
179
192
  });
180
- export const nutritionExperimentPatchSchema = nutritionExperimentCreateSchema.omit({ userId: true }).partial();
193
+ export const nutritionExperimentPatchSchema = nutritionExperimentCreateSchema
194
+ .omit({ userId: true })
195
+ .partial();
181
196
  export const nutritionParseRequestSchema = z.object({
182
197
  text: z.string().trim().min(1),
183
198
  mealTime: z.string().datetime().optional(),
@@ -221,6 +236,245 @@ function average(values) {
221
236
  }
222
237
  return real.reduce((sum, value) => sum + value, 0) / real.length;
223
238
  }
239
+ function metricTotal(metrics, key) {
240
+ const metric = metrics[key];
241
+ if (!metric || typeof metric !== "object") {
242
+ return null;
243
+ }
244
+ const record = metric;
245
+ for (const field of ["total", "average", "latest"]) {
246
+ const value = record[field];
247
+ if (typeof value === "number" && Number.isFinite(value)) {
248
+ return value;
249
+ }
250
+ }
251
+ return null;
252
+ }
253
+ function parsePlanNoteNumber(notes, key) {
254
+ if (!notes) {
255
+ return null;
256
+ }
257
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
258
+ const match = notes.match(new RegExp(`${escapedKey}=([^;]+)`));
259
+ const parsed = Number(match?.[1]?.trim());
260
+ return Number.isFinite(parsed) ? parsed : null;
261
+ }
262
+ function estimateStepActiveCaloriesKcal(input) {
263
+ if (input.stepCount == null ||
264
+ input.stepCount <= 0 ||
265
+ input.weightKg == null ||
266
+ input.weightKg <= 0) {
267
+ return null;
268
+ }
269
+ const estimatedKilometers = (input.stepCount * 0.762) / 1000;
270
+ return estimatedKilometers * input.weightKg * 0.57;
271
+ }
272
+ function mapDailyEnergyOverride(row) {
273
+ if (!row) {
274
+ return null;
275
+ }
276
+ return {
277
+ id: row.id,
278
+ userId: row.user_id,
279
+ dayKey: row.day_key,
280
+ activeCaloriesKcal: row.active_calories_kcal,
281
+ notes: row.notes,
282
+ createdAt: row.created_at,
283
+ updatedAt: row.updated_at
284
+ };
285
+ }
286
+ function getDailyEnergyOverride(userId, dateKey) {
287
+ const row = getDatabase()
288
+ .prepare(`SELECT *
289
+ FROM nutrition_daily_energy_overrides
290
+ WHERE user_id = ?
291
+ AND day_key = ?`)
292
+ .get(userId, dateKey);
293
+ return mapDailyEnergyOverride(row);
294
+ }
295
+ function buildStoredEnergyModel(input) {
296
+ const today = new Date();
297
+ const start = new Date(today);
298
+ start.setUTCDate(today.getUTCDate() - 6);
299
+ const todayKey = today.toISOString().slice(0, 10);
300
+ const startKey = start.toISOString().slice(0, 10);
301
+ const dailySummaryRows = getDatabase()
302
+ .prepare(`SELECT date_key, metrics_json
303
+ FROM health_daily_summaries
304
+ WHERE user_id = ?
305
+ AND summary_type = 'vitals'
306
+ AND date_key >= ?
307
+ ORDER BY date_key DESC`)
308
+ .all(input.userId, startKey);
309
+ const dailyHealthKit = dailySummaryRows.map((row) => {
310
+ const metrics = parseJson(row.metrics_json, {});
311
+ return {
312
+ dateKey: row.date_key,
313
+ activeEnergyKcal: metricTotal(metrics, "activeEnergyBurned"),
314
+ restingEnergyKcal: metricTotal(metrics, "basalEnergyBurned"),
315
+ exerciseMinutes: metricTotal(metrics, "appleExerciseTime"),
316
+ stepCount: metricTotal(metrics, "stepCount")
317
+ };
318
+ });
319
+ const workoutRows = getDatabase()
320
+ .prepare(`SELECT date(started_at) AS date_key,
321
+ SUM(active_energy_kcal) AS active_energy_kcal,
322
+ SUM(total_energy_kcal) AS total_energy_kcal,
323
+ NULL AS movement_calories_kcal
324
+ FROM health_workout_sessions
325
+ WHERE user_id = ?
326
+ AND date(started_at) >= ?
327
+ GROUP BY date(started_at)`)
328
+ .all(input.userId, startKey);
329
+ const movementRows = getDatabase()
330
+ .prepare(`SELECT date(started_at) AS date_key,
331
+ NULL AS active_energy_kcal,
332
+ NULL AS total_energy_kcal,
333
+ SUM(calories_kcal) AS movement_calories_kcal
334
+ FROM movement_trips
335
+ WHERE user_id = ?
336
+ AND date(started_at) >= ?
337
+ GROUP BY date(started_at)`)
338
+ .all(input.userId, startKey);
339
+ const workoutByDay = new Map(workoutRows.map((row) => [
340
+ row.date_key,
341
+ n(row.active_energy_kcal) || n(row.total_energy_kcal) || null
342
+ ]));
343
+ const movementByDay = new Map(movementRows.map((row) => [
344
+ row.date_key,
345
+ n(row.movement_calories_kcal) || null
346
+ ]));
347
+ const activeEnergyAverage = average(dailyHealthKit.map((day) => day.activeEnergyKcal));
348
+ const restingEnergyAverage = average(dailyHealthKit.map((day) => day.restingEnergyKcal));
349
+ const workoutEnergyAverage = average([...workoutByDay.values()]);
350
+ const movementCaloriesAverage = average([...movementByDay.values()]);
351
+ const fallbackActiveBurn = workoutEnergyAverage != null || movementCaloriesAverage != null
352
+ ? n(workoutEnergyAverage) + n(movementCaloriesAverage)
353
+ : null;
354
+ const activeBurnKcal = activeEnergyAverage ?? fallbackActiveBurn;
355
+ const baselineActiveCalories = input.defaultActiveCalories ?? activeBurnKcal ?? 0;
356
+ const todayHealthKitActive = dailyHealthKit.find((day) => day.dateKey === todayKey)?.activeEnergyKcal ??
357
+ null;
358
+ const todayStepCount = dailyHealthKit.find((day) => day.dateKey === todayKey)?.stepCount ?? null;
359
+ const todayWorkoutEnergy = workoutByDay.get(todayKey) ?? null;
360
+ const todayMovementCalories = movementByDay.get(todayKey) ?? null;
361
+ const todayWorkoutMovementCalories = todayWorkoutEnergy != null || todayMovementCalories != null
362
+ ? n(todayWorkoutEnergy) + n(todayMovementCalories)
363
+ : null;
364
+ const todayStepEstimatedCalories = estimateStepActiveCaloriesKcal({
365
+ stepCount: todayStepCount,
366
+ weightKg: input.latestWeightKg
367
+ });
368
+ const todayFallbackPartCount = [
369
+ todayWorkoutEnergy,
370
+ todayMovementCalories,
371
+ todayStepEstimatedCalories
372
+ ].filter((value) => value != null).length;
373
+ const todayFallbackActiveCalories = todayFallbackPartCount > 0
374
+ ? n(todayWorkoutEnergy) +
375
+ n(todayMovementCalories) +
376
+ n(todayStepEstimatedCalories)
377
+ : null;
378
+ const todayObservedActiveCalories = todayHealthKitActive ?? todayFallbackActiveCalories;
379
+ const todayFallbackSource = (() => {
380
+ if (todayWorkoutEnergy != null &&
381
+ todayMovementCalories != null &&
382
+ todayStepEstimatedCalories != null) {
383
+ return "today_workout_movement_step_energy";
384
+ }
385
+ if (todayWorkoutEnergy != null && todayStepEstimatedCalories != null) {
386
+ return "today_workout_step_energy";
387
+ }
388
+ if (todayMovementCalories != null && todayStepEstimatedCalories != null) {
389
+ return "today_movement_step_energy";
390
+ }
391
+ if (todayWorkoutEnergy != null && todayMovementCalories != null) {
392
+ return "today_workout_movement_energy";
393
+ }
394
+ if (todayWorkoutEnergy != null) {
395
+ return "today_workout_energy";
396
+ }
397
+ if (todayMovementCalories != null) {
398
+ return "today_movement_trip_calories";
399
+ }
400
+ if (todayStepEstimatedCalories != null) {
401
+ return "today_step_estimate";
402
+ }
403
+ return "default_active_calories";
404
+ })();
405
+ const todayActiveSource = input.dailyActiveOverride != null
406
+ ? "user_override"
407
+ : todayHealthKitActive != null
408
+ ? "today_healthkit_active_energy"
409
+ : todayFallbackActiveCalories != null
410
+ ? todayFallbackSource
411
+ : "default_active_calories";
412
+ const todayActiveCalories = input.dailyActiveOverride?.activeCaloriesKcal ??
413
+ todayObservedActiveCalories ??
414
+ baselineActiveCalories;
415
+ const todayTargetAdjustmentKcal = todayActiveCalories - baselineActiveCalories;
416
+ const estimatedTdeeKcal = activeBurnKcal != null && restingEnergyAverage != null
417
+ ? round(activeBurnKcal + restingEnergyAverage, 0)
418
+ : input.inferredTdee;
419
+ const hasHealthKitEnergy = activeEnergyAverage != null ||
420
+ restingEnergyAverage != null ||
421
+ workoutEnergyAverage != null;
422
+ const hasMovementEnergy = movementCaloriesAverage != null;
423
+ const sourceConfidence = activeEnergyAverage != null
424
+ ? "healthkit_daily_active_energy"
425
+ : fallbackActiveBurn != null
426
+ ? "workout_movement_fallback"
427
+ : "target_inference_only";
428
+ return {
429
+ activeEnergyCalories: activeEnergyAverage != null ? round(activeEnergyAverage, 0) : null,
430
+ restingEnergyCalories: restingEnergyAverage != null ? round(restingEnergyAverage, 0) : null,
431
+ wearableConfidence: hasHealthKitEnergy
432
+ ? "measured_directional"
433
+ : "directional",
434
+ inferredTdee: input.inferredTdee,
435
+ estimatedTdeeKcal,
436
+ activeBurnKcal: activeBurnKcal != null ? round(activeBurnKcal, 0) : null,
437
+ baselineActiveCaloriesKcal: round(baselineActiveCalories, 0),
438
+ todayActiveCaloriesKcal: round(todayActiveCalories, 0),
439
+ todayObservedActiveCaloriesKcal: todayObservedActiveCalories != null
440
+ ? round(todayObservedActiveCalories, 0)
441
+ : null,
442
+ todayActiveCaloriesSource: todayActiveSource,
443
+ todayTargetAdjustmentKcal: round(todayTargetAdjustmentKcal, 0),
444
+ todayWorkoutEnergyKcal: todayWorkoutEnergy != null ? round(todayWorkoutEnergy, 0) : null,
445
+ todayMovementCaloriesKcal: todayMovementCalories != null ? round(todayMovementCalories, 0) : null,
446
+ todayHealthKitActiveCaloriesKcal: todayHealthKitActive != null ? round(todayHealthKitActive, 0) : null,
447
+ todayStepCount: todayStepCount != null ? round(todayStepCount, 0) : null,
448
+ todayStepEstimatedCaloriesKcal: todayStepEstimatedCalories != null
449
+ ? round(todayStepEstimatedCalories, 0)
450
+ : null,
451
+ todayActiveOverride: input.dailyActiveOverride,
452
+ movementCaloriesKcal: movementCaloriesAverage != null
453
+ ? round(movementCaloriesAverage, 0)
454
+ : null,
455
+ workoutEnergyKcal: workoutEnergyAverage != null ? round(workoutEnergyAverage, 0) : null,
456
+ averageCalorieIntake: input.averageCalories,
457
+ currentDeficitEstimate: estimatedTdeeKcal != null
458
+ ? round(input.averageCalories - estimatedTdeeKcal, 0)
459
+ : null,
460
+ estimatedDailyEnergyBalanceKcal: estimatedTdeeKcal != null
461
+ ? round(input.averageCalories - estimatedTdeeKcal, 0)
462
+ : null,
463
+ energySourceConfidence: sourceConfidence,
464
+ evidenceDays: new Set([
465
+ ...dailyHealthKit.map((day) => day.dateKey),
466
+ ...workoutRows.map((row) => row.date_key),
467
+ ...movementRows.map((row) => row.date_key)
468
+ ]).size,
469
+ exerciseMinutesAverage: average(dailyHealthKit.map((day) => day.exerciseMinutes)),
470
+ stepCountAverage: average(dailyHealthKit.map((day) => day.stepCount)),
471
+ sourceAvailability: {
472
+ healthKitDailyEnergy: hasHealthKitEnergy,
473
+ movementTripCalories: hasMovementEnergy,
474
+ workoutEnergy: workoutEnergyAverage != null
475
+ }
476
+ };
477
+ }
224
478
  function resolveWriteUser(userId) {
225
479
  return resolveUserForMutation(userId ?? null).id;
226
480
  }
@@ -228,6 +482,27 @@ function resolveReadUser(userIds) {
228
482
  return userIds?.[0] ?? getDefaultUser().id;
229
483
  }
230
484
  function mapFood(row) {
485
+ const nutrients = parseJson(row.nutrients_json, {});
486
+ const servingGrams = row.serving_grams ??
487
+ (row.source === "open_food_facts"
488
+ ? parseGramQuantity(row.serving_label)
489
+ : null);
490
+ const openFoodFactsNutrient = (currentValue, per100gKey, perServingKey, multiplier = 1) => {
491
+ if (row.source !== "open_food_facts") {
492
+ return currentValue;
493
+ }
494
+ const perServing = nutrients[perServingKey];
495
+ if (typeof perServing === "number" && Number.isFinite(perServing)) {
496
+ return round(perServing * multiplier, multiplier === 1 ? 1 : 0);
497
+ }
498
+ const per100g = nutrients[per100gKey];
499
+ if (typeof per100g === "number" &&
500
+ Number.isFinite(per100g) &&
501
+ servingGrams != null) {
502
+ return round((per100g * servingGrams * multiplier) / 100, multiplier === 1 ? 1 : 0);
503
+ }
504
+ return currentValue;
505
+ };
231
506
  return {
232
507
  id: row.id,
233
508
  source: row.source,
@@ -236,21 +511,21 @@ function mapFood(row) {
236
511
  name: row.name,
237
512
  brand: row.brand,
238
513
  servingLabel: row.serving_label,
239
- servingGrams: row.serving_grams,
240
- calories: row.calories,
241
- proteinGrams: row.protein_grams,
242
- carbohydrateGrams: row.carbohydrate_grams,
243
- fatGrams: row.fat_grams,
244
- fiberGrams: row.fiber_grams,
245
- sugarGrams: row.sugar_grams,
246
- sodiumMg: row.sodium_mg,
514
+ servingGrams,
515
+ calories: openFoodFactsNutrient(row.calories, "energy-kcal_100g", "energy-kcal_serving"),
516
+ proteinGrams: openFoodFactsNutrient(row.protein_grams, "proteins_100g", "proteins_serving"),
517
+ carbohydrateGrams: openFoodFactsNutrient(row.carbohydrate_grams, "carbohydrates_100g", "carbohydrates_serving"),
518
+ fatGrams: openFoodFactsNutrient(row.fat_grams, "fat_100g", "fat_serving"),
519
+ fiberGrams: openFoodFactsNutrient(row.fiber_grams, "fiber_100g", "fiber_serving"),
520
+ sugarGrams: openFoodFactsNutrient(row.sugar_grams, "sugars_100g", "sugars_serving"),
521
+ sodiumMg: openFoodFactsNutrient(row.sodium_mg, "sodium_100g", "sodium_serving", 1000),
247
522
  potassiumMg: row.potassium_mg,
248
523
  caffeineMg: row.caffeine_mg,
249
524
  alcoholGrams: row.alcohol_grams,
250
525
  novaGroup: row.nova_group,
251
526
  nutriScore: row.nutri_score,
252
527
  tags: parseJson(row.tags_json, []),
253
- nutrients: parseJson(row.nutrients_json, {}),
528
+ nutrients,
254
529
  confidence: row.confidence,
255
530
  createdAt: row.created_at,
256
531
  updatedAt: row.updated_at
@@ -479,6 +754,34 @@ export function updateNutritionTarget(input) {
479
754
  .prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
480
755
  .get(userId), userId);
481
756
  }
757
+ export function updateNutritionDailyActiveCalories(input) {
758
+ const parsed = nutritionDailyActiveCaloriesUpdateSchema.parse(input);
759
+ const userId = resolveWriteUser(parsed.userId);
760
+ const dateKey = parsed.dayKey ?? new Date().toISOString().slice(0, 10);
761
+ const now = nowIso();
762
+ if (parsed.activeCaloriesKcal == null) {
763
+ getDatabase()
764
+ .prepare(`DELETE FROM nutrition_daily_energy_overrides
765
+ WHERE user_id = ?
766
+ AND day_key = ?`)
767
+ .run(userId, dateKey);
768
+ return { override: null, dayKey: dateKey };
769
+ }
770
+ const id = newId("daily_energy");
771
+ getDatabase()
772
+ .prepare(`INSERT INTO nutrition_daily_energy_overrides (
773
+ id, user_id, day_key, active_calories_kcal, notes, created_at, updated_at
774
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
775
+ ON CONFLICT(user_id, day_key) DO UPDATE SET
776
+ active_calories_kcal = excluded.active_calories_kcal,
777
+ notes = excluded.notes,
778
+ updated_at = excluded.updated_at`)
779
+ .run(id, userId, dateKey, parsed.activeCaloriesKcal, parsed.notes, now, now);
780
+ return {
781
+ override: getDailyEnergyOverride(userId, dateKey),
782
+ dayKey: dateKey
783
+ };
784
+ }
482
785
  export function createNutritionBodyCheckin(input) {
483
786
  const parsed = nutritionBodyCheckinCreateSchema.parse(input);
484
787
  const userId = resolveWriteUser(parsed.userId);
@@ -572,7 +875,9 @@ export function patchNutritionExperiment(experimentId, input) {
572
875
  ? parsed.hypothesisId
573
876
  : existing.hypothesis_id, parsed.title ?? existing.title, parsed.status ?? existing.status, parsed.baselineStart !== undefined
574
877
  ? parsed.baselineStart
575
- : existing.baseline_start, parsed.baselineEnd !== undefined ? parsed.baselineEnd : existing.baseline_end, parsed.interventionStart !== undefined
878
+ : existing.baseline_start, parsed.baselineEnd !== undefined
879
+ ? parsed.baselineEnd
880
+ : existing.baseline_end, parsed.interventionStart !== undefined
576
881
  ? parsed.interventionStart
577
882
  : existing.intervention_start, parsed.interventionEnd !== undefined
578
883
  ? parsed.interventionEnd
@@ -730,7 +1035,7 @@ function listExperiments(userId, limit = 20) {
730
1035
  updatedAt: row.updated_at
731
1036
  }));
732
1037
  }
733
- function buildTodayLedger(logs, target) {
1038
+ function buildTodayLedger(logs, target, dynamicTargetCalories, activeAdjustmentCalories, activeCaloriesSource) {
734
1039
  const today = new Date().toISOString().slice(0, 10);
735
1040
  const todayLogs = logs.filter((log) => log.dayKey === today);
736
1041
  const totals = todayLogs.reduce((acc, log) => ({
@@ -756,8 +1061,11 @@ function buildTodayLedger(logs, target) {
756
1061
  dateKey: today,
757
1062
  meals: todayLogs,
758
1063
  totals,
759
- targetCalories: target.calorieTarget,
760
- calorieDelta: round(totals.calories - n(target.calorieTarget), 0),
1064
+ plannedTargetCalories: target.calorieTarget,
1065
+ targetCalories: dynamicTargetCalories,
1066
+ activeAdjustmentCalories,
1067
+ activeCaloriesSource,
1068
+ calorieDelta: round(totals.calories - dynamicTargetCalories, 0),
761
1069
  proteinCoverage: n(target.proteinGramsTarget) > 0
762
1070
  ? round(totals.proteinGrams / n(target.proteinGramsTarget), 2)
763
1071
  : null,
@@ -767,15 +1075,37 @@ function buildTodayLedger(logs, target) {
767
1075
  unconfirmedCount: todayLogs.filter((log) => log.confirmationState !== "confirmed").length
768
1076
  };
769
1077
  }
770
- function buildWeightTrend(body) {
1078
+ function latestHealthKitBodyMass(userId) {
1079
+ const rows = getDatabase()
1080
+ .prepare(`SELECT date_key, metrics_json
1081
+ FROM health_daily_summaries
1082
+ WHERE user_id = ?
1083
+ AND summary_type = 'vitals'
1084
+ ORDER BY date_key DESC
1085
+ LIMIT 30`)
1086
+ .all(userId);
1087
+ for (const row of rows) {
1088
+ const metrics = parseJson(row.metrics_json, {});
1089
+ const bodyMassKg = metricTotal(metrics, "bodyMass");
1090
+ if (bodyMassKg != null && bodyMassKg > 0) {
1091
+ return {
1092
+ weightKg: round(bodyMassKg, 2),
1093
+ checkedAt: `${row.date_key}T12:00:00.000Z`
1094
+ };
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+ function buildWeightTrend(userId, body) {
771
1100
  const withWeight = body
772
1101
  .filter((entry) => typeof entry.weightKg === "number")
773
1102
  .slice()
774
1103
  .reverse();
775
1104
  const latest = withWeight.at(-1) ?? null;
1105
+ const healthKitFallback = latest ? null : latestHealthKitBodyMass(userId);
776
1106
  const previous = withWeight.length > 1 ? withWeight.at(-2) : null;
777
1107
  const first = withWeight[0] ?? null;
778
- const latestWeight = latest?.weightKg ?? null;
1108
+ const latestWeight = latest?.weightKg ?? healthKitFallback?.weightKg ?? null;
779
1109
  const deltaFromPrevious = latestWeight != null && previous?.weightKg != null
780
1110
  ? round(latestWeight - previous.weightKg, 2)
781
1111
  : null;
@@ -784,17 +1114,28 @@ function buildWeightTrend(body) {
784
1114
  : null;
785
1115
  return {
786
1116
  latestWeightKg: latestWeight,
787
- latestCheckedAt: latest?.checkedAt ?? null,
1117
+ latestCheckedAt: latest?.checkedAt ?? healthKitFallback?.checkedAt ?? null,
1118
+ latestWeightSource: latest
1119
+ ? "nutrition_body_checkin"
1120
+ : healthKitFallback
1121
+ ? "healthkit_body_mass"
1122
+ : null,
788
1123
  deltaFromPreviousKg: deltaFromPrevious,
789
1124
  deltaFromFirstKg: deltaFromFirst,
790
1125
  trendWeightKg: withWeight.length > 0
791
1126
  ? round(average(withWeight.slice(-7).map((entry) => entry.weightKg)) ?? 0, 2)
792
- : null,
1127
+ : (healthKitFallback?.weightKg ?? null),
793
1128
  weeklyRateKg: withWeight.length >= 2 && first && latest
794
- ? round(((latest.weightKg - first.weightKg) /
1129
+ ? round((latest.weightKg - first.weightKg) /
795
1130
  Math.max(1, (new Date(latest.checkedAt).getTime() -
796
1131
  new Date(first.checkedAt).getTime()) /
797
- (7 * 24 * 60 * 60 * 1000))), 2)
1132
+ (7 * 24 * 60 * 60 * 1000)), 2)
1133
+ : null,
1134
+ sevenDayRateKg: withWeight.length >= 2 && first && latest
1135
+ ? round((latest.weightKg - first.weightKg) /
1136
+ Math.max(1, (new Date(latest.checkedAt).getTime() -
1137
+ new Date(first.checkedAt).getTime()) /
1138
+ (7 * 24 * 60 * 60 * 1000)), 2)
798
1139
  : null,
799
1140
  waistToHeightRatio: null
800
1141
  };
@@ -812,8 +1153,7 @@ function buildFoodQuality(logs) {
812
1153
  const calorieBase = Math.max(1, totals.calories / 1000);
813
1154
  const highProteinShare = round((tagCounts.get("high_protein") ?? 0) / total, 2);
814
1155
  const highFiberShare = round((tagCounts.get("high_fiber") ?? 0) / total, 2);
815
- const ultraProcessedShare = round(((tagCounts.get("ultra_processed") ?? 0) +
816
- (tagCounts.get("nova_4") ?? 0)) /
1156
+ const ultraProcessedShare = round(((tagCounts.get("ultra_processed") ?? 0) + (tagCounts.get("nova_4") ?? 0)) /
817
1157
  total, 2);
818
1158
  return {
819
1159
  itemCount: items.length,
@@ -862,7 +1202,9 @@ function buildGutSummary(checkins) {
862
1202
  averageBloating,
863
1203
  averageReflux,
864
1204
  averageAbdominalPain,
865
- gutComfortScore: discomfortAverage == null ? null : round(Math.max(0, 10 - discomfortAverage), 1),
1205
+ gutComfortScore: discomfortAverage == null
1206
+ ? null
1207
+ : round(Math.max(0, 10 - discomfortAverage), 1),
866
1208
  bristolDistribution: [1, 2, 3, 4, 5, 6, 7].map((type) => ({
867
1209
  type,
868
1210
  count: checkins.filter((entry) => entry.bristolStoolType === type).length
@@ -874,7 +1216,9 @@ function buildGeneratedHypotheses(logs, subjective, gut, appearance) {
874
1216
  const cards = [];
875
1217
  const lateMeals = logs.filter((log) => new Date(log.loggedAt).getHours() >= 21);
876
1218
  const lowEnergy = subjective.filter((entry) => typeof entry.energy === "number" && entry.energy <= 4);
877
- const gutSymptoms = gut.filter((entry) => n(entry.bloating) >= 6 || n(entry.reflux) >= 6 || n(entry.abdominalPain) >= 6);
1219
+ const gutSymptoms = gut.filter((entry) => n(entry.bloating) >= 6 ||
1220
+ n(entry.reflux) >= 6 ||
1221
+ n(entry.abdominalPain) >= 6);
878
1222
  const puffiness = appearance.filter((entry) => typeof entry.facePuffiness === "number" && entry.facePuffiness >= 6);
879
1223
  if (lateMeals.length >= 2 && puffiness.length >= 1) {
880
1224
  cards.push({
@@ -926,6 +1270,7 @@ function buildGeneratedHypotheses(logs, subjective, gut, appearance) {
926
1270
  export function getWeightLossViewData(userIds) {
927
1271
  const generatedAt = new Date().toISOString();
928
1272
  const userId = resolveReadUser(userIds);
1273
+ const todayKey = generatedAt.slice(0, 10);
929
1274
  const targetRow = getDatabase()
930
1275
  .prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
931
1276
  .get(userId);
@@ -938,7 +1283,7 @@ export function getWeightLossViewData(userIds) {
938
1283
  const storedHypotheses = listHypotheses(userId);
939
1284
  const generatedHypotheses = buildGeneratedHypotheses(logs, subjective, gut, appearance);
940
1285
  const experiments = listExperiments(userId);
941
- const todayLedger = buildTodayLedger(logs, target);
1286
+ const weightTrend = buildWeightTrend(userId, body);
942
1287
  const recentLogs = logs.slice(0, 14);
943
1288
  const recentTotals = sumItems(recentLogs.flatMap((log) => log.items));
944
1289
  const trackedDays = new Set(logs.map((log) => log.dayKey)).size;
@@ -946,6 +1291,18 @@ export function getWeightLossViewData(userIds) {
946
1291
  const inferredTdee = target.calorieTarget != null
947
1292
  ? round(target.calorieTarget + Math.abs(n(target.weeklyRateGoalKg)) * 1100, 0)
948
1293
  : null;
1294
+ const defaultActiveCalories = parsePlanNoteNumber(target.notes, "activity_kcal");
1295
+ const dailyActiveOverride = getDailyEnergyOverride(userId, todayKey);
1296
+ const energyModel = buildStoredEnergyModel({
1297
+ userId,
1298
+ inferredTdee,
1299
+ averageCalories,
1300
+ defaultActiveCalories,
1301
+ latestWeightKg: weightTrend.latestWeightKg,
1302
+ dailyActiveOverride
1303
+ });
1304
+ const todayTargetCalories = Math.max(0, round(target.calorieTarget + energyModel.todayTargetAdjustmentKcal, 0));
1305
+ const todayLedger = buildTodayLedger(logs, target, todayTargetCalories, energyModel.todayTargetAdjustmentKcal, energyModel.todayActiveCaloriesSource);
949
1306
  return {
950
1307
  generatedAt,
951
1308
  userId,
@@ -954,14 +1311,13 @@ export function getWeightLossViewData(userIds) {
954
1311
  loggedMealCount: logs.length,
955
1312
  trackedDays,
956
1313
  todayCalories: todayLedger.totals.calories,
957
- targetCalories: target.calorieTarget,
1314
+ targetCalories: todayLedger.targetCalories,
958
1315
  todayCalorieDelta: todayLedger.calorieDelta,
959
1316
  averageCalories,
960
1317
  inferredTdee,
961
1318
  proteinCoverage: todayLedger.proteinCoverage,
962
1319
  fiberCoverage: todayLedger.fiberCoverage,
963
- unconfirmedCount: logs.filter((log) => log.confirmationState !== "confirmed")
964
- .length,
1320
+ unconfirmedCount: logs.filter((log) => log.confirmationState !== "confirmed").length,
965
1321
  hypothesisCount: storedHypotheses.length + generatedHypotheses.length,
966
1322
  dataQualityScore: round(Math.min(1, trackedDays / 7 +
967
1323
  Math.min(0.2, body.length * 0.04) +
@@ -970,19 +1326,8 @@ export function getWeightLossViewData(userIds) {
970
1326
  },
971
1327
  todayLedger,
972
1328
  recentMeals: logs.slice(0, 30),
973
- energyModel: {
974
- activeEnergyCalories: null,
975
- restingEnergyCalories: null,
976
- wearableConfidence: "directional",
977
- inferredTdee,
978
- estimatedTdeeKcal: inferredTdee,
979
- activeBurnKcal: null,
980
- movementCaloriesKcal: null,
981
- averageCalorieIntake: averageCalories,
982
- currentDeficitEstimate: inferredTdee != null ? round(averageCalories - inferredTdee, 0) : null,
983
- estimatedDailyEnergyBalanceKcal: inferredTdee != null ? round(averageCalories - inferredTdee, 0) : null
984
- },
985
- weightTrend: buildWeightTrend(body),
1329
+ energyModel,
1330
+ weightTrend,
986
1331
  bodyCheckins: body,
987
1332
  appearanceCheckins: appearance,
988
1333
  foodQuality: buildFoodQuality(logs),
@@ -1069,6 +1414,51 @@ function readOffNutrient(product, key) {
1069
1414
  const value = nutriments?.[key];
1070
1415
  return typeof value === "number" ? value : null;
1071
1416
  }
1417
+ function parseGramQuantity(value) {
1418
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
1419
+ return value;
1420
+ }
1421
+ if (typeof value !== "string") {
1422
+ return null;
1423
+ }
1424
+ const match = value
1425
+ .toLowerCase()
1426
+ .replace(",", ".")
1427
+ .match(/(\d+(?:\.\d+)?)\s*(kg|g|gram|grams|ml|l)\b/);
1428
+ if (!match) {
1429
+ return null;
1430
+ }
1431
+ const quantity = Number(match[1]);
1432
+ if (!Number.isFinite(quantity) || quantity <= 0) {
1433
+ return null;
1434
+ }
1435
+ const unit = match[2];
1436
+ return unit === "kg" || unit === "l" ? quantity * 1000 : quantity;
1437
+ }
1438
+ function openFoodFactsServingGrams(product) {
1439
+ const servingSizeGrams = parseGramQuantity(product.serving_size);
1440
+ if (servingSizeGrams != null) {
1441
+ return servingSizeGrams;
1442
+ }
1443
+ const servingQuantity = parseGramQuantity(product.serving_quantity);
1444
+ if (servingQuantity != null) {
1445
+ return servingQuantity;
1446
+ }
1447
+ return null;
1448
+ }
1449
+ function openFoodFactsServingNutrient(product, per100gKey, perServingKey, servingGrams) {
1450
+ const perServing = readOffNutrient(product, perServingKey);
1451
+ if (perServing != null) {
1452
+ return perServing;
1453
+ }
1454
+ const per100g = readOffNutrient(product, per100gKey);
1455
+ if (per100g == null) {
1456
+ return null;
1457
+ }
1458
+ return servingGrams != null
1459
+ ? round((per100g * servingGrams) / 100, 1)
1460
+ : per100g;
1461
+ }
1072
1462
  function mapOpenFoodFactsProduct(product) {
1073
1463
  const code = typeof product.code === "string" ? product.code : "";
1074
1464
  const name = typeof product.product_name === "string" && product.product_name.trim()
@@ -1084,22 +1474,30 @@ function mapOpenFoodFactsProduct(product) {
1084
1474
  nova === 4 ? "ultra_processed" : null,
1085
1475
  nova ? `nova_${nova}` : null
1086
1476
  ].filter((tag) => Boolean(tag));
1477
+ const servingGrams = openFoodFactsServingGrams(product);
1478
+ const servingLabel = typeof product.serving_size === "string" && product.serving_size.trim()
1479
+ ? product.serving_size.trim()
1480
+ : servingGrams != null
1481
+ ? `${servingGrams} g`
1482
+ : "100 g";
1483
+ const sodiumServingGrams = openFoodFactsServingNutrient(product, "sodium_100g", "sodium_serving", servingGrams);
1087
1484
  return cacheFood({
1088
1485
  source: "open_food_facts",
1089
1486
  sourceId: code,
1090
1487
  barcode: code,
1091
1488
  name,
1092
- brand: typeof product.brands === "string" ? product.brands.split(",")[0].trim() : "",
1093
- servingLabel: typeof product.serving_size === "string" ? product.serving_size : "",
1094
- calories: readOffNutrient(product, "energy-kcal_100g"),
1095
- proteinGrams: readOffNutrient(product, "proteins_100g"),
1096
- carbohydrateGrams: readOffNutrient(product, "carbohydrates_100g"),
1097
- fatGrams: readOffNutrient(product, "fat_100g"),
1098
- fiberGrams: readOffNutrient(product, "fiber_100g"),
1099
- sugarGrams: readOffNutrient(product, "sugars_100g"),
1100
- sodiumMg: readOffNutrient(product, "sodium_100g") != null
1101
- ? readOffNutrient(product, "sodium_100g") * 1000
1102
- : null,
1489
+ brand: typeof product.brands === "string"
1490
+ ? product.brands.split(",")[0].trim()
1491
+ : "",
1492
+ servingLabel,
1493
+ servingGrams: servingGrams ?? 100,
1494
+ calories: openFoodFactsServingNutrient(product, "energy-kcal_100g", "energy-kcal_serving", servingGrams),
1495
+ proteinGrams: openFoodFactsServingNutrient(product, "proteins_100g", "proteins_serving", servingGrams),
1496
+ carbohydrateGrams: openFoodFactsServingNutrient(product, "carbohydrates_100g", "carbohydrates_serving", servingGrams),
1497
+ fatGrams: openFoodFactsServingNutrient(product, "fat_100g", "fat_serving", servingGrams),
1498
+ fiberGrams: openFoodFactsServingNutrient(product, "fiber_100g", "fiber_serving", servingGrams),
1499
+ sugarGrams: openFoodFactsServingNutrient(product, "sugars_100g", "sugars_serving", servingGrams),
1500
+ sodiumMg: sodiumServingGrams != null ? round(sodiumServingGrams * 1000, 0) : null,
1103
1501
  novaGroup: nova,
1104
1502
  nutriScore: typeof product.nutriscore_grade === "string"
1105
1503
  ? product.nutriscore_grade
@@ -1133,11 +1531,15 @@ async function lookupOpenFoodFactsBarcode(barcode) {
1133
1531
  return [];
1134
1532
  }
1135
1533
  const payload = (await response.json());
1136
- const food = payload.product ? mapOpenFoodFactsProduct(payload.product) : null;
1534
+ const food = payload.product
1535
+ ? mapOpenFoodFactsProduct(payload.product)
1536
+ : null;
1137
1537
  return food ? [food] : [];
1138
1538
  }
1139
1539
  function mapFdcFood(food) {
1140
- const sourceId = typeof food.fdcId === "number" ? String(food.fdcId) : String(food.fdcId ?? "");
1540
+ const sourceId = typeof food.fdcId === "number"
1541
+ ? String(food.fdcId)
1542
+ : String(food.fdcId ?? "");
1141
1543
  const name = typeof food.description === "string" ? food.description.trim() : "";
1142
1544
  if (!sourceId || !name) {
1143
1545
  return null;