forge-openclaw-plugin 0.2.19 → 0.2.21
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/README.md +133 -2
- package/dist/assets/board-_C6oMy5w.js +6 -0
- package/dist/assets/{board-8L3uX7_O.js.map → board-_C6oMy5w.js.map} +1 -1
- package/dist/assets/index-B4A6TooJ.js +63 -0
- package/dist/assets/index-B4A6TooJ.js.map +1 -0
- package/dist/assets/index-D6Xs_2mo.css +1 -0
- package/dist/assets/{motion-1GAqqi8M.js → motion-D4sZgCHd.js} +2 -2
- package/dist/assets/{motion-1GAqqi8M.js.map → motion-D4sZgCHd.js.map} +1 -1
- package/dist/assets/{table-DBGlgRjk.js → table-BWzTaky1.js} +2 -2
- package/dist/assets/{table-DBGlgRjk.js.map → table-BWzTaky1.js.map} +1 -1
- package/dist/assets/{ui-iTluWjC4.js → ui-BzK4azQb.js} +7 -7
- package/dist/assets/{ui-iTluWjC4.js.map → ui-BzK4azQb.js.map} +1 -1
- package/dist/assets/vendor-DT3pnAKJ.css +1 -0
- package/dist/assets/vendor-De38P6YR.js +729 -0
- package/dist/assets/vendor-De38P6YR.js.map +1 -0
- package/dist/assets/viz-C6hfyqzu.js +34 -0
- package/dist/assets/viz-C6hfyqzu.js.map +1 -0
- package/dist/index.html +9 -9
- package/dist/openclaw/parity.d.ts +1 -1
- package/dist/openclaw/parity.js +29 -2
- package/dist/openclaw/routes.js +207 -24
- package/dist/openclaw/tools.js +324 -35
- package/dist/server/app.js +2080 -92
- package/dist/server/db.js +3 -0
- package/dist/server/health.js +1284 -0
- package/dist/server/managers/platform/background-job-manager.js +138 -2
- package/dist/server/managers/platform/llm-manager.js +126 -0
- package/dist/server/managers/platform/openai-responses-provider.js +773 -0
- package/dist/server/managers/runtime.js +6 -1
- package/dist/server/openapi.js +718 -0
- package/dist/server/preferences-seeds.js +409 -0
- package/dist/server/preferences-types.js +368 -0
- package/dist/server/psyche-types.js +42 -18
- package/dist/server/repositories/activity-events.js +53 -4
- package/dist/server/repositories/calendar.js +89 -15
- package/dist/server/repositories/collaboration.js +8 -3
- package/dist/server/repositories/diagnostic-logs.js +243 -0
- package/dist/server/repositories/entity-ownership.js +92 -0
- package/dist/server/repositories/goals.js +7 -2
- package/dist/server/repositories/habits.js +122 -16
- package/dist/server/repositories/notes.js +119 -41
- package/dist/server/repositories/preferences.js +1765 -0
- package/dist/server/repositories/projects.js +18 -7
- package/dist/server/repositories/psyche.js +84 -27
- package/dist/server/repositories/rewards.js +112 -4
- package/dist/server/repositories/strategies.js +450 -0
- package/dist/server/repositories/tags.js +11 -6
- package/dist/server/repositories/task-runs.js +10 -2
- package/dist/server/repositories/tasks.js +99 -17
- package/dist/server/repositories/users.js +417 -0
- package/dist/server/repositories/wiki-memory.js +3366 -0
- package/dist/server/services/context.js +20 -18
- package/dist/server/services/dashboard.js +29 -6
- package/dist/server/services/entity-crud.js +21 -3
- package/dist/server/services/insights.js +9 -7
- package/dist/server/services/projects.js +2 -1
- package/dist/server/services/psyche.js +10 -9
- package/dist/server/types.js +594 -30
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/015_multi_user_and_strategies.sql +244 -0
- package/server/migrations/016_health_companion.sql +158 -0
- package/server/migrations/016_strategy_contracts_and_user_graph.sql +22 -0
- package/server/migrations/017_preferences.sql +131 -0
- package/server/migrations/018_preference_catalogs.sql +31 -0
- package/server/migrations/019_wiki_memory.sql +255 -0
- package/server/migrations/020_wiki_page_hierarchy.sql +11 -0
- package/server/migrations/021_hide_evidence_from_wiki_index.sql +3 -0
- package/server/migrations/022_wiki_ingest_background.sql +85 -0
- package/server/migrations/023_diagnostic_logs.sql +28 -0
- package/skills/forge-openclaw/SKILL.md +126 -34
- package/skills/forge-openclaw/entity_conversation_playbooks.md +337 -0
- package/skills/forge-openclaw/psyche_entity_playbooks.md +404 -0
- package/dist/assets/board-8L3uX7_O.js +0 -6
- package/dist/assets/index-Cj1IBH_w.js +0 -36
- package/dist/assets/index-Cj1IBH_w.js.map +0 -1
- package/dist/assets/index-DQT6EbuS.css +0 -1
- package/dist/assets/vendor-BvM2F9Dp.js +0 -503
- package/dist/assets/vendor-BvM2F9Dp.js.map +0 -1
- package/dist/assets/vendor-CRS-psbw.css +0 -1
- package/dist/assets/viz-CNeunkfu.js +0 -34
- package/dist/assets/viz-CNeunkfu.js.map +0 -1
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
|
+
import { HttpError } from "./errors.js";
|
|
5
|
+
import { recordActivityEvent } from "./repositories/activity-events.js";
|
|
6
|
+
import { recordHabitGeneratedWorkoutReward } from "./repositories/rewards.js";
|
|
7
|
+
const healthLinkSchema = z.object({
|
|
8
|
+
entityType: z.string().trim().min(1),
|
|
9
|
+
entityId: z.string().trim().min(1),
|
|
10
|
+
relationshipType: z.string().trim().min(1).default("context")
|
|
11
|
+
});
|
|
12
|
+
const healthStageSchema = z.object({
|
|
13
|
+
stage: z.string().trim().min(1),
|
|
14
|
+
seconds: z.number().int().nonnegative()
|
|
15
|
+
});
|
|
16
|
+
const sleepRecoveryMetricSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]));
|
|
17
|
+
const sleepAnnotationSchema = z.object({
|
|
18
|
+
qualitySummary: z.string().trim().default(""),
|
|
19
|
+
notes: z.string().trim().default(""),
|
|
20
|
+
tags: z.array(z.string().trim()).default([]),
|
|
21
|
+
links: z.array(healthLinkSchema).default([])
|
|
22
|
+
});
|
|
23
|
+
const workoutAnnotationSchema = z.object({
|
|
24
|
+
subjectiveEffort: z.number().int().min(1).max(10).nullable().default(null),
|
|
25
|
+
moodBefore: z.string().trim().default(""),
|
|
26
|
+
moodAfter: z.string().trim().default(""),
|
|
27
|
+
meaningText: z.string().trim().default(""),
|
|
28
|
+
plannedContext: z.string().trim().default(""),
|
|
29
|
+
socialContext: z.string().trim().default(""),
|
|
30
|
+
tags: z.array(z.string().trim()).default([]),
|
|
31
|
+
links: z.array(healthLinkSchema).default([])
|
|
32
|
+
});
|
|
33
|
+
const generatedHealthEventTemplateSchema = z.object({
|
|
34
|
+
enabled: z.boolean().default(false),
|
|
35
|
+
workoutType: z.string().trim().min(1).default("workout"),
|
|
36
|
+
title: z.string().trim().default(""),
|
|
37
|
+
durationMinutes: z.number().int().positive().max(24 * 60).default(45),
|
|
38
|
+
xpReward: z.number().int().min(0).max(500).default(0),
|
|
39
|
+
tags: z.array(z.string().trim()).default([]),
|
|
40
|
+
links: z.array(healthLinkSchema).default([]),
|
|
41
|
+
notesTemplate: z.string().trim().default("")
|
|
42
|
+
});
|
|
43
|
+
const pairingStatusSchema = z.enum([
|
|
44
|
+
"pending",
|
|
45
|
+
"paired",
|
|
46
|
+
"healthy",
|
|
47
|
+
"stale",
|
|
48
|
+
"permission_denied",
|
|
49
|
+
"error",
|
|
50
|
+
"revoked"
|
|
51
|
+
]);
|
|
52
|
+
export const createCompanionPairingSessionSchema = z.object({
|
|
53
|
+
label: z.string().trim().default("Forge Companion"),
|
|
54
|
+
userId: z.string().trim().nullable().optional(),
|
|
55
|
+
expiresInMinutes: z.coerce.number().int().min(5).max(24 * 60).default(30),
|
|
56
|
+
capabilities: z
|
|
57
|
+
.array(z.enum([
|
|
58
|
+
"healthkit.sleep",
|
|
59
|
+
"healthkit.fitness",
|
|
60
|
+
"background-sync",
|
|
61
|
+
"location-ready",
|
|
62
|
+
"watch-ready"
|
|
63
|
+
]))
|
|
64
|
+
.default([
|
|
65
|
+
"healthkit.sleep",
|
|
66
|
+
"healthkit.fitness",
|
|
67
|
+
"background-sync",
|
|
68
|
+
"location-ready",
|
|
69
|
+
"watch-ready"
|
|
70
|
+
])
|
|
71
|
+
});
|
|
72
|
+
export const revokeAllCompanionPairingSessionsSchema = z.object({
|
|
73
|
+
userIds: z.array(z.string().trim().min(1)).default([]),
|
|
74
|
+
includeRevoked: z.boolean().default(false)
|
|
75
|
+
});
|
|
76
|
+
export const mobileHealthSyncSchema = z.object({
|
|
77
|
+
sessionId: z.string().trim().min(1),
|
|
78
|
+
pairingToken: z.string().trim().min(1),
|
|
79
|
+
device: z.object({
|
|
80
|
+
name: z.string().trim().default("iPhone"),
|
|
81
|
+
platform: z.string().trim().default("ios"),
|
|
82
|
+
appVersion: z.string().trim().default(""),
|
|
83
|
+
sourceDevice: z.string().trim().default("iPhone")
|
|
84
|
+
}),
|
|
85
|
+
permissions: z.object({
|
|
86
|
+
healthKitAuthorized: z.boolean().default(false),
|
|
87
|
+
backgroundRefreshEnabled: z.boolean().default(false),
|
|
88
|
+
motionReady: z.boolean().default(false),
|
|
89
|
+
locationReady: z.boolean().default(false)
|
|
90
|
+
}),
|
|
91
|
+
sleepSessions: z
|
|
92
|
+
.array(z.object({
|
|
93
|
+
externalUid: z.string().trim().min(1),
|
|
94
|
+
startedAt: z.string().datetime(),
|
|
95
|
+
endedAt: z.string().datetime(),
|
|
96
|
+
timeInBedSeconds: z.number().int().nonnegative().default(0),
|
|
97
|
+
asleepSeconds: z.number().int().nonnegative().default(0),
|
|
98
|
+
awakeSeconds: z.number().int().nonnegative().default(0),
|
|
99
|
+
stageBreakdown: z.array(healthStageSchema).default([]),
|
|
100
|
+
recoveryMetrics: sleepRecoveryMetricSchema.default({}),
|
|
101
|
+
links: z.array(healthLinkSchema).default([]),
|
|
102
|
+
annotations: sleepAnnotationSchema.partial().default({})
|
|
103
|
+
}))
|
|
104
|
+
.default([]),
|
|
105
|
+
workouts: z
|
|
106
|
+
.array(z.object({
|
|
107
|
+
externalUid: z.string().trim().min(1),
|
|
108
|
+
workoutType: z.string().trim().min(1),
|
|
109
|
+
startedAt: z.string().datetime(),
|
|
110
|
+
endedAt: z.string().datetime(),
|
|
111
|
+
activeEnergyKcal: z.number().nonnegative().nullable().optional(),
|
|
112
|
+
totalEnergyKcal: z.number().nonnegative().nullable().optional(),
|
|
113
|
+
distanceMeters: z.number().nonnegative().nullable().optional(),
|
|
114
|
+
stepCount: z.number().int().nonnegative().nullable().optional(),
|
|
115
|
+
exerciseMinutes: z.number().nonnegative().nullable().optional(),
|
|
116
|
+
averageHeartRate: z.number().nonnegative().nullable().optional(),
|
|
117
|
+
maxHeartRate: z.number().nonnegative().nullable().optional(),
|
|
118
|
+
sourceDevice: z.string().trim().default("Apple Health"),
|
|
119
|
+
links: z.array(healthLinkSchema).default([]),
|
|
120
|
+
annotations: workoutAnnotationSchema.partial().default({})
|
|
121
|
+
}))
|
|
122
|
+
.default([])
|
|
123
|
+
});
|
|
124
|
+
export const verifyCompanionPairingSchema = z.object({
|
|
125
|
+
sessionId: z.string().trim().min(1),
|
|
126
|
+
pairingToken: z.string().trim().min(1),
|
|
127
|
+
device: z.object({
|
|
128
|
+
name: z.string().trim().default("iPhone"),
|
|
129
|
+
platform: z.string().trim().default("ios"),
|
|
130
|
+
appVersion: z.string().trim().default(""),
|
|
131
|
+
sourceDevice: z.string().trim().default("iPhone")
|
|
132
|
+
})
|
|
133
|
+
});
|
|
134
|
+
export const updateWorkoutMetadataSchema = z.object({
|
|
135
|
+
subjectiveEffort: z.number().int().min(1).max(10).nullable().optional(),
|
|
136
|
+
moodBefore: z.string().trim().optional(),
|
|
137
|
+
moodAfter: z.string().trim().optional(),
|
|
138
|
+
meaningText: z.string().trim().optional(),
|
|
139
|
+
plannedContext: z.string().trim().optional(),
|
|
140
|
+
socialContext: z.string().trim().optional(),
|
|
141
|
+
tags: z.array(z.string().trim()).optional(),
|
|
142
|
+
links: z.array(healthLinkSchema).optional()
|
|
143
|
+
});
|
|
144
|
+
export const updateSleepMetadataSchema = z.object({
|
|
145
|
+
qualitySummary: z.string().trim().optional(),
|
|
146
|
+
notes: z.string().trim().optional(),
|
|
147
|
+
tags: z.array(z.string().trim()).optional(),
|
|
148
|
+
links: z.array(healthLinkSchema).optional()
|
|
149
|
+
});
|
|
150
|
+
function nowIso() {
|
|
151
|
+
return new Date().toISOString();
|
|
152
|
+
}
|
|
153
|
+
function safeJsonParse(value, fallback) {
|
|
154
|
+
if (!value) {
|
|
155
|
+
return fallback;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(value);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function dayKey(value) {
|
|
165
|
+
return value.slice(0, 10);
|
|
166
|
+
}
|
|
167
|
+
function diffMinutes(left, right) {
|
|
168
|
+
return Math.round(Math.abs(Date.parse(left) - Date.parse(right)) / 60_000);
|
|
169
|
+
}
|
|
170
|
+
function average(values) {
|
|
171
|
+
return values.length > 0
|
|
172
|
+
? values.reduce((sum, value) => sum + value, 0) / values.length
|
|
173
|
+
: 0;
|
|
174
|
+
}
|
|
175
|
+
function round(value, digits = 0) {
|
|
176
|
+
return Number(value.toFixed(digits));
|
|
177
|
+
}
|
|
178
|
+
function mergeStringLists(...groups) {
|
|
179
|
+
return [
|
|
180
|
+
...new Set(groups
|
|
181
|
+
.flatMap((group) => group ?? [])
|
|
182
|
+
.map((value) => value.trim())
|
|
183
|
+
.filter(Boolean))
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
function mergeHealthLinks(...groups) {
|
|
187
|
+
const deduped = new Map();
|
|
188
|
+
for (const group of groups) {
|
|
189
|
+
for (const link of group ?? []) {
|
|
190
|
+
const parsed = healthLinkSchema.safeParse(link);
|
|
191
|
+
if (!parsed.success) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const value = parsed.data;
|
|
195
|
+
deduped.set(`${value.entityType}:${value.entityId}:${value.relationshipType}`, value);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...deduped.values()];
|
|
199
|
+
}
|
|
200
|
+
function sleepMinutesOfDay(value, mode) {
|
|
201
|
+
const date = new Date(value);
|
|
202
|
+
const minutes = date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
203
|
+
if (mode === "bedtime" && minutes < 12 * 60) {
|
|
204
|
+
return minutes + 24 * 60;
|
|
205
|
+
}
|
|
206
|
+
return minutes;
|
|
207
|
+
}
|
|
208
|
+
function computeSleepDerivedMetrics(input) {
|
|
209
|
+
const efficiency = input.timeInBedSeconds > 0
|
|
210
|
+
? input.asleepSeconds / input.timeInBedSeconds
|
|
211
|
+
: 0;
|
|
212
|
+
const restorativeSeconds = input.stageBreakdown
|
|
213
|
+
.filter((stage) => {
|
|
214
|
+
const label = stage.stage.toLowerCase();
|
|
215
|
+
return label.includes("deep") || label.includes("rem");
|
|
216
|
+
})
|
|
217
|
+
.reduce((total, stage) => total + stage.seconds, 0);
|
|
218
|
+
const restorativeShare = input.asleepSeconds > 0 ? restorativeSeconds / input.asleepSeconds : 0;
|
|
219
|
+
const sleepDebtHours = input.asleepSeconds > 0
|
|
220
|
+
? Math.max(0, 8 - input.asleepSeconds / 3600)
|
|
221
|
+
: 8;
|
|
222
|
+
return {
|
|
223
|
+
durationHours: round(input.asleepSeconds / 3600, 2),
|
|
224
|
+
efficiency: round(efficiency, 3),
|
|
225
|
+
restorativeShare: round(restorativeShare, 3),
|
|
226
|
+
awakeRatio: input.asleepSeconds > 0
|
|
227
|
+
? round(input.awakeSeconds / input.asleepSeconds, 3)
|
|
228
|
+
: 0,
|
|
229
|
+
sleepDebtHours: round(sleepDebtHours, 2),
|
|
230
|
+
recoveryState: input.sleepScore >= 82
|
|
231
|
+
? "recovered"
|
|
232
|
+
: input.sleepScore >= 68
|
|
233
|
+
? "stable"
|
|
234
|
+
: input.sleepScore >= 52
|
|
235
|
+
? "strained"
|
|
236
|
+
: "depleted"
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function computeSleepTimingMetrics(input) {
|
|
240
|
+
const params = [input.userId];
|
|
241
|
+
const excludeSql = input.excludeSleepId ? "AND id != ?" : "";
|
|
242
|
+
if (input.excludeSleepId) {
|
|
243
|
+
params.push(input.excludeSleepId);
|
|
244
|
+
}
|
|
245
|
+
const rows = getDatabase()
|
|
246
|
+
.prepare(`SELECT id, started_at, ended_at
|
|
247
|
+
FROM health_sleep_sessions
|
|
248
|
+
WHERE user_id = ?
|
|
249
|
+
${excludeSql}
|
|
250
|
+
ORDER BY started_at DESC
|
|
251
|
+
LIMIT 14`)
|
|
252
|
+
.all(...params);
|
|
253
|
+
if (rows.length === 0) {
|
|
254
|
+
return {
|
|
255
|
+
bedtimeConsistencyMinutes: null,
|
|
256
|
+
wakeConsistencyMinutes: null,
|
|
257
|
+
regularityScore: computeRegularityScore(input.startedAt)
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const bedtimeReference = average(rows.map((row) => sleepMinutesOfDay(row.started_at, "bedtime")));
|
|
261
|
+
const wakeReference = average(rows.map((row) => sleepMinutesOfDay(row.ended_at, "wake")));
|
|
262
|
+
const bedtimeConsistencyMinutes = Math.round(Math.abs(sleepMinutesOfDay(input.startedAt, "bedtime") - bedtimeReference));
|
|
263
|
+
const wakeConsistencyMinutes = Math.round(Math.abs(sleepMinutesOfDay(input.endedAt, "wake") - wakeReference));
|
|
264
|
+
const regularityScore = Math.max(0, Math.min(100, Math.round(100 - average([bedtimeConsistencyMinutes, wakeConsistencyMinutes]) / 1.8)));
|
|
265
|
+
return {
|
|
266
|
+
bedtimeConsistencyMinutes,
|
|
267
|
+
wakeConsistencyMinutes,
|
|
268
|
+
regularityScore
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function computeSleepScore(input) {
|
|
272
|
+
if (input.asleepSeconds <= 0) {
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
const efficiency = input.timeInBedSeconds > 0
|
|
276
|
+
? input.asleepSeconds / input.timeInBedSeconds
|
|
277
|
+
: 1;
|
|
278
|
+
const deepSeconds = input.stageBreakdown
|
|
279
|
+
.filter((stage) => stage.stage.toLowerCase().includes("deep"))
|
|
280
|
+
.reduce((total, stage) => total + stage.seconds, 0);
|
|
281
|
+
const remSeconds = input.stageBreakdown
|
|
282
|
+
.filter((stage) => stage.stage.toLowerCase().includes("rem"))
|
|
283
|
+
.reduce((total, stage) => total + stage.seconds, 0);
|
|
284
|
+
const restorativeRatio = (deepSeconds + remSeconds) / input.asleepSeconds;
|
|
285
|
+
const wakePenalty = input.asleepSeconds > 0 ? input.awakeSeconds / input.asleepSeconds : 0;
|
|
286
|
+
const durationHours = input.asleepSeconds / 3600;
|
|
287
|
+
const durationScore = Math.max(0, 1 - Math.abs(durationHours - 8) / 4);
|
|
288
|
+
return Math.round(Math.max(0, Math.min(100, 45 * efficiency +
|
|
289
|
+
25 * durationScore +
|
|
290
|
+
20 * Math.min(1, restorativeRatio * 1.5) +
|
|
291
|
+
10 * Math.max(0, 1 - wakePenalty))));
|
|
292
|
+
}
|
|
293
|
+
function computeRegularityScore(startedAt) {
|
|
294
|
+
const date = new Date(startedAt);
|
|
295
|
+
const bedtimeMinutes = date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
296
|
+
const distanceFromTarget = Math.min(Math.abs(bedtimeMinutes - 22 * 60 - 30), Math.abs(bedtimeMinutes - (24 * 60 + 22 * 60 + 30)));
|
|
297
|
+
return Math.round(Math.max(0, 100 - distanceFromTarget / 3));
|
|
298
|
+
}
|
|
299
|
+
function mapPairingSession(row) {
|
|
300
|
+
const stale = row.last_sync_at &&
|
|
301
|
+
Date.now() - Date.parse(row.last_sync_at) > 1000 * 60 * 60 * 24;
|
|
302
|
+
const effectiveStatus = row.status === "healthy" && stale ? "stale" : row.status;
|
|
303
|
+
return {
|
|
304
|
+
id: row.id,
|
|
305
|
+
userId: row.user_id,
|
|
306
|
+
label: row.label,
|
|
307
|
+
status: pairingStatusSchema.parse(effectiveStatus),
|
|
308
|
+
capabilities: safeJsonParse(row.capability_flags_json, []),
|
|
309
|
+
deviceName: row.device_name,
|
|
310
|
+
platform: row.platform,
|
|
311
|
+
appVersion: row.app_version,
|
|
312
|
+
apiBaseUrl: row.api_base_url,
|
|
313
|
+
lastSeenAt: row.last_seen_at,
|
|
314
|
+
lastSyncAt: row.last_sync_at,
|
|
315
|
+
lastSyncError: row.last_sync_error,
|
|
316
|
+
pairedAt: row.paired_at,
|
|
317
|
+
expiresAt: row.expires_at,
|
|
318
|
+
createdAt: row.created_at,
|
|
319
|
+
updatedAt: row.updated_at
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function mapSleepSession(row) {
|
|
323
|
+
return {
|
|
324
|
+
id: row.id,
|
|
325
|
+
externalUid: row.external_uid,
|
|
326
|
+
pairingSessionId: row.pairing_session_id,
|
|
327
|
+
userId: row.user_id,
|
|
328
|
+
source: row.source,
|
|
329
|
+
sourceType: row.source_type,
|
|
330
|
+
sourceDevice: row.source_device,
|
|
331
|
+
startedAt: row.started_at,
|
|
332
|
+
endedAt: row.ended_at,
|
|
333
|
+
timeInBedSeconds: row.time_in_bed_seconds,
|
|
334
|
+
asleepSeconds: row.asleep_seconds,
|
|
335
|
+
awakeSeconds: row.awake_seconds,
|
|
336
|
+
sleepScore: row.sleep_score,
|
|
337
|
+
regularityScore: row.regularity_score,
|
|
338
|
+
bedtimeConsistencyMinutes: row.bedtime_consistency_minutes,
|
|
339
|
+
wakeConsistencyMinutes: row.wake_consistency_minutes,
|
|
340
|
+
stageBreakdown: safeJsonParse(row.stage_breakdown_json, []),
|
|
341
|
+
recoveryMetrics: safeJsonParse(row.recovery_metrics_json, {}),
|
|
342
|
+
links: safeJsonParse(row.links_json, []),
|
|
343
|
+
annotations: safeJsonParse(row.annotations_json, {}),
|
|
344
|
+
provenance: safeJsonParse(row.provenance_json, {}),
|
|
345
|
+
derived: safeJsonParse(row.derived_json, {}),
|
|
346
|
+
createdAt: row.created_at,
|
|
347
|
+
updatedAt: row.updated_at
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function mapWorkoutSession(row) {
|
|
351
|
+
return {
|
|
352
|
+
id: row.id,
|
|
353
|
+
externalUid: row.external_uid,
|
|
354
|
+
pairingSessionId: row.pairing_session_id,
|
|
355
|
+
userId: row.user_id,
|
|
356
|
+
source: row.source,
|
|
357
|
+
sourceType: row.source_type,
|
|
358
|
+
workoutType: row.workout_type,
|
|
359
|
+
sourceDevice: row.source_device,
|
|
360
|
+
startedAt: row.started_at,
|
|
361
|
+
endedAt: row.ended_at,
|
|
362
|
+
durationSeconds: row.duration_seconds,
|
|
363
|
+
activeEnergyKcal: row.active_energy_kcal,
|
|
364
|
+
totalEnergyKcal: row.total_energy_kcal,
|
|
365
|
+
distanceMeters: row.distance_meters,
|
|
366
|
+
stepCount: row.step_count,
|
|
367
|
+
exerciseMinutes: row.exercise_minutes,
|
|
368
|
+
averageHeartRate: row.average_heart_rate,
|
|
369
|
+
maxHeartRate: row.max_heart_rate,
|
|
370
|
+
subjectiveEffort: row.subjective_effort,
|
|
371
|
+
moodBefore: row.mood_before,
|
|
372
|
+
moodAfter: row.mood_after,
|
|
373
|
+
meaningText: row.meaning_text,
|
|
374
|
+
plannedContext: row.planned_context,
|
|
375
|
+
socialContext: row.social_context,
|
|
376
|
+
links: safeJsonParse(row.links_json, []),
|
|
377
|
+
tags: safeJsonParse(row.tags_json, []),
|
|
378
|
+
annotations: safeJsonParse(row.annotations_json, {}),
|
|
379
|
+
provenance: safeJsonParse(row.provenance_json, {}),
|
|
380
|
+
derived: safeJsonParse(row.derived_json, {}),
|
|
381
|
+
generatedFromHabitId: row.generated_from_habit_id,
|
|
382
|
+
generatedFromCheckInId: row.generated_from_check_in_id,
|
|
383
|
+
reconciliationStatus: row.reconciliation_status,
|
|
384
|
+
createdAt: row.created_at,
|
|
385
|
+
updatedAt: row.updated_at
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function mapHealthImportRun(row) {
|
|
389
|
+
return {
|
|
390
|
+
id: row.id,
|
|
391
|
+
pairingSessionId: row.pairing_session_id,
|
|
392
|
+
userId: row.user_id,
|
|
393
|
+
source: row.source,
|
|
394
|
+
sourceDevice: row.source_device,
|
|
395
|
+
status: row.status,
|
|
396
|
+
payloadSummary: safeJsonParse(row.payload_summary_json, {}),
|
|
397
|
+
importedCount: row.imported_count,
|
|
398
|
+
createdCount: row.created_count,
|
|
399
|
+
updatedCount: row.updated_count,
|
|
400
|
+
mergedCount: row.merged_count,
|
|
401
|
+
errorMessage: row.error_message,
|
|
402
|
+
importedAt: row.imported_at,
|
|
403
|
+
createdAt: row.created_at,
|
|
404
|
+
updatedAt: row.updated_at
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function listSleepRows(userIds) {
|
|
408
|
+
const params = [];
|
|
409
|
+
const where = userIds && userIds.length > 0
|
|
410
|
+
? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
|
|
411
|
+
: "";
|
|
412
|
+
if (userIds) {
|
|
413
|
+
params.push(...userIds);
|
|
414
|
+
}
|
|
415
|
+
return getDatabase()
|
|
416
|
+
.prepare(`SELECT *
|
|
417
|
+
FROM health_sleep_sessions
|
|
418
|
+
${where}
|
|
419
|
+
ORDER BY started_at DESC`)
|
|
420
|
+
.all(...params);
|
|
421
|
+
}
|
|
422
|
+
function listWorkoutRows(userIds) {
|
|
423
|
+
const params = [];
|
|
424
|
+
const where = userIds && userIds.length > 0
|
|
425
|
+
? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
|
|
426
|
+
: "";
|
|
427
|
+
if (userIds) {
|
|
428
|
+
params.push(...userIds);
|
|
429
|
+
}
|
|
430
|
+
return getDatabase()
|
|
431
|
+
.prepare(`SELECT *
|
|
432
|
+
FROM health_workout_sessions
|
|
433
|
+
${where}
|
|
434
|
+
ORDER BY started_at DESC`)
|
|
435
|
+
.all(...params);
|
|
436
|
+
}
|
|
437
|
+
function listPairingRows(userIds) {
|
|
438
|
+
const params = [];
|
|
439
|
+
const where = userIds && userIds.length > 0
|
|
440
|
+
? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
|
|
441
|
+
: "";
|
|
442
|
+
if (userIds) {
|
|
443
|
+
params.push(...userIds);
|
|
444
|
+
}
|
|
445
|
+
return getDatabase()
|
|
446
|
+
.prepare(`SELECT *
|
|
447
|
+
FROM companion_pairing_sessions
|
|
448
|
+
${where}
|
|
449
|
+
ORDER BY updated_at DESC, created_at DESC`)
|
|
450
|
+
.all(...params);
|
|
451
|
+
}
|
|
452
|
+
function revokePairingRows(rows, activity) {
|
|
453
|
+
if (rows.length === 0) {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
const now = nowIso();
|
|
457
|
+
const reason = activity?.reason ?? "Revoked by operator";
|
|
458
|
+
const revokeStatement = getDatabase().prepare(`UPDATE companion_pairing_sessions
|
|
459
|
+
SET status = 'revoked', last_sync_error = ?, updated_at = ?
|
|
460
|
+
WHERE id = ?`);
|
|
461
|
+
const refetchStatement = getDatabase().prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`);
|
|
462
|
+
for (const row of rows) {
|
|
463
|
+
revokeStatement.run(reason, now, row.id);
|
|
464
|
+
recordActivityEvent({
|
|
465
|
+
entityType: "system",
|
|
466
|
+
entityId: row.id,
|
|
467
|
+
eventType: "companion_pairing_revoked",
|
|
468
|
+
title: "Companion pairing revoked",
|
|
469
|
+
description: "An operator revoked a Forge Companion pairing session and blocked further syncs for that device.",
|
|
470
|
+
actor: activity?.actor ?? null,
|
|
471
|
+
source: activity?.source ?? "ui",
|
|
472
|
+
metadata: {
|
|
473
|
+
label: row.label,
|
|
474
|
+
deviceName: row.device_name,
|
|
475
|
+
platform: row.platform
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return rows.map((row) => mapPairingSession(refetchStatement.get(row.id)));
|
|
480
|
+
}
|
|
481
|
+
function listHealthImportRunRows(userIds, limit = 12) {
|
|
482
|
+
const params = [];
|
|
483
|
+
const where = userIds && userIds.length > 0
|
|
484
|
+
? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
|
|
485
|
+
: "";
|
|
486
|
+
if (userIds) {
|
|
487
|
+
params.push(...userIds);
|
|
488
|
+
}
|
|
489
|
+
params.push(limit);
|
|
490
|
+
return getDatabase()
|
|
491
|
+
.prepare(`SELECT *
|
|
492
|
+
FROM health_import_runs
|
|
493
|
+
${where}
|
|
494
|
+
ORDER BY imported_at DESC, created_at DESC
|
|
495
|
+
LIMIT ?`)
|
|
496
|
+
.all(...params);
|
|
497
|
+
}
|
|
498
|
+
export function listPairingSessions(userIds) {
|
|
499
|
+
return listPairingRows(userIds).map(mapPairingSession);
|
|
500
|
+
}
|
|
501
|
+
export function revokeCompanionPairingSession(pairingSessionId, activity) {
|
|
502
|
+
const current = getDatabase()
|
|
503
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
504
|
+
.get(pairingSessionId);
|
|
505
|
+
if (!current) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return revokePairingRows([current], activity)[0];
|
|
509
|
+
}
|
|
510
|
+
export function revokeAllCompanionPairingSessions(input, activity) {
|
|
511
|
+
const parsed = revokeAllCompanionPairingSessionsSchema.parse(input ?? {});
|
|
512
|
+
const rows = listPairingRows(parsed.userIds.length > 0 ? parsed.userIds : undefined)
|
|
513
|
+
.filter((row) => parsed.includeRevoked || row.status !== "revoked");
|
|
514
|
+
const sessions = revokePairingRows(rows, {
|
|
515
|
+
actor: activity?.actor ?? null,
|
|
516
|
+
source: activity?.source ?? "ui",
|
|
517
|
+
reason: "Revoked by operator (bulk)"
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
revokedCount: sessions.length,
|
|
521
|
+
sessions
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
export function createCompanionPairingSession(baseApiUrl, input) {
|
|
525
|
+
const parsed = createCompanionPairingSessionSchema.parse(input);
|
|
526
|
+
const now = new Date();
|
|
527
|
+
const userId = parsed.userId ?? "user_operator";
|
|
528
|
+
const serializedCapabilities = JSON.stringify(parsed.capabilities);
|
|
529
|
+
const expiresAt = new Date(now.getTime() + parsed.expiresInMinutes * 60_000).toISOString();
|
|
530
|
+
const id = `pair_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
531
|
+
const pairingToken = randomUUID().replaceAll("-", "");
|
|
532
|
+
const stalePendingRows = getDatabase()
|
|
533
|
+
.prepare(`SELECT *
|
|
534
|
+
FROM companion_pairing_sessions
|
|
535
|
+
WHERE user_id = ?
|
|
536
|
+
AND label = ?
|
|
537
|
+
AND api_base_url = ?
|
|
538
|
+
AND capability_flags_json = ?
|
|
539
|
+
AND status = 'pending'`)
|
|
540
|
+
.all(userId, parsed.label, baseApiUrl, serializedCapabilities);
|
|
541
|
+
if (stalePendingRows.length > 0) {
|
|
542
|
+
revokePairingRows(stalePendingRows, {
|
|
543
|
+
actor: null,
|
|
544
|
+
source: "system",
|
|
545
|
+
reason: "Superseded by a newer pairing QR"
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
getDatabase()
|
|
549
|
+
.prepare(`INSERT INTO companion_pairing_sessions (
|
|
550
|
+
id, user_id, label, pairing_token, status, capability_flags_json, api_base_url,
|
|
551
|
+
expires_at, created_at, updated_at
|
|
552
|
+
)
|
|
553
|
+
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`)
|
|
554
|
+
.run(id, userId, parsed.label, pairingToken, serializedCapabilities, baseApiUrl, expiresAt, now.toISOString(), now.toISOString());
|
|
555
|
+
const qrPayload = {
|
|
556
|
+
kind: "forge-companion-pairing",
|
|
557
|
+
apiBaseUrl: baseApiUrl,
|
|
558
|
+
sessionId: id,
|
|
559
|
+
pairingToken,
|
|
560
|
+
expiresAt,
|
|
561
|
+
capabilities: parsed.capabilities
|
|
562
|
+
};
|
|
563
|
+
return {
|
|
564
|
+
session: mapPairingSession(getDatabase()
|
|
565
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
566
|
+
.get(id)),
|
|
567
|
+
qrPayload
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
export function verifyCompanionPairing(payload) {
|
|
571
|
+
const parsed = verifyCompanionPairingSchema.parse(payload);
|
|
572
|
+
const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
|
|
573
|
+
const now = nowIso();
|
|
574
|
+
const nextStatus = pairing.status === "healthy" ||
|
|
575
|
+
pairing.status === "stale" ||
|
|
576
|
+
pairing.status === "permission_denied"
|
|
577
|
+
? pairing.status
|
|
578
|
+
: "paired";
|
|
579
|
+
getDatabase()
|
|
580
|
+
.prepare(`UPDATE companion_pairing_sessions
|
|
581
|
+
SET status = ?, device_name = ?, platform = ?, app_version = ?,
|
|
582
|
+
last_seen_at = ?, paired_at = COALESCE(paired_at, ?), updated_at = ?
|
|
583
|
+
WHERE id = ?`)
|
|
584
|
+
.run(nextStatus, parsed.device.name, parsed.device.platform, parsed.device.appVersion, now, now, now, pairing.id);
|
|
585
|
+
if (parsed.device.name.trim().length > 0) {
|
|
586
|
+
const duplicateRows = getDatabase()
|
|
587
|
+
.prepare(`SELECT *
|
|
588
|
+
FROM companion_pairing_sessions
|
|
589
|
+
WHERE user_id = ?
|
|
590
|
+
AND id != ?
|
|
591
|
+
AND status != 'revoked'
|
|
592
|
+
AND COALESCE(device_name, '') = ?
|
|
593
|
+
AND COALESCE(platform, '') = ?`)
|
|
594
|
+
.all(pairing.user_id, pairing.id, parsed.device.name, parsed.device.platform);
|
|
595
|
+
if (duplicateRows.length > 0) {
|
|
596
|
+
revokePairingRows(duplicateRows, {
|
|
597
|
+
actor: null,
|
|
598
|
+
source: "system",
|
|
599
|
+
reason: "Superseded by a newer verified device pairing"
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
pairingSession: mapPairingSession(getDatabase()
|
|
605
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
606
|
+
.get(pairing.id))
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function requireValidPairing(sessionId, pairingToken) {
|
|
610
|
+
const row = getDatabase()
|
|
611
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
612
|
+
.get(sessionId);
|
|
613
|
+
if (!row || row.pairing_token !== pairingToken) {
|
|
614
|
+
throw new HttpError(401, "invalid_pairing_token", "The pairing session or pairing token is invalid.");
|
|
615
|
+
}
|
|
616
|
+
if (Date.parse(row.expires_at) < Date.now()) {
|
|
617
|
+
throw new HttpError(410, "pairing_expired", "The pairing session expired. Generate a new QR code in Forge.");
|
|
618
|
+
}
|
|
619
|
+
if (row.status === "revoked") {
|
|
620
|
+
throw new HttpError(403, "pairing_revoked", "This companion pairing was revoked.");
|
|
621
|
+
}
|
|
622
|
+
return row;
|
|
623
|
+
}
|
|
624
|
+
function findMatchingGeneratedWorkout(input) {
|
|
625
|
+
const rows = getDatabase()
|
|
626
|
+
.prepare(`SELECT *
|
|
627
|
+
FROM health_workout_sessions
|
|
628
|
+
WHERE user_id = ?
|
|
629
|
+
AND generated_from_habit_id IS NOT NULL
|
|
630
|
+
AND workout_type = ?
|
|
631
|
+
AND ABS(strftime('%s', started_at) - strftime('%s', ?)) <= 5400
|
|
632
|
+
AND ABS(strftime('%s', ended_at) - strftime('%s', ?)) <= 5400
|
|
633
|
+
ORDER BY (
|
|
634
|
+
ABS(strftime('%s', started_at) - strftime('%s', ?)) +
|
|
635
|
+
ABS(strftime('%s', ended_at) - strftime('%s', ?))
|
|
636
|
+
) ASC
|
|
637
|
+
LIMIT 1`)
|
|
638
|
+
.all(input.userId, input.workoutType, input.startedAt, input.endedAt, input.startedAt, input.endedAt);
|
|
639
|
+
return rows[0] ?? null;
|
|
640
|
+
}
|
|
641
|
+
function upsertDailySummary(userId, dateKey, summaryType, metrics, derived = {}) {
|
|
642
|
+
const existing = getDatabase()
|
|
643
|
+
.prepare(`SELECT id, user_id, date_key, summary_type, metrics_json, derived_json, source, created_at, updated_at
|
|
644
|
+
FROM health_daily_summaries
|
|
645
|
+
WHERE user_id = ? AND date_key = ? AND summary_type = ?`)
|
|
646
|
+
.get(userId, dateKey, summaryType);
|
|
647
|
+
const now = nowIso();
|
|
648
|
+
if (existing) {
|
|
649
|
+
getDatabase()
|
|
650
|
+
.prepare(`UPDATE health_daily_summaries
|
|
651
|
+
SET metrics_json = ?, derived_json = ?, updated_at = ?
|
|
652
|
+
WHERE id = ?`)
|
|
653
|
+
.run(JSON.stringify(metrics), JSON.stringify(derived), now, existing.id);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
getDatabase()
|
|
657
|
+
.prepare(`INSERT INTO health_daily_summaries (
|
|
658
|
+
id, user_id, date_key, summary_type, metrics_json, derived_json, source, created_at, updated_at
|
|
659
|
+
)
|
|
660
|
+
VALUES (?, ?, ?, ?, ?, ?, 'derived', ?, ?)`)
|
|
661
|
+
.run(`hds_${randomUUID().replaceAll("-", "").slice(0, 10)}`, userId, dateKey, summaryType, JSON.stringify(metrics), JSON.stringify(derived), now, now);
|
|
662
|
+
}
|
|
663
|
+
function summarizeUserHealthDay(userId, dateKeyValue) {
|
|
664
|
+
const sleeps = listSleepRows([userId]).filter((row) => dayKey(row.ended_at) === dateKeyValue || dayKey(row.started_at) === dateKeyValue);
|
|
665
|
+
const workouts = listWorkoutRows([userId]).filter((row) => dayKey(row.started_at) === dateKeyValue);
|
|
666
|
+
const totalSleepSeconds = sleeps.reduce((sum, row) => sum + row.asleep_seconds, 0);
|
|
667
|
+
const totalWorkoutSeconds = workouts.reduce((sum, row) => sum + row.duration_seconds, 0);
|
|
668
|
+
const totalExerciseMinutes = workouts.reduce((sum, row) => sum + (row.exercise_minutes ?? row.duration_seconds / 60), 0);
|
|
669
|
+
const totalEnergyKcal = workouts.reduce((sum, row) => sum + (row.total_energy_kcal ?? row.active_energy_kcal ?? 0), 0);
|
|
670
|
+
upsertDailySummary(userId, dateKeyValue, "health", {
|
|
671
|
+
totalSleepSeconds,
|
|
672
|
+
totalWorkoutSeconds,
|
|
673
|
+
totalExerciseMinutes,
|
|
674
|
+
totalEnergyKcal,
|
|
675
|
+
workoutCount: workouts.length,
|
|
676
|
+
sleepSessionCount: sleeps.length
|
|
677
|
+
}, {
|
|
678
|
+
recoveryState: totalSleepSeconds >= 7 * 3600
|
|
679
|
+
? "recovered"
|
|
680
|
+
: totalSleepSeconds >= 6 * 3600
|
|
681
|
+
? "borderline"
|
|
682
|
+
: "strained"
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
function insertOrUpdateSleepSession(pairing, input) {
|
|
686
|
+
const existing = getDatabase()
|
|
687
|
+
.prepare(`SELECT *
|
|
688
|
+
FROM health_sleep_sessions
|
|
689
|
+
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
|
|
690
|
+
.get(pairing.user_id, input.externalUid);
|
|
691
|
+
const stageBreakdown = input.stageBreakdown;
|
|
692
|
+
const sleepScore = computeSleepScore({
|
|
693
|
+
asleepSeconds: input.asleepSeconds,
|
|
694
|
+
timeInBedSeconds: input.timeInBedSeconds,
|
|
695
|
+
awakeSeconds: input.awakeSeconds,
|
|
696
|
+
stageBreakdown
|
|
697
|
+
});
|
|
698
|
+
const timingMetrics = computeSleepTimingMetrics({
|
|
699
|
+
userId: pairing.user_id,
|
|
700
|
+
startedAt: input.startedAt,
|
|
701
|
+
endedAt: input.endedAt,
|
|
702
|
+
excludeSleepId: existing?.id
|
|
703
|
+
});
|
|
704
|
+
const annotations = sleepAnnotationSchema.parse(input.annotations ?? {});
|
|
705
|
+
const derivedMetrics = computeSleepDerivedMetrics({
|
|
706
|
+
asleepSeconds: input.asleepSeconds,
|
|
707
|
+
timeInBedSeconds: input.timeInBedSeconds,
|
|
708
|
+
awakeSeconds: input.awakeSeconds,
|
|
709
|
+
sleepScore,
|
|
710
|
+
stageBreakdown
|
|
711
|
+
});
|
|
712
|
+
const now = nowIso();
|
|
713
|
+
if (existing) {
|
|
714
|
+
getDatabase()
|
|
715
|
+
.prepare(`UPDATE health_sleep_sessions
|
|
716
|
+
SET pairing_session_id = ?, source_device = ?, started_at = ?, ended_at = ?, time_in_bed_seconds = ?,
|
|
717
|
+
asleep_seconds = ?, awake_seconds = ?, sleep_score = ?, regularity_score = ?,
|
|
718
|
+
bedtime_consistency_minutes = ?, wake_consistency_minutes = ?, stage_breakdown_json = ?, recovery_metrics_json = ?, links_json = ?, annotations_json = ?,
|
|
719
|
+
provenance_json = ?, derived_json = ?, updated_at = ?
|
|
720
|
+
WHERE id = ?`)
|
|
721
|
+
.run(pairing.id, pairing.device_name ?? "", input.startedAt, input.endedAt, input.timeInBedSeconds, input.asleepSeconds, input.awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(stageBreakdown), JSON.stringify(input.recoveryMetrics), JSON.stringify(mergeHealthLinks(safeJsonParse(existing.links_json, []), input.links, annotations.links)), JSON.stringify(annotations), JSON.stringify({
|
|
722
|
+
importedVia: "ios_companion",
|
|
723
|
+
pairingSessionId: pairing.id,
|
|
724
|
+
updatedAt: now
|
|
725
|
+
}), JSON.stringify(derivedMetrics), now, existing.id);
|
|
726
|
+
return { mode: "updated", id: existing.id };
|
|
727
|
+
}
|
|
728
|
+
const id = `sleep_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
729
|
+
getDatabase()
|
|
730
|
+
.prepare(`INSERT INTO health_sleep_sessions (
|
|
731
|
+
id, external_uid, pairing_session_id, user_id, source, source_type, source_device,
|
|
732
|
+
started_at, ended_at, time_in_bed_seconds, asleep_seconds, awake_seconds,
|
|
733
|
+
sleep_score, regularity_score, bedtime_consistency_minutes, wake_consistency_minutes,
|
|
734
|
+
stage_breakdown_json, recovery_metrics_json, links_json, annotations_json,
|
|
735
|
+
provenance_json, derived_json, created_at, updated_at
|
|
736
|
+
)
|
|
737
|
+
VALUES (?, ?, ?, ?, 'apple_health', 'healthkit', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
738
|
+
.run(id, input.externalUid, pairing.id, pairing.user_id, pairing.device_name ?? "", input.startedAt, input.endedAt, input.timeInBedSeconds, input.asleepSeconds, input.awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(stageBreakdown), JSON.stringify(input.recoveryMetrics), JSON.stringify(mergeHealthLinks(input.links, annotations.links)), JSON.stringify(annotations), JSON.stringify({
|
|
739
|
+
importedVia: "ios_companion",
|
|
740
|
+
pairingSessionId: pairing.id,
|
|
741
|
+
createdAt: now
|
|
742
|
+
}), JSON.stringify(derivedMetrics), now, now);
|
|
743
|
+
return { mode: "created", id };
|
|
744
|
+
}
|
|
745
|
+
function insertOrUpdateWorkoutSession(pairing, input) {
|
|
746
|
+
const existing = getDatabase()
|
|
747
|
+
.prepare(`SELECT *
|
|
748
|
+
FROM health_workout_sessions
|
|
749
|
+
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
|
|
750
|
+
.get(pairing.user_id, input.externalUid);
|
|
751
|
+
const annotations = workoutAnnotationSchema.parse(input.annotations ?? {});
|
|
752
|
+
const now = nowIso();
|
|
753
|
+
const matchedGenerated = existing ??
|
|
754
|
+
findMatchingGeneratedWorkout({
|
|
755
|
+
userId: pairing.user_id,
|
|
756
|
+
workoutType: input.workoutType,
|
|
757
|
+
startedAt: input.startedAt,
|
|
758
|
+
endedAt: input.endedAt
|
|
759
|
+
});
|
|
760
|
+
if (matchedGenerated) {
|
|
761
|
+
const existingLinks = safeJsonParse(matchedGenerated.links_json, []);
|
|
762
|
+
const existingTags = safeJsonParse(matchedGenerated.tags_json, []);
|
|
763
|
+
const existingAnnotations = safeJsonParse(matchedGenerated.annotations_json, {});
|
|
764
|
+
const mergedLinks = mergeHealthLinks(existingLinks, input.links, annotations.links);
|
|
765
|
+
const mergedTags = mergeStringLists(existingTags, annotations.tags);
|
|
766
|
+
const nextSubjectiveEffort = matchedGenerated.subjective_effort ?? annotations.subjectiveEffort ?? null;
|
|
767
|
+
const nextMoodBefore = matchedGenerated.mood_before || annotations.moodBefore;
|
|
768
|
+
const nextMoodAfter = matchedGenerated.mood_after || annotations.moodAfter;
|
|
769
|
+
const nextMeaningText = matchedGenerated.meaning_text || annotations.meaningText;
|
|
770
|
+
const nextPlannedContext = matchedGenerated.planned_context || annotations.plannedContext;
|
|
771
|
+
const nextSocialContext = matchedGenerated.social_context || annotations.socialContext;
|
|
772
|
+
const nextAnnotations = {
|
|
773
|
+
...existingAnnotations,
|
|
774
|
+
subjectiveEffort: nextSubjectiveEffort,
|
|
775
|
+
moodBefore: nextMoodBefore,
|
|
776
|
+
moodAfter: nextMoodAfter,
|
|
777
|
+
meaningText: nextMeaningText,
|
|
778
|
+
plannedContext: nextPlannedContext,
|
|
779
|
+
socialContext: nextSocialContext,
|
|
780
|
+
tags: mergedTags,
|
|
781
|
+
links: mergedLinks
|
|
782
|
+
};
|
|
783
|
+
getDatabase()
|
|
784
|
+
.prepare(`UPDATE health_workout_sessions
|
|
785
|
+
SET external_uid = ?, pairing_session_id = ?, source = 'apple_health', source_type = ?,
|
|
786
|
+
source_device = ?, started_at = ?, ended_at = ?, duration_seconds = ?, active_energy_kcal = ?,
|
|
787
|
+
total_energy_kcal = ?, distance_meters = ?, step_count = ?, exercise_minutes = ?, average_heart_rate = ?,
|
|
788
|
+
max_heart_rate = ?, subjective_effort = ?, mood_before = ?, mood_after = ?, meaning_text = ?,
|
|
789
|
+
planned_context = ?, social_context = ?,
|
|
790
|
+
links_json = ?, tags_json = ?, annotations_json = ?, provenance_json = ?, derived_json = ?,
|
|
791
|
+
reconciliation_status = ?, updated_at = ?
|
|
792
|
+
WHERE id = ?`)
|
|
793
|
+
.run(input.externalUid, pairing.id, matchedGenerated.generated_from_habit_id ? "reconciled" : "healthkit", input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, nextSubjectiveEffort, nextMoodBefore, nextMoodAfter, nextMeaningText, nextPlannedContext, nextSocialContext, JSON.stringify(mergedLinks), JSON.stringify(mergedTags), JSON.stringify(nextAnnotations), JSON.stringify({
|
|
794
|
+
importedVia: "ios_companion",
|
|
795
|
+
pairingSessionId: pairing.id,
|
|
796
|
+
mergedWithGenerated: matchedGenerated.generated_from_habit_id !== null,
|
|
797
|
+
priorSource: matchedGenerated.source,
|
|
798
|
+
updatedAt: now
|
|
799
|
+
}), JSON.stringify({
|
|
800
|
+
paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
|
|
801
|
+
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
802
|
+
: null
|
|
803
|
+
}), matchedGenerated.generated_from_habit_id ? "merged" : "standalone", now, matchedGenerated.id);
|
|
804
|
+
return {
|
|
805
|
+
mode: matchedGenerated.generated_from_habit_id || matchedGenerated.source !== "apple_health"
|
|
806
|
+
? "merged"
|
|
807
|
+
: "updated",
|
|
808
|
+
id: matchedGenerated.id
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
812
|
+
getDatabase()
|
|
813
|
+
.prepare(`INSERT INTO health_workout_sessions (
|
|
814
|
+
id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
|
|
815
|
+
started_at, ended_at, duration_seconds, active_energy_kcal, total_energy_kcal, distance_meters,
|
|
816
|
+
step_count, exercise_minutes, average_heart_rate, max_heart_rate, subjective_effort, mood_before,
|
|
817
|
+
mood_after, meaning_text, planned_context, social_context, links_json, tags_json, annotations_json,
|
|
818
|
+
provenance_json, derived_json, reconciliation_status, created_at, updated_at
|
|
819
|
+
)
|
|
820
|
+
VALUES (?, ?, ?, ?, 'apple_health', 'healthkit', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
|
|
821
|
+
.run(id, input.externalUid, pairing.id, pairing.user_id, input.workoutType, input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(input.links), JSON.stringify(annotations.tags), JSON.stringify(annotations), JSON.stringify({
|
|
822
|
+
importedVia: "ios_companion",
|
|
823
|
+
pairingSessionId: pairing.id,
|
|
824
|
+
createdAt: now
|
|
825
|
+
}), JSON.stringify({
|
|
826
|
+
paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
|
|
827
|
+
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
828
|
+
: null
|
|
829
|
+
}), now, now);
|
|
830
|
+
return { mode: "created", id };
|
|
831
|
+
}
|
|
832
|
+
export function ingestMobileHealthSync(payload) {
|
|
833
|
+
const parsed = mobileHealthSyncSchema.parse(payload);
|
|
834
|
+
const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
|
|
835
|
+
return runInTransaction(() => {
|
|
836
|
+
const now = nowIso();
|
|
837
|
+
const runId = `hir_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
838
|
+
let createdCount = 0;
|
|
839
|
+
let updatedCount = 0;
|
|
840
|
+
let mergedCount = 0;
|
|
841
|
+
for (const sleep of parsed.sleepSessions) {
|
|
842
|
+
const result = insertOrUpdateSleepSession(pairing, sleep);
|
|
843
|
+
if (result.mode === "created") {
|
|
844
|
+
createdCount += 1;
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
updatedCount += 1;
|
|
848
|
+
}
|
|
849
|
+
summarizeUserHealthDay(pairing.user_id, dayKey(sleep.endedAt));
|
|
850
|
+
}
|
|
851
|
+
for (const workout of parsed.workouts) {
|
|
852
|
+
const result = insertOrUpdateWorkoutSession(pairing, workout);
|
|
853
|
+
if (result.mode === "created") {
|
|
854
|
+
createdCount += 1;
|
|
855
|
+
}
|
|
856
|
+
else if (result.mode === "merged") {
|
|
857
|
+
mergedCount += 1;
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
updatedCount += 1;
|
|
861
|
+
}
|
|
862
|
+
summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
|
|
863
|
+
}
|
|
864
|
+
const permissionStatus = parsed.permissions.healthKitAuthorized === false
|
|
865
|
+
? "permission_denied"
|
|
866
|
+
: "healthy";
|
|
867
|
+
getDatabase()
|
|
868
|
+
.prepare(`UPDATE companion_pairing_sessions
|
|
869
|
+
SET status = ?, device_name = ?, platform = ?, app_version = ?, last_seen_at = ?,
|
|
870
|
+
last_sync_at = ?, last_sync_error = NULL, paired_at = COALESCE(paired_at, ?), updated_at = ?
|
|
871
|
+
WHERE id = ?`)
|
|
872
|
+
.run(permissionStatus, parsed.device.name, parsed.device.platform, parsed.device.appVersion, now, now, now, now, pairing.id);
|
|
873
|
+
getDatabase()
|
|
874
|
+
.prepare(`INSERT INTO health_import_runs (
|
|
875
|
+
id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
|
|
876
|
+
imported_count, created_count, updated_count, merged_count, imported_at, created_at, updated_at
|
|
877
|
+
)
|
|
878
|
+
VALUES (?, ?, ?, 'ios_companion', ?, 'completed', ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
879
|
+
.run(runId, pairing.id, pairing.user_id, parsed.device.sourceDevice, JSON.stringify({
|
|
880
|
+
permissions: parsed.permissions,
|
|
881
|
+
sleepSessions: parsed.sleepSessions.length,
|
|
882
|
+
workouts: parsed.workouts.length
|
|
883
|
+
}), parsed.sleepSessions.length + parsed.workouts.length, createdCount, updatedCount, mergedCount, now, now, now);
|
|
884
|
+
recordActivityEvent({
|
|
885
|
+
entityType: "system",
|
|
886
|
+
entityId: pairing.id,
|
|
887
|
+
eventType: "companion_sync_completed",
|
|
888
|
+
title: `Forge Companion sync: ${parsed.device.name}`,
|
|
889
|
+
description: "The iOS companion imported Apple Health sleep and workout records into Forge.",
|
|
890
|
+
actor: "Forge Companion",
|
|
891
|
+
source: "system",
|
|
892
|
+
metadata: {
|
|
893
|
+
sleepSessions: parsed.sleepSessions.length,
|
|
894
|
+
workouts: parsed.workouts.length,
|
|
895
|
+
createdCount,
|
|
896
|
+
updatedCount,
|
|
897
|
+
mergedCount
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
return {
|
|
901
|
+
pairingSession: mapPairingSession(getDatabase()
|
|
902
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
903
|
+
.get(pairing.id)),
|
|
904
|
+
imported: {
|
|
905
|
+
sleepSessions: parsed.sleepSessions.length,
|
|
906
|
+
workouts: parsed.workouts.length,
|
|
907
|
+
createdCount,
|
|
908
|
+
updatedCount,
|
|
909
|
+
mergedCount
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
export function getCompanionOverview(userIds) {
|
|
915
|
+
const pairings = listPairingSessions(userIds);
|
|
916
|
+
const importRuns = listHealthImportRunRows(userIds).map(mapHealthImportRun);
|
|
917
|
+
const sleepSessions = listSleepRows(userIds).map(mapSleepSession);
|
|
918
|
+
const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
|
|
919
|
+
const activePairings = pairings.filter((pairing) => pairing.status !== "revoked");
|
|
920
|
+
const recentPermissionStates = importRuns
|
|
921
|
+
.map((run) => safeJsonParse(JSON.stringify(run.payloadSummary), {}))
|
|
922
|
+
.map((payloadSummary) => safeJsonParse(JSON.stringify(payloadSummary.permissions ?? {}), {}));
|
|
923
|
+
const lastSyncAt = pairings
|
|
924
|
+
.map((pairing) => pairing.lastSyncAt)
|
|
925
|
+
.filter((value) => Boolean(value))
|
|
926
|
+
.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? null;
|
|
927
|
+
return {
|
|
928
|
+
pairings,
|
|
929
|
+
importRuns,
|
|
930
|
+
healthState: activePairings.length === 0
|
|
931
|
+
? "disconnected"
|
|
932
|
+
: activePairings.some((pairing) => pairing.status === "permission_denied")
|
|
933
|
+
? "partially_connected"
|
|
934
|
+
: activePairings.some((pairing) => pairing.status === "stale")
|
|
935
|
+
? "stale_sync"
|
|
936
|
+
: activePairings.some((pairing) => pairing.status === "healthy")
|
|
937
|
+
? "healthy_sync"
|
|
938
|
+
: "connected",
|
|
939
|
+
lastSyncAt,
|
|
940
|
+
counts: {
|
|
941
|
+
sleepSessions: sleepSessions.length,
|
|
942
|
+
workouts: workouts.length,
|
|
943
|
+
reflectiveSleepSessions: sleepSessions.filter((session) => {
|
|
944
|
+
const annotations = session.annotations;
|
|
945
|
+
const tags = Array.isArray(annotations.tags) ? annotations.tags : [];
|
|
946
|
+
return (session.links.length > 0 ||
|
|
947
|
+
(typeof annotations.qualitySummary === "string" &&
|
|
948
|
+
annotations.qualitySummary.length > 0) ||
|
|
949
|
+
(typeof annotations.notes === "string" && annotations.notes.length > 0) ||
|
|
950
|
+
tags.length > 0);
|
|
951
|
+
}).length,
|
|
952
|
+
linkedWorkouts: workouts.filter((session) => session.links.length > 0).length,
|
|
953
|
+
habitGeneratedWorkouts: workouts.filter((session) => session.sourceType === "habit_generated").length,
|
|
954
|
+
reconciledWorkouts: workouts.filter((session) => session.reconciliationStatus === "merged").length
|
|
955
|
+
},
|
|
956
|
+
permissions: {
|
|
957
|
+
healthKitAuthorized: recentPermissionStates.some((state) => state.healthKitAuthorized === true),
|
|
958
|
+
backgroundRefreshEnabled: recentPermissionStates.some((state) => state.backgroundRefreshEnabled === true),
|
|
959
|
+
locationReady: recentPermissionStates.some((state) => state.locationReady === true),
|
|
960
|
+
motionReady: recentPermissionStates.some((state) => state.motionReady === true)
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
export function getSleepViewData(userIds) {
|
|
965
|
+
const sessions = listSleepRows(userIds).map(mapSleepSession);
|
|
966
|
+
const recent = sessions.slice(0, 30);
|
|
967
|
+
const weekly = recent.slice(0, 7);
|
|
968
|
+
const monthly = recent.slice(0, 30);
|
|
969
|
+
const stageTotals = new Map();
|
|
970
|
+
const linkTotals = new Map();
|
|
971
|
+
for (const session of monthly) {
|
|
972
|
+
for (const stage of session.stageBreakdown) {
|
|
973
|
+
stageTotals.set(stage.stage, (stageTotals.get(stage.stage) ?? 0) + stage.seconds);
|
|
974
|
+
}
|
|
975
|
+
for (const link of session.links) {
|
|
976
|
+
linkTotals.set(link.entityType, (linkTotals.get(link.entityType) ?? 0) + 1);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
summary: {
|
|
981
|
+
totalSleepSeconds: weekly.reduce((sum, session) => sum + session.asleepSeconds, 0),
|
|
982
|
+
averageSleepSeconds: Math.round(average(weekly.map((session) => session.asleepSeconds))),
|
|
983
|
+
averageTimeInBedSeconds: Math.round(average(weekly.map((session) => session.timeInBedSeconds))),
|
|
984
|
+
averageSleepScore: Math.round(average(weekly
|
|
985
|
+
.map((session) => session.sleepScore)
|
|
986
|
+
.filter((value) => value !== null))),
|
|
987
|
+
averageRegularityScore: Math.round(average(weekly
|
|
988
|
+
.map((session) => session.regularityScore)
|
|
989
|
+
.filter((value) => value !== null))),
|
|
990
|
+
averageEfficiency: round(average(weekly.map((session) => typeof session.derived.efficiency ===
|
|
991
|
+
"number"
|
|
992
|
+
? session.derived
|
|
993
|
+
.efficiency
|
|
994
|
+
: session.timeInBedSeconds > 0
|
|
995
|
+
? session.asleepSeconds / session.timeInBedSeconds
|
|
996
|
+
: 0)), 2),
|
|
997
|
+
averageRestorativeShare: round(average(weekly.map((session) => typeof session.derived
|
|
998
|
+
.restorativeShare === "number"
|
|
999
|
+
? session.derived
|
|
1000
|
+
.restorativeShare
|
|
1001
|
+
: 0)), 2),
|
|
1002
|
+
reflectiveNightCount: weekly.filter((session) => {
|
|
1003
|
+
const annotations = session.annotations;
|
|
1004
|
+
const tags = Array.isArray(annotations.tags) ? annotations.tags : [];
|
|
1005
|
+
return (session.links.length > 0 ||
|
|
1006
|
+
(typeof annotations.qualitySummary === "string" &&
|
|
1007
|
+
annotations.qualitySummary.length > 0) ||
|
|
1008
|
+
(typeof annotations.notes === "string" && annotations.notes.length > 0) ||
|
|
1009
|
+
tags.length > 0);
|
|
1010
|
+
}).length,
|
|
1011
|
+
linkedNightCount: weekly.filter((session) => session.links.length > 0).length,
|
|
1012
|
+
averageBedtimeConsistencyMinutes: Math.round(average(weekly
|
|
1013
|
+
.map((session) => session.bedtimeConsistencyMinutes)
|
|
1014
|
+
.filter((value) => value !== null))),
|
|
1015
|
+
averageWakeConsistencyMinutes: Math.round(average(weekly
|
|
1016
|
+
.map((session) => session.wakeConsistencyMinutes)
|
|
1017
|
+
.filter((value) => value !== null))),
|
|
1018
|
+
latestBedtime: recent[0]?.startedAt ?? null,
|
|
1019
|
+
latestWakeTime: recent[0]?.endedAt ?? null
|
|
1020
|
+
},
|
|
1021
|
+
weeklyTrend: weekly
|
|
1022
|
+
.map((session) => ({
|
|
1023
|
+
id: session.id,
|
|
1024
|
+
dateKey: dayKey(session.endedAt),
|
|
1025
|
+
sleepHours: Number((session.asleepSeconds / 3600).toFixed(2)),
|
|
1026
|
+
score: session.sleepScore ?? 0,
|
|
1027
|
+
regularity: session.regularityScore ?? 0
|
|
1028
|
+
}))
|
|
1029
|
+
.reverse(),
|
|
1030
|
+
monthlyPattern: monthly
|
|
1031
|
+
.map((session) => ({
|
|
1032
|
+
id: session.id,
|
|
1033
|
+
dateKey: dayKey(session.endedAt),
|
|
1034
|
+
onsetHour: new Date(session.startedAt).getHours(),
|
|
1035
|
+
wakeHour: new Date(session.endedAt).getHours(),
|
|
1036
|
+
sleepHours: Number((session.asleepSeconds / 3600).toFixed(2))
|
|
1037
|
+
}))
|
|
1038
|
+
.reverse(),
|
|
1039
|
+
stageAverages: [...stageTotals.entries()]
|
|
1040
|
+
.map(([stage, totalSeconds]) => ({
|
|
1041
|
+
stage,
|
|
1042
|
+
averageSeconds: Math.round(totalSeconds / Math.max(1, monthly.length))
|
|
1043
|
+
}))
|
|
1044
|
+
.sort((left, right) => right.averageSeconds - left.averageSeconds),
|
|
1045
|
+
linkBreakdown: [...linkTotals.entries()]
|
|
1046
|
+
.map(([entityType, count]) => ({ entityType, count }))
|
|
1047
|
+
.sort((left, right) => right.count - left.count),
|
|
1048
|
+
sessions: recent
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
export function getFitnessViewData(userIds) {
|
|
1052
|
+
const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
|
|
1053
|
+
const recent = workouts.slice(0, 40);
|
|
1054
|
+
const weekly = recent.filter((session) => Date.now() - Date.parse(session.startedAt) <= 7 * 24 * 60 * 60 * 1000);
|
|
1055
|
+
const weeklyVolumeSeconds = weekly.reduce((sum, session) => sum + session.durationSeconds, 0);
|
|
1056
|
+
const exerciseMinutes = weekly.reduce((sum, session) => sum + (session.exerciseMinutes ?? session.durationSeconds / 60), 0);
|
|
1057
|
+
const energyBurned = weekly.reduce((sum, session) => sum + (session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0), 0);
|
|
1058
|
+
const distanceMeters = weekly.reduce((sum, session) => sum + (session.distanceMeters ?? 0), 0);
|
|
1059
|
+
const workoutTypes = Array.from(new Set(recent.map((session) => session.workoutType)));
|
|
1060
|
+
const workoutTypeBreakdown = new Map();
|
|
1061
|
+
for (const session of recent) {
|
|
1062
|
+
const current = workoutTypeBreakdown.get(session.workoutType) ?? {
|
|
1063
|
+
sessionCount: 0,
|
|
1064
|
+
totalMinutes: 0,
|
|
1065
|
+
energyKcal: 0
|
|
1066
|
+
};
|
|
1067
|
+
current.sessionCount += 1;
|
|
1068
|
+
current.totalMinutes += Math.round(session.durationSeconds / 60);
|
|
1069
|
+
current.energyKcal += Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0);
|
|
1070
|
+
workoutTypeBreakdown.set(session.workoutType, current);
|
|
1071
|
+
}
|
|
1072
|
+
const orderedWorkoutTypes = [...workoutTypeBreakdown.entries()].sort((left, right) => right[1].totalMinutes - left[1].totalMinutes);
|
|
1073
|
+
return {
|
|
1074
|
+
summary: {
|
|
1075
|
+
workoutCount: weekly.length,
|
|
1076
|
+
weeklyVolumeSeconds,
|
|
1077
|
+
exerciseMinutes: Math.round(exerciseMinutes),
|
|
1078
|
+
energyBurnedKcal: Math.round(energyBurned),
|
|
1079
|
+
distanceMeters: Math.round(distanceMeters),
|
|
1080
|
+
workoutTypes,
|
|
1081
|
+
averageSessionMinutes: Math.round(average(recent.map((session) => session.durationSeconds / 60))),
|
|
1082
|
+
averageEffort: round(average(recent
|
|
1083
|
+
.map((session) => session.subjectiveEffort)
|
|
1084
|
+
.filter((value) => value !== null)), 1),
|
|
1085
|
+
linkedSessionCount: recent.filter((session) => session.links.length > 0).length,
|
|
1086
|
+
plannedSessionCount: recent.filter((session) => session.plannedContext.trim().length > 0).length,
|
|
1087
|
+
importedSessionCount: recent.filter((session) => session.source === "apple_health").length,
|
|
1088
|
+
habitGeneratedSessionCount: recent.filter((session) => session.sourceType === "habit_generated").length,
|
|
1089
|
+
reconciledSessionCount: recent.filter((session) => session.reconciliationStatus === "merged").length,
|
|
1090
|
+
topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
|
|
1091
|
+
streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
|
|
1092
|
+
},
|
|
1093
|
+
weeklyTrend: weekly
|
|
1094
|
+
.map((session) => ({
|
|
1095
|
+
id: session.id,
|
|
1096
|
+
dateKey: dayKey(session.startedAt),
|
|
1097
|
+
workoutType: session.workoutType,
|
|
1098
|
+
durationMinutes: Math.round(session.durationSeconds / 60),
|
|
1099
|
+
energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
|
|
1100
|
+
}))
|
|
1101
|
+
.reverse(),
|
|
1102
|
+
typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
|
|
1103
|
+
workoutType,
|
|
1104
|
+
sessionCount: metrics.sessionCount,
|
|
1105
|
+
totalMinutes: metrics.totalMinutes,
|
|
1106
|
+
energyKcal: metrics.energyKcal
|
|
1107
|
+
})),
|
|
1108
|
+
sessions: recent
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
export function updateWorkoutMetadata(workoutId, patch, activity) {
|
|
1112
|
+
const parsed = updateWorkoutMetadataSchema.parse(patch);
|
|
1113
|
+
const current = getDatabase()
|
|
1114
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1115
|
+
.get(workoutId);
|
|
1116
|
+
if (!current) {
|
|
1117
|
+
return undefined;
|
|
1118
|
+
}
|
|
1119
|
+
const nextLinks = parsed.links ?? safeJsonParse(current.links_json, []);
|
|
1120
|
+
const nextTags = parsed.tags ?? safeJsonParse(current.tags_json, []);
|
|
1121
|
+
const nextAnnotations = {
|
|
1122
|
+
...safeJsonParse(current.annotations_json, {}),
|
|
1123
|
+
...(parsed.subjectiveEffort !== undefined
|
|
1124
|
+
? { subjectiveEffort: parsed.subjectiveEffort }
|
|
1125
|
+
: {}),
|
|
1126
|
+
...(parsed.moodBefore !== undefined ? { moodBefore: parsed.moodBefore } : {}),
|
|
1127
|
+
...(parsed.moodAfter !== undefined ? { moodAfter: parsed.moodAfter } : {}),
|
|
1128
|
+
...(parsed.meaningText !== undefined
|
|
1129
|
+
? { meaningText: parsed.meaningText }
|
|
1130
|
+
: {}),
|
|
1131
|
+
...(parsed.plannedContext !== undefined
|
|
1132
|
+
? { plannedContext: parsed.plannedContext }
|
|
1133
|
+
: {}),
|
|
1134
|
+
...(parsed.socialContext !== undefined
|
|
1135
|
+
? { socialContext: parsed.socialContext }
|
|
1136
|
+
: {}),
|
|
1137
|
+
...(parsed.tags !== undefined ? { tags: parsed.tags } : {}),
|
|
1138
|
+
...(parsed.links !== undefined ? { links: parsed.links } : {})
|
|
1139
|
+
};
|
|
1140
|
+
const now = nowIso();
|
|
1141
|
+
getDatabase()
|
|
1142
|
+
.prepare(`UPDATE health_workout_sessions
|
|
1143
|
+
SET subjective_effort = ?, mood_before = ?, mood_after = ?, meaning_text = ?, planned_context = ?,
|
|
1144
|
+
social_context = ?, links_json = ?, tags_json = ?, annotations_json = ?, updated_at = ?
|
|
1145
|
+
WHERE id = ?`)
|
|
1146
|
+
.run(parsed.subjectiveEffort ?? current.subjective_effort, parsed.moodBefore ?? current.mood_before, parsed.moodAfter ?? current.mood_after, parsed.meaningText ?? current.meaning_text, parsed.plannedContext ?? current.planned_context, parsed.socialContext ?? current.social_context, JSON.stringify(nextLinks), JSON.stringify(nextTags), JSON.stringify(nextAnnotations), now, workoutId);
|
|
1147
|
+
recordActivityEvent({
|
|
1148
|
+
entityType: "system",
|
|
1149
|
+
entityId: workoutId,
|
|
1150
|
+
eventType: "workout_metadata_updated",
|
|
1151
|
+
title: "Workout metadata updated",
|
|
1152
|
+
description: "Forge metadata, tags, or linked context was updated for a workout session.",
|
|
1153
|
+
actor: activity?.actor ?? null,
|
|
1154
|
+
source: activity?.source ?? "ui",
|
|
1155
|
+
metadata: {
|
|
1156
|
+
hasLinks: nextLinks.length > 0,
|
|
1157
|
+
tagCount: nextTags.length
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
return mapWorkoutSession(getDatabase()
|
|
1161
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1162
|
+
.get(workoutId));
|
|
1163
|
+
}
|
|
1164
|
+
export function updateSleepMetadata(sleepId, patch, activity) {
|
|
1165
|
+
const parsed = updateSleepMetadataSchema.parse(patch);
|
|
1166
|
+
const current = getDatabase()
|
|
1167
|
+
.prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
|
|
1168
|
+
.get(sleepId);
|
|
1169
|
+
if (!current) {
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
const nextAnnotations = {
|
|
1173
|
+
...safeJsonParse(current.annotations_json, {}),
|
|
1174
|
+
...(parsed.qualitySummary !== undefined
|
|
1175
|
+
? { qualitySummary: parsed.qualitySummary }
|
|
1176
|
+
: {}),
|
|
1177
|
+
...(parsed.notes !== undefined ? { notes: parsed.notes } : {}),
|
|
1178
|
+
...(parsed.tags !== undefined ? { tags: parsed.tags } : {}),
|
|
1179
|
+
...(parsed.links !== undefined ? { links: parsed.links } : {})
|
|
1180
|
+
};
|
|
1181
|
+
const nextLinks = parsed.links ?? safeJsonParse(current.links_json, []);
|
|
1182
|
+
const now = nowIso();
|
|
1183
|
+
getDatabase()
|
|
1184
|
+
.prepare(`UPDATE health_sleep_sessions
|
|
1185
|
+
SET links_json = ?, annotations_json = ?, updated_at = ?
|
|
1186
|
+
WHERE id = ?`)
|
|
1187
|
+
.run(JSON.stringify(nextLinks), JSON.stringify(nextAnnotations), now, sleepId);
|
|
1188
|
+
recordActivityEvent({
|
|
1189
|
+
entityType: "system",
|
|
1190
|
+
entityId: sleepId,
|
|
1191
|
+
eventType: "sleep_metadata_updated",
|
|
1192
|
+
title: "Sleep reflection updated",
|
|
1193
|
+
description: "Forge links or reflective notes were updated for a sleep session.",
|
|
1194
|
+
actor: activity?.actor ?? null,
|
|
1195
|
+
source: activity?.source ?? "ui",
|
|
1196
|
+
metadata: {
|
|
1197
|
+
hasLinks: nextLinks.length > 0
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
return mapSleepSession(getDatabase()
|
|
1201
|
+
.prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
|
|
1202
|
+
.get(sleepId));
|
|
1203
|
+
}
|
|
1204
|
+
export function createGeneratedWorkoutFromHabit(args) {
|
|
1205
|
+
const template = generatedHealthEventTemplateSchema.parse(args.template);
|
|
1206
|
+
if (!template.enabled) {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
const startedAt = new Date(`${args.dateKey}T07:00:00.000Z`).toISOString();
|
|
1210
|
+
const endedAt = new Date(Date.parse(startedAt) + template.durationMinutes * 60_000).toISOString();
|
|
1211
|
+
const existing = getDatabase()
|
|
1212
|
+
.prepare(`SELECT *
|
|
1213
|
+
FROM health_workout_sessions
|
|
1214
|
+
WHERE generated_from_check_in_id = ?`)
|
|
1215
|
+
.get(args.checkInId);
|
|
1216
|
+
if (existing) {
|
|
1217
|
+
return mapWorkoutSession(existing);
|
|
1218
|
+
}
|
|
1219
|
+
const now = nowIso();
|
|
1220
|
+
const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
1221
|
+
getDatabase()
|
|
1222
|
+
.prepare(`INSERT INTO health_workout_sessions (
|
|
1223
|
+
id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
|
|
1224
|
+
started_at, ended_at, duration_seconds, links_json, tags_json, annotations_json, provenance_json,
|
|
1225
|
+
derived_json, generated_from_habit_id, generated_from_check_in_id, reconciliation_status, created_at, updated_at
|
|
1226
|
+
)
|
|
1227
|
+
VALUES (?, ?, NULL, ?, 'forge_habit', 'habit_generated', ?, 'Habit automation', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'awaiting_import_match', ?, ?)`)
|
|
1228
|
+
.run(id, `habit_${args.checkInId}`, args.userId, template.workoutType, startedAt, endedAt, template.durationMinutes * 60, JSON.stringify([
|
|
1229
|
+
...(template.links ?? []),
|
|
1230
|
+
...(args.linkedEntities ?? [])
|
|
1231
|
+
]), JSON.stringify(template.tags), JSON.stringify({
|
|
1232
|
+
meaningText: template.notesTemplate,
|
|
1233
|
+
plannedContext: args.habitTitle
|
|
1234
|
+
}), JSON.stringify({
|
|
1235
|
+
generatedFrom: "habit_completion",
|
|
1236
|
+
habitId: args.habitId,
|
|
1237
|
+
checkInId: args.checkInId
|
|
1238
|
+
}), JSON.stringify({
|
|
1239
|
+
xpReward: template.xpReward
|
|
1240
|
+
}), args.habitId, args.checkInId, now, now);
|
|
1241
|
+
recordActivityEvent({
|
|
1242
|
+
entityType: "habit",
|
|
1243
|
+
entityId: args.habitId,
|
|
1244
|
+
eventType: "habit_generated_workout",
|
|
1245
|
+
title: `Habit generated workout: ${args.habitTitle}`,
|
|
1246
|
+
description: "Completing this habit generated a structured workout record inside Forge.",
|
|
1247
|
+
actor: "Forge",
|
|
1248
|
+
source: "system",
|
|
1249
|
+
metadata: {
|
|
1250
|
+
workoutId: id,
|
|
1251
|
+
workoutType: template.workoutType,
|
|
1252
|
+
xpReward: template.xpReward
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
recordHabitGeneratedWorkoutReward({
|
|
1256
|
+
habitId: args.habitId,
|
|
1257
|
+
habitTitle: args.habitTitle,
|
|
1258
|
+
checkInId: args.checkInId,
|
|
1259
|
+
workoutId: id,
|
|
1260
|
+
workoutType: template.workoutType,
|
|
1261
|
+
xpReward: template.xpReward
|
|
1262
|
+
}, {
|
|
1263
|
+
actor: "Forge",
|
|
1264
|
+
source: "system"
|
|
1265
|
+
});
|
|
1266
|
+
return mapWorkoutSession(getDatabase()
|
|
1267
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1268
|
+
.get(id));
|
|
1269
|
+
}
|
|
1270
|
+
export function parseGeneratedHealthEventTemplate(value) {
|
|
1271
|
+
if (typeof value === "string") {
|
|
1272
|
+
const trimmed = value.trim();
|
|
1273
|
+
if (!trimmed) {
|
|
1274
|
+
return generatedHealthEventTemplateSchema.parse({});
|
|
1275
|
+
}
|
|
1276
|
+
try {
|
|
1277
|
+
return generatedHealthEventTemplateSchema.parse(JSON.parse(trimmed));
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
return generatedHealthEventTemplateSchema.parse({});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return generatedHealthEventTemplateSchema.parse(value ?? {});
|
|
1284
|
+
}
|