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.
- package/dist/assets/{activity-page-CpjuNSHw.js → activity-page-CgF7K2ww.js} +1 -1
- package/dist/assets/ai-surface-workspace-xtB5RFQu.js +1 -0
- package/dist/assets/atlas-panel-B3dPHCmZ.js +1 -0
- package/dist/assets/{board-BkDRaMp6.js → board-DqHzdCPQ.js} +1 -1
- package/dist/assets/{calendar-page-DNNt6lfz.js → calendar-page-C1Wfd2Fy.js} +1 -1
- package/dist/assets/{calendar-rules-DNJFNsxi.js → calendar-rules-CaZXtlxt.js} +1 -1
- package/dist/assets/calendar-week-toolbar-BZ_-X3Wb.js +1 -0
- package/dist/assets/{charts-P7EVhIog.js → charts-FcU0F3XV.js} +8 -8
- package/dist/assets/{companion-sync-lab-page-KxEDigM6.js → companion-sync-lab-page-NgeK-O-P.js} +1 -1
- package/dist/assets/daily-metrics-dashboard-BMyL0Qr4.js +1 -0
- package/dist/assets/{entity-note-count-link-DrhjJZ4i.js → entity-note-count-link-BrS1-O0o.js} +1 -1
- package/dist/assets/entity-notes-surface-CBylYDwy.js +1 -0
- package/dist/assets/{execution-board-D07gOocB.js → execution-board-CpO2ch6v.js} +1 -1
- package/dist/assets/faceted-token-search-D7xPWfOl.js +1 -0
- package/dist/assets/flagship-signal-deck-BEFKOhvx.js +1 -0
- package/dist/assets/{floating-action-menu-Fs_ZiUMo.js → floating-action-menu-zzC2r0Ob.js} +1 -1
- package/dist/assets/{forms-BFlTgZ3W.js → forms-CmLAyGqz.js} +1 -1
- package/dist/assets/{goal-detail-page-CqLiNz4f.js → goal-detail-page-JK_Nva8e.js} +1 -1
- package/dist/assets/goals-page-yeoJ06Vw.js +1 -0
- package/dist/assets/{graph-D6JLqDbD.js → graph-BTa79qum.js} +14 -14
- package/dist/assets/{habits-page-BJxagdzx.js → habits-page-Dx5EhkJi.js} +1 -1
- package/dist/assets/index-BHIKoiZ6.js +19 -0
- package/dist/assets/index-H8R-ABM3.css +1 -0
- package/dist/assets/insight-flow-dialog-BtIQXXsS.js +1 -0
- package/dist/assets/{insights-page-D6rOa7uk.js → insights-page-CujrosD_.js} +1 -1
- package/dist/assets/{kanban-page-XQ7Se6dH.js → kanban-page-Q9NuIz5w.js} +1 -1
- package/dist/assets/knowledge-graph-page-DaJmlvvM.js +1 -0
- package/dist/assets/{life-force-page-Dy0JTS2G.js → life-force-page-BGDkKfbJ.js} +1 -1
- package/dist/assets/{life-force-workspace-OfyB9HJM.js → life-force-workspace-CLVexVnb.js} +1 -1
- package/dist/assets/{maps-ClgJoCjz.js → maps-CF1RagUX.js} +1 -1
- package/dist/assets/metric-tile-4iMd_WnJ.js +1 -0
- package/dist/assets/{motion-BeD44FeG.js → motion-CfdU2F35.js} +1 -1
- package/dist/assets/movement-page-ClaoTNuX.js +1 -0
- package/dist/assets/note-markdown-CsGQhQXF.js +3 -0
- package/dist/assets/note-tags-input-DdZi93tj.js +1 -0
- package/dist/assets/notes-page-DYI8s3NN.js +1 -0
- package/dist/assets/{open-in-graph-button-IXe9SGth.js → open-in-graph-button-C0TGev7c.js} +1 -1
- package/dist/assets/orbit-map-DVXkfQdd.js +1 -0
- package/dist/assets/overview-page-BwMlFQKX.js +1 -0
- package/dist/assets/page-hero-oFHaAjtL.js +1 -0
- package/dist/assets/pill-cluster-7UZd_lHp.js +1 -0
- package/dist/assets/{preference-entity-handoff-button-5PzUn42S.js → preference-entity-handoff-button-DOKV9bZc.js} +1 -1
- package/dist/assets/preferences-page-BrKEkmfD.js +1 -0
- package/dist/assets/{project-collections-xPz2mlRr.js → project-collections-CZagCmeH.js} +1 -1
- package/dist/assets/{project-detail-page-BXK5-4xW.js → project-detail-page-DogvVNEn.js} +1 -1
- package/dist/assets/{project-management-hierarchy-page-DtRpMABw.js → project-management-hierarchy-page-Dr7hW9PW.js} +1 -1
- package/dist/assets/{project-management-section-nav-P3ixzPa-.js → project-management-section-nav-BLdAgTge.js} +1 -1
- package/dist/assets/{projects-page-C5ViRuf4.js → projects-page-UKrby_5-.js} +1 -1
- package/dist/assets/psyche-behaviors-page-BeVlXvbj.js +5 -0
- package/dist/assets/psyche-flashcards-page-CqAWCO4E.js +1 -0
- package/dist/assets/psyche-goal-map-page-IyNnbk_W.js +1 -0
- package/dist/assets/psyche-graph-IkUQRaDK.js +1 -0
- package/dist/assets/{psyche-metrics-page-CuR9oqEy.js → psyche-metrics-page-uai0a7Lx.js} +1 -1
- package/dist/assets/psyche-mode-guide-page-C7p-ABiF.js +1 -0
- package/dist/assets/psyche-modes-page-CLQ5V3w0.js +1 -0
- package/dist/assets/psyche-page-NQBHkkpU.js +1 -0
- package/dist/assets/psyche-patterns-page-BkRiNpI_.js +5 -0
- package/dist/assets/psyche-questionnaire-builder-page-Du-7HwPC.js +1 -0
- package/dist/assets/psyche-questionnaire-detail-page-C5-yf8MF.js +1 -0
- package/dist/assets/psyche-questionnaire-run-detail-page-CoSH5O6R.js +1 -0
- package/dist/assets/psyche-questionnaire-run-page-ChI7c1wy.js +1 -0
- package/dist/assets/psyche-questionnaires-page-xUcJJzbI.js +1 -0
- package/dist/assets/psyche-report-detail-page-BPrZCFZS.js +3 -0
- package/dist/assets/psyche-reports-page-D2EXJmm6.js +1 -0
- package/dist/assets/{psyche-schemas-HFmg37Wj.js → psyche-schemas-RcZYaokx.js} +1 -1
- package/dist/assets/psyche-schemas-beliefs-page-h0ooZ1lp.js +9 -0
- package/dist/assets/psyche-screen-time-page-_3-4LikV.js +1 -0
- package/dist/assets/psyche-self-observation-page-BdbCsgR5.js +1 -0
- package/dist/assets/psyche-values-page-DuUVki5e.js +5 -0
- package/dist/assets/report-chain-fields-BBMz0sGI.js +1 -0
- package/dist/assets/{rewards-page-DmC4R_Ps.js → rewards-page-PZFPa6rR.js} +1 -1
- package/dist/assets/{scheduling-rules-editor-D02s70hr.js → scheduling-rules-editor-B9KNKsQz.js} +1 -1
- package/dist/assets/schema-badge-D93RcG36.js +1 -0
- package/dist/assets/schema-visuals-CvC9a3i6.js +1 -0
- package/dist/assets/select-menu-Cdq7foRu.js +1 -0
- package/dist/assets/{settings-agents-page-C_v_hMJF.js → settings-agents-page-DPx2F6wf.js} +3 -3
- package/dist/assets/{settings-bin-page-DY5bg81n.js → settings-bin-page-COwP3gjo.js} +1 -1
- package/dist/assets/settings-calendar-page-C-ghE0YT.js +5 -0
- package/dist/assets/{settings-data-page-CHRQFU9H.js → settings-data-page-CBubzKBw.js} +1 -1
- package/dist/assets/{settings-logs-page-B04pUwEv.js → settings-logs-page-uOuXMeBm.js} +1 -1
- package/dist/assets/{settings-mobile-page-D9kTlYDS.js → settings-mobile-page-C5rfj8_r.js} +1 -1
- package/dist/assets/settings-models-page-DijmUWdU.js +1 -0
- package/dist/assets/{settings-page-DYDTFlnv.js → settings-page-B4u5TR5g.js} +1 -1
- package/dist/assets/{settings-rewards-page-cl4vqqO_.js → settings-rewards-page-COiTwkMH.js} +1 -1
- package/dist/assets/{settings-section-nav-DSOuht_F.js → settings-section-nav-Dc4IeVBt.js} +1 -1
- package/dist/assets/{settings-users-page-BU79JB_T.js → settings-users-page-DEGa5DNn.js} +1 -1
- package/dist/assets/settings-wiki-page-Cvsiz5_e.js +1 -0
- package/dist/assets/{sleep-page-D8NbdhyS.js → sleep-page-BnSOwkEU.js} +1 -1
- package/dist/assets/{sports-page-CV4Cnzwn.js → sports-page-l1RqXzA_.js} +1 -1
- package/dist/assets/{state-B-4sS1xO.js → state-VYvD1QVP.js} +1 -1
- package/dist/assets/{strategies-page-C4qvXnql.js → strategies-page-DEnPlpAs.js} +1 -1
- package/dist/assets/{strategy-detail-page-DJLo5rfy.js → strategy-detail-page-Ls8bxKeH.js} +1 -1
- package/dist/assets/strategy-dialog-DMyRKrWf.js +1 -0
- package/dist/assets/surface-Bfz_sLX6.js +1 -0
- package/dist/assets/{table-WfAPUppN.js → table-C0VTeqw0.js} +1 -1
- package/dist/assets/task-detail-page-CgrYgQLD.js +1 -0
- package/dist/assets/{timebox-planning-dialog-DB6FLqmI.js → timebox-planning-dialog-Ww0NGLLo.js} +1 -1
- package/dist/assets/today-page-CIuFHMi1.js +1 -0
- package/dist/assets/training-load-page-BIwc648i.js +1 -0
- package/dist/assets/{ui-C13Nbgas.js → ui-CsEkP2V8.js} +4 -4
- package/dist/assets/use-psyche-focus-target-qxT5Oy_z.js +1 -0
- package/dist/assets/{vendor-DHkYh85p.js → vendor-kIz9EZnX.js} +237 -222
- package/dist/assets/{vitals-page-qre17Nw8.js → vitals-page-Dz1Jt5H8.js} +1 -1
- package/dist/assets/{weekly-review-page-Cz4vkRcx.js → weekly-review-page-BFpBe1kI.js} +1 -1
- package/dist/assets/weight-loss-page-BgMoBpBt.js +5 -0
- package/dist/assets/{wiki-article-markdown-DdiR2TJE.js → wiki-article-markdown-gsPTXTg1.js} +1 -1
- package/dist/assets/{wiki-editor-page-DqwoqVFb.js → wiki-editor-page-BpAZHooY.js} +7 -7
- package/dist/assets/{wiki-ingest-history-page--evBLbOw.js → wiki-ingest-history-page-C-ig8O22.js} +1 -1
- package/dist/assets/{wiki-ingest-modal--ohzFnj2.js → wiki-ingest-modal-BK4eQgqs.js} +1 -1
- package/dist/assets/{wiki-page-B_VJFBPA.js → wiki-page-CMTZ60Zt.js} +1 -1
- package/dist/assets/workbench-flow-page-BIpWUcLJ.js +5 -0
- package/dist/assets/workbench-page-CtCjYSRe.js +1 -0
- package/dist/assets/workout-detail-page-DD9IGN6l.js +2 -0
- package/dist/index.html +9 -9
- package/dist/openclaw/local-runtime.js +41 -14
- package/dist/server/server/migrations/067_weight_loss_daily_active_overrides.sql +13 -0
- package/dist/server/server/src/app.js +103 -30
- package/dist/server/server/src/health-weight-loss.js +457 -55
- package/dist/server/server/src/health.js +12 -4
- package/dist/server/server/src/movement.js +84 -1
- package/dist/server/server/src/openapi.js +123 -18
- package/dist/server/server/src/repositories/model-settings.js +12 -9
- package/dist/server/server/src/repositories/settings.js +19 -5
- package/dist/server/src/components/ui/info-tooltip.js +6 -6
- package/dist/server/src/lib/api.js +14 -4
- package/dist/server/src/lib/theme-system.js +8 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/server/migrations/067_weight_loss_daily_active_overrides.sql +13 -0
- package/skills/forge-openclaw/SKILL.md +13 -0
- package/skills/forge-openclaw/entity_conversation_playbooks.md +7 -1
- package/dist/assets/ai-surface-workspace-DEAFZruS.js +0 -1
- package/dist/assets/atlas-panel-CdVNPotj.js +0 -1
- package/dist/assets/calendar-week-toolbar-BbPwYeN0.js +0 -1
- package/dist/assets/daily-metrics-dashboard-B3cqJgDt.js +0 -1
- package/dist/assets/entity-notes-surface-CkcRsKJQ.js +0 -1
- package/dist/assets/faceted-token-search-BxRRcM3q.js +0 -1
- package/dist/assets/flagship-signal-deck-cmy82b8_.js +0 -1
- package/dist/assets/goals-page-BTk7mg_T.js +0 -1
- package/dist/assets/index-CF4J4R9L.js +0 -19
- package/dist/assets/index-CZbuZQjw.css +0 -1
- package/dist/assets/insight-flow-dialog-8f3D0GuC.js +0 -1
- package/dist/assets/knowledge-graph-page-BtAg8iv3.js +0 -1
- package/dist/assets/metric-tile-DKpo-8xw.js +0 -1
- package/dist/assets/movement-page-Bg_T_Stx.js +0 -1
- package/dist/assets/note-markdown-N-uxD3Xt.js +0 -3
- package/dist/assets/note-tags-input-Cdu7wiw6.js +0 -1
- package/dist/assets/notes-page-CKXnF_KU.js +0 -1
- package/dist/assets/orbit-map-Dzi6KliQ.js +0 -1
- package/dist/assets/overview-page-1miYqaVS.js +0 -1
- package/dist/assets/page-hero-DRy5b2MU.js +0 -1
- package/dist/assets/pill-cluster-C9QczVJ2.js +0 -1
- package/dist/assets/preferences-page-DtNaF5Q3.js +0 -1
- package/dist/assets/psyche-behaviors-page-Dco46sC4.js +0 -5
- package/dist/assets/psyche-flashcards-page-ZcoEB8gV.js +0 -1
- package/dist/assets/psyche-goal-map-page-CLBAQOI0.js +0 -1
- package/dist/assets/psyche-graph-k4tX2tdp.js +0 -1
- package/dist/assets/psyche-mode-guide-page-hIVXcCnE.js +0 -1
- package/dist/assets/psyche-modes-page-0lYtBlhO.js +0 -1
- package/dist/assets/psyche-page-VZ9k9ISp.js +0 -1
- package/dist/assets/psyche-patterns-page-gx5nmdGq.js +0 -5
- package/dist/assets/psyche-questionnaire-builder-page-Bn0TOISd.js +0 -1
- package/dist/assets/psyche-questionnaire-detail-page-CmVzSd_s.js +0 -1
- package/dist/assets/psyche-questionnaire-run-detail-page-BsMbmXCG.js +0 -1
- package/dist/assets/psyche-questionnaire-run-page-CgkRL2vi.js +0 -1
- package/dist/assets/psyche-questionnaires-page-D7V8uLXM.js +0 -1
- package/dist/assets/psyche-report-detail-page-OlFq57eL.js +0 -3
- package/dist/assets/psyche-reports-page-dVUZjna1.js +0 -1
- package/dist/assets/psyche-schemas-beliefs-page-BCgc8FUd.js +0 -9
- package/dist/assets/psyche-screen-time-page-B_6BT_WN.js +0 -1
- package/dist/assets/psyche-self-observation-page-CEG5mluK.js +0 -1
- package/dist/assets/psyche-values-page-DRbRfEd6.js +0 -5
- package/dist/assets/report-chain-fields-CALCV3V5.js +0 -1
- package/dist/assets/schema-badge-BZO-qNhO.js +0 -1
- package/dist/assets/schema-visuals-D6nxjbYC.js +0 -1
- package/dist/assets/select-menu-fYyreSdQ.js +0 -1
- package/dist/assets/settings-calendar-page-D1CzE6cg.js +0 -5
- package/dist/assets/settings-models-page-D26270R2.js +0 -1
- package/dist/assets/settings-wiki-page-DwAUlyA3.js +0 -1
- package/dist/assets/strategy-dialog-D3AuUlVz.js +0 -1
- package/dist/assets/task-detail-page-z-9u9rF0.js +0 -1
- package/dist/assets/today-page-BKlu6gx5.js +0 -1
- package/dist/assets/training-load-page-CyJQqo_3.js +0 -1
- package/dist/assets/use-psyche-focus-target-C1C_XjYG.js +0 -1
- package/dist/assets/weight-loss-page-BQrnOI0y.js +0 -1
- package/dist/assets/workbench-flow-page-Du62mtJU.js +0 -5
- package/dist/assets/workbench-page-4MKr3iRm.js +0 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
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
|
|
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((
|
|
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))
|
|
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
|
|
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 ||
|
|
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
|
|
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:
|
|
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
|
-
|
|
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"
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
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"
|
|
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;
|