forge-openclaw-plugin 0.2.26 → 0.2.27
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 +59 -3
- package/dist/assets/{board-ta0rUHOf.js → board-C6jCchjI.js} +2 -2
- package/dist/assets/{board-ta0rUHOf.js.map → board-C6jCchjI.js.map} +1 -1
- package/dist/assets/index-DVvS8iiU.css +1 -0
- package/dist/assets/index-zYB-9Dfo.js +85 -0
- package/dist/assets/index-zYB-9Dfo.js.map +1 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
- package/dist/assets/{motion-fBKPB6yw.js → motion-DFHrH2rd.js} +2 -2
- package/dist/assets/{motion-fBKPB6yw.js.map → motion-DFHrH2rd.js.map} +1 -1
- package/dist/assets/{table-C-IGTQni.js → table-ZL7Di_u3.js} +2 -2
- package/dist/assets/{table-C-IGTQni.js.map → table-ZL7Di_u3.js.map} +1 -1
- package/dist/assets/{ui-DInOpaYF.js → ui-CKNPpz7q.js} +2 -2
- package/dist/assets/{ui-DInOpaYF.js.map → ui-CKNPpz7q.js.map} +1 -1
- package/dist/assets/vendor-DoNZuFhn.js +1247 -0
- package/dist/assets/vendor-DoNZuFhn.js.map +1 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/local-runtime.js +16 -0
- package/dist/openclaw/routes.d.ts +27 -0
- package/dist/openclaw/routes.js +16 -12
- package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
- package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
- package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
- package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
- package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/dist/server/server/src/app.js +1684 -117
- package/dist/server/server/src/connectors/box-registry.js +44 -9
- package/dist/server/server/src/data-management-types.js +107 -0
- package/dist/server/server/src/db.js +68 -4
- package/dist/server/server/src/demo-data.js +2 -2
- package/dist/server/server/src/health.js +702 -18
- package/dist/server/server/src/managers/platform/llm-manager.js +7 -4
- package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
- package/dist/server/server/src/managers/platform/secrets-manager.js +18 -1
- package/dist/server/server/src/managers/runtime.js +9 -0
- package/dist/server/server/src/movement.js +1971 -112
- package/dist/server/server/src/openapi.js +489 -1
- package/dist/server/server/src/psyche-types.js +9 -1
- package/dist/server/server/src/repositories/activity-events.js +8 -0
- package/dist/server/server/src/repositories/ai-connectors.js +522 -74
- package/dist/server/server/src/repositories/habits.js +37 -1
- package/dist/server/server/src/repositories/model-settings.js +13 -3
- package/dist/server/server/src/repositories/notes.js +3 -0
- package/dist/server/server/src/repositories/settings.js +380 -18
- package/dist/server/server/src/repositories/tasks.js +170 -10
- package/dist/server/server/src/runtime-data-root.js +82 -0
- package/dist/server/server/src/screen-time.js +802 -0
- package/dist/server/server/src/services/data-management.js +788 -0
- package/dist/server/server/src/services/entity-crud.js +205 -2
- package/dist/server/server/src/services/knowledge-graph.js +1455 -0
- package/dist/server/server/src/services/life-force-model.js +197 -0
- package/dist/server/server/src/services/life-force.js +1270 -0
- package/dist/server/server/src/services/psyche-observation-calendar.js +383 -16
- package/dist/server/server/src/types.js +286 -13
- package/dist/server/server/src/web.js +228 -13
- package/dist/server/src/components/customization/utility-widgets.js +136 -27
- package/dist/server/src/components/ui/info-tooltip.js +25 -0
- package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/health/health-boxes.js +63 -8
- package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
- package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +62 -54
- package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +18 -8
- package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +56 -38
- package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
- package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +35 -30
- package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
- package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
- package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
- package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +3 -1
- package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +39 -3
- package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
- package/dist/server/src/components/workbench-boxes/today/today-boxes.js +47 -32
- package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
- package/dist/server/src/lib/api.js +280 -21
- package/dist/server/src/lib/data-management-types.js +1 -0
- package/dist/server/src/lib/entity-visuals.js +279 -0
- package/dist/server/src/lib/knowledge-graph-types.js +276 -0
- package/dist/server/src/lib/knowledge-graph.js +470 -0
- package/dist/server/src/lib/schemas.js +4 -0
- package/dist/server/src/lib/snapshot-normalizer.js +43 -1
- package/dist/server/src/lib/workbench/contracts.js +229 -0
- package/dist/server/src/lib/workbench/nodes.js +200 -0
- package/dist/server/src/lib/workbench/registry.js +52 -5
- package/dist/server/src/lib/workbench/runtime.js +254 -38
- package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/server/migrations/038_data_management_settings.sql +11 -0
- package/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/server/migrations/040_screen_time_domain.sql +89 -0
- package/server/migrations/041_companion_source_states.sql +21 -0
- package/server/migrations/042_movement_boxes.sql +47 -0
- package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/skills/forge-openclaw/SKILL.md +24 -11
- package/skills/forge-openclaw/entity_conversation_playbooks.md +210 -34
- package/skills/forge-openclaw/psyche_entity_playbooks.md +113 -17
- package/dist/assets/index-Ro0ZF_az.css +0 -1
- package/dist/assets/index-ytlpSj23.js +0 -79
- package/dist/assets/index-ytlpSj23.js.map +0 -1
- package/dist/assets/vendor-lE3tZJcC.js +0 -876
- package/dist/assets/vendor-lE3tZJcC.js.map +0 -1
|
@@ -3,8 +3,10 @@ import { z } from "zod";
|
|
|
3
3
|
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
4
|
import { HttpError } from "./errors.js";
|
|
5
5
|
import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
|
|
6
|
+
import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
|
|
6
7
|
import { recordActivityEvent } from "./repositories/activity-events.js";
|
|
7
8
|
import { recordHabitGeneratedWorkoutReward } from "./repositories/rewards.js";
|
|
9
|
+
import { resolveUserForMutation } from "./repositories/users.js";
|
|
8
10
|
const healthLinkSchema = z.object({
|
|
9
11
|
entityType: z.string().trim().min(1),
|
|
10
12
|
entityId: z.string().trim().min(1),
|
|
@@ -50,6 +52,34 @@ const pairingStatusSchema = z.enum([
|
|
|
50
52
|
"error",
|
|
51
53
|
"revoked"
|
|
52
54
|
]);
|
|
55
|
+
export const companionSourceKeySchema = z.enum([
|
|
56
|
+
"health",
|
|
57
|
+
"movement",
|
|
58
|
+
"screenTime"
|
|
59
|
+
]);
|
|
60
|
+
const companionSourceAuthorizationStatusSchema = z.enum([
|
|
61
|
+
"not_determined",
|
|
62
|
+
"pending",
|
|
63
|
+
"approved",
|
|
64
|
+
"denied",
|
|
65
|
+
"restricted",
|
|
66
|
+
"unavailable",
|
|
67
|
+
"partial",
|
|
68
|
+
"disabled"
|
|
69
|
+
]);
|
|
70
|
+
const companionSourceStateSchema = z.object({
|
|
71
|
+
desiredEnabled: z.boolean().default(true),
|
|
72
|
+
appliedEnabled: z.boolean().default(false),
|
|
73
|
+
authorizationStatus: companionSourceAuthorizationStatusSchema.default("not_determined"),
|
|
74
|
+
syncEligible: z.boolean().default(false),
|
|
75
|
+
lastObservedAt: z.string().datetime().nullable().default(null),
|
|
76
|
+
metadata: z.record(z.string(), z.unknown()).default({})
|
|
77
|
+
});
|
|
78
|
+
const companionSourceStatesSchema = z.object({
|
|
79
|
+
health: companionSourceStateSchema.default({}),
|
|
80
|
+
movement: companionSourceStateSchema.default({}),
|
|
81
|
+
screenTime: companionSourceStateSchema.default({})
|
|
82
|
+
});
|
|
53
83
|
export const createCompanionPairingSessionSchema = z.object({
|
|
54
84
|
label: z.string().trim().default("Forge Companion"),
|
|
55
85
|
userId: z.string().trim().nullable().optional(),
|
|
@@ -74,6 +104,20 @@ export const revokeAllCompanionPairingSessionsSchema = z.object({
|
|
|
74
104
|
userIds: z.array(z.string().trim().min(1)).default([]),
|
|
75
105
|
includeRevoked: z.boolean().default(false)
|
|
76
106
|
});
|
|
107
|
+
export const patchCompanionPairingSourceStateSchema = z.object({
|
|
108
|
+
desiredEnabled: z.boolean()
|
|
109
|
+
});
|
|
110
|
+
export const updateMobileCompanionSourceStateSchema = z.object({
|
|
111
|
+
sessionId: z.string().trim().min(1),
|
|
112
|
+
pairingToken: z.string().trim().min(1),
|
|
113
|
+
source: companionSourceKeySchema,
|
|
114
|
+
desiredEnabled: z.boolean(),
|
|
115
|
+
appliedEnabled: z.boolean(),
|
|
116
|
+
authorizationStatus: companionSourceAuthorizationStatusSchema,
|
|
117
|
+
syncEligible: z.boolean().default(false),
|
|
118
|
+
lastObservedAt: z.string().datetime().nullable().optional(),
|
|
119
|
+
metadata: z.record(z.string(), z.unknown()).default({})
|
|
120
|
+
});
|
|
77
121
|
export const mobileHealthSyncSchema = z.object({
|
|
78
122
|
sessionId: z.string().trim().min(1),
|
|
79
123
|
pairingToken: z.string().trim().min(1),
|
|
@@ -87,7 +131,8 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
87
131
|
healthKitAuthorized: z.boolean().default(false),
|
|
88
132
|
backgroundRefreshEnabled: z.boolean().default(false),
|
|
89
133
|
motionReady: z.boolean().default(false),
|
|
90
|
-
locationReady: z.boolean().default(false)
|
|
134
|
+
locationReady: z.boolean().default(false),
|
|
135
|
+
screenTimeReady: z.boolean().default(false)
|
|
91
136
|
}),
|
|
92
137
|
sleepSessions: z
|
|
93
138
|
.array(z.object({
|
|
@@ -121,7 +166,9 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
121
166
|
annotations: workoutAnnotationSchema.partial().default({})
|
|
122
167
|
}))
|
|
123
168
|
.default([]),
|
|
124
|
-
|
|
169
|
+
sourceStates: companionSourceStatesSchema.default({}),
|
|
170
|
+
movement: movementSyncPayloadSchema.default({}),
|
|
171
|
+
screenTime: screenTimeSyncPayloadSchema.default({})
|
|
125
172
|
});
|
|
126
173
|
export const verifyCompanionPairingSchema = z.object({
|
|
127
174
|
sessionId: z.string().trim().min(1),
|
|
@@ -149,9 +196,67 @@ export const updateSleepMetadataSchema = z.object({
|
|
|
149
196
|
tags: z.array(z.string().trim()).optional(),
|
|
150
197
|
links: z.array(healthLinkSchema).optional()
|
|
151
198
|
});
|
|
199
|
+
const manualHealthProvenanceSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]));
|
|
200
|
+
export const createSleepSessionSchema = z.object({
|
|
201
|
+
userId: z.string().trim().min(1).nullable().optional(),
|
|
202
|
+
externalUid: z.string().trim().min(1).optional(),
|
|
203
|
+
source: z.string().trim().min(1).default("manual"),
|
|
204
|
+
sourceType: z.string().trim().min(1).default("manual"),
|
|
205
|
+
sourceDevice: z.string().trim().min(1).default("Forge"),
|
|
206
|
+
startedAt: z.string().datetime(),
|
|
207
|
+
endedAt: z.string().datetime(),
|
|
208
|
+
timeInBedSeconds: z.number().int().nonnegative().optional(),
|
|
209
|
+
asleepSeconds: z.number().int().nonnegative().optional(),
|
|
210
|
+
awakeSeconds: z.number().int().nonnegative().optional(),
|
|
211
|
+
stageBreakdown: z.array(healthStageSchema).default([]),
|
|
212
|
+
recoveryMetrics: sleepRecoveryMetricSchema.default({}),
|
|
213
|
+
qualitySummary: z.string().trim().default(""),
|
|
214
|
+
notes: z.string().trim().default(""),
|
|
215
|
+
tags: z.array(z.string().trim()).default([]),
|
|
216
|
+
links: z.array(healthLinkSchema).default([]),
|
|
217
|
+
provenance: manualHealthProvenanceSchema.default({})
|
|
218
|
+
});
|
|
219
|
+
export const updateSleepSessionSchema = createSleepSessionSchema
|
|
220
|
+
.omit({ userId: true })
|
|
221
|
+
.partial();
|
|
222
|
+
export const createWorkoutSessionSchema = z.object({
|
|
223
|
+
userId: z.string().trim().min(1).nullable().optional(),
|
|
224
|
+
externalUid: z.string().trim().min(1).optional(),
|
|
225
|
+
source: z.string().trim().min(1).default("manual"),
|
|
226
|
+
sourceType: z.string().trim().min(1).default("manual"),
|
|
227
|
+
sourceDevice: z.string().trim().min(1).default("Forge"),
|
|
228
|
+
workoutType: z.string().trim().min(1),
|
|
229
|
+
startedAt: z.string().datetime(),
|
|
230
|
+
endedAt: z.string().datetime(),
|
|
231
|
+
activeEnergyKcal: z.number().nonnegative().nullable().optional(),
|
|
232
|
+
totalEnergyKcal: z.number().nonnegative().nullable().optional(),
|
|
233
|
+
distanceMeters: z.number().nonnegative().nullable().optional(),
|
|
234
|
+
stepCount: z.number().int().nonnegative().nullable().optional(),
|
|
235
|
+
exerciseMinutes: z.number().nonnegative().nullable().optional(),
|
|
236
|
+
averageHeartRate: z.number().nonnegative().nullable().optional(),
|
|
237
|
+
maxHeartRate: z.number().nonnegative().nullable().optional(),
|
|
238
|
+
subjectiveEffort: z.number().int().min(1).max(10).nullable().optional(),
|
|
239
|
+
moodBefore: z.string().trim().default(""),
|
|
240
|
+
moodAfter: z.string().trim().default(""),
|
|
241
|
+
meaningText: z.string().trim().default(""),
|
|
242
|
+
plannedContext: z.string().trim().default(""),
|
|
243
|
+
socialContext: z.string().trim().default(""),
|
|
244
|
+
tags: z.array(z.string().trim()).default([]),
|
|
245
|
+
links: z.array(healthLinkSchema).default([]),
|
|
246
|
+
provenance: manualHealthProvenanceSchema.default({})
|
|
247
|
+
});
|
|
248
|
+
export const updateWorkoutSessionSchema = createWorkoutSessionSchema
|
|
249
|
+
.omit({ userId: true })
|
|
250
|
+
.partial();
|
|
152
251
|
function nowIso() {
|
|
153
252
|
return new Date().toISOString();
|
|
154
253
|
}
|
|
254
|
+
function durationSecondsBetween(startedAt, endedAt) {
|
|
255
|
+
return Math.max(0, Math.round((Date.parse(endedAt) - Date.parse(startedAt)) / 1000));
|
|
256
|
+
}
|
|
257
|
+
function resolveHealthUserId(userId) {
|
|
258
|
+
return resolveUserForMutation(userId ?? null).id;
|
|
259
|
+
}
|
|
155
260
|
function safeJsonParse(value, fallback) {
|
|
156
261
|
if (!value) {
|
|
157
262
|
return fallback;
|
|
@@ -163,6 +268,86 @@ function safeJsonParse(value, fallback) {
|
|
|
163
268
|
return fallback;
|
|
164
269
|
}
|
|
165
270
|
}
|
|
271
|
+
function listPairingSourceStateRows(pairingSessionId) {
|
|
272
|
+
return getDatabase()
|
|
273
|
+
.prepare(`SELECT *
|
|
274
|
+
FROM companion_pairing_source_states
|
|
275
|
+
WHERE pairing_session_id = ?
|
|
276
|
+
ORDER BY source_key ASC`)
|
|
277
|
+
.all(pairingSessionId);
|
|
278
|
+
}
|
|
279
|
+
function defaultCompanionSourceState(source, pairing) {
|
|
280
|
+
const defaultAuthorizationStatus = source === "screenTime" ? "not_determined" : "pending";
|
|
281
|
+
return {
|
|
282
|
+
id: `pairsrc_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
|
283
|
+
pairing_session_id: pairing.id,
|
|
284
|
+
user_id: pairing.user_id,
|
|
285
|
+
source_key: source,
|
|
286
|
+
desired_enabled: 1,
|
|
287
|
+
applied_enabled: pairing.paired_at ? 1 : 0,
|
|
288
|
+
authorization_status: defaultAuthorizationStatus,
|
|
289
|
+
sync_eligible: 0,
|
|
290
|
+
last_observed_at: pairing.paired_at ?? null,
|
|
291
|
+
metadata_json: "{}",
|
|
292
|
+
created_at: pairing.updated_at,
|
|
293
|
+
updated_at: pairing.updated_at
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function ensurePairingSourceStates(pairing) {
|
|
297
|
+
const existing = listPairingSourceStateRows(pairing.id);
|
|
298
|
+
const bySource = new Map(existing.map((row) => [row.source_key, row]));
|
|
299
|
+
const missing = companionSourceKeySchema.options.filter((source) => bySource.has(source) === false);
|
|
300
|
+
if (missing.length > 0) {
|
|
301
|
+
const insert = getDatabase().prepare(`INSERT INTO companion_pairing_source_states (
|
|
302
|
+
id, pairing_session_id, user_id, source_key, desired_enabled, applied_enabled,
|
|
303
|
+
authorization_status, sync_eligible, last_observed_at, metadata_json, created_at, updated_at
|
|
304
|
+
)
|
|
305
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
306
|
+
for (const source of missing) {
|
|
307
|
+
const row = defaultCompanionSourceState(source, pairing);
|
|
308
|
+
insert.run(row.id, row.pairing_session_id, row.user_id, row.source_key, row.desired_enabled, row.applied_enabled, row.authorization_status, row.sync_eligible, row.last_observed_at, row.metadata_json, row.created_at, row.updated_at);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const refreshed = listPairingSourceStateRows(pairing.id);
|
|
312
|
+
return companionSourceStatesSchema.parse(refreshed.reduce((accumulator, row) => {
|
|
313
|
+
accumulator[row.source_key] = {
|
|
314
|
+
desiredEnabled: row.desired_enabled === 1,
|
|
315
|
+
appliedEnabled: row.applied_enabled === 1,
|
|
316
|
+
authorizationStatus: row.authorization_status,
|
|
317
|
+
syncEligible: row.sync_eligible === 1,
|
|
318
|
+
lastObservedAt: row.last_observed_at,
|
|
319
|
+
metadata: safeJsonParse(row.metadata_json, {})
|
|
320
|
+
};
|
|
321
|
+
return accumulator;
|
|
322
|
+
}, {}));
|
|
323
|
+
}
|
|
324
|
+
function upsertPairingSourceState(pairing, source, patch) {
|
|
325
|
+
ensurePairingSourceStates(pairing);
|
|
326
|
+
const current = listPairingSourceStateRows(pairing.id).find((row) => row.source_key === source);
|
|
327
|
+
if (!current) {
|
|
328
|
+
throw new Error(`Missing companion pairing source state for ${source}`);
|
|
329
|
+
}
|
|
330
|
+
const nextMetadata = patch.metadata != null
|
|
331
|
+
? {
|
|
332
|
+
...safeJsonParse(current.metadata_json, {}),
|
|
333
|
+
...patch.metadata
|
|
334
|
+
}
|
|
335
|
+
: safeJsonParse(current.metadata_json, {});
|
|
336
|
+
const nextDesiredEnabled = patch.desiredEnabled ?? (current.desired_enabled === 1);
|
|
337
|
+
const nextAppliedEnabled = patch.appliedEnabled ?? (current.applied_enabled === 1);
|
|
338
|
+
const nextSyncEligible = patch.syncEligible ?? (current.sync_eligible === 1);
|
|
339
|
+
const nextUpdatedAt = nowIso();
|
|
340
|
+
getDatabase()
|
|
341
|
+
.prepare(`UPDATE companion_pairing_source_states
|
|
342
|
+
SET desired_enabled = ?, applied_enabled = ?, authorization_status = ?,
|
|
343
|
+
sync_eligible = ?, last_observed_at = ?, metadata_json = ?, updated_at = ?
|
|
344
|
+
WHERE pairing_session_id = ? AND source_key = ?`)
|
|
345
|
+
.run(nextDesiredEnabled ? 1 : 0, nextAppliedEnabled ? 1 : 0, patch.authorizationStatus ?? current.authorization_status, nextSyncEligible ? 1 : 0, patch.lastObservedAt ?? current.last_observed_at, JSON.stringify(nextMetadata), nextUpdatedAt, pairing.id, source);
|
|
346
|
+
const refreshedPairing = getDatabase()
|
|
347
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
348
|
+
.get(pairing.id);
|
|
349
|
+
return mapPairingSession(refreshedPairing);
|
|
350
|
+
}
|
|
166
351
|
function dayKey(value) {
|
|
167
352
|
return value.slice(0, 10);
|
|
168
353
|
}
|
|
@@ -302,6 +487,7 @@ function mapPairingSession(row) {
|
|
|
302
487
|
const stale = row.last_sync_at &&
|
|
303
488
|
Date.now() - Date.parse(row.last_sync_at) > 1000 * 60 * 60 * 24;
|
|
304
489
|
const effectiveStatus = row.status === "healthy" && stale ? "stale" : row.status;
|
|
490
|
+
const sourceStates = ensurePairingSourceStates(row);
|
|
305
491
|
return {
|
|
306
492
|
id: row.id,
|
|
307
493
|
userId: row.user_id,
|
|
@@ -316,6 +502,7 @@ function mapPairingSession(row) {
|
|
|
316
502
|
lastSyncAt: row.last_sync_at,
|
|
317
503
|
lastSyncError: row.last_sync_error,
|
|
318
504
|
pairedAt: row.paired_at,
|
|
505
|
+
sourceStates,
|
|
319
506
|
expiresAt: row.expires_at,
|
|
320
507
|
createdAt: row.created_at,
|
|
321
508
|
updatedAt: row.updated_at
|
|
@@ -436,6 +623,24 @@ function listWorkoutRows(userIds) {
|
|
|
436
623
|
ORDER BY started_at DESC`)
|
|
437
624
|
.all(...params);
|
|
438
625
|
}
|
|
626
|
+
export function listSleepSessions(userIds) {
|
|
627
|
+
return listSleepRows(userIds).map(mapSleepSession);
|
|
628
|
+
}
|
|
629
|
+
export function listWorkoutSessions(userIds) {
|
|
630
|
+
return listWorkoutRows(userIds).map(mapWorkoutSession);
|
|
631
|
+
}
|
|
632
|
+
export function getSleepSessionById(sleepId) {
|
|
633
|
+
const row = getDatabase()
|
|
634
|
+
.prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
|
|
635
|
+
.get(sleepId);
|
|
636
|
+
return row ? mapSleepSession(row) : undefined;
|
|
637
|
+
}
|
|
638
|
+
export function getWorkoutSessionById(workoutId) {
|
|
639
|
+
const row = getDatabase()
|
|
640
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
641
|
+
.get(workoutId);
|
|
642
|
+
return row ? mapWorkoutSession(row) : undefined;
|
|
643
|
+
}
|
|
439
644
|
function listPairingRows(userIds) {
|
|
440
645
|
const params = [];
|
|
441
646
|
const where = userIds && userIds.length > 0
|
|
@@ -500,6 +705,12 @@ function listHealthImportRunRows(userIds, limit = 12) {
|
|
|
500
705
|
export function listPairingSessions(userIds) {
|
|
501
706
|
return listPairingRows(userIds).map(mapPairingSession);
|
|
502
707
|
}
|
|
708
|
+
export function getCompanionPairingSessionById(pairingSessionId) {
|
|
709
|
+
const row = getDatabase()
|
|
710
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
711
|
+
.get(pairingSessionId);
|
|
712
|
+
return row ? mapPairingSession(row) : undefined;
|
|
713
|
+
}
|
|
503
714
|
export function revokeCompanionPairingSession(pairingSessionId, activity) {
|
|
504
715
|
const current = getDatabase()
|
|
505
716
|
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
@@ -523,6 +734,36 @@ export function revokeAllCompanionPairingSessions(input, activity) {
|
|
|
523
734
|
sessions
|
|
524
735
|
};
|
|
525
736
|
}
|
|
737
|
+
export function patchCompanionPairingSourceState(pairingSessionId, source, patch) {
|
|
738
|
+
const pairing = getDatabase()
|
|
739
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
740
|
+
.get(pairingSessionId);
|
|
741
|
+
if (!pairing) {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
return upsertPairingSourceState(pairing, source, {
|
|
745
|
+
desiredEnabled: patch.desiredEnabled,
|
|
746
|
+
metadata: {
|
|
747
|
+
desiredEnabledUpdatedAt: nowIso(),
|
|
748
|
+
desiredEnabledUpdatedBy: "forge_web"
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
export function updateMobileCompanionSourceState(payload) {
|
|
753
|
+
const parsed = updateMobileCompanionSourceStateSchema.parse(payload);
|
|
754
|
+
const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
|
|
755
|
+
return upsertPairingSourceState(pairing, parsed.source, {
|
|
756
|
+
desiredEnabled: parsed.desiredEnabled,
|
|
757
|
+
appliedEnabled: parsed.appliedEnabled,
|
|
758
|
+
authorizationStatus: parsed.authorizationStatus,
|
|
759
|
+
syncEligible: parsed.syncEligible,
|
|
760
|
+
lastObservedAt: parsed.lastObservedAt ?? nowIso(),
|
|
761
|
+
metadata: {
|
|
762
|
+
...parsed.metadata,
|
|
763
|
+
source: "companion"
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
526
767
|
export function createCompanionPairingSession(baseApiUrl, input) {
|
|
527
768
|
const parsed = createCompanionPairingSessionSchema.parse(input);
|
|
528
769
|
const now = new Date();
|
|
@@ -547,13 +788,18 @@ export function createCompanionPairingSession(baseApiUrl, input) {
|
|
|
547
788
|
reason: "Superseded by a newer pairing QR"
|
|
548
789
|
});
|
|
549
790
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
791
|
+
runInTransaction(() => {
|
|
792
|
+
getDatabase()
|
|
793
|
+
.prepare(`INSERT INTO companion_pairing_sessions (
|
|
794
|
+
id, user_id, label, pairing_token, status, capability_flags_json, api_base_url,
|
|
795
|
+
expires_at, created_at, updated_at
|
|
796
|
+
)
|
|
797
|
+
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`)
|
|
798
|
+
.run(id, userId, parsed.label, pairingToken, serializedCapabilities, baseApiUrl, expiresAt, now.toISOString(), now.toISOString());
|
|
799
|
+
ensurePairingSourceStates(getDatabase()
|
|
800
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
801
|
+
.get(id));
|
|
802
|
+
});
|
|
557
803
|
const qrPayload = {
|
|
558
804
|
kind: "forge-companion-pairing",
|
|
559
805
|
apiBaseUrl: baseApiUrl,
|
|
@@ -602,6 +848,9 @@ export function verifyCompanionPairing(payload) {
|
|
|
602
848
|
});
|
|
603
849
|
}
|
|
604
850
|
}
|
|
851
|
+
ensurePairingSourceStates(getDatabase()
|
|
852
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
853
|
+
.get(pairing.id));
|
|
605
854
|
return {
|
|
606
855
|
pairingSession: mapPairingSession(getDatabase()
|
|
607
856
|
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
@@ -623,6 +872,61 @@ export function requireValidPairing(sessionId, pairingToken) {
|
|
|
623
872
|
}
|
|
624
873
|
return row;
|
|
625
874
|
}
|
|
875
|
+
function normalizeHealthAuthorizationStatus(healthAccessAuthorized, sleepCount, workoutCount) {
|
|
876
|
+
if (healthAccessAuthorized) {
|
|
877
|
+
return "approved";
|
|
878
|
+
}
|
|
879
|
+
if (sleepCount > 0 || workoutCount > 0) {
|
|
880
|
+
return "partial";
|
|
881
|
+
}
|
|
882
|
+
return "not_determined";
|
|
883
|
+
}
|
|
884
|
+
function syncPairingSourceStatesFromPayload(pairing, payload) {
|
|
885
|
+
upsertPairingSourceState(pairing, "health", {
|
|
886
|
+
desiredEnabled: payload.sourceStates.health.desiredEnabled,
|
|
887
|
+
appliedEnabled: payload.sourceStates.health.appliedEnabled,
|
|
888
|
+
authorizationStatus: normalizeHealthAuthorizationStatus(payload.permissions.healthKitAuthorized, payload.sleepSessions.length, payload.workouts.length),
|
|
889
|
+
syncEligible: payload.sourceStates.health.syncEligible,
|
|
890
|
+
lastObservedAt: payload.sourceStates.health.lastObservedAt ?? nowIso(),
|
|
891
|
+
metadata: payload.sourceStates.health.metadata
|
|
892
|
+
});
|
|
893
|
+
upsertPairingSourceState(pairing, "movement", {
|
|
894
|
+
desiredEnabled: payload.sourceStates.movement.desiredEnabled,
|
|
895
|
+
appliedEnabled: payload.sourceStates.movement.appliedEnabled,
|
|
896
|
+
authorizationStatus: payload.movement.settings.locationPermissionStatus === "always" ||
|
|
897
|
+
payload.movement.settings.locationPermissionStatus === "when_in_use"
|
|
898
|
+
? "approved"
|
|
899
|
+
: payload.movement.settings.locationPermissionStatus === "restricted"
|
|
900
|
+
? "restricted"
|
|
901
|
+
: payload.movement.settings.locationPermissionStatus === "denied"
|
|
902
|
+
? "denied"
|
|
903
|
+
: "not_determined",
|
|
904
|
+
syncEligible: payload.sourceStates.movement.syncEligible,
|
|
905
|
+
lastObservedAt: payload.sourceStates.movement.lastObservedAt ?? nowIso(),
|
|
906
|
+
metadata: {
|
|
907
|
+
...payload.sourceStates.movement.metadata,
|
|
908
|
+
motionPermissionStatus: payload.movement.settings.motionPermissionStatus,
|
|
909
|
+
backgroundTrackingReady: payload.movement.settings.backgroundTrackingReady
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
upsertPairingSourceState(pairing, "screenTime", {
|
|
913
|
+
desiredEnabled: payload.sourceStates.screenTime.desiredEnabled,
|
|
914
|
+
appliedEnabled: payload.sourceStates.screenTime.appliedEnabled,
|
|
915
|
+
authorizationStatus: payload.screenTime.settings.authorizationStatus === "approved"
|
|
916
|
+
? "approved"
|
|
917
|
+
: payload.screenTime.settings.authorizationStatus === "denied"
|
|
918
|
+
? "denied"
|
|
919
|
+
: payload.screenTime.settings.authorizationStatus === "unavailable"
|
|
920
|
+
? "unavailable"
|
|
921
|
+
: "not_determined",
|
|
922
|
+
syncEligible: payload.sourceStates.screenTime.syncEligible,
|
|
923
|
+
lastObservedAt: payload.sourceStates.screenTime.lastObservedAt ?? nowIso(),
|
|
924
|
+
metadata: {
|
|
925
|
+
...payload.sourceStates.screenTime.metadata,
|
|
926
|
+
captureState: payload.screenTime.settings.captureState
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
}
|
|
626
930
|
function findMatchingGeneratedWorkout(input) {
|
|
627
931
|
const rows = getDatabase()
|
|
628
932
|
.prepare(`SELECT *
|
|
@@ -840,7 +1144,9 @@ export function ingestMobileHealthSync(payload) {
|
|
|
840
1144
|
let createdCount = 0;
|
|
841
1145
|
let updatedCount = 0;
|
|
842
1146
|
let mergedCount = 0;
|
|
1147
|
+
syncPairingSourceStatesFromPayload(pairing, parsed);
|
|
843
1148
|
const movementSync = ingestMovementSync(pairing, parsed.movement);
|
|
1149
|
+
const screenTimeSync = ingestScreenTimeSync(pairing, parsed.screenTime, parsed.device.sourceDevice);
|
|
844
1150
|
for (const sleep of parsed.sleepSessions) {
|
|
845
1151
|
const result = insertOrUpdateSleepSession(pairing, sleep);
|
|
846
1152
|
if (result.mode === "created") {
|
|
@@ -887,11 +1193,19 @@ export function ingestMobileHealthSync(payload) {
|
|
|
887
1193
|
knownPlaces: parsed.movement.knownPlaces.length,
|
|
888
1194
|
stays: parsed.movement.stays.length,
|
|
889
1195
|
trips: parsed.movement.trips.length
|
|
1196
|
+
},
|
|
1197
|
+
screenTime: {
|
|
1198
|
+
daySummaries: parsed.screenTime.daySummaries.length,
|
|
1199
|
+
hourlySegments: parsed.screenTime.hourlySegments.length,
|
|
1200
|
+
authorizationStatus: parsed.screenTime.settings.authorizationStatus,
|
|
1201
|
+
captureState: parsed.screenTime.settings.captureState
|
|
890
1202
|
}
|
|
891
1203
|
}), parsed.sleepSessions.length +
|
|
892
1204
|
parsed.workouts.length +
|
|
893
1205
|
parsed.movement.stays.length +
|
|
894
|
-
parsed.movement.trips.length
|
|
1206
|
+
parsed.movement.trips.length +
|
|
1207
|
+
parsed.screenTime.daySummaries.length +
|
|
1208
|
+
parsed.screenTime.hourlySegments.length, createdCount + movementSync.createdCount + screenTimeSync.createdCount, updatedCount + movementSync.updatedCount + screenTimeSync.updatedCount, mergedCount, now, now, now);
|
|
895
1209
|
recordActivityEvent({
|
|
896
1210
|
entityType: "system",
|
|
897
1211
|
entityId: pairing.id,
|
|
@@ -905,8 +1219,10 @@ export function ingestMobileHealthSync(payload) {
|
|
|
905
1219
|
workouts: parsed.workouts.length,
|
|
906
1220
|
movementStays: parsed.movement.stays.length,
|
|
907
1221
|
movementTrips: parsed.movement.trips.length,
|
|
1222
|
+
screenTimeDaySummaries: parsed.screenTime.daySummaries.length,
|
|
1223
|
+
screenTimeHourlySegments: parsed.screenTime.hourlySegments.length,
|
|
908
1224
|
createdCount: createdCount + movementSync.createdCount,
|
|
909
|
-
updatedCount: updatedCount + movementSync.updatedCount,
|
|
1225
|
+
updatedCount: updatedCount + movementSync.updatedCount + screenTimeSync.updatedCount,
|
|
910
1226
|
mergedCount
|
|
911
1227
|
}
|
|
912
1228
|
});
|
|
@@ -917,12 +1233,14 @@ export function ingestMobileHealthSync(payload) {
|
|
|
917
1233
|
imported: {
|
|
918
1234
|
sleepSessions: parsed.sleepSessions.length,
|
|
919
1235
|
workouts: parsed.workouts.length,
|
|
920
|
-
createdCount: createdCount + movementSync.createdCount,
|
|
921
|
-
updatedCount: updatedCount + movementSync.updatedCount,
|
|
1236
|
+
createdCount: createdCount + movementSync.createdCount + screenTimeSync.createdCount,
|
|
1237
|
+
updatedCount: updatedCount + movementSync.updatedCount + screenTimeSync.updatedCount,
|
|
922
1238
|
mergedCount,
|
|
923
1239
|
movementStays: parsed.movement.stays.length,
|
|
924
1240
|
movementTrips: parsed.movement.trips.length,
|
|
925
|
-
movementKnownPlaces: parsed.movement.knownPlaces.length
|
|
1241
|
+
movementKnownPlaces: parsed.movement.knownPlaces.length,
|
|
1242
|
+
screenTimeDaySummaries: parsed.screenTime.daySummaries.length,
|
|
1243
|
+
screenTimeHourlySegments: parsed.screenTime.hourlySegments.length
|
|
926
1244
|
},
|
|
927
1245
|
movement: getMovementMobileBootstrap(pairing)
|
|
928
1246
|
};
|
|
@@ -938,9 +1256,19 @@ export function getCompanionOverview(userIds) {
|
|
|
938
1256
|
return {
|
|
939
1257
|
knownPlaces: totals.knownPlaces + (movement.knownPlaces ?? 0),
|
|
940
1258
|
stays: totals.stays + (movement.stays ?? 0),
|
|
941
|
-
trips: totals.trips + (movement.trips ?? 0)
|
|
1259
|
+
trips: totals.trips + (movement.trips ?? 0),
|
|
1260
|
+
screenTimeDays: totals.screenTimeDays +
|
|
1261
|
+
(run.payloadSummary.screenTime?.daySummaries ?? 0),
|
|
1262
|
+
screenTimeHourlySegments: totals.screenTimeHourlySegments +
|
|
1263
|
+
(run.payloadSummary.screenTime?.hourlySegments ?? 0)
|
|
942
1264
|
};
|
|
943
|
-
}, {
|
|
1265
|
+
}, {
|
|
1266
|
+
knownPlaces: 0,
|
|
1267
|
+
stays: 0,
|
|
1268
|
+
trips: 0,
|
|
1269
|
+
screenTimeDays: 0,
|
|
1270
|
+
screenTimeHourlySegments: 0
|
|
1271
|
+
});
|
|
944
1272
|
const activePairings = pairings.filter((pairing) => pairing.status !== "revoked");
|
|
945
1273
|
const recentPermissionStates = importRuns
|
|
946
1274
|
.map((run) => safeJsonParse(JSON.stringify(run.payloadSummary), {}))
|
|
@@ -979,13 +1307,16 @@ export function getCompanionOverview(userIds) {
|
|
|
979
1307
|
reconciledWorkouts: workouts.filter((session) => session.reconciliationStatus === "merged").length,
|
|
980
1308
|
movementKnownPlaces: movementSummary.knownPlaces,
|
|
981
1309
|
movementStays: movementSummary.stays,
|
|
982
|
-
movementTrips: movementSummary.trips
|
|
1310
|
+
movementTrips: movementSummary.trips,
|
|
1311
|
+
screenTimeDaySummaries: movementSummary.screenTimeDays,
|
|
1312
|
+
screenTimeHourlySegments: movementSummary.screenTimeHourlySegments
|
|
983
1313
|
},
|
|
984
1314
|
permissions: {
|
|
985
1315
|
healthKitAuthorized: recentPermissionStates.some((state) => state.healthKitAuthorized === true),
|
|
986
1316
|
backgroundRefreshEnabled: recentPermissionStates.some((state) => state.backgroundRefreshEnabled === true),
|
|
987
1317
|
locationReady: recentPermissionStates.some((state) => state.locationReady === true),
|
|
988
|
-
motionReady: recentPermissionStates.some((state) => state.motionReady === true)
|
|
1318
|
+
motionReady: recentPermissionStates.some((state) => state.motionReady === true),
|
|
1319
|
+
screenTimeReady: recentPermissionStates.some((state) => state.screenTimeReady === true)
|
|
989
1320
|
}
|
|
990
1321
|
};
|
|
991
1322
|
}
|
|
@@ -1136,6 +1467,359 @@ export function getFitnessViewData(userIds) {
|
|
|
1136
1467
|
sessions: recent
|
|
1137
1468
|
};
|
|
1138
1469
|
}
|
|
1470
|
+
export function createSleepSession(input, activity) {
|
|
1471
|
+
const parsed = createSleepSessionSchema.parse(input);
|
|
1472
|
+
const userId = resolveHealthUserId(parsed.userId);
|
|
1473
|
+
const now = nowIso();
|
|
1474
|
+
const id = `sleep_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
1475
|
+
const externalUid = parsed.externalUid ??
|
|
1476
|
+
`manual_sleep_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
1477
|
+
const timeInBedSeconds = parsed.timeInBedSeconds ??
|
|
1478
|
+
durationSecondsBetween(parsed.startedAt, parsed.endedAt);
|
|
1479
|
+
const asleepSeconds = parsed.asleepSeconds ?? timeInBedSeconds;
|
|
1480
|
+
const awakeSeconds = parsed.awakeSeconds ?? Math.max(0, timeInBedSeconds - asleepSeconds);
|
|
1481
|
+
const sleepScore = computeSleepScore({
|
|
1482
|
+
asleepSeconds,
|
|
1483
|
+
timeInBedSeconds,
|
|
1484
|
+
awakeSeconds,
|
|
1485
|
+
stageBreakdown: parsed.stageBreakdown
|
|
1486
|
+
});
|
|
1487
|
+
const timingMetrics = computeSleepTimingMetrics({
|
|
1488
|
+
userId,
|
|
1489
|
+
startedAt: parsed.startedAt,
|
|
1490
|
+
endedAt: parsed.endedAt
|
|
1491
|
+
});
|
|
1492
|
+
const annotations = {
|
|
1493
|
+
qualitySummary: parsed.qualitySummary,
|
|
1494
|
+
notes: parsed.notes,
|
|
1495
|
+
tags: parsed.tags,
|
|
1496
|
+
links: parsed.links
|
|
1497
|
+
};
|
|
1498
|
+
const derived = computeSleepDerivedMetrics({
|
|
1499
|
+
asleepSeconds,
|
|
1500
|
+
timeInBedSeconds,
|
|
1501
|
+
awakeSeconds,
|
|
1502
|
+
sleepScore,
|
|
1503
|
+
stageBreakdown: parsed.stageBreakdown
|
|
1504
|
+
});
|
|
1505
|
+
getDatabase()
|
|
1506
|
+
.prepare(`INSERT INTO health_sleep_sessions (
|
|
1507
|
+
id, external_uid, pairing_session_id, user_id, source, source_type, source_device,
|
|
1508
|
+
started_at, ended_at, time_in_bed_seconds, asleep_seconds, awake_seconds,
|
|
1509
|
+
sleep_score, regularity_score, bedtime_consistency_minutes, wake_consistency_minutes,
|
|
1510
|
+
stage_breakdown_json, recovery_metrics_json, links_json, annotations_json,
|
|
1511
|
+
provenance_json, derived_json, created_at, updated_at
|
|
1512
|
+
)
|
|
1513
|
+
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1514
|
+
.run(id, externalUid, userId, parsed.source, parsed.sourceType, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, timeInBedSeconds, asleepSeconds, awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(parsed.stageBreakdown), JSON.stringify(parsed.recoveryMetrics), JSON.stringify(parsed.links), JSON.stringify(annotations), JSON.stringify({
|
|
1515
|
+
manualEntry: true,
|
|
1516
|
+
entryMode: "local",
|
|
1517
|
+
source: parsed.source,
|
|
1518
|
+
sourceType: parsed.sourceType,
|
|
1519
|
+
sourceDevice: parsed.sourceDevice,
|
|
1520
|
+
actor: activity?.actor ?? null,
|
|
1521
|
+
createdAt: now,
|
|
1522
|
+
...parsed.provenance
|
|
1523
|
+
}), JSON.stringify(derived), now, now);
|
|
1524
|
+
summarizeUserHealthDay(userId, dayKey(parsed.endedAt));
|
|
1525
|
+
recordActivityEvent({
|
|
1526
|
+
entityType: "sleep_session",
|
|
1527
|
+
entityId: id,
|
|
1528
|
+
eventType: "sleep_session_created",
|
|
1529
|
+
title: "Sleep session created",
|
|
1530
|
+
description: "A manual sleep session was created in Forge.",
|
|
1531
|
+
actor: activity?.actor ?? null,
|
|
1532
|
+
source: activity?.source ?? "ui",
|
|
1533
|
+
metadata: {
|
|
1534
|
+
userId,
|
|
1535
|
+
startedAt: parsed.startedAt,
|
|
1536
|
+
endedAt: parsed.endedAt
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
return getSleepSessionById(id);
|
|
1540
|
+
}
|
|
1541
|
+
export function updateSleepSession(sleepId, patch, activity) {
|
|
1542
|
+
const parsed = updateSleepSessionSchema.parse(patch);
|
|
1543
|
+
const current = getDatabase()
|
|
1544
|
+
.prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
|
|
1545
|
+
.get(sleepId);
|
|
1546
|
+
if (!current) {
|
|
1547
|
+
return undefined;
|
|
1548
|
+
}
|
|
1549
|
+
const now = nowIso();
|
|
1550
|
+
const startedAt = parsed.startedAt ?? current.started_at;
|
|
1551
|
+
const endedAt = parsed.endedAt ?? current.ended_at;
|
|
1552
|
+
const timeInBedSeconds = parsed.timeInBedSeconds ??
|
|
1553
|
+
(parsed.startedAt !== undefined || parsed.endedAt !== undefined
|
|
1554
|
+
? durationSecondsBetween(startedAt, endedAt)
|
|
1555
|
+
: current.time_in_bed_seconds);
|
|
1556
|
+
const asleepSeconds = parsed.asleepSeconds ?? current.asleep_seconds;
|
|
1557
|
+
const awakeSeconds = parsed.awakeSeconds ?? Math.max(0, timeInBedSeconds - asleepSeconds);
|
|
1558
|
+
const stageBreakdown = parsed.stageBreakdown ?? safeJsonParse(current.stage_breakdown_json, []);
|
|
1559
|
+
const recoveryMetrics = parsed.recoveryMetrics ?? safeJsonParse(current.recovery_metrics_json, {});
|
|
1560
|
+
const currentAnnotations = safeJsonParse(current.annotations_json, {});
|
|
1561
|
+
const links = parsed.links ?? safeJsonParse(current.links_json, []);
|
|
1562
|
+
const annotations = {
|
|
1563
|
+
qualitySummary: parsed.qualitySummary ??
|
|
1564
|
+
(typeof currentAnnotations.qualitySummary === "string"
|
|
1565
|
+
? currentAnnotations.qualitySummary
|
|
1566
|
+
: ""),
|
|
1567
|
+
notes: parsed.notes ??
|
|
1568
|
+
(typeof currentAnnotations.notes === "string"
|
|
1569
|
+
? currentAnnotations.notes
|
|
1570
|
+
: ""),
|
|
1571
|
+
tags: parsed.tags ??
|
|
1572
|
+
(Array.isArray(currentAnnotations.tags)
|
|
1573
|
+
? currentAnnotations.tags
|
|
1574
|
+
: []),
|
|
1575
|
+
links
|
|
1576
|
+
};
|
|
1577
|
+
const sleepScore = computeSleepScore({
|
|
1578
|
+
asleepSeconds,
|
|
1579
|
+
timeInBedSeconds,
|
|
1580
|
+
awakeSeconds,
|
|
1581
|
+
stageBreakdown
|
|
1582
|
+
});
|
|
1583
|
+
const timingMetrics = computeSleepTimingMetrics({
|
|
1584
|
+
userId: current.user_id,
|
|
1585
|
+
startedAt,
|
|
1586
|
+
endedAt,
|
|
1587
|
+
excludeSleepId: current.id
|
|
1588
|
+
});
|
|
1589
|
+
const derived = computeSleepDerivedMetrics({
|
|
1590
|
+
asleepSeconds,
|
|
1591
|
+
timeInBedSeconds,
|
|
1592
|
+
awakeSeconds,
|
|
1593
|
+
sleepScore,
|
|
1594
|
+
stageBreakdown
|
|
1595
|
+
});
|
|
1596
|
+
const currentProvenance = safeJsonParse(current.provenance_json, {});
|
|
1597
|
+
getDatabase()
|
|
1598
|
+
.prepare(`UPDATE health_sleep_sessions
|
|
1599
|
+
SET external_uid = ?, source = ?, source_type = ?, source_device = ?,
|
|
1600
|
+
started_at = ?, ended_at = ?, time_in_bed_seconds = ?, asleep_seconds = ?, awake_seconds = ?,
|
|
1601
|
+
sleep_score = ?, regularity_score = ?, bedtime_consistency_minutes = ?, wake_consistency_minutes = ?,
|
|
1602
|
+
stage_breakdown_json = ?, recovery_metrics_json = ?, links_json = ?, annotations_json = ?,
|
|
1603
|
+
provenance_json = ?, derived_json = ?, updated_at = ?
|
|
1604
|
+
WHERE id = ?`)
|
|
1605
|
+
.run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, timeInBedSeconds, asleepSeconds, awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(stageBreakdown), JSON.stringify(recoveryMetrics), JSON.stringify(links), JSON.stringify(annotations), JSON.stringify({
|
|
1606
|
+
...currentProvenance,
|
|
1607
|
+
...(parsed.provenance ?? {}),
|
|
1608
|
+
updatedAt: now,
|
|
1609
|
+
updatedByActor: activity?.actor ?? null
|
|
1610
|
+
}), JSON.stringify(derived), now, sleepId);
|
|
1611
|
+
summarizeUserHealthDay(current.user_id, dayKey(current.ended_at));
|
|
1612
|
+
summarizeUserHealthDay(current.user_id, dayKey(endedAt));
|
|
1613
|
+
recordActivityEvent({
|
|
1614
|
+
entityType: "sleep_session",
|
|
1615
|
+
entityId: sleepId,
|
|
1616
|
+
eventType: "sleep_session_updated",
|
|
1617
|
+
title: "Sleep session updated",
|
|
1618
|
+
description: "A sleep session was updated through Forge CRUD.",
|
|
1619
|
+
actor: activity?.actor ?? null,
|
|
1620
|
+
source: activity?.source ?? "ui",
|
|
1621
|
+
metadata: {
|
|
1622
|
+
startedAt,
|
|
1623
|
+
endedAt
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
return getSleepSessionById(sleepId);
|
|
1627
|
+
}
|
|
1628
|
+
export function deleteSleepSession(sleepId, activity) {
|
|
1629
|
+
const current = getDatabase()
|
|
1630
|
+
.prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
|
|
1631
|
+
.get(sleepId);
|
|
1632
|
+
if (!current) {
|
|
1633
|
+
return undefined;
|
|
1634
|
+
}
|
|
1635
|
+
getDatabase()
|
|
1636
|
+
.prepare(`DELETE FROM health_sleep_sessions WHERE id = ?`)
|
|
1637
|
+
.run(sleepId);
|
|
1638
|
+
summarizeUserHealthDay(current.user_id, dayKey(current.ended_at));
|
|
1639
|
+
recordActivityEvent({
|
|
1640
|
+
entityType: "sleep_session",
|
|
1641
|
+
entityId: sleepId,
|
|
1642
|
+
eventType: "sleep_session_deleted",
|
|
1643
|
+
title: "Sleep session deleted",
|
|
1644
|
+
description: "A sleep session was permanently removed from Forge.",
|
|
1645
|
+
actor: activity?.actor ?? null,
|
|
1646
|
+
source: activity?.source ?? "ui",
|
|
1647
|
+
metadata: {
|
|
1648
|
+
startedAt: current.started_at,
|
|
1649
|
+
endedAt: current.ended_at
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
return mapSleepSession(current);
|
|
1653
|
+
}
|
|
1654
|
+
export function createWorkoutSession(input, activity) {
|
|
1655
|
+
const parsed = createWorkoutSessionSchema.parse(input);
|
|
1656
|
+
const userId = resolveHealthUserId(parsed.userId);
|
|
1657
|
+
const now = nowIso();
|
|
1658
|
+
const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
1659
|
+
const externalUid = parsed.externalUid ??
|
|
1660
|
+
`manual_workout_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
1661
|
+
const durationSeconds = durationSecondsBetween(parsed.startedAt, parsed.endedAt);
|
|
1662
|
+
const annotations = {
|
|
1663
|
+
subjectiveEffort: parsed.subjectiveEffort ?? null,
|
|
1664
|
+
moodBefore: parsed.moodBefore,
|
|
1665
|
+
moodAfter: parsed.moodAfter,
|
|
1666
|
+
meaningText: parsed.meaningText,
|
|
1667
|
+
plannedContext: parsed.plannedContext,
|
|
1668
|
+
socialContext: parsed.socialContext,
|
|
1669
|
+
tags: parsed.tags,
|
|
1670
|
+
links: parsed.links
|
|
1671
|
+
};
|
|
1672
|
+
getDatabase()
|
|
1673
|
+
.prepare(`INSERT INTO health_workout_sessions (
|
|
1674
|
+
id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
|
|
1675
|
+
started_at, ended_at, duration_seconds, active_energy_kcal, total_energy_kcal, distance_meters,
|
|
1676
|
+
step_count, exercise_minutes, average_heart_rate, max_heart_rate, subjective_effort, mood_before,
|
|
1677
|
+
mood_after, meaning_text, planned_context, social_context, links_json, tags_json, annotations_json,
|
|
1678
|
+
provenance_json, derived_json, reconciliation_status, created_at, updated_at
|
|
1679
|
+
)
|
|
1680
|
+
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
|
|
1681
|
+
.run(id, externalUid, userId, parsed.source, parsed.sourceType, parsed.workoutType, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, durationSeconds, parsed.activeEnergyKcal ?? null, parsed.totalEnergyKcal ?? null, parsed.distanceMeters ?? null, parsed.stepCount ?? null, parsed.exerciseMinutes ?? null, parsed.averageHeartRate ?? null, parsed.maxHeartRate ?? null, parsed.subjectiveEffort ?? null, parsed.moodBefore, parsed.moodAfter, parsed.meaningText, parsed.plannedContext, parsed.socialContext, JSON.stringify(parsed.links), JSON.stringify(parsed.tags), JSON.stringify(annotations), JSON.stringify({
|
|
1682
|
+
manualEntry: true,
|
|
1683
|
+
entryMode: "local",
|
|
1684
|
+
source: parsed.source,
|
|
1685
|
+
sourceType: parsed.sourceType,
|
|
1686
|
+
sourceDevice: parsed.sourceDevice,
|
|
1687
|
+
actor: activity?.actor ?? null,
|
|
1688
|
+
createdAt: now,
|
|
1689
|
+
...parsed.provenance
|
|
1690
|
+
}), JSON.stringify({
|
|
1691
|
+
paceMetersPerMinute: parsed.distanceMeters && parsed.exerciseMinutes
|
|
1692
|
+
? Number((parsed.distanceMeters / parsed.exerciseMinutes).toFixed(2))
|
|
1693
|
+
: null
|
|
1694
|
+
}), now, now);
|
|
1695
|
+
summarizeUserHealthDay(userId, dayKey(parsed.startedAt));
|
|
1696
|
+
recordActivityEvent({
|
|
1697
|
+
entityType: "workout_session",
|
|
1698
|
+
entityId: id,
|
|
1699
|
+
eventType: "workout_session_created",
|
|
1700
|
+
title: "Workout session created",
|
|
1701
|
+
description: "A manual workout session was created in Forge.",
|
|
1702
|
+
actor: activity?.actor ?? null,
|
|
1703
|
+
source: activity?.source ?? "ui",
|
|
1704
|
+
metadata: {
|
|
1705
|
+
userId,
|
|
1706
|
+
workoutType: parsed.workoutType
|
|
1707
|
+
}
|
|
1708
|
+
});
|
|
1709
|
+
return getWorkoutSessionById(id);
|
|
1710
|
+
}
|
|
1711
|
+
export function updateWorkoutSession(workoutId, patch, activity) {
|
|
1712
|
+
const parsed = updateWorkoutSessionSchema.parse(patch);
|
|
1713
|
+
const current = getDatabase()
|
|
1714
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1715
|
+
.get(workoutId);
|
|
1716
|
+
if (!current) {
|
|
1717
|
+
return undefined;
|
|
1718
|
+
}
|
|
1719
|
+
const now = nowIso();
|
|
1720
|
+
const startedAt = parsed.startedAt ?? current.started_at;
|
|
1721
|
+
const endedAt = parsed.endedAt ?? current.ended_at;
|
|
1722
|
+
const durationSeconds = parsed.startedAt !== undefined || parsed.endedAt !== undefined
|
|
1723
|
+
? durationSecondsBetween(startedAt, endedAt)
|
|
1724
|
+
: current.duration_seconds;
|
|
1725
|
+
const currentAnnotations = safeJsonParse(current.annotations_json, {});
|
|
1726
|
+
const tags = parsed.tags ??
|
|
1727
|
+
safeJsonParse(current.tags_json, []) ??
|
|
1728
|
+
[];
|
|
1729
|
+
const links = parsed.links ??
|
|
1730
|
+
safeJsonParse(current.links_json, []);
|
|
1731
|
+
const annotations = {
|
|
1732
|
+
subjectiveEffort: parsed.subjectiveEffort ??
|
|
1733
|
+
(typeof currentAnnotations.subjectiveEffort === "number"
|
|
1734
|
+
? currentAnnotations.subjectiveEffort
|
|
1735
|
+
: current.subjective_effort),
|
|
1736
|
+
moodBefore: parsed.moodBefore ??
|
|
1737
|
+
(typeof currentAnnotations.moodBefore === "string"
|
|
1738
|
+
? currentAnnotations.moodBefore
|
|
1739
|
+
: current.mood_before),
|
|
1740
|
+
moodAfter: parsed.moodAfter ??
|
|
1741
|
+
(typeof currentAnnotations.moodAfter === "string"
|
|
1742
|
+
? currentAnnotations.moodAfter
|
|
1743
|
+
: current.mood_after),
|
|
1744
|
+
meaningText: parsed.meaningText ??
|
|
1745
|
+
(typeof currentAnnotations.meaningText === "string"
|
|
1746
|
+
? currentAnnotations.meaningText
|
|
1747
|
+
: current.meaning_text),
|
|
1748
|
+
plannedContext: parsed.plannedContext ??
|
|
1749
|
+
(typeof currentAnnotations.plannedContext === "string"
|
|
1750
|
+
? currentAnnotations.plannedContext
|
|
1751
|
+
: current.planned_context),
|
|
1752
|
+
socialContext: parsed.socialContext ??
|
|
1753
|
+
(typeof currentAnnotations.socialContext === "string"
|
|
1754
|
+
? currentAnnotations.socialContext
|
|
1755
|
+
: current.social_context),
|
|
1756
|
+
tags,
|
|
1757
|
+
links
|
|
1758
|
+
};
|
|
1759
|
+
const currentProvenance = safeJsonParse(current.provenance_json, {});
|
|
1760
|
+
const nextExerciseMinutes = parsed.exerciseMinutes ?? current.exercise_minutes;
|
|
1761
|
+
const nextDistanceMeters = parsed.distanceMeters ?? current.distance_meters;
|
|
1762
|
+
getDatabase()
|
|
1763
|
+
.prepare(`UPDATE health_workout_sessions
|
|
1764
|
+
SET external_uid = ?, source = ?, source_type = ?, workout_type = ?, source_device = ?,
|
|
1765
|
+
started_at = ?, ended_at = ?, duration_seconds = ?, active_energy_kcal = ?, total_energy_kcal = ?,
|
|
1766
|
+
distance_meters = ?, step_count = ?, exercise_minutes = ?, average_heart_rate = ?, max_heart_rate = ?,
|
|
1767
|
+
subjective_effort = ?, mood_before = ?, mood_after = ?, meaning_text = ?, planned_context = ?,
|
|
1768
|
+
social_context = ?, links_json = ?, tags_json = ?, annotations_json = ?, provenance_json = ?,
|
|
1769
|
+
derived_json = ?, updated_at = ?
|
|
1770
|
+
WHERE id = ?`)
|
|
1771
|
+
.run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, parsed.workoutType ?? current.workout_type, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, durationSeconds, parsed.activeEnergyKcal ?? current.active_energy_kcal, parsed.totalEnergyKcal ?? current.total_energy_kcal, nextDistanceMeters, parsed.stepCount ?? current.step_count, nextExerciseMinutes, parsed.averageHeartRate ?? current.average_heart_rate, parsed.maxHeartRate ?? current.max_heart_rate, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(links), JSON.stringify(tags), JSON.stringify(annotations), JSON.stringify({
|
|
1772
|
+
...currentProvenance,
|
|
1773
|
+
...(parsed.provenance ?? {}),
|
|
1774
|
+
updatedAt: now,
|
|
1775
|
+
updatedByActor: activity?.actor ?? null
|
|
1776
|
+
}), JSON.stringify({
|
|
1777
|
+
paceMetersPerMinute: nextDistanceMeters && nextExerciseMinutes
|
|
1778
|
+
? Number((nextDistanceMeters / nextExerciseMinutes).toFixed(2))
|
|
1779
|
+
: null
|
|
1780
|
+
}), now, workoutId);
|
|
1781
|
+
summarizeUserHealthDay(current.user_id, dayKey(current.started_at));
|
|
1782
|
+
summarizeUserHealthDay(current.user_id, dayKey(startedAt));
|
|
1783
|
+
recordActivityEvent({
|
|
1784
|
+
entityType: "workout_session",
|
|
1785
|
+
entityId: workoutId,
|
|
1786
|
+
eventType: "workout_session_updated",
|
|
1787
|
+
title: "Workout session updated",
|
|
1788
|
+
description: "A workout session was updated through Forge CRUD.",
|
|
1789
|
+
actor: activity?.actor ?? null,
|
|
1790
|
+
source: activity?.source ?? "ui",
|
|
1791
|
+
metadata: {
|
|
1792
|
+
workoutType: parsed.workoutType ?? current.workout_type
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
return getWorkoutSessionById(workoutId);
|
|
1796
|
+
}
|
|
1797
|
+
export function deleteWorkoutSession(workoutId, activity) {
|
|
1798
|
+
const current = getDatabase()
|
|
1799
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1800
|
+
.get(workoutId);
|
|
1801
|
+
if (!current) {
|
|
1802
|
+
return undefined;
|
|
1803
|
+
}
|
|
1804
|
+
getDatabase()
|
|
1805
|
+
.prepare(`DELETE FROM health_workout_sessions WHERE id = ?`)
|
|
1806
|
+
.run(workoutId);
|
|
1807
|
+
summarizeUserHealthDay(current.user_id, dayKey(current.started_at));
|
|
1808
|
+
recordActivityEvent({
|
|
1809
|
+
entityType: "workout_session",
|
|
1810
|
+
entityId: workoutId,
|
|
1811
|
+
eventType: "workout_session_deleted",
|
|
1812
|
+
title: "Workout session deleted",
|
|
1813
|
+
description: "A workout session was permanently removed from Forge.",
|
|
1814
|
+
actor: activity?.actor ?? null,
|
|
1815
|
+
source: activity?.source ?? "ui",
|
|
1816
|
+
metadata: {
|
|
1817
|
+
workoutType: current.workout_type,
|
|
1818
|
+
startedAt: current.started_at
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
return mapWorkoutSession(current);
|
|
1822
|
+
}
|
|
1139
1823
|
export function updateWorkoutMetadata(workoutId, patch, activity) {
|
|
1140
1824
|
const parsed = updateWorkoutMetadataSchema.parse(patch);
|
|
1141
1825
|
const current = getDatabase()
|