forge-openclaw-plugin 0.2.99 → 0.2.100
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-copy-Bj4h9OcF.js +1 -0
- package/dist/assets/activity-page-5oyCFOns.js +1 -0
- package/dist/assets/ai-surface-workspace-qgk_B57-.js +1 -0
- package/dist/assets/atlas-panel-rfH2qOez.js +1 -0
- package/dist/assets/{board-Ju0h0SeG.js → board-BkDRaMp6.js} +1 -1
- package/dist/assets/calendar-display-preferences-Cid-2RnL.js +1 -0
- package/dist/assets/calendar-page-Bo2iua-a.js +1 -0
- package/dist/assets/calendar-rules-DA1g3QUk.js +1 -0
- package/dist/assets/calendar-ui-Cy1XRwzV.js +1 -0
- package/dist/assets/calendar-week-toolbar-DU1Q4RYj.js +1 -0
- package/dist/assets/charts-P7EVhIog.js +36 -0
- package/dist/assets/companion-sync-lab-page-CosNknOK.js +1 -0
- package/dist/assets/daily-metrics-dashboard-LjuGAB3f.js +1 -0
- package/dist/assets/date-keys-Cj1G3TOn.js +1 -0
- package/dist/assets/entity-links-DwpxhW2H.js +1 -0
- package/dist/assets/entity-note-count-link-BmGDB572.js +1 -0
- package/dist/assets/entity-notes-surface-DgEgicaE.js +1 -0
- package/dist/assets/execution-board-CDRXQB85.js +1 -0
- package/dist/assets/faceted-token-search-CE1YauRd.js +1 -0
- package/dist/assets/flagship-signal-deck-DDds90Gl.js +1 -0
- package/dist/assets/floating-action-menu-CJkI2iFy.js +1 -0
- package/dist/assets/forms-BFlTgZ3W.js +1 -0
- package/dist/assets/goal-detail-page-cJvHaLMQ.js +1 -0
- package/dist/assets/goals-page-f_39hvUV.js +1 -0
- package/dist/assets/graph-BZV40eAE.css +1 -0
- package/dist/assets/graph-D6JLqDbD.js +318 -0
- package/dist/assets/habits-page-DKb96_mj.js +1 -0
- package/dist/assets/health-link-options-Cpx8w7uM.js +1 -0
- package/dist/assets/index-BHTUu_4M.js +19 -0
- package/dist/assets/index-CZbuZQjw.css +1 -0
- package/dist/assets/insight-flow-dialog-pzAzyayN.js +1 -0
- package/dist/assets/insights-page-Dc9oFltJ.js +8 -0
- package/dist/assets/kanban-page-JAxerYh6.js +1 -0
- package/dist/assets/knowledge-graph-page-UQ3skqEi.js +1 -0
- package/dist/assets/life-force-page-BGDbQuVh.js +1 -0
- package/dist/assets/life-force-workspace-B1fYSXRC.js +1 -0
- package/dist/assets/maps-B-YMMjus.css +1 -0
- package/dist/assets/maps-ClgJoCjz.js +803 -0
- package/dist/assets/metric-tile-DX6TclqM.js +1 -0
- package/dist/assets/{motion-DRPJkN3a.js → motion-BeD44FeG.js} +1 -1
- package/dist/assets/movement-page-6HP6nGJx.js +1 -0
- package/dist/assets/note-markdown-DiW2-5d3.js +3 -0
- package/dist/assets/note-tags-input-DDLXf54U.js +1 -0
- package/dist/assets/notes-page-BuguDjhz.js +1 -0
- package/dist/assets/open-in-graph-button-Cg5VrKsC.js +1 -0
- package/dist/assets/orbit-map-GD05-0oS.js +1 -0
- package/dist/assets/overview-page-DuOs2OCB.js +1 -0
- package/dist/assets/page-hero-CQWo1Mm_.js +1 -0
- package/dist/assets/pill-cluster-BJogDRDJ.js +1 -0
- package/dist/assets/preference-entity-handoff-button-D4WAs9pC.js +1 -0
- package/dist/assets/preferences-page-BaJTMU1I.js +1 -0
- package/dist/assets/project-collections-DvaX20q_.js +1 -0
- package/dist/assets/project-detail-page-drPIFZGb.js +1 -0
- package/dist/assets/project-management-hierarchy-page-BUbRXvny.js +1 -0
- package/dist/assets/project-management-section-nav-C2Ud8Zdd.js +1 -0
- package/dist/assets/projects-page-BGzEZUtg.js +1 -0
- package/dist/assets/psyche-behaviors-page-Dmm_Io9D.js +5 -0
- package/dist/assets/psyche-flashcards-page-BgNKJ6QJ.js +1 -0
- package/dist/assets/psyche-goal-map-page-DXJs98Vr.js +1 -0
- package/dist/assets/psyche-graph-CFgs_Bqc.js +1 -0
- package/dist/assets/psyche-metrics-page-zYTJDbyZ.js +1 -0
- package/dist/assets/psyche-mode-guide-page-XPgRfCOf.js +1 -0
- package/dist/assets/psyche-modes-page-B-GA8oRF.js +1 -0
- package/dist/assets/psyche-page--r6a3e1t.js +1 -0
- package/dist/assets/psyche-patterns-page-BM5-3bMm.js +5 -0
- package/dist/assets/psyche-questionnaire-builder-page-CJshQ-mg.js +1 -0
- package/dist/assets/psyche-questionnaire-detail-page-USmR5G5A.js +1 -0
- package/dist/assets/psyche-questionnaire-run-detail-page-D7iBCmTi.js +1 -0
- package/dist/assets/psyche-questionnaire-run-page-Cpil-kDh.js +1 -0
- package/dist/assets/psyche-questionnaires-page-C-_y3VwS.js +1 -0
- package/dist/assets/psyche-report-detail-page--dkSPRaj.js +3 -0
- package/dist/assets/psyche-reports-page-CUaOXmIN.js +1 -0
- package/dist/assets/psyche-schemas-HFmg37Wj.js +1 -0
- package/dist/assets/psyche-schemas-beliefs-page-BX6xaap3.js +9 -0
- package/dist/assets/psyche-screen-time-page-CAAI4mD7.js +1 -0
- package/dist/assets/psyche-self-observation-page-BZ6FLuwa.js +1 -0
- package/dist/assets/psyche-values-page-yEV6MGt8.js +5 -0
- package/dist/assets/query-cache-IQ8W-LNC.js +1 -0
- package/dist/assets/report-chain-fields-fZ8Xd4H6.js +1 -0
- package/dist/assets/rewards-page-C2HQjIAf.js +1 -0
- package/dist/assets/scheduling-rules-editor-BHOpHOrV.js +1 -0
- package/dist/assets/schema-badge-DyKbxb51.js +1 -0
- package/dist/assets/schema-visuals-D6nxjbYC.js +1 -0
- package/dist/assets/select-menu-BX-pZNqL.js +1 -0
- package/dist/assets/settings-agents-page-VuYXTiyc.js +6 -0
- package/dist/assets/settings-bin-page-BNzvYaOk.js +1 -0
- package/dist/assets/settings-calendar-page-CjSFB53S.js +5 -0
- package/dist/assets/settings-data-page-CGSlryuI.js +1 -0
- package/dist/assets/settings-logs-page-BTK5fine.js +1 -0
- package/dist/assets/settings-mobile-page-CRaObOGo.js +1 -0
- package/dist/assets/settings-models-page-DFshpYF8.js +1 -0
- package/dist/assets/settings-page-BP81Mb5R.js +1 -0
- package/dist/assets/settings-rewards-page-CDJ1PH2G.js +1 -0
- package/dist/assets/settings-section-nav-CCFm27r2.js +1 -0
- package/dist/assets/settings-users-page-TdUocFPa.js +1 -0
- package/dist/assets/settings-wiki-page-B2zX0QQG.js +1 -0
- package/dist/assets/sleep-page-cI1GMVzk.js +1 -0
- package/dist/assets/sports-page-06LTqp0V.js +1 -0
- package/dist/assets/state-B-4sS1xO.js +1 -0
- package/dist/assets/strategies-page-DXP9Kx8s.js +1 -0
- package/dist/assets/strategy-detail-page-D6mx_Mik.js +1 -0
- package/dist/assets/strategy-dialog-BvzomTaF.js +1 -0
- package/dist/assets/{table-DewbFlTh.js → table-WfAPUppN.js} +1 -1
- package/dist/assets/task-detail-page-BIWIggdp.js +1 -0
- package/dist/assets/timebox-planning-dialog-CaCnoslG.js +1 -0
- package/dist/assets/today-page-DO2mRPT2.js +1 -0
- package/dist/assets/training-load-page-CyZ0mlEr.js +1 -0
- package/dist/assets/{ui-C2IvSrAz.js → ui-C13Nbgas.js} +4 -4
- package/dist/assets/use-psyche-focus-target-C1C_XjYG.js +1 -0
- package/dist/assets/vendor-CRS-psbw.css +1 -0
- package/dist/assets/vendor-DHkYh85p.js +1052 -0
- package/dist/assets/vitals-page-BQvEjTc6.js +1 -0
- package/dist/assets/weekly-review-page-Tp6Q9CRj.js +1 -0
- package/dist/assets/weight-loss-page-BBzlhLVV.js +1 -0
- package/dist/assets/wiki-article-markdown-DQYohmW2.js +4 -0
- package/dist/assets/wiki-editor-page-Dem_3eZv.js +26 -0
- package/dist/assets/wiki-ingest-history-page-BxoOcCoJ.js +1 -0
- package/dist/assets/wiki-ingest-modal-DhguKk3J.js +1 -0
- package/dist/assets/wiki-page-BLRxVXkl.js +1 -0
- package/dist/assets/workbench-flow-page-DqMkCCTy.js +5 -0
- package/dist/assets/workbench-page-BWd02wPw.js +1 -0
- package/dist/assets/workout-detail-page-BD8u7GyL.js +2 -0
- package/dist/index.html +148 -9
- package/dist/openclaw/tools.js +340 -0
- package/dist/server/server/migrations/065_weight_loss_nutrition_insights.sql +236 -0
- package/dist/server/server/migrations/066_watch_action_receipts.sql +20 -0
- package/dist/server/server/src/app.js +266 -13
- package/dist/server/server/src/health-weight-loss.js +1378 -0
- package/dist/server/server/src/health.js +188 -35
- package/dist/server/server/src/openapi.js +449 -0
- package/dist/server/server/src/services/context.js +6 -7
- package/dist/server/server/src/services/doctor.js +39 -4
- package/dist/server/server/src/services/gamification.js +146 -34
- package/dist/server/server/src/watch-mobile.js +564 -4
- package/dist/server/server/src/web.js +18 -5
- package/dist/server/src/components/ui/info-tooltip.js +48 -3
- package/dist/server/src/lib/api.js +131 -0
- package/dist/server/src/lib/weight-loss-types.js +1 -0
- package/openclaw.plugin.json +14 -1
- package/package.json +1 -1
- package/server/migrations/065_weight_loss_nutrition_insights.sql +236 -0
- package/server/migrations/066_watch_action_receipts.sql +20 -0
- package/skills/forge-openclaw/SKILL.md +26 -5
- package/skills/forge-openclaw/entity_conversation_playbooks.md +134 -5
- package/skills/forge-openclaw/psyche_entity_playbooks.md +45 -0
- package/dist/assets/index-Cn5Wpwau.css +0 -1
- package/dist/assets/index-CwvGs8n4.js +0 -91
- package/dist/assets/vendor-B-Lq_OG3.css +0 -1
- package/dist/assets/vendor-DL2K5ayT.js +0 -2186
|
@@ -0,0 +1,1378 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
|
+
import { getSettings } from "./repositories/settings.js";
|
|
5
|
+
import { getDefaultUser, resolveUserForMutation } from "./repositories/users.js";
|
|
6
|
+
const optionalNumberSchema = z
|
|
7
|
+
.union([z.coerce.number().finite(), z.null()])
|
|
8
|
+
.optional();
|
|
9
|
+
const scoreSchema = z
|
|
10
|
+
.union([z.coerce.number().int().min(0).max(10), z.null()])
|
|
11
|
+
.optional();
|
|
12
|
+
const tagsSchema = z.array(z.string().trim().min(1)).default([]);
|
|
13
|
+
const linksSchema = z
|
|
14
|
+
.array(z.object({
|
|
15
|
+
entityType: z.string().trim().min(1),
|
|
16
|
+
entityId: z.string().trim().min(1),
|
|
17
|
+
relationshipType: z.string().trim().min(1).default("context")
|
|
18
|
+
}))
|
|
19
|
+
.default([]);
|
|
20
|
+
const mealItemInputSchema = z.preprocess((value) => {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
const record = value;
|
|
25
|
+
return {
|
|
26
|
+
...record,
|
|
27
|
+
calories: record.calories ?? record.caloriesKcal,
|
|
28
|
+
proteinGrams: record.proteinGrams ?? record.proteinG,
|
|
29
|
+
carbohydrateGrams: record.carbohydrateGrams ?? record.carbsG,
|
|
30
|
+
fatGrams: record.fatGrams ?? record.fatG,
|
|
31
|
+
fiberGrams: record.fiberGrams ?? record.fiberG,
|
|
32
|
+
sugarGrams: record.sugarGrams ?? record.sugarG,
|
|
33
|
+
alcoholGrams: record.alcoholGrams ?? record.alcoholG
|
|
34
|
+
};
|
|
35
|
+
}, z.object({
|
|
36
|
+
id: z.string().trim().min(1).optional(),
|
|
37
|
+
foodId: z.string().trim().min(1).nullable().optional(),
|
|
38
|
+
name: z.string().trim().min(1),
|
|
39
|
+
quantity: z.coerce.number().positive().default(1),
|
|
40
|
+
unit: z.string().trim().min(1).default("serving"),
|
|
41
|
+
grams: optionalNumberSchema,
|
|
42
|
+
calories: optionalNumberSchema,
|
|
43
|
+
proteinGrams: optionalNumberSchema,
|
|
44
|
+
carbohydrateGrams: optionalNumberSchema,
|
|
45
|
+
fatGrams: optionalNumberSchema,
|
|
46
|
+
fiberGrams: optionalNumberSchema,
|
|
47
|
+
sugarGrams: optionalNumberSchema,
|
|
48
|
+
sodiumMg: optionalNumberSchema,
|
|
49
|
+
potassiumMg: optionalNumberSchema,
|
|
50
|
+
caffeineMg: optionalNumberSchema,
|
|
51
|
+
alcoholGrams: optionalNumberSchema,
|
|
52
|
+
tags: tagsSchema,
|
|
53
|
+
nutrients: z.record(z.string(), z.unknown()).default({}),
|
|
54
|
+
confidence: z.coerce.number().min(0).max(1).default(0.65)
|
|
55
|
+
}));
|
|
56
|
+
export const nutritionFoodSearchSchema = z.object({
|
|
57
|
+
query: z.string().trim().min(1),
|
|
58
|
+
limit: z.coerce.number().int().min(1).max(25).default(12)
|
|
59
|
+
});
|
|
60
|
+
export const nutritionBarcodeLookupSchema = z.object({
|
|
61
|
+
barcode: z.string().trim().min(3),
|
|
62
|
+
limit: z.coerce.number().int().min(1).max(10).default(5)
|
|
63
|
+
});
|
|
64
|
+
export const nutritionTargetUpdateSchema = z.object({
|
|
65
|
+
userId: z.string().trim().min(1).optional(),
|
|
66
|
+
calorieTarget: optionalNumberSchema,
|
|
67
|
+
proteinGramsTarget: optionalNumberSchema,
|
|
68
|
+
fiberGramsTarget: optionalNumberSchema,
|
|
69
|
+
carbohydrateGramsTarget: optionalNumberSchema,
|
|
70
|
+
fatGramsTarget: optionalNumberSchema,
|
|
71
|
+
weightGoalKg: optionalNumberSchema,
|
|
72
|
+
weeklyRateGoalKg: optionalNumberSchema,
|
|
73
|
+
dietStyle: z.string().trim().default(""),
|
|
74
|
+
bodyGoal: z.string().trim().default(""),
|
|
75
|
+
notes: z.string().trim().default("")
|
|
76
|
+
});
|
|
77
|
+
export const nutritionFoodLogCreateSchema = z.object({
|
|
78
|
+
userId: z.string().trim().min(1).optional(),
|
|
79
|
+
loggedAt: z.string().datetime().optional(),
|
|
80
|
+
mealLabel: z.string().trim().default(""),
|
|
81
|
+
source: z
|
|
82
|
+
.enum(["manual", "search", "barcode", "chatgpt", "photo", "saved_meal"])
|
|
83
|
+
.default("manual"),
|
|
84
|
+
confirmationState: z
|
|
85
|
+
.enum(["candidate", "confirmed", "needs_review", "discarded"])
|
|
86
|
+
.default("confirmed"),
|
|
87
|
+
notes: z.string().trim().default(""),
|
|
88
|
+
placeId: z.string().trim().min(1).nullable().optional(),
|
|
89
|
+
stayId: z.string().trim().min(1).nullable().optional(),
|
|
90
|
+
workoutId: z.string().trim().min(1).nullable().optional(),
|
|
91
|
+
sleepId: z.string().trim().min(1).nullable().optional(),
|
|
92
|
+
imageRefs: z.array(z.string().trim().min(1)).default([]),
|
|
93
|
+
parserProvenance: z.record(z.string(), z.unknown()).default({}),
|
|
94
|
+
links: linksSchema,
|
|
95
|
+
items: z.array(mealItemInputSchema).min(1)
|
|
96
|
+
});
|
|
97
|
+
export const nutritionFoodLogPatchSchema = nutritionFoodLogCreateSchema
|
|
98
|
+
.omit({ userId: true })
|
|
99
|
+
.partial()
|
|
100
|
+
.extend({
|
|
101
|
+
items: z.array(mealItemInputSchema).optional()
|
|
102
|
+
});
|
|
103
|
+
export const nutritionBodyCheckinCreateSchema = z.object({
|
|
104
|
+
userId: z.string().trim().min(1).optional(),
|
|
105
|
+
checkedAt: z.string().datetime().optional(),
|
|
106
|
+
weightKg: optionalNumberSchema,
|
|
107
|
+
waistCm: optionalNumberSchema,
|
|
108
|
+
hipCm: optionalNumberSchema,
|
|
109
|
+
neckCm: optionalNumberSchema,
|
|
110
|
+
chestCm: optionalNumberSchema,
|
|
111
|
+
armCm: optionalNumberSchema,
|
|
112
|
+
thighCm: optionalNumberSchema,
|
|
113
|
+
bodyFatPercent: optionalNumberSchema,
|
|
114
|
+
clothingFitScore: scoreSchema,
|
|
115
|
+
notes: z.string().trim().default("")
|
|
116
|
+
});
|
|
117
|
+
export const nutritionAppearanceCheckinCreateSchema = z.object({
|
|
118
|
+
userId: z.string().trim().min(1).optional(),
|
|
119
|
+
checkedAt: z.string().datetime().optional(),
|
|
120
|
+
photoRefs: z.array(z.string().trim().min(1)).default([]),
|
|
121
|
+
facePuffiness: scoreSchema,
|
|
122
|
+
leanness: scoreSchema,
|
|
123
|
+
muscularity: scoreSchema,
|
|
124
|
+
posture: scoreSchema,
|
|
125
|
+
bloatingLook: scoreSchema,
|
|
126
|
+
confidenceScore: scoreSchema,
|
|
127
|
+
notes: z.string().trim().default("")
|
|
128
|
+
});
|
|
129
|
+
export const nutritionSubjectiveCheckinCreateSchema = z.object({
|
|
130
|
+
userId: z.string().trim().min(1).optional(),
|
|
131
|
+
checkedAt: z.string().datetime().optional(),
|
|
132
|
+
mealLogId: z.string().trim().min(1).nullable().optional(),
|
|
133
|
+
timeRelation: z
|
|
134
|
+
.enum(["before_meal", "with_meal", "after_2h", "end_of_day", "unspecified"])
|
|
135
|
+
.default("unspecified"),
|
|
136
|
+
hunger: scoreSchema,
|
|
137
|
+
fullness: scoreSchema,
|
|
138
|
+
cravings: scoreSchema,
|
|
139
|
+
mood: scoreSchema,
|
|
140
|
+
energy: scoreSchema,
|
|
141
|
+
focus: scoreSchema,
|
|
142
|
+
stress: scoreSchema,
|
|
143
|
+
sleepiness: scoreSchema,
|
|
144
|
+
crashScore: scoreSchema,
|
|
145
|
+
notes: z.string().trim().default("")
|
|
146
|
+
});
|
|
147
|
+
export const nutritionGutCheckinCreateSchema = z.object({
|
|
148
|
+
userId: z.string().trim().min(1).optional(),
|
|
149
|
+
checkedAt: z.string().datetime().optional(),
|
|
150
|
+
mealLogId: z.string().trim().min(1).nullable().optional(),
|
|
151
|
+
bristolStoolType: z
|
|
152
|
+
.union([z.coerce.number().int().min(1).max(7), z.null()])
|
|
153
|
+
.optional(),
|
|
154
|
+
stoolFrequency: optionalNumberSchema,
|
|
155
|
+
bloating: scoreSchema,
|
|
156
|
+
gas: scoreSchema,
|
|
157
|
+
reflux: scoreSchema,
|
|
158
|
+
abdominalPain: scoreSchema,
|
|
159
|
+
urgency: scoreSchema,
|
|
160
|
+
nausea: scoreSchema,
|
|
161
|
+
constipation: scoreSchema,
|
|
162
|
+
diarrhea: scoreSchema,
|
|
163
|
+
triggerTags: tagsSchema,
|
|
164
|
+
notes: z.string().trim().default("")
|
|
165
|
+
});
|
|
166
|
+
export const nutritionExperimentCreateSchema = z.object({
|
|
167
|
+
userId: z.string().trim().min(1).optional(),
|
|
168
|
+
hypothesisId: z.string().trim().min(1).nullable().optional(),
|
|
169
|
+
title: z.string().trim().min(1),
|
|
170
|
+
status: z.enum(["planned", "running", "complete", "paused"]).default("planned"),
|
|
171
|
+
baselineStart: z.string().trim().nullable().optional(),
|
|
172
|
+
baselineEnd: z.string().trim().nullable().optional(),
|
|
173
|
+
interventionStart: z.string().trim().nullable().optional(),
|
|
174
|
+
interventionEnd: z.string().trim().nullable().optional(),
|
|
175
|
+
trackedOutcomes: tagsSchema,
|
|
176
|
+
protocol: z.record(z.string(), z.unknown()).default({}),
|
|
177
|
+
adherence: z.record(z.string(), z.unknown()).default({}),
|
|
178
|
+
resultSummary: z.string().trim().default("")
|
|
179
|
+
});
|
|
180
|
+
export const nutritionExperimentPatchSchema = nutritionExperimentCreateSchema.omit({ userId: true }).partial();
|
|
181
|
+
export const nutritionParseRequestSchema = z.object({
|
|
182
|
+
text: z.string().trim().min(1),
|
|
183
|
+
mealTime: z.string().datetime().optional(),
|
|
184
|
+
imageRefs: z.array(z.string().trim().min(1)).default([]),
|
|
185
|
+
userId: z.string().trim().min(1).optional(),
|
|
186
|
+
connectionId: z.string().trim().min(1).optional(),
|
|
187
|
+
commitCandidate: z.boolean().default(true)
|
|
188
|
+
});
|
|
189
|
+
function nowIso() {
|
|
190
|
+
return new Date().toISOString();
|
|
191
|
+
}
|
|
192
|
+
function newId(prefix) {
|
|
193
|
+
return `${prefix}_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
194
|
+
}
|
|
195
|
+
function dayKey(value) {
|
|
196
|
+
return value.slice(0, 10);
|
|
197
|
+
}
|
|
198
|
+
function jsonString(value) {
|
|
199
|
+
return JSON.stringify(value ?? null);
|
|
200
|
+
}
|
|
201
|
+
function parseJson(value, fallback) {
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(value);
|
|
204
|
+
return parsed ?? fallback;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return fallback;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function n(value) {
|
|
211
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
212
|
+
}
|
|
213
|
+
function round(value, digits = 1) {
|
|
214
|
+
const factor = 10 ** digits;
|
|
215
|
+
return Math.round(value * factor) / factor;
|
|
216
|
+
}
|
|
217
|
+
function average(values) {
|
|
218
|
+
const real = values.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
219
|
+
if (real.length === 0) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return real.reduce((sum, value) => sum + value, 0) / real.length;
|
|
223
|
+
}
|
|
224
|
+
function resolveWriteUser(userId) {
|
|
225
|
+
return resolveUserForMutation(userId ?? null).id;
|
|
226
|
+
}
|
|
227
|
+
function resolveReadUser(userIds) {
|
|
228
|
+
return userIds?.[0] ?? getDefaultUser().id;
|
|
229
|
+
}
|
|
230
|
+
function mapFood(row) {
|
|
231
|
+
return {
|
|
232
|
+
id: row.id,
|
|
233
|
+
source: row.source,
|
|
234
|
+
sourceId: row.source_id,
|
|
235
|
+
barcode: row.barcode,
|
|
236
|
+
name: row.name,
|
|
237
|
+
brand: row.brand,
|
|
238
|
+
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,
|
|
247
|
+
potassiumMg: row.potassium_mg,
|
|
248
|
+
caffeineMg: row.caffeine_mg,
|
|
249
|
+
alcoholGrams: row.alcohol_grams,
|
|
250
|
+
novaGroup: row.nova_group,
|
|
251
|
+
nutriScore: row.nutri_score,
|
|
252
|
+
tags: parseJson(row.tags_json, []),
|
|
253
|
+
nutrients: parseJson(row.nutrients_json, {}),
|
|
254
|
+
confidence: row.confidence,
|
|
255
|
+
createdAt: row.created_at,
|
|
256
|
+
updatedAt: row.updated_at
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function mapItem(row) {
|
|
260
|
+
return {
|
|
261
|
+
id: row.id,
|
|
262
|
+
logId: row.log_id,
|
|
263
|
+
foodId: row.food_id,
|
|
264
|
+
name: row.name,
|
|
265
|
+
quantity: row.quantity,
|
|
266
|
+
unit: row.unit,
|
|
267
|
+
grams: row.grams,
|
|
268
|
+
calories: row.calories,
|
|
269
|
+
proteinGrams: row.protein_grams,
|
|
270
|
+
carbohydrateGrams: row.carbohydrate_grams,
|
|
271
|
+
fatGrams: row.fat_grams,
|
|
272
|
+
fiberGrams: row.fiber_grams,
|
|
273
|
+
sugarGrams: row.sugar_grams,
|
|
274
|
+
sodiumMg: row.sodium_mg,
|
|
275
|
+
potassiumMg: row.potassium_mg,
|
|
276
|
+
caffeineMg: row.caffeine_mg,
|
|
277
|
+
alcoholGrams: row.alcohol_grams,
|
|
278
|
+
tags: parseJson(row.tags_json, []),
|
|
279
|
+
nutrients: parseJson(row.nutrients_json, {}),
|
|
280
|
+
confidence: row.confidence,
|
|
281
|
+
createdAt: row.created_at,
|
|
282
|
+
updatedAt: row.updated_at
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function mapFoodLog(row, items = []) {
|
|
286
|
+
return {
|
|
287
|
+
id: row.id,
|
|
288
|
+
userId: row.user_id,
|
|
289
|
+
loggedAt: row.logged_at,
|
|
290
|
+
mealLabel: row.meal_label,
|
|
291
|
+
source: row.source,
|
|
292
|
+
confirmationState: row.confirmation_state,
|
|
293
|
+
notes: row.notes,
|
|
294
|
+
placeId: row.place_id,
|
|
295
|
+
stayId: row.stay_id,
|
|
296
|
+
workoutId: row.workout_id,
|
|
297
|
+
sleepId: row.sleep_id,
|
|
298
|
+
dayKey: row.day_key,
|
|
299
|
+
imageRefs: parseJson(row.image_refs_json, []),
|
|
300
|
+
parserProvenance: parseJson(row.parser_provenance_json, {}),
|
|
301
|
+
links: parseJson(row.links_json, []),
|
|
302
|
+
items: items.map(mapItem),
|
|
303
|
+
totals: sumItems(items.map(mapItem)),
|
|
304
|
+
createdAt: row.created_at,
|
|
305
|
+
updatedAt: row.updated_at
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function mapTarget(row, userId) {
|
|
309
|
+
return {
|
|
310
|
+
id: row?.id ?? null,
|
|
311
|
+
userId,
|
|
312
|
+
calorieTarget: row?.calorie_target ?? 2200,
|
|
313
|
+
proteinGramsTarget: row?.protein_grams_target ?? 140,
|
|
314
|
+
fiberGramsTarget: row?.fiber_grams_target ?? 30,
|
|
315
|
+
carbohydrateGramsTarget: row?.carbohydrate_grams_target ?? null,
|
|
316
|
+
fatGramsTarget: row?.fat_grams_target ?? null,
|
|
317
|
+
weightGoalKg: row?.weight_goal_kg ?? null,
|
|
318
|
+
weeklyRateGoalKg: row?.weekly_rate_goal_kg ?? -0.35,
|
|
319
|
+
dietStyle: row?.diet_style ?? "",
|
|
320
|
+
bodyGoal: row?.body_goal ?? "",
|
|
321
|
+
notes: row?.notes ?? "",
|
|
322
|
+
createdAt: row?.created_at ?? null,
|
|
323
|
+
updatedAt: row?.updated_at ?? null
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function sumItems(items) {
|
|
327
|
+
return {
|
|
328
|
+
calories: round(items.reduce((sum, item) => sum + n(item.calories), 0), 0),
|
|
329
|
+
proteinGrams: round(items.reduce((sum, item) => sum + n(item.proteinGrams), 0), 1),
|
|
330
|
+
carbohydrateGrams: round(items.reduce((sum, item) => sum + n(item.carbohydrateGrams), 0), 1),
|
|
331
|
+
fatGrams: round(items.reduce((sum, item) => sum + n(item.fatGrams), 0), 1),
|
|
332
|
+
fiberGrams: round(items.reduce((sum, item) => sum + n(item.fiberGrams), 0), 1),
|
|
333
|
+
sugarGrams: round(items.reduce((sum, item) => sum + n(item.sugarGrams), 0), 1),
|
|
334
|
+
sodiumMg: round(items.reduce((sum, item) => sum + n(item.sodiumMg), 0), 0),
|
|
335
|
+
potassiumMg: round(items.reduce((sum, item) => sum + n(item.potassiumMg), 0), 0),
|
|
336
|
+
caffeineMg: round(items.reduce((sum, item) => sum + n(item.caffeineMg), 0), 0),
|
|
337
|
+
alcoholGrams: round(items.reduce((sum, item) => sum + n(item.alcoholGrams), 0), 1)
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function readMealItems(logIds) {
|
|
341
|
+
if (logIds.length === 0) {
|
|
342
|
+
return new Map();
|
|
343
|
+
}
|
|
344
|
+
const placeholders = logIds.map(() => "?").join(",");
|
|
345
|
+
const rows = getDatabase()
|
|
346
|
+
.prepare(`SELECT *
|
|
347
|
+
FROM nutrition_meal_items
|
|
348
|
+
WHERE log_id IN (${placeholders})
|
|
349
|
+
ORDER BY created_at ASC`)
|
|
350
|
+
.all(...logIds);
|
|
351
|
+
const byLog = new Map();
|
|
352
|
+
for (const row of rows) {
|
|
353
|
+
byLog.set(row.log_id, [...(byLog.get(row.log_id) ?? []), row]);
|
|
354
|
+
}
|
|
355
|
+
return byLog;
|
|
356
|
+
}
|
|
357
|
+
function listFoodLogs(userId, limit = 120) {
|
|
358
|
+
const rows = getDatabase()
|
|
359
|
+
.prepare(`SELECT *
|
|
360
|
+
FROM nutrition_food_logs
|
|
361
|
+
WHERE user_id = ?
|
|
362
|
+
AND confirmation_state != 'discarded'
|
|
363
|
+
ORDER BY logged_at DESC
|
|
364
|
+
LIMIT ?`)
|
|
365
|
+
.all(userId, limit);
|
|
366
|
+
const itemsByLog = readMealItems(rows.map((row) => row.id));
|
|
367
|
+
return rows.map((row) => mapFoodLog(row, itemsByLog.get(row.id) ?? []));
|
|
368
|
+
}
|
|
369
|
+
function insertMealItem(logId, input) {
|
|
370
|
+
const now = nowIso();
|
|
371
|
+
const id = input.id ?? newId("meal_item");
|
|
372
|
+
getDatabase()
|
|
373
|
+
.prepare(`INSERT INTO nutrition_meal_items (
|
|
374
|
+
id, log_id, food_id, name, quantity, unit, grams, calories,
|
|
375
|
+
protein_grams, carbohydrate_grams, fat_grams, fiber_grams, sugar_grams,
|
|
376
|
+
sodium_mg, potassium_mg, caffeine_mg, alcohol_grams, tags_json,
|
|
377
|
+
nutrients_json, confidence, created_at, updated_at
|
|
378
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
379
|
+
.run(id, logId, input.foodId ?? null, input.name, input.quantity, input.unit, input.grams ?? null, input.calories ?? null, input.proteinGrams ?? null, input.carbohydrateGrams ?? null, input.fatGrams ?? null, input.fiberGrams ?? null, input.sugarGrams ?? null, input.sodiumMg ?? null, input.potassiumMg ?? null, input.caffeineMg ?? null, input.alcoholGrams ?? null, jsonString(input.tags), jsonString(input.nutrients), input.confidence, now, now);
|
|
380
|
+
}
|
|
381
|
+
export function createNutritionFoodLog(input) {
|
|
382
|
+
const parsed = nutritionFoodLogCreateSchema.parse(input);
|
|
383
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
384
|
+
const loggedAt = parsed.loggedAt ?? nowIso();
|
|
385
|
+
const id = newId("meal");
|
|
386
|
+
const now = nowIso();
|
|
387
|
+
runInTransaction(() => {
|
|
388
|
+
getDatabase()
|
|
389
|
+
.prepare(`INSERT INTO nutrition_food_logs (
|
|
390
|
+
id, user_id, logged_at, meal_label, source, confirmation_state, notes,
|
|
391
|
+
place_id, stay_id, workout_id, sleep_id, day_key, image_refs_json,
|
|
392
|
+
parser_provenance_json, links_json, created_at, updated_at
|
|
393
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
394
|
+
.run(id, userId, loggedAt, parsed.mealLabel, parsed.source, parsed.confirmationState, parsed.notes, parsed.placeId ?? null, parsed.stayId ?? null, parsed.workoutId ?? null, parsed.sleepId ?? null, dayKey(loggedAt), jsonString(parsed.imageRefs), jsonString(parsed.parserProvenance), jsonString(parsed.links), now, now);
|
|
395
|
+
for (const item of parsed.items) {
|
|
396
|
+
insertMealItem(id, item);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
return getNutritionFoodLogById(id);
|
|
400
|
+
}
|
|
401
|
+
export function patchNutritionFoodLog(logId, input) {
|
|
402
|
+
const parsed = nutritionFoodLogPatchSchema.parse(input);
|
|
403
|
+
const existing = getNutritionFoodLogById(logId);
|
|
404
|
+
if (!existing) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const nextLoggedAt = parsed.loggedAt ?? existing.loggedAt;
|
|
408
|
+
const now = nowIso();
|
|
409
|
+
runInTransaction(() => {
|
|
410
|
+
getDatabase()
|
|
411
|
+
.prepare(`UPDATE nutrition_food_logs
|
|
412
|
+
SET logged_at = ?, meal_label = ?, source = ?, confirmation_state = ?,
|
|
413
|
+
notes = ?, place_id = ?, stay_id = ?, workout_id = ?, sleep_id = ?,
|
|
414
|
+
day_key = ?, image_refs_json = ?, parser_provenance_json = ?,
|
|
415
|
+
links_json = ?, updated_at = ?
|
|
416
|
+
WHERE id = ?`)
|
|
417
|
+
.run(nextLoggedAt, parsed.mealLabel ?? existing.mealLabel, parsed.source ?? existing.source, parsed.confirmationState ?? existing.confirmationState, parsed.notes ?? existing.notes, parsed.placeId !== undefined ? parsed.placeId : existing.placeId, parsed.stayId !== undefined ? parsed.stayId : existing.stayId, parsed.workoutId !== undefined ? parsed.workoutId : existing.workoutId, parsed.sleepId !== undefined ? parsed.sleepId : existing.sleepId, dayKey(nextLoggedAt), jsonString(parsed.imageRefs ?? existing.imageRefs), jsonString(parsed.parserProvenance ?? existing.parserProvenance), jsonString(parsed.links ?? existing.links), now, logId);
|
|
418
|
+
if (parsed.items) {
|
|
419
|
+
getDatabase()
|
|
420
|
+
.prepare(`DELETE FROM nutrition_meal_items WHERE log_id = ?`)
|
|
421
|
+
.run(logId);
|
|
422
|
+
for (const item of parsed.items) {
|
|
423
|
+
insertMealItem(logId, item);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
return getNutritionFoodLogById(logId);
|
|
428
|
+
}
|
|
429
|
+
export function deleteNutritionFoodLog(logId) {
|
|
430
|
+
const existing = getNutritionFoodLogById(logId);
|
|
431
|
+
if (!existing) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
getDatabase()
|
|
435
|
+
.prepare(`UPDATE nutrition_food_logs
|
|
436
|
+
SET confirmation_state = 'discarded', updated_at = ?
|
|
437
|
+
WHERE id = ?`)
|
|
438
|
+
.run(nowIso(), logId);
|
|
439
|
+
return existing;
|
|
440
|
+
}
|
|
441
|
+
export function getNutritionFoodLogById(logId) {
|
|
442
|
+
const row = getDatabase()
|
|
443
|
+
.prepare(`SELECT * FROM nutrition_food_logs WHERE id = ?`)
|
|
444
|
+
.get(logId);
|
|
445
|
+
if (!row) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
const itemsByLog = readMealItems([logId]);
|
|
449
|
+
return mapFoodLog(row, itemsByLog.get(logId) ?? []);
|
|
450
|
+
}
|
|
451
|
+
export function updateNutritionTarget(input) {
|
|
452
|
+
const parsed = nutritionTargetUpdateSchema.parse(input);
|
|
453
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
454
|
+
const existing = getDatabase()
|
|
455
|
+
.prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
|
|
456
|
+
.get(userId);
|
|
457
|
+
const id = existing?.id ?? newId("nutrition_target");
|
|
458
|
+
const now = nowIso();
|
|
459
|
+
getDatabase()
|
|
460
|
+
.prepare(`INSERT INTO nutrition_targets (
|
|
461
|
+
id, user_id, calorie_target, protein_grams_target, fiber_grams_target,
|
|
462
|
+
carbohydrate_grams_target, fat_grams_target, weight_goal_kg,
|
|
463
|
+
weekly_rate_goal_kg, diet_style, body_goal, notes, created_at, updated_at
|
|
464
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
465
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
466
|
+
calorie_target = excluded.calorie_target,
|
|
467
|
+
protein_grams_target = excluded.protein_grams_target,
|
|
468
|
+
fiber_grams_target = excluded.fiber_grams_target,
|
|
469
|
+
carbohydrate_grams_target = excluded.carbohydrate_grams_target,
|
|
470
|
+
fat_grams_target = excluded.fat_grams_target,
|
|
471
|
+
weight_goal_kg = excluded.weight_goal_kg,
|
|
472
|
+
weekly_rate_goal_kg = excluded.weekly_rate_goal_kg,
|
|
473
|
+
diet_style = excluded.diet_style,
|
|
474
|
+
body_goal = excluded.body_goal,
|
|
475
|
+
notes = excluded.notes,
|
|
476
|
+
updated_at = excluded.updated_at`)
|
|
477
|
+
.run(id, userId, parsed.calorieTarget ?? null, parsed.proteinGramsTarget ?? null, parsed.fiberGramsTarget ?? null, parsed.carbohydrateGramsTarget ?? null, parsed.fatGramsTarget ?? null, parsed.weightGoalKg ?? null, parsed.weeklyRateGoalKg ?? null, parsed.dietStyle, parsed.bodyGoal, parsed.notes, existing?.created_at ?? now, now);
|
|
478
|
+
return mapTarget(getDatabase()
|
|
479
|
+
.prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
|
|
480
|
+
.get(userId), userId);
|
|
481
|
+
}
|
|
482
|
+
export function createNutritionBodyCheckin(input) {
|
|
483
|
+
const parsed = nutritionBodyCheckinCreateSchema.parse(input);
|
|
484
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
485
|
+
const checkedAt = parsed.checkedAt ?? nowIso();
|
|
486
|
+
const id = newId("body");
|
|
487
|
+
const now = nowIso();
|
|
488
|
+
getDatabase()
|
|
489
|
+
.prepare(`INSERT INTO nutrition_body_checkins (
|
|
490
|
+
id, user_id, checked_at, weight_kg, waist_cm, hip_cm, neck_cm, chest_cm,
|
|
491
|
+
arm_cm, thigh_cm, body_fat_percent, clothing_fit_score, notes,
|
|
492
|
+
created_at, updated_at
|
|
493
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
494
|
+
.run(id, userId, checkedAt, parsed.weightKg ?? null, parsed.waistCm ?? null, parsed.hipCm ?? null, parsed.neckCm ?? null, parsed.chestCm ?? null, parsed.armCm ?? null, parsed.thighCm ?? null, parsed.bodyFatPercent ?? null, parsed.clothingFitScore ?? null, parsed.notes, now, now);
|
|
495
|
+
return listBodyCheckins(userId, 1)[0];
|
|
496
|
+
}
|
|
497
|
+
export function createNutritionAppearanceCheckin(input) {
|
|
498
|
+
const parsed = nutritionAppearanceCheckinCreateSchema.parse(input);
|
|
499
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
500
|
+
const checkedAt = parsed.checkedAt ?? nowIso();
|
|
501
|
+
const id = newId("appearance");
|
|
502
|
+
const now = nowIso();
|
|
503
|
+
getDatabase()
|
|
504
|
+
.prepare(`INSERT INTO nutrition_appearance_checkins (
|
|
505
|
+
id, user_id, checked_at, photo_refs_json, face_puffiness, leanness,
|
|
506
|
+
muscularity, posture, bloating_look, confidence_score, notes,
|
|
507
|
+
created_at, updated_at
|
|
508
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
509
|
+
.run(id, userId, checkedAt, jsonString(parsed.photoRefs), parsed.facePuffiness ?? null, parsed.leanness ?? null, parsed.muscularity ?? null, parsed.posture ?? null, parsed.bloatingLook ?? null, parsed.confidenceScore ?? null, parsed.notes, now, now);
|
|
510
|
+
return listAppearanceCheckins(userId, 1)[0];
|
|
511
|
+
}
|
|
512
|
+
export function createNutritionSubjectiveCheckin(input) {
|
|
513
|
+
const parsed = nutritionSubjectiveCheckinCreateSchema.parse(input);
|
|
514
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
515
|
+
const checkedAt = parsed.checkedAt ?? nowIso();
|
|
516
|
+
const id = newId("subjective");
|
|
517
|
+
const now = nowIso();
|
|
518
|
+
getDatabase()
|
|
519
|
+
.prepare(`INSERT INTO nutrition_subjective_checkins (
|
|
520
|
+
id, user_id, checked_at, meal_log_id, time_relation, hunger, fullness,
|
|
521
|
+
cravings, mood, energy, focus, stress, sleepiness, crash_score, notes,
|
|
522
|
+
created_at, updated_at
|
|
523
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
524
|
+
.run(id, userId, checkedAt, parsed.mealLogId ?? null, parsed.timeRelation, parsed.hunger ?? null, parsed.fullness ?? null, parsed.cravings ?? null, parsed.mood ?? null, parsed.energy ?? null, parsed.focus ?? null, parsed.stress ?? null, parsed.sleepiness ?? null, parsed.crashScore ?? null, parsed.notes, now, now);
|
|
525
|
+
return listSubjectiveCheckins(userId, 1)[0];
|
|
526
|
+
}
|
|
527
|
+
export function createNutritionGutCheckin(input) {
|
|
528
|
+
const parsed = nutritionGutCheckinCreateSchema.parse(input);
|
|
529
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
530
|
+
const checkedAt = parsed.checkedAt ?? nowIso();
|
|
531
|
+
const id = newId("gut");
|
|
532
|
+
const now = nowIso();
|
|
533
|
+
getDatabase()
|
|
534
|
+
.prepare(`INSERT INTO nutrition_gut_checkins (
|
|
535
|
+
id, user_id, checked_at, meal_log_id, bristol_stool_type,
|
|
536
|
+
stool_frequency, bloating, gas, reflux, abdominal_pain, urgency, nausea,
|
|
537
|
+
constipation, diarrhea, trigger_tags_json, notes, created_at, updated_at
|
|
538
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
539
|
+
.run(id, userId, checkedAt, parsed.mealLogId ?? null, parsed.bristolStoolType ?? null, parsed.stoolFrequency ?? null, parsed.bloating ?? null, parsed.gas ?? null, parsed.reflux ?? null, parsed.abdominalPain ?? null, parsed.urgency ?? null, parsed.nausea ?? null, parsed.constipation ?? null, parsed.diarrhea ?? null, jsonString(parsed.triggerTags), parsed.notes, now, now);
|
|
540
|
+
return listGutCheckins(userId, 1)[0];
|
|
541
|
+
}
|
|
542
|
+
export function createNutritionExperiment(input) {
|
|
543
|
+
const parsed = nutritionExperimentCreateSchema.parse(input);
|
|
544
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
545
|
+
const id = newId("nutrition_experiment");
|
|
546
|
+
const now = nowIso();
|
|
547
|
+
getDatabase()
|
|
548
|
+
.prepare(`INSERT INTO nutrition_experiments (
|
|
549
|
+
id, user_id, hypothesis_id, title, status, baseline_start, baseline_end,
|
|
550
|
+
intervention_start, intervention_end, tracked_outcomes_json,
|
|
551
|
+
protocol_json, adherence_json, result_summary, created_at, updated_at
|
|
552
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
553
|
+
.run(id, userId, parsed.hypothesisId ?? null, parsed.title, parsed.status, parsed.baselineStart ?? null, parsed.baselineEnd ?? null, parsed.interventionStart ?? null, parsed.interventionEnd ?? null, jsonString(parsed.trackedOutcomes), jsonString(parsed.protocol), jsonString(parsed.adherence), parsed.resultSummary, now, now);
|
|
554
|
+
return listExperiments(userId, 1)[0];
|
|
555
|
+
}
|
|
556
|
+
export function patchNutritionExperiment(experimentId, input) {
|
|
557
|
+
const parsed = nutritionExperimentPatchSchema.parse(input);
|
|
558
|
+
const existing = getDatabase()
|
|
559
|
+
.prepare(`SELECT * FROM nutrition_experiments WHERE id = ?`)
|
|
560
|
+
.get(experimentId);
|
|
561
|
+
if (!existing) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
getDatabase()
|
|
565
|
+
.prepare(`UPDATE nutrition_experiments
|
|
566
|
+
SET hypothesis_id = ?, title = ?, status = ?, baseline_start = ?,
|
|
567
|
+
baseline_end = ?, intervention_start = ?, intervention_end = ?,
|
|
568
|
+
tracked_outcomes_json = ?, protocol_json = ?, adherence_json = ?,
|
|
569
|
+
result_summary = ?, updated_at = ?
|
|
570
|
+
WHERE id = ?`)
|
|
571
|
+
.run(parsed.hypothesisId !== undefined
|
|
572
|
+
? parsed.hypothesisId
|
|
573
|
+
: existing.hypothesis_id, parsed.title ?? existing.title, parsed.status ?? existing.status, parsed.baselineStart !== undefined
|
|
574
|
+
? parsed.baselineStart
|
|
575
|
+
: existing.baseline_start, parsed.baselineEnd !== undefined ? parsed.baselineEnd : existing.baseline_end, parsed.interventionStart !== undefined
|
|
576
|
+
? parsed.interventionStart
|
|
577
|
+
: existing.intervention_start, parsed.interventionEnd !== undefined
|
|
578
|
+
? parsed.interventionEnd
|
|
579
|
+
: existing.intervention_end, parsed.trackedOutcomes
|
|
580
|
+
? jsonString(parsed.trackedOutcomes)
|
|
581
|
+
: existing.tracked_outcomes_json, parsed.protocol ? jsonString(parsed.protocol) : existing.protocol_json, parsed.adherence ? jsonString(parsed.adherence) : existing.adherence_json, parsed.resultSummary ?? existing.result_summary, nowIso(), experimentId);
|
|
582
|
+
return getDatabase()
|
|
583
|
+
.prepare(`SELECT * FROM nutrition_experiments WHERE id = ?`)
|
|
584
|
+
.get(experimentId);
|
|
585
|
+
}
|
|
586
|
+
function listBodyCheckins(userId, limit = 30) {
|
|
587
|
+
return getDatabase()
|
|
588
|
+
.prepare(`SELECT * FROM nutrition_body_checkins
|
|
589
|
+
WHERE user_id = ?
|
|
590
|
+
ORDER BY checked_at DESC
|
|
591
|
+
LIMIT ?`)
|
|
592
|
+
.all(userId, limit).map((row) => ({
|
|
593
|
+
id: row.id,
|
|
594
|
+
userId: row.user_id,
|
|
595
|
+
checkedAt: row.checked_at,
|
|
596
|
+
weightKg: row.weight_kg,
|
|
597
|
+
waistCm: row.waist_cm,
|
|
598
|
+
hipCm: row.hip_cm,
|
|
599
|
+
neckCm: row.neck_cm,
|
|
600
|
+
chestCm: row.chest_cm,
|
|
601
|
+
armCm: row.arm_cm,
|
|
602
|
+
thighCm: row.thigh_cm,
|
|
603
|
+
bodyFatPercent: row.body_fat_percent,
|
|
604
|
+
clothingFitScore: row.clothing_fit_score,
|
|
605
|
+
notes: row.notes,
|
|
606
|
+
createdAt: row.created_at,
|
|
607
|
+
updatedAt: row.updated_at
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
function listAppearanceCheckins(userId, limit = 20) {
|
|
611
|
+
return getDatabase()
|
|
612
|
+
.prepare(`SELECT * FROM nutrition_appearance_checkins
|
|
613
|
+
WHERE user_id = ?
|
|
614
|
+
ORDER BY checked_at DESC
|
|
615
|
+
LIMIT ?`)
|
|
616
|
+
.all(userId, limit).map((row) => ({
|
|
617
|
+
id: row.id,
|
|
618
|
+
userId: row.user_id,
|
|
619
|
+
checkedAt: row.checked_at,
|
|
620
|
+
photoRefs: parseJson(row.photo_refs_json, []),
|
|
621
|
+
facePuffiness: row.face_puffiness,
|
|
622
|
+
leanness: row.leanness,
|
|
623
|
+
muscularity: row.muscularity,
|
|
624
|
+
posture: row.posture,
|
|
625
|
+
bloatingLook: row.bloating_look,
|
|
626
|
+
confidenceScore: row.confidence_score,
|
|
627
|
+
notes: row.notes,
|
|
628
|
+
createdAt: row.created_at,
|
|
629
|
+
updatedAt: row.updated_at
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
function listSubjectiveCheckins(userId, limit = 40) {
|
|
633
|
+
return getDatabase()
|
|
634
|
+
.prepare(`SELECT * FROM nutrition_subjective_checkins
|
|
635
|
+
WHERE user_id = ?
|
|
636
|
+
ORDER BY checked_at DESC
|
|
637
|
+
LIMIT ?`)
|
|
638
|
+
.all(userId, limit).map((row) => ({
|
|
639
|
+
id: row.id,
|
|
640
|
+
userId: row.user_id,
|
|
641
|
+
checkedAt: row.checked_at,
|
|
642
|
+
mealLogId: row.meal_log_id,
|
|
643
|
+
timeRelation: row.time_relation,
|
|
644
|
+
hunger: row.hunger,
|
|
645
|
+
fullness: row.fullness,
|
|
646
|
+
cravings: row.cravings,
|
|
647
|
+
mood: row.mood,
|
|
648
|
+
energy: row.energy,
|
|
649
|
+
focus: row.focus,
|
|
650
|
+
stress: row.stress,
|
|
651
|
+
sleepiness: row.sleepiness,
|
|
652
|
+
crashScore: row.crash_score,
|
|
653
|
+
notes: row.notes,
|
|
654
|
+
createdAt: row.created_at,
|
|
655
|
+
updatedAt: row.updated_at
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
function listGutCheckins(userId, limit = 40) {
|
|
659
|
+
return getDatabase()
|
|
660
|
+
.prepare(`SELECT * FROM nutrition_gut_checkins
|
|
661
|
+
WHERE user_id = ?
|
|
662
|
+
ORDER BY checked_at DESC
|
|
663
|
+
LIMIT ?`)
|
|
664
|
+
.all(userId, limit).map((row) => ({
|
|
665
|
+
id: row.id,
|
|
666
|
+
userId: row.user_id,
|
|
667
|
+
checkedAt: row.checked_at,
|
|
668
|
+
mealLogId: row.meal_log_id,
|
|
669
|
+
bristolStoolType: row.bristol_stool_type,
|
|
670
|
+
stoolFrequency: row.stool_frequency,
|
|
671
|
+
bloating: row.bloating,
|
|
672
|
+
gas: row.gas,
|
|
673
|
+
reflux: row.reflux,
|
|
674
|
+
abdominalPain: row.abdominal_pain,
|
|
675
|
+
urgency: row.urgency,
|
|
676
|
+
nausea: row.nausea,
|
|
677
|
+
constipation: row.constipation,
|
|
678
|
+
diarrhea: row.diarrhea,
|
|
679
|
+
triggerTags: parseJson(row.trigger_tags_json, []),
|
|
680
|
+
notes: row.notes,
|
|
681
|
+
createdAt: row.created_at,
|
|
682
|
+
updatedAt: row.updated_at
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
function listHypotheses(userId, limit = 20) {
|
|
686
|
+
return getDatabase()
|
|
687
|
+
.prepare(`SELECT * FROM nutrition_hypotheses
|
|
688
|
+
WHERE user_id = ?
|
|
689
|
+
ORDER BY confidence DESC, evidence_count DESC, updated_at DESC
|
|
690
|
+
LIMIT ?`)
|
|
691
|
+
.all(userId, limit).map((row) => ({
|
|
692
|
+
id: row.id,
|
|
693
|
+
userId: row.user_id,
|
|
694
|
+
title: row.title,
|
|
695
|
+
summary: row.summary,
|
|
696
|
+
status: row.status,
|
|
697
|
+
confidence: row.confidence,
|
|
698
|
+
evidenceCount: row.evidence_count,
|
|
699
|
+
signalKey: row.signal_key,
|
|
700
|
+
outcomeKey: row.outcome_key,
|
|
701
|
+
lagWindow: row.lag_window,
|
|
702
|
+
evidence: parseJson(row.evidence_json, {}),
|
|
703
|
+
confounders: parseJson(row.confounders_json, []),
|
|
704
|
+
suggestedAction: row.suggested_action,
|
|
705
|
+
createdAt: row.created_at,
|
|
706
|
+
updatedAt: row.updated_at
|
|
707
|
+
}));
|
|
708
|
+
}
|
|
709
|
+
function listExperiments(userId, limit = 20) {
|
|
710
|
+
return getDatabase()
|
|
711
|
+
.prepare(`SELECT * FROM nutrition_experiments
|
|
712
|
+
WHERE user_id = ?
|
|
713
|
+
ORDER BY updated_at DESC
|
|
714
|
+
LIMIT ?`)
|
|
715
|
+
.all(userId, limit).map((row) => ({
|
|
716
|
+
id: row.id,
|
|
717
|
+
userId: row.user_id,
|
|
718
|
+
hypothesisId: row.hypothesis_id,
|
|
719
|
+
title: row.title,
|
|
720
|
+
status: row.status,
|
|
721
|
+
baselineStart: row.baseline_start,
|
|
722
|
+
baselineEnd: row.baseline_end,
|
|
723
|
+
interventionStart: row.intervention_start,
|
|
724
|
+
interventionEnd: row.intervention_end,
|
|
725
|
+
trackedOutcomes: parseJson(row.tracked_outcomes_json, []),
|
|
726
|
+
protocol: parseJson(row.protocol_json, {}),
|
|
727
|
+
adherence: parseJson(row.adherence_json, {}),
|
|
728
|
+
resultSummary: row.result_summary,
|
|
729
|
+
createdAt: row.created_at,
|
|
730
|
+
updatedAt: row.updated_at
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
function buildTodayLedger(logs, target) {
|
|
734
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
735
|
+
const todayLogs = logs.filter((log) => log.dayKey === today);
|
|
736
|
+
const totals = todayLogs.reduce((acc, log) => ({
|
|
737
|
+
calories: acc.calories + log.totals.calories,
|
|
738
|
+
proteinGrams: acc.proteinGrams + log.totals.proteinGrams,
|
|
739
|
+
carbohydrateGrams: acc.carbohydrateGrams + log.totals.carbohydrateGrams,
|
|
740
|
+
fatGrams: acc.fatGrams + log.totals.fatGrams,
|
|
741
|
+
fiberGrams: acc.fiberGrams + log.totals.fiberGrams,
|
|
742
|
+
sodiumMg: acc.sodiumMg + log.totals.sodiumMg,
|
|
743
|
+
caffeineMg: acc.caffeineMg + log.totals.caffeineMg,
|
|
744
|
+
alcoholGrams: acc.alcoholGrams + log.totals.alcoholGrams
|
|
745
|
+
}), {
|
|
746
|
+
calories: 0,
|
|
747
|
+
proteinGrams: 0,
|
|
748
|
+
carbohydrateGrams: 0,
|
|
749
|
+
fatGrams: 0,
|
|
750
|
+
fiberGrams: 0,
|
|
751
|
+
sodiumMg: 0,
|
|
752
|
+
caffeineMg: 0,
|
|
753
|
+
alcoholGrams: 0
|
|
754
|
+
});
|
|
755
|
+
return {
|
|
756
|
+
dateKey: today,
|
|
757
|
+
meals: todayLogs,
|
|
758
|
+
totals,
|
|
759
|
+
targetCalories: target.calorieTarget,
|
|
760
|
+
calorieDelta: round(totals.calories - n(target.calorieTarget), 0),
|
|
761
|
+
proteinCoverage: n(target.proteinGramsTarget) > 0
|
|
762
|
+
? round(totals.proteinGrams / n(target.proteinGramsTarget), 2)
|
|
763
|
+
: null,
|
|
764
|
+
fiberCoverage: n(target.fiberGramsTarget) > 0
|
|
765
|
+
? round(totals.fiberGrams / n(target.fiberGramsTarget), 2)
|
|
766
|
+
: null,
|
|
767
|
+
unconfirmedCount: todayLogs.filter((log) => log.confirmationState !== "confirmed").length
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
function buildWeightTrend(body) {
|
|
771
|
+
const withWeight = body
|
|
772
|
+
.filter((entry) => typeof entry.weightKg === "number")
|
|
773
|
+
.slice()
|
|
774
|
+
.reverse();
|
|
775
|
+
const latest = withWeight.at(-1) ?? null;
|
|
776
|
+
const previous = withWeight.length > 1 ? withWeight.at(-2) : null;
|
|
777
|
+
const first = withWeight[0] ?? null;
|
|
778
|
+
const latestWeight = latest?.weightKg ?? null;
|
|
779
|
+
const deltaFromPrevious = latestWeight != null && previous?.weightKg != null
|
|
780
|
+
? round(latestWeight - previous.weightKg, 2)
|
|
781
|
+
: null;
|
|
782
|
+
const deltaFromFirst = latestWeight != null && first?.weightKg != null
|
|
783
|
+
? round(latestWeight - first.weightKg, 2)
|
|
784
|
+
: null;
|
|
785
|
+
return {
|
|
786
|
+
latestWeightKg: latestWeight,
|
|
787
|
+
latestCheckedAt: latest?.checkedAt ?? null,
|
|
788
|
+
deltaFromPreviousKg: deltaFromPrevious,
|
|
789
|
+
deltaFromFirstKg: deltaFromFirst,
|
|
790
|
+
trendWeightKg: withWeight.length > 0
|
|
791
|
+
? round(average(withWeight.slice(-7).map((entry) => entry.weightKg)) ?? 0, 2)
|
|
792
|
+
: null,
|
|
793
|
+
weeklyRateKg: withWeight.length >= 2 && first && latest
|
|
794
|
+
? round(((latest.weightKg - first.weightKg) /
|
|
795
|
+
Math.max(1, (new Date(latest.checkedAt).getTime() -
|
|
796
|
+
new Date(first.checkedAt).getTime()) /
|
|
797
|
+
(7 * 24 * 60 * 60 * 1000))), 2)
|
|
798
|
+
: null,
|
|
799
|
+
waistToHeightRatio: null
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function buildFoodQuality(logs) {
|
|
803
|
+
const items = logs.flatMap((log) => log.items);
|
|
804
|
+
const totals = sumItems(items);
|
|
805
|
+
const tagCounts = new Map();
|
|
806
|
+
for (const item of items) {
|
|
807
|
+
for (const tag of item.tags) {
|
|
808
|
+
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const total = Math.max(1, items.length);
|
|
812
|
+
const calorieBase = Math.max(1, totals.calories / 1000);
|
|
813
|
+
const highProteinShare = round((tagCounts.get("high_protein") ?? 0) / total, 2);
|
|
814
|
+
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)) /
|
|
817
|
+
total, 2);
|
|
818
|
+
return {
|
|
819
|
+
itemCount: items.length,
|
|
820
|
+
qualityScore: items.length > 0
|
|
821
|
+
? round(Math.max(0, Math.min(10, 4 +
|
|
822
|
+
highProteinShare * 2 +
|
|
823
|
+
highFiberShare * 2 -
|
|
824
|
+
ultraProcessedShare * 2)), 1)
|
|
825
|
+
: null,
|
|
826
|
+
proteinPer1000Kcal: items.length > 0 ? round(totals.proteinGrams / calorieBase, 1) : null,
|
|
827
|
+
fiberPer1000Kcal: items.length > 0 ? round(totals.fiberGrams / calorieBase, 1) : null,
|
|
828
|
+
highProteinShare,
|
|
829
|
+
highFiberShare,
|
|
830
|
+
ultraProcessedShare,
|
|
831
|
+
lateMealCount: logs.filter((log) => {
|
|
832
|
+
const hour = new Date(log.loggedAt).getHours();
|
|
833
|
+
return hour >= 21 || hour < 4;
|
|
834
|
+
}).length,
|
|
835
|
+
topTags: Array.from(tagCounts.entries())
|
|
836
|
+
.sort((a, b) => b[1] - a[1])
|
|
837
|
+
.slice(0, 8)
|
|
838
|
+
.map(([tag, count]) => ({ tag, count }))
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function buildSubjectiveSummary(checkins) {
|
|
842
|
+
return {
|
|
843
|
+
checkinCount: checkins.length,
|
|
844
|
+
averageEnergy: average(checkins.map((entry) => entry.energy)),
|
|
845
|
+
averageFocus: average(checkins.map((entry) => entry.focus)),
|
|
846
|
+
averageCravings: average(checkins.map((entry) => entry.cravings)),
|
|
847
|
+
averageCrash: average(checkins.map((entry) => entry.crashScore)),
|
|
848
|
+
recent: checkins.slice(0, 8)
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function buildGutSummary(checkins) {
|
|
852
|
+
const averageBloating = average(checkins.map((entry) => entry.bloating));
|
|
853
|
+
const averageReflux = average(checkins.map((entry) => entry.reflux));
|
|
854
|
+
const averageAbdominalPain = average(checkins.map((entry) => entry.abdominalPain));
|
|
855
|
+
const discomfortAverage = average([
|
|
856
|
+
averageBloating,
|
|
857
|
+
averageReflux,
|
|
858
|
+
averageAbdominalPain
|
|
859
|
+
]);
|
|
860
|
+
return {
|
|
861
|
+
checkinCount: checkins.length,
|
|
862
|
+
averageBloating,
|
|
863
|
+
averageReflux,
|
|
864
|
+
averageAbdominalPain,
|
|
865
|
+
gutComfortScore: discomfortAverage == null ? null : round(Math.max(0, 10 - discomfortAverage), 1),
|
|
866
|
+
bristolDistribution: [1, 2, 3, 4, 5, 6, 7].map((type) => ({
|
|
867
|
+
type,
|
|
868
|
+
count: checkins.filter((entry) => entry.bristolStoolType === type).length
|
|
869
|
+
})),
|
|
870
|
+
recent: checkins.slice(0, 8)
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function buildGeneratedHypotheses(logs, subjective, gut, appearance) {
|
|
874
|
+
const cards = [];
|
|
875
|
+
const lateMeals = logs.filter((log) => new Date(log.loggedAt).getHours() >= 21);
|
|
876
|
+
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);
|
|
878
|
+
const puffiness = appearance.filter((entry) => typeof entry.facePuffiness === "number" && entry.facePuffiness >= 6);
|
|
879
|
+
if (lateMeals.length >= 2 && puffiness.length >= 1) {
|
|
880
|
+
cards.push({
|
|
881
|
+
id: "generated_late_meal_puffiness",
|
|
882
|
+
title: "Late meals may be affecting next-day look",
|
|
883
|
+
summary: "Forge sees repeated late eating plus recent face-puffiness check-ins. Track sodium, alcohol, and bedtime for a cleaner read.",
|
|
884
|
+
status: "candidate",
|
|
885
|
+
confidence: 0.35,
|
|
886
|
+
evidenceCount: lateMeals.length + puffiness.length,
|
|
887
|
+
signalKey: "late_meal",
|
|
888
|
+
outcomeKey: "face_puffiness",
|
|
889
|
+
lagWindow: "next_morning",
|
|
890
|
+
confounders: ["sleep", "sodium", "alcohol", "stress"],
|
|
891
|
+
suggestedAction: "Run a 7-day experiment: log dinner time, sodium-heavy foods, and morning face puffiness."
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
if (lowEnergy.length >= 2 && logs.length >= 3) {
|
|
895
|
+
cards.push({
|
|
896
|
+
id: "generated_post_meal_energy",
|
|
897
|
+
title: "Food and afternoon energy need tighter logging",
|
|
898
|
+
summary: "Low-energy check-ins exist near logged meals. Add 2-hour post-meal energy scores to identify stable lunches versus crash meals.",
|
|
899
|
+
status: "candidate",
|
|
900
|
+
confidence: 0.3,
|
|
901
|
+
evidenceCount: lowEnergy.length,
|
|
902
|
+
signalKey: "meal_composition",
|
|
903
|
+
outcomeKey: "energy",
|
|
904
|
+
lagWindow: "2h_post_meal",
|
|
905
|
+
confounders: ["sleep", "caffeine", "training", "work stress"],
|
|
906
|
+
suggestedAction: "For the next five lunches, log protein/fiber and a 2-hour energy score."
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (gutSymptoms.length >= 2) {
|
|
910
|
+
cards.push({
|
|
911
|
+
id: "generated_gut_trigger_window",
|
|
912
|
+
title: "Gut trigger window is ready for an n-of-1 test",
|
|
913
|
+
summary: "Bloating, reflux, or abdominal pain has repeated enough to start tagging dairy, gluten, high-FODMAP, spice, fat, alcohol, and fiber jumps.",
|
|
914
|
+
status: "candidate",
|
|
915
|
+
confidence: 0.32,
|
|
916
|
+
evidenceCount: gutSymptoms.length,
|
|
917
|
+
signalKey: "food_trigger_tags",
|
|
918
|
+
outcomeKey: "gut_symptoms",
|
|
919
|
+
lagWindow: "6h_to_48h",
|
|
920
|
+
confounders: ["stress", "sleep", "travel", "training load"],
|
|
921
|
+
suggestedAction: "Choose one suspected trigger and compare two baseline weeks with one intervention week."
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
return cards;
|
|
925
|
+
}
|
|
926
|
+
export function getWeightLossViewData(userIds) {
|
|
927
|
+
const generatedAt = new Date().toISOString();
|
|
928
|
+
const userId = resolveReadUser(userIds);
|
|
929
|
+
const targetRow = getDatabase()
|
|
930
|
+
.prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
|
|
931
|
+
.get(userId);
|
|
932
|
+
const target = mapTarget(targetRow, userId);
|
|
933
|
+
const logs = listFoodLogs(userId);
|
|
934
|
+
const body = listBodyCheckins(userId);
|
|
935
|
+
const appearance = listAppearanceCheckins(userId);
|
|
936
|
+
const subjective = listSubjectiveCheckins(userId);
|
|
937
|
+
const gut = listGutCheckins(userId);
|
|
938
|
+
const storedHypotheses = listHypotheses(userId);
|
|
939
|
+
const generatedHypotheses = buildGeneratedHypotheses(logs, subjective, gut, appearance);
|
|
940
|
+
const experiments = listExperiments(userId);
|
|
941
|
+
const todayLedger = buildTodayLedger(logs, target);
|
|
942
|
+
const recentLogs = logs.slice(0, 14);
|
|
943
|
+
const recentTotals = sumItems(recentLogs.flatMap((log) => log.items));
|
|
944
|
+
const trackedDays = new Set(logs.map((log) => log.dayKey)).size;
|
|
945
|
+
const averageCalories = trackedDays > 0 ? round(recentTotals.calories / trackedDays, 0) : 0;
|
|
946
|
+
const inferredTdee = target.calorieTarget != null
|
|
947
|
+
? round(target.calorieTarget + Math.abs(n(target.weeklyRateGoalKg)) * 1100, 0)
|
|
948
|
+
: null;
|
|
949
|
+
return {
|
|
950
|
+
generatedAt,
|
|
951
|
+
userId,
|
|
952
|
+
target,
|
|
953
|
+
summary: {
|
|
954
|
+
loggedMealCount: logs.length,
|
|
955
|
+
trackedDays,
|
|
956
|
+
todayCalories: todayLedger.totals.calories,
|
|
957
|
+
targetCalories: target.calorieTarget,
|
|
958
|
+
todayCalorieDelta: todayLedger.calorieDelta,
|
|
959
|
+
averageCalories,
|
|
960
|
+
inferredTdee,
|
|
961
|
+
proteinCoverage: todayLedger.proteinCoverage,
|
|
962
|
+
fiberCoverage: todayLedger.fiberCoverage,
|
|
963
|
+
unconfirmedCount: logs.filter((log) => log.confirmationState !== "confirmed")
|
|
964
|
+
.length,
|
|
965
|
+
hypothesisCount: storedHypotheses.length + generatedHypotheses.length,
|
|
966
|
+
dataQualityScore: round(Math.min(1, trackedDays / 7 +
|
|
967
|
+
Math.min(0.2, body.length * 0.04) +
|
|
968
|
+
Math.min(0.2, subjective.length * 0.02) +
|
|
969
|
+
Math.min(0.2, gut.length * 0.02)), 2)
|
|
970
|
+
},
|
|
971
|
+
todayLedger,
|
|
972
|
+
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),
|
|
986
|
+
bodyCheckins: body,
|
|
987
|
+
appearanceCheckins: appearance,
|
|
988
|
+
foodQuality: buildFoodQuality(logs),
|
|
989
|
+
trainingFuel: {
|
|
990
|
+
linkedWorkoutMealCount: logs.filter((log) => log.workoutId).length,
|
|
991
|
+
preWorkoutFuelCount: logs.filter((log) => log.items.some((item) => item.tags.includes("pre_workout"))).length,
|
|
992
|
+
postWorkoutProteinCount: logs.filter((log) => log.items.some((item) => item.tags.includes("post_workout"))).length,
|
|
993
|
+
fuelingScore: logs.length > 0 ? 5 : null,
|
|
994
|
+
recentTrainingLoad: null,
|
|
995
|
+
carbsPerTrainingLoad: null,
|
|
996
|
+
lowEnergyAvailabilityFlag: averageCalories > 0 && inferredTdee != null
|
|
997
|
+
? averageCalories < inferredTdee - 750
|
|
998
|
+
: false
|
|
999
|
+
},
|
|
1000
|
+
subjective: buildSubjectiveSummary(subjective),
|
|
1001
|
+
gut: buildGutSummary(gut),
|
|
1002
|
+
hypotheses: [...storedHypotheses, ...generatedHypotheses],
|
|
1003
|
+
experiments,
|
|
1004
|
+
dataQuality: {
|
|
1005
|
+
sourceConfidence: logs.length === 0
|
|
1006
|
+
? "empty"
|
|
1007
|
+
: logs.some((log) => log.confirmationState !== "confirmed")
|
|
1008
|
+
? "mixed"
|
|
1009
|
+
: "confirmed",
|
|
1010
|
+
missingHighValueCheckins: [
|
|
1011
|
+
logs.length === 0 ? "food log" : null,
|
|
1012
|
+
body.length === 0 ? "body measurement" : null,
|
|
1013
|
+
subjective.length < 3 ? "post-meal energy" : null,
|
|
1014
|
+
gut.length < 3 ? "gut symptom" : null
|
|
1015
|
+
].filter((value) => Boolean(value)),
|
|
1016
|
+
notes: "AI, photo, and barcode estimates stay provisional until confirmed by the user."
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
function cacheFood(input) {
|
|
1021
|
+
const now = nowIso();
|
|
1022
|
+
const existing = getDatabase()
|
|
1023
|
+
.prepare(`SELECT id FROM nutrition_food_catalog WHERE source = ? AND source_id = ?`)
|
|
1024
|
+
.get(input.source, input.sourceId);
|
|
1025
|
+
const id = existing?.id ?? newId("food");
|
|
1026
|
+
getDatabase()
|
|
1027
|
+
.prepare(`INSERT INTO nutrition_food_catalog (
|
|
1028
|
+
id, source, source_id, barcode, name, brand, serving_label,
|
|
1029
|
+
serving_grams, calories, protein_grams, carbohydrate_grams, fat_grams,
|
|
1030
|
+
fiber_grams, sugar_grams, sodium_mg, potassium_mg, nova_group,
|
|
1031
|
+
nutri_score, tags_json, nutrients_json, confidence, created_at, updated_at
|
|
1032
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1033
|
+
ON CONFLICT(source, source_id) DO UPDATE SET
|
|
1034
|
+
barcode = excluded.barcode,
|
|
1035
|
+
name = excluded.name,
|
|
1036
|
+
brand = excluded.brand,
|
|
1037
|
+
serving_label = excluded.serving_label,
|
|
1038
|
+
serving_grams = excluded.serving_grams,
|
|
1039
|
+
calories = excluded.calories,
|
|
1040
|
+
protein_grams = excluded.protein_grams,
|
|
1041
|
+
carbohydrate_grams = excluded.carbohydrate_grams,
|
|
1042
|
+
fat_grams = excluded.fat_grams,
|
|
1043
|
+
fiber_grams = excluded.fiber_grams,
|
|
1044
|
+
sugar_grams = excluded.sugar_grams,
|
|
1045
|
+
sodium_mg = excluded.sodium_mg,
|
|
1046
|
+
potassium_mg = excluded.potassium_mg,
|
|
1047
|
+
nova_group = excluded.nova_group,
|
|
1048
|
+
nutri_score = excluded.nutri_score,
|
|
1049
|
+
tags_json = excluded.tags_json,
|
|
1050
|
+
nutrients_json = excluded.nutrients_json,
|
|
1051
|
+
confidence = excluded.confidence,
|
|
1052
|
+
updated_at = excluded.updated_at`)
|
|
1053
|
+
.run(id, input.source, input.sourceId, input.barcode ?? null, input.name, input.brand ?? "", input.servingLabel ?? "", input.servingGrams ?? null, input.calories ?? null, input.proteinGrams ?? null, input.carbohydrateGrams ?? null, input.fatGrams ?? null, input.fiberGrams ?? null, input.sugarGrams ?? null, input.sodiumMg ?? null, input.potassiumMg ?? null, input.novaGroup ?? null, input.nutriScore ?? null, jsonString(input.tags ?? []), jsonString(input.nutrients ?? {}), input.confidence ?? 0.65, now, now);
|
|
1054
|
+
return mapFood(getDatabase()
|
|
1055
|
+
.prepare(`SELECT * FROM nutrition_food_catalog WHERE id = ?`)
|
|
1056
|
+
.get(id));
|
|
1057
|
+
}
|
|
1058
|
+
function localFoodSearch(query, limit) {
|
|
1059
|
+
return getDatabase()
|
|
1060
|
+
.prepare(`SELECT *
|
|
1061
|
+
FROM nutrition_food_catalog
|
|
1062
|
+
WHERE name LIKE ? OR brand LIKE ? OR barcode = ?
|
|
1063
|
+
ORDER BY updated_at DESC
|
|
1064
|
+
LIMIT ?`)
|
|
1065
|
+
.all(`%${query}%`, `%${query}%`, query, limit).map(mapFood);
|
|
1066
|
+
}
|
|
1067
|
+
function readOffNutrient(product, key) {
|
|
1068
|
+
const nutriments = product.nutriments;
|
|
1069
|
+
const value = nutriments?.[key];
|
|
1070
|
+
return typeof value === "number" ? value : null;
|
|
1071
|
+
}
|
|
1072
|
+
function mapOpenFoodFactsProduct(product) {
|
|
1073
|
+
const code = typeof product.code === "string" ? product.code : "";
|
|
1074
|
+
const name = typeof product.product_name === "string" && product.product_name.trim()
|
|
1075
|
+
? product.product_name.trim()
|
|
1076
|
+
: typeof product.generic_name === "string"
|
|
1077
|
+
? product.generic_name.trim()
|
|
1078
|
+
: "";
|
|
1079
|
+
if (!code || !name) {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
const nova = typeof product.nova_group === "number" ? product.nova_group : null;
|
|
1083
|
+
const tags = [
|
|
1084
|
+
nova === 4 ? "ultra_processed" : null,
|
|
1085
|
+
nova ? `nova_${nova}` : null
|
|
1086
|
+
].filter((tag) => Boolean(tag));
|
|
1087
|
+
return cacheFood({
|
|
1088
|
+
source: "open_food_facts",
|
|
1089
|
+
sourceId: code,
|
|
1090
|
+
barcode: code,
|
|
1091
|
+
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,
|
|
1103
|
+
novaGroup: nova,
|
|
1104
|
+
nutriScore: typeof product.nutriscore_grade === "string"
|
|
1105
|
+
? product.nutriscore_grade
|
|
1106
|
+
: null,
|
|
1107
|
+
tags,
|
|
1108
|
+
nutrients: product.nutriments ?? {},
|
|
1109
|
+
confidence: 0.72
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
async function searchOpenFoodFacts(query, limit) {
|
|
1113
|
+
const url = new URL("https://world.openfoodfacts.org/cgi/search.pl");
|
|
1114
|
+
url.searchParams.set("search_terms", query);
|
|
1115
|
+
url.searchParams.set("search_simple", "1");
|
|
1116
|
+
url.searchParams.set("action", "process");
|
|
1117
|
+
url.searchParams.set("json", "1");
|
|
1118
|
+
url.searchParams.set("page_size", String(Math.min(limit, 20)));
|
|
1119
|
+
const response = await fetch(url, {
|
|
1120
|
+
headers: { accept: "application/json" }
|
|
1121
|
+
});
|
|
1122
|
+
if (!response.ok) {
|
|
1123
|
+
return [];
|
|
1124
|
+
}
|
|
1125
|
+
const payload = (await response.json());
|
|
1126
|
+
return (payload.products ?? [])
|
|
1127
|
+
.map(mapOpenFoodFactsProduct)
|
|
1128
|
+
.filter((food) => Boolean(food));
|
|
1129
|
+
}
|
|
1130
|
+
async function lookupOpenFoodFactsBarcode(barcode) {
|
|
1131
|
+
const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(barcode)}.json`, { headers: { accept: "application/json" } });
|
|
1132
|
+
if (!response.ok) {
|
|
1133
|
+
return [];
|
|
1134
|
+
}
|
|
1135
|
+
const payload = (await response.json());
|
|
1136
|
+
const food = payload.product ? mapOpenFoodFactsProduct(payload.product) : null;
|
|
1137
|
+
return food ? [food] : [];
|
|
1138
|
+
}
|
|
1139
|
+
function mapFdcFood(food) {
|
|
1140
|
+
const sourceId = typeof food.fdcId === "number" ? String(food.fdcId) : String(food.fdcId ?? "");
|
|
1141
|
+
const name = typeof food.description === "string" ? food.description.trim() : "";
|
|
1142
|
+
if (!sourceId || !name) {
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
const nutrients = Array.isArray(food.foodNutrients)
|
|
1146
|
+
? food.foodNutrients
|
|
1147
|
+
: [];
|
|
1148
|
+
const findNutrient = (names) => {
|
|
1149
|
+
const entry = nutrients.find((nutrient) => names.some((namePart) => String(nutrient.nutrientName ?? "")
|
|
1150
|
+
.toLowerCase()
|
|
1151
|
+
.includes(namePart)));
|
|
1152
|
+
return typeof entry?.value === "number" ? entry.value : null;
|
|
1153
|
+
};
|
|
1154
|
+
return cacheFood({
|
|
1155
|
+
source: "usda_fdc",
|
|
1156
|
+
sourceId,
|
|
1157
|
+
name,
|
|
1158
|
+
brand: typeof food.brandOwner === "string"
|
|
1159
|
+
? food.brandOwner
|
|
1160
|
+
: typeof food.brandName === "string"
|
|
1161
|
+
? food.brandName
|
|
1162
|
+
: "",
|
|
1163
|
+
calories: findNutrient(["energy"]),
|
|
1164
|
+
proteinGrams: findNutrient(["protein"]),
|
|
1165
|
+
carbohydrateGrams: findNutrient(["carbohydrate"]),
|
|
1166
|
+
fatGrams: findNutrient(["total lipid", "total fat"]),
|
|
1167
|
+
fiberGrams: findNutrient(["fiber"]),
|
|
1168
|
+
sugarGrams: findNutrient(["sugars"]),
|
|
1169
|
+
sodiumMg: findNutrient(["sodium"]),
|
|
1170
|
+
potassiumMg: findNutrient(["potassium"]),
|
|
1171
|
+
nutrients: { foodNutrients: nutrients },
|
|
1172
|
+
confidence: 0.78
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
async function searchFoodDataCentral(query, limit) {
|
|
1176
|
+
const apiKey = process.env.FDC_API_KEY?.trim() || "DEMO_KEY";
|
|
1177
|
+
const response = await fetch("https://api.nal.usda.gov/fdc/v1/foods/search", {
|
|
1178
|
+
method: "POST",
|
|
1179
|
+
headers: {
|
|
1180
|
+
accept: "application/json",
|
|
1181
|
+
"content-type": "application/json"
|
|
1182
|
+
},
|
|
1183
|
+
body: JSON.stringify({
|
|
1184
|
+
api_key: apiKey,
|
|
1185
|
+
query,
|
|
1186
|
+
pageSize: Math.min(limit, 15)
|
|
1187
|
+
})
|
|
1188
|
+
});
|
|
1189
|
+
if (!response.ok) {
|
|
1190
|
+
return [];
|
|
1191
|
+
}
|
|
1192
|
+
const payload = (await response.json());
|
|
1193
|
+
return (payload.foods ?? [])
|
|
1194
|
+
.map(mapFdcFood)
|
|
1195
|
+
.filter((food) => Boolean(food));
|
|
1196
|
+
}
|
|
1197
|
+
export async function searchNutritionFoods(input) {
|
|
1198
|
+
const parsed = nutritionFoodSearchSchema.parse(input);
|
|
1199
|
+
const local = localFoodSearch(parsed.query, parsed.limit);
|
|
1200
|
+
const seen = new Set(local.map((food) => `${food.source}:${food.sourceId}`));
|
|
1201
|
+
const external = local.length >= parsed.limit
|
|
1202
|
+
? []
|
|
1203
|
+
: [
|
|
1204
|
+
...(await searchOpenFoodFacts(parsed.query, parsed.limit - local.length).catch(() => [])),
|
|
1205
|
+
...(await searchFoodDataCentral(parsed.query, parsed.limit - local.length).catch(() => []))
|
|
1206
|
+
].filter((food) => {
|
|
1207
|
+
const key = `${food.source}:${food.sourceId}`;
|
|
1208
|
+
if (seen.has(key)) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
seen.add(key);
|
|
1212
|
+
return true;
|
|
1213
|
+
});
|
|
1214
|
+
return {
|
|
1215
|
+
foods: [...local, ...external].slice(0, parsed.limit),
|
|
1216
|
+
sources: ["local_cache", "open_food_facts", "usda_fdc"]
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
export async function lookupNutritionBarcode(input) {
|
|
1220
|
+
const parsed = nutritionBarcodeLookupSchema.parse(input);
|
|
1221
|
+
const local = localFoodSearch(parsed.barcode, parsed.limit);
|
|
1222
|
+
const barcodeMatches = local.filter((food) => food.barcode === parsed.barcode);
|
|
1223
|
+
if (barcodeMatches.length > 0) {
|
|
1224
|
+
const foods = barcodeMatches.slice(0, parsed.limit);
|
|
1225
|
+
return { food: foods[0] ?? null, foods };
|
|
1226
|
+
}
|
|
1227
|
+
const foods = (await lookupOpenFoodFactsBarcode(parsed.barcode).catch(() => [])).slice(0, parsed.limit);
|
|
1228
|
+
return { food: foods[0] ?? null, foods };
|
|
1229
|
+
}
|
|
1230
|
+
function extractJsonObject(text) {
|
|
1231
|
+
const trimmed = text.trim();
|
|
1232
|
+
if (trimmed.startsWith("{")) {
|
|
1233
|
+
return trimmed;
|
|
1234
|
+
}
|
|
1235
|
+
const start = trimmed.indexOf("{");
|
|
1236
|
+
const end = trimmed.lastIndexOf("}");
|
|
1237
|
+
if (start >= 0 && end > start) {
|
|
1238
|
+
return trimmed.slice(start, end + 1);
|
|
1239
|
+
}
|
|
1240
|
+
return trimmed;
|
|
1241
|
+
}
|
|
1242
|
+
function getNutritionCodexProfile(connectionId) {
|
|
1243
|
+
const settings = getSettings();
|
|
1244
|
+
const selectedConnectionId = connectionId?.trim() ||
|
|
1245
|
+
settings.modelSettings.forgeAgent.basicChat.connectionId ||
|
|
1246
|
+
settings.modelSettings.forgeAgent.wiki.connectionId ||
|
|
1247
|
+
"";
|
|
1248
|
+
if (!selectedConnectionId) {
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
const row = getDatabase()
|
|
1252
|
+
.prepare(`SELECT provider, auth_mode, base_url, model, secret_id, enabled
|
|
1253
|
+
FROM ai_model_connections
|
|
1254
|
+
WHERE id = ?`)
|
|
1255
|
+
.get(selectedConnectionId);
|
|
1256
|
+
if (!row ||
|
|
1257
|
+
row.enabled !== 1 ||
|
|
1258
|
+
row.provider !== "openai-codex" ||
|
|
1259
|
+
row.auth_mode !== "oauth" ||
|
|
1260
|
+
!row.secret_id) {
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
provider: "openai-codex",
|
|
1265
|
+
baseUrl: row.base_url || "https://chatgpt.com/backend-api",
|
|
1266
|
+
model: row.model,
|
|
1267
|
+
systemPrompt: "",
|
|
1268
|
+
secretId: row.secret_id,
|
|
1269
|
+
metadata: {}
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
const parsedMealItemSchema = z.object({
|
|
1273
|
+
name: z.string().trim().min(1),
|
|
1274
|
+
quantity: z.coerce.number().positive().default(1),
|
|
1275
|
+
unit: z.string().trim().min(1).default("serving"),
|
|
1276
|
+
grams: optionalNumberSchema,
|
|
1277
|
+
calories: optionalNumberSchema,
|
|
1278
|
+
proteinGrams: optionalNumberSchema,
|
|
1279
|
+
carbohydrateGrams: optionalNumberSchema,
|
|
1280
|
+
fatGrams: optionalNumberSchema,
|
|
1281
|
+
fiberGrams: optionalNumberSchema,
|
|
1282
|
+
sugarGrams: optionalNumberSchema,
|
|
1283
|
+
sodiumMg: optionalNumberSchema,
|
|
1284
|
+
tags: tagsSchema,
|
|
1285
|
+
confidence: z.coerce.number().min(0).max(1).default(0.45)
|
|
1286
|
+
});
|
|
1287
|
+
const parsedMealSchema = z.object({
|
|
1288
|
+
mealLabel: z.string().trim().default(""),
|
|
1289
|
+
loggedAt: z.string().datetime().optional(),
|
|
1290
|
+
items: z.array(parsedMealItemSchema).min(1),
|
|
1291
|
+
uncertaintyReasons: z.array(z.string()).default([]),
|
|
1292
|
+
clarificationQuestions: z.array(z.string()).default([]),
|
|
1293
|
+
tags: tagsSchema
|
|
1294
|
+
});
|
|
1295
|
+
export async function parseNutritionFoodLogWithChatGpt(input, llm) {
|
|
1296
|
+
const parsed = nutritionParseRequestSchema.parse(input);
|
|
1297
|
+
const profile = getNutritionCodexProfile(parsed.connectionId);
|
|
1298
|
+
if (!profile) {
|
|
1299
|
+
throw new Error("Connect an OpenAI Codex OAuth model in Settings -> Models before using ChatGPT food parsing.");
|
|
1300
|
+
}
|
|
1301
|
+
const userId = resolveWriteUser(parsed.userId);
|
|
1302
|
+
const context = getWeightLossViewData([userId]);
|
|
1303
|
+
const prompt = `Parse this food log into strict JSON only.
|
|
1304
|
+
|
|
1305
|
+
Input:
|
|
1306
|
+
${parsed.text}
|
|
1307
|
+
|
|
1308
|
+
Known targets:
|
|
1309
|
+
${JSON.stringify(context.target)}
|
|
1310
|
+
|
|
1311
|
+
Recent meals:
|
|
1312
|
+
${JSON.stringify(context.recentMeals.slice(0, 8).map((meal) => ({
|
|
1313
|
+
mealLabel: meal.mealLabel,
|
|
1314
|
+
items: meal.items.map((item) => item.name)
|
|
1315
|
+
})))}
|
|
1316
|
+
|
|
1317
|
+
Return this JSON shape:
|
|
1318
|
+
{
|
|
1319
|
+
"mealLabel": "short label",
|
|
1320
|
+
"loggedAt": "ISO time if known",
|
|
1321
|
+
"items": [
|
|
1322
|
+
{
|
|
1323
|
+
"name": "food name",
|
|
1324
|
+
"quantity": 1,
|
|
1325
|
+
"unit": "serving|g|ml|piece|cup|tbsp|custom",
|
|
1326
|
+
"grams": null,
|
|
1327
|
+
"calories": null,
|
|
1328
|
+
"proteinGrams": null,
|
|
1329
|
+
"carbohydrateGrams": null,
|
|
1330
|
+
"fatGrams": null,
|
|
1331
|
+
"fiberGrams": null,
|
|
1332
|
+
"sugarGrams": null,
|
|
1333
|
+
"sodiumMg": null,
|
|
1334
|
+
"tags": ["high_protein", "late_meal", "spicy", "dairy", "high_fodmap_candidate"],
|
|
1335
|
+
"confidence": 0.0
|
|
1336
|
+
}
|
|
1337
|
+
],
|
|
1338
|
+
"uncertaintyReasons": [],
|
|
1339
|
+
"clarificationQuestions": [],
|
|
1340
|
+
"tags": []
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
Use null for unknown nutrients. Prefer conservative estimates and mark uncertainty.`;
|
|
1344
|
+
const result = await llm.runTextPrompt(profile, {
|
|
1345
|
+
systemPrompt: "You are Forge's nutrition parser. Return strict JSON only. Never claim precision when food quantity is unclear.",
|
|
1346
|
+
prompt
|
|
1347
|
+
});
|
|
1348
|
+
const parsedResult = parsedMealSchema.parse(JSON.parse(extractJsonObject(result.outputText)));
|
|
1349
|
+
const candidate = {
|
|
1350
|
+
userId,
|
|
1351
|
+
loggedAt: parsedResult.loggedAt ?? parsed.mealTime ?? nowIso(),
|
|
1352
|
+
mealLabel: parsedResult.mealLabel || "ChatGPT parsed meal",
|
|
1353
|
+
source: parsed.imageRefs.length > 0 ? "photo" : "chatgpt",
|
|
1354
|
+
confirmationState: "candidate",
|
|
1355
|
+
notes: parsedResult.uncertaintyReasons.join("; "),
|
|
1356
|
+
imageRefs: parsed.imageRefs,
|
|
1357
|
+
parserProvenance: {
|
|
1358
|
+
provider: "openai-codex",
|
|
1359
|
+
model: profile.model,
|
|
1360
|
+
uncertaintyReasons: parsedResult.uncertaintyReasons,
|
|
1361
|
+
clarificationQuestions: parsedResult.clarificationQuestions,
|
|
1362
|
+
rawText: parsed.text
|
|
1363
|
+
},
|
|
1364
|
+
links: [],
|
|
1365
|
+
items: parsedResult.items.map((item) => ({
|
|
1366
|
+
...item,
|
|
1367
|
+
nutrients: {},
|
|
1368
|
+
tags: Array.from(new Set([...item.tags, ...parsedResult.tags]))
|
|
1369
|
+
}))
|
|
1370
|
+
};
|
|
1371
|
+
const log = parsed.commitCandidate ? createNutritionFoodLog(candidate) : null;
|
|
1372
|
+
return {
|
|
1373
|
+
candidate,
|
|
1374
|
+
log,
|
|
1375
|
+
clarificationQuestions: parsedResult.clarificationQuestions,
|
|
1376
|
+
uncertaintyReasons: parsedResult.uncertaintyReasons
|
|
1377
|
+
};
|
|
1378
|
+
}
|