forge-openclaw-plugin 0.2.23 → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +13 -0
  2. package/dist/assets/{board-_C6oMy5w.js → board-VmF4FAfr.js} +3 -3
  3. package/dist/assets/{board-_C6oMy5w.js.map → board-VmF4FAfr.js.map} +1 -1
  4. package/dist/assets/index-CFCKDIMH.js +67 -0
  5. package/dist/assets/index-CFCKDIMH.js.map +1 -0
  6. package/dist/assets/index-ZPY6U1TU.css +1 -0
  7. package/dist/assets/{motion-D4sZgCHd.js → motion-DvkU14p-.js} +3 -3
  8. package/dist/assets/motion-DvkU14p-.js.map +1 -0
  9. package/dist/assets/{table-BWzTaky1.js → table-DgiPof9E.js} +2 -2
  10. package/dist/assets/{table-BWzTaky1.js.map → table-DgiPof9E.js.map} +1 -1
  11. package/dist/assets/{ui-BzK4azQb.js → ui-nYfoC0Gq.js} +2 -2
  12. package/dist/assets/{ui-BzK4azQb.js.map → ui-nYfoC0Gq.js.map} +1 -1
  13. package/dist/assets/vendor-D9PTEPSB.js +824 -0
  14. package/dist/assets/vendor-D9PTEPSB.js.map +1 -0
  15. package/dist/assets/viz-Cqb6s--o.js +34 -0
  16. package/dist/assets/viz-Cqb6s--o.js.map +1 -0
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/parity.d.ts +1 -1
  19. package/dist/openclaw/parity.js +29 -0
  20. package/dist/openclaw/plugin-entry-shared.d.ts +1 -0
  21. package/dist/openclaw/plugin-entry-shared.js +7 -4
  22. package/dist/openclaw/plugin-sdk-types.d.ts +12 -0
  23. package/dist/openclaw/routes.js +236 -0
  24. package/dist/openclaw/session-bootstrap.d.ts +78 -0
  25. package/dist/openclaw/session-bootstrap.js +240 -0
  26. package/dist/openclaw/tools.js +279 -3
  27. package/dist/server/app.js +855 -19
  28. package/dist/server/connectors/box-registry.js +257 -0
  29. package/dist/server/db.js +2 -0
  30. package/dist/server/discovery-advertiser.js +114 -0
  31. package/dist/server/health.js +39 -11
  32. package/dist/server/index.js +4 -0
  33. package/dist/server/managers/platform/llm-manager.js +40 -4
  34. package/dist/server/managers/platform/openai-responses-provider.js +129 -19
  35. package/dist/server/movement.js +2935 -0
  36. package/dist/server/openapi.js +628 -5
  37. package/dist/server/psyche-types.js +15 -1
  38. package/dist/server/questionnaire-flow.js +552 -0
  39. package/dist/server/questionnaire-seeds.js +853 -0
  40. package/dist/server/questionnaire-types.js +340 -0
  41. package/dist/server/repositories/ai-connectors.js +944 -0
  42. package/dist/server/repositories/ai-processors.js +547 -0
  43. package/dist/server/repositories/diagnostic-logs.js +57 -4
  44. package/dist/server/repositories/entity-ownership.js +9 -1
  45. package/dist/server/repositories/habits.js +77 -9
  46. package/dist/server/repositories/model-settings.js +216 -0
  47. package/dist/server/repositories/notes.js +57 -15
  48. package/dist/server/repositories/preferences.js +124 -0
  49. package/dist/server/repositories/questionnaires.js +1338 -0
  50. package/dist/server/repositories/rewards.js +2 -2
  51. package/dist/server/repositories/settings.js +108 -12
  52. package/dist/server/repositories/surface-layouts.js +76 -0
  53. package/dist/server/repositories/wiki-memory.js +5 -1
  54. package/dist/server/services/entity-crud.js +81 -2
  55. package/dist/server/services/openai-codex-oauth.js +153 -0
  56. package/dist/server/services/psyche-observation-calendar.js +46 -0
  57. package/dist/server/types.js +492 -3
  58. package/dist/server/watch-mobile.js +562 -0
  59. package/dist/server/web.js +9 -2
  60. package/openclaw.plugin.json +1 -1
  61. package/package.json +6 -1
  62. package/server/migrations/024_questionnaires.sql +96 -0
  63. package/server/migrations/025_ai_model_connections.sql +26 -0
  64. package/server/migrations/026_custom_theme_settings.sql +2 -0
  65. package/server/migrations/027_ai_processors.sql +31 -0
  66. package/server/migrations/028_movement_domain.sql +136 -0
  67. package/server/migrations/029_watch_micro_capture.sql +23 -0
  68. package/server/migrations/030_surface_layouts.sql +5 -0
  69. package/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
  70. package/server/migrations/032_ai_connectors.sql +44 -0
  71. package/server/migrations/033_movement_trip_point_sync.sql +36 -0
  72. package/server/migrations/034_movement_segment_sync.sql +49 -0
  73. package/skills/forge-openclaw/SKILL.md +12 -1
  74. package/skills/forge-openclaw/entity_conversation_playbooks.md +331 -84
  75. package/skills/forge-openclaw/psyche_entity_playbooks.md +252 -221
  76. package/dist/assets/index-Ch_xeZ2u.js +0 -63
  77. package/dist/assets/index-Ch_xeZ2u.js.map +0 -1
  78. package/dist/assets/index-DvVM7K6j.css +0 -1
  79. package/dist/assets/motion-D4sZgCHd.js.map +0 -1
  80. package/dist/assets/vendor-De38P6YR.js +0 -729
  81. package/dist/assets/vendor-De38P6YR.js.map +0 -1
  82. package/dist/assets/viz-C6hfyqzu.js +0 -34
  83. package/dist/assets/viz-C6hfyqzu.js.map +0 -1
  84. package/skills/forge-openclaw/cron_jobs.md +0 -395
@@ -0,0 +1,2935 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { getDatabase } from "./db.js";
4
+ import { HttpError } from "./errors.js";
5
+ import { recordActivityEvent } from "./repositories/activity-events.js";
6
+ import { createNote, getNoteById, updateNote } from "./repositories/notes.js";
7
+ import { createManualRewardGrant } from "./repositories/rewards.js";
8
+ import { listTaskRuns } from "./repositories/task-runs.js";
9
+ import { getDefaultUser } from "./repositories/users.js";
10
+ import { listWikiSpaces } from "./repositories/wiki-memory.js";
11
+ const movementPublishModeSchema = z.enum([
12
+ "auto_publish",
13
+ "draft_review",
14
+ "no_publish"
15
+ ]);
16
+ const movementRetentionModeSchema = z.enum([
17
+ "aggregates_only",
18
+ "keep_recent_raw"
19
+ ]);
20
+ const movementVisibilitySchema = z.enum(["personal", "shared"]);
21
+ export const movementCategoryTags = [
22
+ "home",
23
+ "workplace",
24
+ "school",
25
+ "grocery",
26
+ "bar",
27
+ "cafe",
28
+ "clinic",
29
+ "gym",
30
+ "forest",
31
+ "mountain",
32
+ "nature",
33
+ "holiday",
34
+ "social",
35
+ "travel",
36
+ "other"
37
+ ];
38
+ const movementCanonicalCategoryTagSet = new Set(movementCategoryTags);
39
+ const movementCategoryTagAliases = new Map([
40
+ ["home", "home"],
41
+ ["house", "home"],
42
+ ["flat", "home"],
43
+ ["apartment", "home"],
44
+ ["work", "workplace"],
45
+ ["workplace", "workplace"],
46
+ ["office", "workplace"],
47
+ ["coworking", "workplace"],
48
+ ["school", "school"],
49
+ ["campus", "school"],
50
+ ["university", "school"],
51
+ ["college", "school"],
52
+ ["grocery", "grocery"],
53
+ ["groceries", "grocery"],
54
+ ["supermarket", "grocery"],
55
+ ["market", "grocery"],
56
+ ["bar", "bar"],
57
+ ["pub", "bar"],
58
+ ["cafe", "cafe"],
59
+ ["coffee", "cafe"],
60
+ ["coffee-shop", "cafe"],
61
+ ["coffeehouse", "cafe"],
62
+ ["clinic", "clinic"],
63
+ ["hospital", "clinic"],
64
+ ["medical", "clinic"],
65
+ ["gym", "gym"],
66
+ ["fitness", "gym"],
67
+ ["fitness-center", "gym"],
68
+ ["forest", "forest"],
69
+ ["woods", "forest"],
70
+ ["woodland", "forest"],
71
+ ["mountain", "mountain"],
72
+ ["mountains", "mountain"],
73
+ ["alps", "mountain"],
74
+ ["nature", "nature"],
75
+ ["outdoors", "nature"],
76
+ ["park", "nature"],
77
+ ["holiday", "holiday"],
78
+ ["vacation", "holiday"],
79
+ ["travel-holiday", "holiday"],
80
+ ["social", "social"],
81
+ ["friends", "social"],
82
+ ["socializing", "social"],
83
+ ["travel", "travel"],
84
+ ["trip", "travel"],
85
+ ["commute", "travel"],
86
+ ["other", "other"]
87
+ ]);
88
+ function slugifyMovementTag(value) {
89
+ return value
90
+ .normalize("NFKD")
91
+ .replace(/[\u0300-\u036f]/g, "")
92
+ .trim()
93
+ .toLowerCase()
94
+ .replace(/['’]/g, "")
95
+ .replace(/[^a-z0-9]+/g, "-")
96
+ .replace(/^-+|-+$/g, "");
97
+ }
98
+ export function normalizeMovementCategoryTag(value) {
99
+ const slug = slugifyMovementTag(value);
100
+ if (!slug) {
101
+ return "";
102
+ }
103
+ return movementCategoryTagAliases.get(slug) ?? slug;
104
+ }
105
+ export function canonicalizeMovementCategoryTags(values) {
106
+ return uniqStrings(values
107
+ .map((value) => normalizeMovementCategoryTag(value))
108
+ .filter((value) => value.length > 0));
109
+ }
110
+ export function isImportantMovementCategoryTag(value) {
111
+ return movementCanonicalCategoryTagSet.has(normalizeMovementCategoryTag(value));
112
+ }
113
+ export const movementCategoryTagSchema = z
114
+ .string()
115
+ .trim()
116
+ .min(1)
117
+ .max(64)
118
+ .transform((value) => normalizeMovementCategoryTag(value))
119
+ .refine((value) => value.length > 0, {
120
+ message: "Movement category tags must contain letters or numbers."
121
+ });
122
+ const linkedEntitySchema = z.object({
123
+ entityType: z.string().trim().min(1),
124
+ entityId: z.string().trim().min(1),
125
+ label: z.string().trim().default("")
126
+ });
127
+ const linkedPersonSchema = z.object({
128
+ noteId: z.string().trim().min(1).optional(),
129
+ label: z.string().trim().min(1)
130
+ });
131
+ const movementPlaceInputSchema = z.object({
132
+ externalUid: z.string().trim().min(1).default(""),
133
+ label: z.string().trim().min(1),
134
+ aliases: z.array(z.string().trim()).default([]),
135
+ latitude: z.number().finite(),
136
+ longitude: z.number().finite(),
137
+ radiusMeters: z.number().positive().max(2000).default(100),
138
+ categoryTags: z.array(movementCategoryTagSchema).default([]).transform(canonicalizeMovementCategoryTags),
139
+ visibility: movementVisibilitySchema.default("shared"),
140
+ wikiNoteId: z.string().trim().min(1).nullable().default(null),
141
+ linkedEntities: z.array(linkedEntitySchema).default([]),
142
+ linkedPeople: z.array(linkedPersonSchema).default([]),
143
+ metadata: z.record(z.string(), z.unknown()).default({})
144
+ });
145
+ const movementStayInputSchema = z.object({
146
+ externalUid: z.string().trim().min(1),
147
+ label: z.string().trim().default(""),
148
+ status: z.string().trim().default("completed"),
149
+ classification: z.string().trim().default("stationary"),
150
+ startedAt: z.string().datetime(),
151
+ endedAt: z.string().datetime(),
152
+ centerLatitude: z.number().finite(),
153
+ centerLongitude: z.number().finite(),
154
+ radiusMeters: z.number().positive().default(100),
155
+ sampleCount: z.number().int().nonnegative().default(0),
156
+ placeExternalUid: z.string().trim().default(""),
157
+ placeLabel: z.string().trim().default(""),
158
+ tags: z.array(z.string().trim()).default([]),
159
+ metadata: z.record(z.string(), z.unknown()).default({})
160
+ });
161
+ const movementTripPointInputSchema = z.object({
162
+ externalUid: z.string().trim().default(""),
163
+ recordedAt: z.string().datetime(),
164
+ latitude: z.number().finite(),
165
+ longitude: z.number().finite(),
166
+ accuracyMeters: z.number().nonnegative().nullable().default(null),
167
+ altitudeMeters: z.number().nullable().default(null),
168
+ speedMps: z.number().nonnegative().nullable().default(null),
169
+ isStopAnchor: z.boolean().default(false)
170
+ });
171
+ const movementTripStopInputSchema = z.object({
172
+ externalUid: z.string().trim().default(""),
173
+ label: z.string().trim().default(""),
174
+ startedAt: z.string().datetime(),
175
+ endedAt: z.string().datetime(),
176
+ latitude: z.number().finite(),
177
+ longitude: z.number().finite(),
178
+ radiusMeters: z.number().positive().default(80),
179
+ placeExternalUid: z.string().trim().default(""),
180
+ metadata: z.record(z.string(), z.unknown()).default({})
181
+ });
182
+ const movementTripInputSchema = z.object({
183
+ externalUid: z.string().trim().min(1),
184
+ label: z.string().trim().default(""),
185
+ status: z.string().trim().default("completed"),
186
+ travelMode: z.string().trim().default("travel"),
187
+ activityType: z.string().trim().default(""),
188
+ startedAt: z.string().datetime(),
189
+ endedAt: z.string().datetime(),
190
+ startPlaceExternalUid: z.string().trim().default(""),
191
+ endPlaceExternalUid: z.string().trim().default(""),
192
+ distanceMeters: z.number().nonnegative().default(0),
193
+ movingSeconds: z.number().int().nonnegative().default(0),
194
+ idleSeconds: z.number().int().nonnegative().default(0),
195
+ averageSpeedMps: z.number().nonnegative().nullable().default(null),
196
+ maxSpeedMps: z.number().nonnegative().nullable().default(null),
197
+ caloriesKcal: z.number().nonnegative().nullable().default(null),
198
+ expectedMet: z.number().nonnegative().nullable().default(null),
199
+ tags: z.array(z.string().trim()).default([]),
200
+ linkedEntities: z.array(linkedEntitySchema).default([]),
201
+ linkedPeople: z.array(linkedPersonSchema).default([]),
202
+ metadata: z.record(z.string(), z.unknown()).default({}),
203
+ points: z.array(movementTripPointInputSchema).default([]),
204
+ stops: z.array(movementTripStopInputSchema).default([])
205
+ });
206
+ export const movementSettingsInputSchema = z.object({
207
+ trackingEnabled: z.boolean().default(false),
208
+ publishMode: movementPublishModeSchema.default("auto_publish"),
209
+ retentionMode: movementRetentionModeSchema.default("aggregates_only"),
210
+ locationPermissionStatus: z.string().trim().default("not_determined"),
211
+ motionPermissionStatus: z.string().trim().default("unknown"),
212
+ backgroundTrackingReady: z.boolean().default(false),
213
+ metadata: z.record(z.string(), z.unknown()).default({})
214
+ });
215
+ export const movementSyncPayloadSchema = z.object({
216
+ settings: movementSettingsInputSchema.default({}),
217
+ knownPlaces: z.array(movementPlaceInputSchema).default([]),
218
+ stays: z.array(movementStayInputSchema).default([]),
219
+ trips: z.array(movementTripInputSchema).default([])
220
+ });
221
+ export const movementPlaceMutationSchema = movementPlaceInputSchema.extend({
222
+ userId: z.string().trim().min(1).nullable().optional(),
223
+ source: z.string().trim().default("user")
224
+ });
225
+ export const movementPlacePatchSchema = movementPlaceInputSchema.partial();
226
+ export const movementSelectionAggregateSchema = z.object({
227
+ stayIds: z.array(z.string().trim().min(1)).default([]),
228
+ tripIds: z.array(z.string().trim().min(1)).default([]),
229
+ startedAt: z.string().datetime().optional(),
230
+ endedAt: z.string().datetime().optional(),
231
+ userIds: z.array(z.string().trim().min(1)).default([])
232
+ });
233
+ export const movementSettingsPatchSchema = movementSettingsInputSchema.partial();
234
+ export const movementTimelineQuerySchema = z.object({
235
+ before: z.string().trim().min(1).optional(),
236
+ limit: z.coerce.number().int().min(1).max(120).default(40),
237
+ includeInvalid: z.coerce.boolean().default(false),
238
+ userIds: z.array(z.string().trim().min(1)).default([])
239
+ });
240
+ export const movementStayPatchSchema = z.object({
241
+ label: z.string().trim().optional(),
242
+ status: z.string().trim().optional(),
243
+ classification: z.string().trim().optional(),
244
+ startedAt: z.string().datetime().optional(),
245
+ endedAt: z.string().datetime().optional(),
246
+ centerLatitude: z.number().finite().optional(),
247
+ centerLongitude: z.number().finite().optional(),
248
+ radiusMeters: z.number().positive().optional(),
249
+ sampleCount: z.number().int().nonnegative().optional(),
250
+ placeId: z.string().trim().min(1).nullable().optional(),
251
+ placeExternalUid: z.string().trim().min(1).nullable().optional(),
252
+ placeLabel: z.string().trim().optional(),
253
+ tags: z.array(z.string().trim()).optional(),
254
+ metadata: z.record(z.string(), z.unknown()).optional()
255
+ });
256
+ export const movementTripPatchSchema = z.object({
257
+ label: z.string().trim().optional(),
258
+ status: z.string().trim().optional(),
259
+ travelMode: z.string().trim().optional(),
260
+ activityType: z.string().trim().optional(),
261
+ startedAt: z.string().datetime().optional(),
262
+ endedAt: z.string().datetime().optional(),
263
+ startPlaceId: z.string().trim().min(1).nullable().optional(),
264
+ endPlaceId: z.string().trim().min(1).nullable().optional(),
265
+ startPlaceExternalUid: z.string().trim().min(1).nullable().optional(),
266
+ endPlaceExternalUid: z.string().trim().min(1).nullable().optional(),
267
+ distanceMeters: z.number().nonnegative().optional(),
268
+ movingSeconds: z.number().int().nonnegative().optional(),
269
+ idleSeconds: z.number().int().nonnegative().optional(),
270
+ averageSpeedMps: z.number().nonnegative().nullable().optional(),
271
+ maxSpeedMps: z.number().nonnegative().nullable().optional(),
272
+ caloriesKcal: z.number().nonnegative().nullable().optional(),
273
+ expectedMet: z.number().nonnegative().nullable().optional(),
274
+ tags: z.array(z.string().trim()).optional(),
275
+ metadata: z.record(z.string(), z.unknown()).optional()
276
+ });
277
+ export const movementTripPointPatchSchema = z.object({
278
+ recordedAt: z.string().datetime().optional(),
279
+ latitude: z.number().finite().optional(),
280
+ longitude: z.number().finite().optional(),
281
+ accuracyMeters: z.number().nonnegative().nullable().optional(),
282
+ altitudeMeters: z.number().nullable().optional(),
283
+ speedMps: z.number().nonnegative().nullable().optional(),
284
+ isStopAnchor: z.boolean().optional()
285
+ });
286
+ export const movementMobileBootstrapSchema = z.object({
287
+ sessionId: z.string().trim().min(1),
288
+ pairingToken: z.string().trim().min(1)
289
+ });
290
+ export const movementMobileTimelineSchema = movementMobileBootstrapSchema.extend({
291
+ before: z.string().trim().min(1).optional(),
292
+ limit: z.coerce.number().int().min(1).max(120).default(40)
293
+ });
294
+ export const movementMobilePlaceMutationSchema = movementMobileBootstrapSchema.extend({
295
+ place: movementPlaceMutationSchema.omit({ userId: true, source: true })
296
+ });
297
+ export const movementMobileStayPatchSchema = movementMobileBootstrapSchema.extend({
298
+ patch: movementStayPatchSchema
299
+ });
300
+ export const movementMobileTripPatchSchema = movementMobileBootstrapSchema.extend({
301
+ patch: movementTripPatchSchema
302
+ });
303
+ function nowIso() {
304
+ return new Date().toISOString();
305
+ }
306
+ function normalizeTripPointExternalUid(tripExternalUid, point, index) {
307
+ const explicit = point.externalUid.trim();
308
+ if (explicit.length > 0) {
309
+ return explicit;
310
+ }
311
+ return `${tripExternalUid}::${point.recordedAt}::${index}`;
312
+ }
313
+ function deriveTripMetricsFromPoints(points, current) {
314
+ if (points.length === 0) {
315
+ return {
316
+ startedAt: current.started_at,
317
+ endedAt: current.ended_at,
318
+ distanceMeters: 0,
319
+ movingSeconds: 0,
320
+ idleSeconds: 0,
321
+ averageSpeedMps: null,
322
+ maxSpeedMps: null
323
+ };
324
+ }
325
+ const sorted = [...points].sort((left, right) => Date.parse(left.recordedAt) - Date.parse(right.recordedAt));
326
+ let distanceMeters = 0;
327
+ let movingSeconds = 0;
328
+ let maxSpeedMps = 0;
329
+ const speedSamples = [];
330
+ for (let index = 1; index < sorted.length; index += 1) {
331
+ const previous = sorted[index - 1];
332
+ const next = sorted[index];
333
+ const elapsedSeconds = Math.max(0, Math.round((Date.parse(next.recordedAt) - Date.parse(previous.recordedAt)) / 1000));
334
+ const segmentDistance = haversineDistanceMeters({ latitude: previous.latitude, longitude: previous.longitude }, { latitude: next.latitude, longitude: next.longitude });
335
+ const inferredSpeed = elapsedSeconds > 0 ? segmentDistance / elapsedSeconds : 0;
336
+ distanceMeters += segmentDistance;
337
+ movingSeconds += elapsedSeconds;
338
+ maxSpeedMps = Math.max(maxSpeedMps, previous.speedMps ?? 0, next.speedMps ?? 0, inferredSpeed);
339
+ speedSamples.push(...(previous.speedMps != null ? [previous.speedMps] : []), ...(next.speedMps != null ? [next.speedMps] : []), ...(inferredSpeed > 0 ? [inferredSpeed] : []));
340
+ }
341
+ const startedAt = sorted[0].recordedAt;
342
+ const endedAt = sorted[sorted.length - 1].recordedAt;
343
+ const durationSeconds = Math.max(0, Math.round((Date.parse(endedAt) - Date.parse(startedAt)) / 1000));
344
+ return {
345
+ startedAt,
346
+ endedAt,
347
+ distanceMeters: round(distanceMeters, 2),
348
+ movingSeconds,
349
+ idleSeconds: Math.max(0, durationSeconds - movingSeconds),
350
+ averageSpeedMps: speedSamples.length > 0
351
+ ? round(speedSamples.reduce((sum, value) => sum + value, 0) /
352
+ speedSamples.length, 3)
353
+ : null,
354
+ maxSpeedMps: maxSpeedMps > 0 ? round(maxSpeedMps, 3) : null
355
+ };
356
+ }
357
+ function listMovementTripPointTombstones(userId, tripExternalUid) {
358
+ return getDatabase()
359
+ .prepare(`SELECT *
360
+ FROM movement_trip_point_tombstones
361
+ WHERE user_id = ?
362
+ AND trip_external_uid = ?`)
363
+ .all(userId, tripExternalUid);
364
+ }
365
+ function listMovementTripPointOverrides(userId, tripExternalUid) {
366
+ return getDatabase()
367
+ .prepare(`SELECT *
368
+ FROM movement_trip_point_overrides
369
+ WHERE user_id = ?
370
+ AND trip_external_uid = ?`)
371
+ .all(userId, tripExternalUid);
372
+ }
373
+ function listMovementStayTombstones(userId) {
374
+ return getDatabase()
375
+ .prepare(`SELECT *
376
+ FROM movement_stay_tombstones
377
+ WHERE user_id = ?`)
378
+ .all(userId);
379
+ }
380
+ function listMovementStayOverrides(userId) {
381
+ return getDatabase()
382
+ .prepare(`SELECT *
383
+ FROM movement_stay_overrides
384
+ WHERE user_id = ?`)
385
+ .all(userId);
386
+ }
387
+ function listMovementTripTombstones(userId) {
388
+ return getDatabase()
389
+ .prepare(`SELECT *
390
+ FROM movement_trip_tombstones
391
+ WHERE user_id = ?`)
392
+ .all(userId);
393
+ }
394
+ function listMovementTripOverrides(userId) {
395
+ return getDatabase()
396
+ .prepare(`SELECT *
397
+ FROM movement_trip_overrides
398
+ WHERE user_id = ?`)
399
+ .all(userId);
400
+ }
401
+ function applyMovementStaySyncDirectives(userId, stay) {
402
+ const tombstoned = new Set(listMovementStayTombstones(userId).map((row) => row.stay_external_uid));
403
+ if (tombstoned.has(stay.externalUid)) {
404
+ return null;
405
+ }
406
+ const override = listMovementStayOverrides(userId).find((row) => row.stay_external_uid === stay.externalUid);
407
+ if (!override) {
408
+ return stay;
409
+ }
410
+ return movementStayInputSchema.parse({
411
+ ...stay,
412
+ ...safeJsonParse(override.stay_json, {}),
413
+ externalUid: stay.externalUid
414
+ });
415
+ }
416
+ function applyMovementTripSyncDirectives(userId, trip) {
417
+ const tombstoned = new Set(listMovementTripTombstones(userId).map((row) => row.trip_external_uid));
418
+ if (tombstoned.has(trip.externalUid)) {
419
+ return null;
420
+ }
421
+ const override = listMovementTripOverrides(userId).find((row) => row.trip_external_uid === trip.externalUid);
422
+ if (!override) {
423
+ return trip;
424
+ }
425
+ return movementTripInputSchema.parse({
426
+ ...trip,
427
+ ...safeJsonParse(override.trip_json, {}),
428
+ externalUid: trip.externalUid
429
+ });
430
+ }
431
+ function applyTripPointSyncDirectives(input) {
432
+ const tombstonedExternalUids = new Set(listMovementTripPointTombstones(input.userId, input.tripExternalUid).map((row) => row.point_external_uid));
433
+ const overridesByExternalUid = new Map(listMovementTripPointOverrides(input.userId, input.tripExternalUid).map((row) => {
434
+ const parsed = safeJsonParse(row.point_json, {});
435
+ return [row.point_external_uid, parsed];
436
+ }));
437
+ return input.points
438
+ .map((point, index) => ({
439
+ ...point,
440
+ externalUid: normalizeTripPointExternalUid(input.tripExternalUid, point, index)
441
+ }))
442
+ .filter((point) => !tombstonedExternalUids.has(point.externalUid))
443
+ .map((point) => {
444
+ const override = overridesByExternalUid.get(point.externalUid);
445
+ if (!override) {
446
+ return point;
447
+ }
448
+ return movementTripPointInputSchema.parse({
449
+ ...point,
450
+ ...override,
451
+ externalUid: point.externalUid
452
+ });
453
+ });
454
+ }
455
+ function mapTripStopRowToInput(stop) {
456
+ const place = stop.place_id
457
+ ? getDatabase()
458
+ .prepare(`SELECT external_uid
459
+ FROM movement_places
460
+ WHERE id = ?`)
461
+ .get(stop.place_id)
462
+ : undefined;
463
+ return {
464
+ externalUid: stop.external_uid,
465
+ label: stop.label,
466
+ startedAt: stop.started_at,
467
+ endedAt: stop.ended_at,
468
+ latitude: stop.latitude,
469
+ longitude: stop.longitude,
470
+ radiusMeters: stop.radius_meters,
471
+ placeExternalUid: place?.external_uid ?? "",
472
+ metadata: safeJsonParse(stop.metadata_json, {})
473
+ };
474
+ }
475
+ function replaceTripPoints(tripId, tripExternalUid, points) {
476
+ getDatabase()
477
+ .prepare(`DELETE FROM movement_trip_points WHERE trip_id = ?`)
478
+ .run(tripId);
479
+ const pointInsert = getDatabase().prepare(`INSERT INTO movement_trip_points (
480
+ id, trip_id, external_uid, sequence_index, recorded_at, latitude, longitude,
481
+ accuracy_meters, altitude_meters, speed_mps, is_stop_anchor, created_at
482
+ )
483
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
484
+ const now = nowIso();
485
+ points.forEach((point, index) => {
486
+ const externalUid = normalizeTripPointExternalUid(tripExternalUid, point, index);
487
+ pointInsert.run(`mtp_${randomUUID().replaceAll("-", "").slice(0, 10)}`, tripId, externalUid, index, point.recordedAt, point.latitude, point.longitude, point.accuracyMeters, point.altitudeMeters, point.speedMps, point.isStopAnchor ? 1 : 0, now);
488
+ });
489
+ }
490
+ function refreshTripDerivedFields(tripId) {
491
+ const trip = getDatabase()
492
+ .prepare(`SELECT *
493
+ FROM movement_trips
494
+ WHERE id = ?`)
495
+ .get(tripId);
496
+ if (!trip) {
497
+ return undefined;
498
+ }
499
+ const points = listTripPoints([tripId]).map((point) => ({
500
+ externalUid: point.external_uid,
501
+ recordedAt: point.recorded_at,
502
+ latitude: point.latitude,
503
+ longitude: point.longitude,
504
+ accuracyMeters: point.accuracy_meters,
505
+ altitudeMeters: point.altitude_meters,
506
+ speedMps: point.speed_mps,
507
+ isStopAnchor: point.is_stop_anchor === 1
508
+ }));
509
+ const metrics = deriveTripMetricsFromPoints(points, trip);
510
+ const stops = listTripStops([tripId]);
511
+ const startPlace = resolvePlaceForCoordinates(trip.user_id, points[0]
512
+ ? {
513
+ latitude: points[0].latitude,
514
+ longitude: points[0].longitude
515
+ }
516
+ : stops[0]
517
+ ? {
518
+ latitude: stops[0].latitude,
519
+ longitude: stops[0].longitude
520
+ }
521
+ : {
522
+ latitude: 0,
523
+ longitude: 0
524
+ }) ?? resolvePlaceRowById(trip.user_id, trip.start_place_id);
525
+ const endPlace = resolvePlaceForCoordinates(trip.user_id, points[points.length - 1]
526
+ ? {
527
+ latitude: points[points.length - 1].latitude,
528
+ longitude: points[points.length - 1].longitude
529
+ }
530
+ : stops[stops.length - 1]
531
+ ? {
532
+ latitude: stops[stops.length - 1].latitude,
533
+ longitude: stops[stops.length - 1].longitude
534
+ }
535
+ : {
536
+ latitude: 0,
537
+ longitude: 0
538
+ }) ?? resolvePlaceRowById(trip.user_id, trip.end_place_id);
539
+ const expectedMet = inferExpectedMet(trip.activity_type, metrics.averageSpeedMps);
540
+ getDatabase()
541
+ .prepare(`UPDATE movement_trips
542
+ SET start_place_id = ?,
543
+ end_place_id = ?,
544
+ started_at = ?,
545
+ ended_at = ?,
546
+ distance_meters = ?,
547
+ moving_seconds = ?,
548
+ idle_seconds = ?,
549
+ average_speed_mps = ?,
550
+ max_speed_mps = ?,
551
+ expected_met = ?,
552
+ updated_at = ?
553
+ WHERE id = ?`)
554
+ .run(startPlace?.id ?? null, endPlace?.id ?? null, metrics.startedAt, metrics.endedAt, metrics.distanceMeters, metrics.movingSeconds, metrics.idleSeconds, metrics.averageSpeedMps, metrics.maxSpeedMps, expectedMet, nowIso(), tripId);
555
+ reconcileMovementOverlapValidation(trip.user_id);
556
+ return getDatabase()
557
+ .prepare(`SELECT *
558
+ FROM movement_trips
559
+ WHERE id = ?`)
560
+ .get(tripId);
561
+ }
562
+ function safeJsonParse(value, fallback) {
563
+ if (!value) {
564
+ return fallback;
565
+ }
566
+ try {
567
+ return JSON.parse(value);
568
+ }
569
+ catch {
570
+ return fallback;
571
+ }
572
+ }
573
+ function uniqStrings(values) {
574
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
575
+ }
576
+ function round(value, digits = 0) {
577
+ return Number(value.toFixed(digits));
578
+ }
579
+ function average(values) {
580
+ return values.length > 0
581
+ ? values.reduce((sum, value) => sum + value, 0) / values.length
582
+ : 0;
583
+ }
584
+ function durationSeconds(startedAt, endedAt) {
585
+ return Math.max(0, Math.round((Date.parse(endedAt) - Date.parse(startedAt)) / 1000));
586
+ }
587
+ function dayKey(value) {
588
+ return value.slice(0, 10);
589
+ }
590
+ function monthKey(value) {
591
+ return value.slice(0, 7);
592
+ }
593
+ function hasOwn(value, key) {
594
+ return Object.prototype.hasOwnProperty.call(value, key);
595
+ }
596
+ function encodeMovementTimelineCursor(cursor) {
597
+ return Buffer.from(JSON.stringify(cursor)).toString("base64url");
598
+ }
599
+ function decodeMovementTimelineCursor(rawValue) {
600
+ if (!rawValue) {
601
+ return null;
602
+ }
603
+ try {
604
+ const parsed = JSON.parse(Buffer.from(rawValue, "base64url").toString("utf8"));
605
+ if (typeof parsed.id !== "string" ||
606
+ typeof parsed.kind !== "string" ||
607
+ typeof parsed.startedAt !== "string" ||
608
+ typeof parsed.endedAt !== "string") {
609
+ return null;
610
+ }
611
+ if (parsed.kind !== "stay" && parsed.kind !== "trip") {
612
+ return null;
613
+ }
614
+ return parsed;
615
+ }
616
+ catch {
617
+ return null;
618
+ }
619
+ }
620
+ function haversineDistanceMeters(left, right) {
621
+ const toRadians = (degrees) => (degrees * Math.PI) / 180;
622
+ const earthRadius = 6_371_000;
623
+ const deltaLatitude = toRadians(right.latitude - left.latitude);
624
+ const deltaLongitude = toRadians(right.longitude - left.longitude);
625
+ const a = Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) +
626
+ Math.cos(toRadians(left.latitude)) *
627
+ Math.cos(toRadians(right.latitude)) *
628
+ Math.sin(deltaLongitude / 2) *
629
+ Math.sin(deltaLongitude / 2);
630
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
631
+ return earthRadius * c;
632
+ }
633
+ function estimateMovementXp(categoryTags, distanceMeters) {
634
+ const tags = new Set(canonicalizeMovementCategoryTags(categoryTags));
635
+ if (tags.has("holiday")) {
636
+ return 45;
637
+ }
638
+ if (tags.has("nature") || tags.has("forest") || tags.has("mountain")) {
639
+ return 28;
640
+ }
641
+ if (tags.has("social") || tags.has("bar")) {
642
+ return 22;
643
+ }
644
+ if (tags.has("grocery")) {
645
+ return 14;
646
+ }
647
+ if (tags.has("workplace") || tags.has("school")) {
648
+ return 6;
649
+ }
650
+ if (distanceMeters >= 10_000) {
651
+ return 18;
652
+ }
653
+ if (distanceMeters >= 3_000) {
654
+ return 10;
655
+ }
656
+ return 4;
657
+ }
658
+ function inferExpectedMet(activityType, averageSpeedMps) {
659
+ const normalized = activityType.trim().toLowerCase();
660
+ if (normalized.includes("bike") || normalized.includes("cycle")) {
661
+ return averageSpeedMps && averageSpeedMps > 5 ? 7.5 : 6.8;
662
+ }
663
+ if (normalized.includes("run")) {
664
+ return 8.5;
665
+ }
666
+ if (normalized.includes("walk")) {
667
+ return averageSpeedMps && averageSpeedMps > 1.8 ? 4.3 : 3.2;
668
+ }
669
+ if (averageSpeedMps && averageSpeedMps > 5) {
670
+ return 5.5;
671
+ }
672
+ if (averageSpeedMps && averageSpeedMps > 1.3) {
673
+ return 3.1;
674
+ }
675
+ return 1.8;
676
+ }
677
+ function defaultMovementSettings(userId) {
678
+ const now = nowIso();
679
+ return {
680
+ userId,
681
+ trackingEnabled: false,
682
+ publishMode: "auto_publish",
683
+ retentionMode: "aggregates_only",
684
+ locationPermissionStatus: "not_determined",
685
+ motionPermissionStatus: "unknown",
686
+ backgroundTrackingReady: false,
687
+ lastCompanionSyncAt: null,
688
+ metadata: {},
689
+ createdAt: now,
690
+ updatedAt: now
691
+ };
692
+ }
693
+ function mapMovementSettings(row) {
694
+ if (!row) {
695
+ return null;
696
+ }
697
+ return {
698
+ userId: row.user_id,
699
+ trackingEnabled: row.tracking_enabled === 1,
700
+ publishMode: row.publish_mode,
701
+ retentionMode: row.retention_mode,
702
+ locationPermissionStatus: row.location_permission_status,
703
+ motionPermissionStatus: row.motion_permission_status,
704
+ backgroundTrackingReady: row.background_tracking_ready === 1,
705
+ lastCompanionSyncAt: row.last_companion_sync_at,
706
+ metadata: safeJsonParse(row.metadata_json, {}),
707
+ createdAt: row.created_at,
708
+ updatedAt: row.updated_at
709
+ };
710
+ }
711
+ function mapMovementPlace(row) {
712
+ const linkedEntities = safeJsonParse(row.linked_entities_json, []);
713
+ const linkedPeople = safeJsonParse(row.linked_people_json, []);
714
+ const wikiNote = row.wiki_note_id ? getNoteById(row.wiki_note_id) : null;
715
+ return {
716
+ id: row.id,
717
+ externalUid: row.external_uid,
718
+ userId: row.user_id,
719
+ label: row.label,
720
+ aliases: safeJsonParse(row.aliases_json, []),
721
+ latitude: row.latitude,
722
+ longitude: row.longitude,
723
+ radiusMeters: row.radius_meters,
724
+ categoryTags: safeJsonParse(row.category_tags_json, []),
725
+ visibility: row.visibility,
726
+ wikiNoteId: row.wiki_note_id,
727
+ linkedEntities,
728
+ linkedPeople,
729
+ metadata: safeJsonParse(row.metadata_json, {}),
730
+ source: row.source,
731
+ createdAt: row.created_at,
732
+ updatedAt: row.updated_at,
733
+ wikiNote: wikiNote && !Array.isArray(wikiNote)
734
+ ? {
735
+ id: wikiNote.id,
736
+ title: wikiNote.title,
737
+ slug: wikiNote.slug
738
+ }
739
+ : null
740
+ };
741
+ }
742
+ function mapMovementStay(row, placesById) {
743
+ const note = row.published_note_id ? getNoteById(row.published_note_id) : null;
744
+ const metrics = safeJsonParse(row.metrics_json, {});
745
+ return {
746
+ id: row.id,
747
+ externalUid: row.external_uid,
748
+ pairingSessionId: row.pairing_session_id,
749
+ userId: row.user_id,
750
+ placeId: row.place_id,
751
+ label: row.label,
752
+ status: row.status,
753
+ classification: row.classification,
754
+ startedAt: row.started_at,
755
+ endedAt: row.ended_at,
756
+ durationSeconds: durationSeconds(row.started_at, row.ended_at),
757
+ centerLatitude: row.center_latitude,
758
+ centerLongitude: row.center_longitude,
759
+ radiusMeters: row.radius_meters,
760
+ sampleCount: row.sample_count,
761
+ weather: safeJsonParse(row.weather_json, {}),
762
+ metrics,
763
+ metadata: safeJsonParse(row.metadata_json, {}),
764
+ publishedNoteId: row.published_note_id,
765
+ createdAt: row.created_at,
766
+ updatedAt: row.updated_at,
767
+ place: row.place_id ? placesById.get(row.place_id) ?? null : null,
768
+ note: note && !Array.isArray(note)
769
+ ? {
770
+ id: note.id,
771
+ title: note.title,
772
+ slug: note.slug
773
+ }
774
+ : null
775
+ };
776
+ }
777
+ function mapMovementTrip(row, placesById, points = [], stops = []) {
778
+ const note = row.published_note_id ? getNoteById(row.published_note_id) : null;
779
+ return {
780
+ id: row.id,
781
+ externalUid: row.external_uid,
782
+ pairingSessionId: row.pairing_session_id,
783
+ userId: row.user_id,
784
+ startPlaceId: row.start_place_id,
785
+ endPlaceId: row.end_place_id,
786
+ label: row.label,
787
+ status: row.status,
788
+ travelMode: row.travel_mode,
789
+ activityType: row.activity_type,
790
+ startedAt: row.started_at,
791
+ endedAt: row.ended_at,
792
+ durationSeconds: durationSeconds(row.started_at, row.ended_at),
793
+ distanceMeters: row.distance_meters,
794
+ movingSeconds: row.moving_seconds,
795
+ idleSeconds: row.idle_seconds,
796
+ averageSpeedMps: row.average_speed_mps,
797
+ maxSpeedMps: row.max_speed_mps,
798
+ caloriesKcal: row.calories_kcal,
799
+ expectedMet: row.expected_met,
800
+ weather: safeJsonParse(row.weather_json, {}),
801
+ tags: safeJsonParse(row.tags_json, []),
802
+ linkedEntities: safeJsonParse(row.linked_entities_json, []),
803
+ linkedPeople: safeJsonParse(row.linked_people_json, []),
804
+ metadata: safeJsonParse(row.metadata_json, {}),
805
+ publishedNoteId: row.published_note_id,
806
+ createdAt: row.created_at,
807
+ updatedAt: row.updated_at,
808
+ startPlace: row.start_place_id ? placesById.get(row.start_place_id) ?? null : null,
809
+ endPlace: row.end_place_id ? placesById.get(row.end_place_id) ?? null : null,
810
+ points: points.map((point) => ({
811
+ id: point.id,
812
+ externalUid: point.external_uid,
813
+ recordedAt: point.recorded_at,
814
+ latitude: point.latitude,
815
+ longitude: point.longitude,
816
+ accuracyMeters: point.accuracy_meters,
817
+ altitudeMeters: point.altitude_meters,
818
+ speedMps: point.speed_mps,
819
+ isStopAnchor: point.is_stop_anchor === 1
820
+ })),
821
+ stops: stops.map((stop) => ({
822
+ id: stop.id,
823
+ externalUid: stop.external_uid,
824
+ sequenceIndex: stop.sequence_index,
825
+ label: stop.label,
826
+ placeId: stop.place_id,
827
+ startedAt: stop.started_at,
828
+ endedAt: stop.ended_at,
829
+ durationSeconds: stop.duration_seconds,
830
+ latitude: stop.latitude,
831
+ longitude: stop.longitude,
832
+ radiusMeters: stop.radius_meters,
833
+ metadata: safeJsonParse(stop.metadata_json, {}),
834
+ place: stop.place_id ? placesById.get(stop.place_id) ?? null : null
835
+ })),
836
+ note: note && !Array.isArray(note)
837
+ ? {
838
+ id: note.id,
839
+ title: note.title,
840
+ slug: note.slug
841
+ }
842
+ : null
843
+ };
844
+ }
845
+ function getMovementSettingsRow(userId) {
846
+ return getDatabase()
847
+ .prepare(`SELECT *
848
+ FROM movement_settings
849
+ WHERE user_id = ?`)
850
+ .get(userId);
851
+ }
852
+ function ensureMovementSettings(userId) {
853
+ const existing = getMovementSettingsRow(userId);
854
+ if (existing) {
855
+ return existing;
856
+ }
857
+ const now = nowIso();
858
+ getDatabase()
859
+ .prepare(`INSERT INTO movement_settings (
860
+ user_id, tracking_enabled, publish_mode, retention_mode,
861
+ location_permission_status, motion_permission_status,
862
+ background_tracking_ready, last_companion_sync_at, metadata_json,
863
+ created_at, updated_at
864
+ )
865
+ VALUES (?, 0, 'auto_publish', 'aggregates_only', 'not_determined', 'unknown', 0, NULL, '{}', ?, ?)`)
866
+ .run(userId, now, now);
867
+ return getMovementSettingsRow(userId);
868
+ }
869
+ function listMovementPlaceRows(userIds) {
870
+ const params = [];
871
+ const where = userIds && userIds.length > 0
872
+ ? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
873
+ : "";
874
+ if (userIds) {
875
+ params.push(...userIds);
876
+ }
877
+ return getDatabase()
878
+ .prepare(`SELECT *
879
+ FROM movement_places
880
+ ${where}
881
+ ORDER BY label COLLATE NOCASE ASC`)
882
+ .all(...params);
883
+ }
884
+ function listMovementStayRows(userIds, dateKey) {
885
+ const params = [];
886
+ const whereClauses = [];
887
+ if (userIds && userIds.length > 0) {
888
+ whereClauses.push(`user_id IN (${userIds.map(() => "?").join(",")})`);
889
+ params.push(...userIds);
890
+ }
891
+ if (dateKey) {
892
+ whereClauses.push("substr(started_at, 1, 10) <= ? AND substr(ended_at, 1, 10) >= ?");
893
+ params.push(dateKey, dateKey);
894
+ }
895
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
896
+ return getDatabase()
897
+ .prepare(`SELECT *
898
+ FROM movement_stays
899
+ ${where}
900
+ ORDER BY started_at DESC`)
901
+ .all(...params);
902
+ }
903
+ function listMovementTripRows(userIds, options = {}) {
904
+ const params = [];
905
+ const whereClauses = [];
906
+ if (userIds && userIds.length > 0) {
907
+ whereClauses.push(`user_id IN (${userIds.map(() => "?").join(",")})`);
908
+ params.push(...userIds);
909
+ }
910
+ if (options.dateKey) {
911
+ whereClauses.push("substr(started_at, 1, 10) <= ? AND substr(ended_at, 1, 10) >= ?");
912
+ params.push(options.dateKey, options.dateKey);
913
+ }
914
+ if (options.month) {
915
+ whereClauses.push("substr(started_at, 1, 7) = ?");
916
+ params.push(options.month);
917
+ }
918
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
919
+ return getDatabase()
920
+ .prepare(`SELECT *
921
+ FROM movement_trips
922
+ ${where}
923
+ ORDER BY started_at DESC`)
924
+ .all(...params);
925
+ }
926
+ function listTripPoints(tripIds) {
927
+ if (tripIds.length === 0) {
928
+ return [];
929
+ }
930
+ const placeholders = tripIds.map(() => "?").join(", ");
931
+ return getDatabase()
932
+ .prepare(`SELECT *
933
+ FROM movement_trip_points
934
+ WHERE trip_id IN (${placeholders})
935
+ ORDER BY trip_id ASC, sequence_index ASC`)
936
+ .all(...tripIds);
937
+ }
938
+ function listTripStops(tripIds) {
939
+ if (tripIds.length === 0) {
940
+ return [];
941
+ }
942
+ const placeholders = tripIds.map(() => "?").join(", ");
943
+ return getDatabase()
944
+ .prepare(`SELECT *
945
+ FROM movement_trip_stops
946
+ WHERE trip_id IN (${placeholders})
947
+ ORDER BY trip_id ASC, sequence_index ASC`)
948
+ .all(...tripIds);
949
+ }
950
+ function defaultSpaceId() {
951
+ return listWikiSpaces()[0]?.id;
952
+ }
953
+ function syncPlaceWikiMetadata(placeId) {
954
+ const row = getDatabase()
955
+ .prepare(`SELECT *
956
+ FROM movement_places
957
+ WHERE id = ?`)
958
+ .get(placeId);
959
+ if (!row?.wiki_note_id) {
960
+ return;
961
+ }
962
+ const note = getNoteById(row.wiki_note_id);
963
+ if (!note || Array.isArray(note)) {
964
+ return;
965
+ }
966
+ updateNote(row.wiki_note_id, {
967
+ frontmatter: {
968
+ ...note.frontmatter,
969
+ location: {
970
+ latitude: row.latitude,
971
+ longitude: row.longitude,
972
+ radiusMeters: row.radius_meters
973
+ },
974
+ locationTags: safeJsonParse(row.category_tags_json, [])
975
+ }
976
+ }, { actor: "Movement sync", source: "system" });
977
+ }
978
+ function resolvePlaceForCoordinates(userId, point, preferredExternalUid = "") {
979
+ const rows = listMovementPlaceRows([userId]);
980
+ if (preferredExternalUid.trim().length > 0) {
981
+ const direct = rows.find((row) => row.external_uid.trim().length > 0 &&
982
+ row.external_uid === preferredExternalUid);
983
+ if (direct) {
984
+ return direct;
985
+ }
986
+ }
987
+ let best = null;
988
+ for (const row of rows) {
989
+ const distance = haversineDistanceMeters(point, {
990
+ latitude: row.latitude,
991
+ longitude: row.longitude
992
+ });
993
+ if (distance <= Math.max(100, row.radius_meters)) {
994
+ if (!best || distance < best.distance) {
995
+ best = { row, distance };
996
+ }
997
+ }
998
+ }
999
+ return best?.row;
1000
+ }
1001
+ function rangesOverlap(leftStartedAt, leftEndedAt, rightStartedAt, rightEndedAt) {
1002
+ return leftStartedAt < rightEndedAt && rightStartedAt < leftEndedAt;
1003
+ }
1004
+ function movementValidation(metadata) {
1005
+ const validation = metadata.validation &&
1006
+ typeof metadata.validation === "object" &&
1007
+ !Array.isArray(metadata.validation)
1008
+ ? metadata.validation
1009
+ : null;
1010
+ return validation;
1011
+ }
1012
+ function hasInvalidMovementRecord(metadata) {
1013
+ const validation = movementValidation(metadata);
1014
+ return (validation?.invalid === true ||
1015
+ validation?.invalidOverlap === true ||
1016
+ validation?.invalidTinyMove === true);
1017
+ }
1018
+ function reconcileMovementOverlapValidation(userId) {
1019
+ const stayRows = listMovementStayRows([userId]);
1020
+ const tripRows = listMovementTripRows([userId]);
1021
+ const entries = [
1022
+ ...stayRows.map((row) => ({
1023
+ id: row.id,
1024
+ kind: "stay",
1025
+ startedAt: row.started_at,
1026
+ endedAt: row.ended_at,
1027
+ metadataJson: row.metadata_json,
1028
+ label: row.label || "stay"
1029
+ })),
1030
+ ...tripRows.map((row) => ({
1031
+ id: row.id,
1032
+ kind: "trip",
1033
+ startedAt: row.started_at,
1034
+ endedAt: row.ended_at,
1035
+ metadataJson: row.metadata_json,
1036
+ label: row.label || "trip"
1037
+ }))
1038
+ ].sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
1039
+ left.endedAt.localeCompare(right.endedAt) ||
1040
+ left.kind.localeCompare(right.kind) ||
1041
+ left.id.localeCompare(right.id));
1042
+ const overlapIssuesByKey = new Map();
1043
+ for (let index = 0; index < entries.length; index += 1) {
1044
+ const current = entries[index];
1045
+ for (let nextIndex = index + 1; nextIndex < entries.length; nextIndex += 1) {
1046
+ const next = entries[nextIndex];
1047
+ if (next.startedAt >= current.endedAt) {
1048
+ break;
1049
+ }
1050
+ if (!rangesOverlap(current.startedAt, current.endedAt, next.startedAt, next.endedAt)) {
1051
+ continue;
1052
+ }
1053
+ const currentKey = `${current.kind}:${current.id}`;
1054
+ const nextKey = `${next.kind}:${next.id}`;
1055
+ const currentIssues = overlapIssuesByKey.get(currentKey) ?? [];
1056
+ currentIssues.push(`Overlaps ${next.kind} ${next.id} from ${next.startedAt} to ${next.endedAt}.`);
1057
+ overlapIssuesByKey.set(currentKey, currentIssues);
1058
+ const nextIssues = overlapIssuesByKey.get(nextKey) ?? [];
1059
+ nextIssues.push(`Overlaps ${current.kind} ${current.id} from ${current.startedAt} to ${current.endedAt}.`);
1060
+ overlapIssuesByKey.set(nextKey, nextIssues);
1061
+ }
1062
+ }
1063
+ const now = nowIso();
1064
+ stayRows.forEach((row) => {
1065
+ const metadata = safeJsonParse(row.metadata_json, {});
1066
+ const issues = overlapIssuesByKey.get(`stay:${row.id}`) ?? [];
1067
+ const validation = {
1068
+ ...(metadata.validation &&
1069
+ typeof metadata.validation === "object" &&
1070
+ !Array.isArray(metadata.validation)
1071
+ ? metadata.validation
1072
+ : {}),
1073
+ invalidOverlap: issues.length > 0,
1074
+ overlapIssues: issues,
1075
+ checkedAt: now
1076
+ };
1077
+ const nextMetadata = {
1078
+ ...metadata,
1079
+ validation
1080
+ };
1081
+ if (JSON.stringify(nextMetadata) !== JSON.stringify(metadata)) {
1082
+ getDatabase()
1083
+ .prepare(`UPDATE movement_stays
1084
+ SET metadata_json = ?, updated_at = ?
1085
+ WHERE id = ?`)
1086
+ .run(JSON.stringify(nextMetadata), now, row.id);
1087
+ }
1088
+ });
1089
+ tripRows.forEach((row) => {
1090
+ const metadata = safeJsonParse(row.metadata_json, {});
1091
+ const issues = overlapIssuesByKey.get(`trip:${row.id}`) ?? [];
1092
+ const tinyMoveIssues = [
1093
+ row.distance_meters < 100
1094
+ ? `Distance ${Math.round(row.distance_meters)}m is below the 100m minimum for a valid move.`
1095
+ : null,
1096
+ durationSeconds(row.started_at, row.ended_at) < 5 * 60
1097
+ ? `Duration ${durationSeconds(row.started_at, row.ended_at)}s is below the 5 minute minimum for a valid move.`
1098
+ : null
1099
+ ].filter((value) => Boolean(value));
1100
+ const validation = {
1101
+ ...(metadata.validation &&
1102
+ typeof metadata.validation === "object" &&
1103
+ !Array.isArray(metadata.validation)
1104
+ ? metadata.validation
1105
+ : {}),
1106
+ invalid: issues.length > 0 || tinyMoveIssues.length > 0,
1107
+ invalidOverlap: issues.length > 0,
1108
+ invalidTinyMove: tinyMoveIssues.length > 0,
1109
+ overlapIssues: issues,
1110
+ tinyMoveIssues,
1111
+ checkedAt: now
1112
+ };
1113
+ const nextMetadata = {
1114
+ ...metadata,
1115
+ validation
1116
+ };
1117
+ if (JSON.stringify(nextMetadata) !== JSON.stringify(metadata)) {
1118
+ getDatabase()
1119
+ .prepare(`UPDATE movement_trips
1120
+ SET metadata_json = ?, updated_at = ?
1121
+ WHERE id = ?`)
1122
+ .run(JSON.stringify(nextMetadata), now, row.id);
1123
+ }
1124
+ });
1125
+ }
1126
+ function resolvePlaceRowById(userId, placeId) {
1127
+ if (!placeId) {
1128
+ return null;
1129
+ }
1130
+ return (getDatabase()
1131
+ .prepare(`SELECT *
1132
+ FROM movement_places
1133
+ WHERE id = ?
1134
+ AND user_id = ?`)
1135
+ .get(placeId, userId) ?? null);
1136
+ }
1137
+ function resolvePlaceForPatch(input) {
1138
+ if (input.explicitPlaceId !== undefined) {
1139
+ return resolvePlaceRowById(input.userId, input.explicitPlaceId);
1140
+ }
1141
+ if (input.explicitPlaceExternalUid !== undefined) {
1142
+ return input.explicitPlaceExternalUid
1143
+ ? resolvePlaceForCoordinates(input.userId, input.fallbackCoordinates, input.explicitPlaceExternalUid) ?? null
1144
+ : null;
1145
+ }
1146
+ return undefined;
1147
+ }
1148
+ function createMovementNote(input) {
1149
+ const spaceId = defaultSpaceId();
1150
+ if (!spaceId) {
1151
+ return null;
1152
+ }
1153
+ return createNote({
1154
+ kind: "evidence",
1155
+ title: input.title,
1156
+ slug: "",
1157
+ summary: "",
1158
+ contentMarkdown: input.contentMarkdown,
1159
+ spaceId,
1160
+ parentSlug: null,
1161
+ indexOrder: 0,
1162
+ showInIndex: false,
1163
+ aliases: [],
1164
+ userId: input.userId,
1165
+ author: null,
1166
+ links: [],
1167
+ tags: input.tags,
1168
+ destroyAt: null,
1169
+ sourcePath: "",
1170
+ frontmatter: input.frontmatter,
1171
+ revisionHash: ""
1172
+ }, { actor: "Movement sync", source: "system" });
1173
+ }
1174
+ function formatMovementDurationForNote(valueSeconds) {
1175
+ if (valueSeconds >= 86_400) {
1176
+ return `${round(valueSeconds / 86_400, 1)} days`;
1177
+ }
1178
+ if (valueSeconds >= 3_600) {
1179
+ return `${round(valueSeconds / 3_600, 1)} hours`;
1180
+ }
1181
+ return `${Math.max(1, Math.round(valueSeconds / 60))} minutes`;
1182
+ }
1183
+ function mergeMovementNoteTags(existingTags, existingFrontmatter, generatedTags) {
1184
+ const movement = existingFrontmatter.movement &&
1185
+ typeof existingFrontmatter.movement === "object" &&
1186
+ !Array.isArray(existingFrontmatter.movement)
1187
+ ? existingFrontmatter.movement
1188
+ : null;
1189
+ const previousGeneratedTags = Array.isArray(movement?.generatedTags)
1190
+ ? movement.generatedTags.filter((value) => typeof value === "string")
1191
+ : [];
1192
+ const previousGeneratedTagSet = new Set(previousGeneratedTags.map((tag) => tag.toLowerCase()));
1193
+ const preservedTags = existingTags.filter((tag) => !previousGeneratedTagSet.has(tag.toLowerCase()));
1194
+ return uniqStrings([...preservedTags, ...generatedTags]);
1195
+ }
1196
+ function syncMovementNote(input) {
1197
+ const existingNote = input.publishedNoteId
1198
+ ? getNoteById(input.publishedNoteId)
1199
+ : null;
1200
+ if (existingNote && !Array.isArray(existingNote)) {
1201
+ const updated = updateNote(existingNote.id, {
1202
+ title: input.title,
1203
+ contentMarkdown: input.contentMarkdown,
1204
+ tags: mergeMovementNoteTags(existingNote.tags ?? [], existingNote.frontmatter, input.generatedTags),
1205
+ frontmatter: {
1206
+ ...existingNote.frontmatter,
1207
+ ...input.frontmatter
1208
+ }
1209
+ }, { actor: "Movement sync", source: "system" });
1210
+ return updated?.id ?? existingNote.id;
1211
+ }
1212
+ const created = createMovementNote({
1213
+ userId: input.userId,
1214
+ title: input.title,
1215
+ contentMarkdown: input.contentMarkdown,
1216
+ tags: input.generatedTags,
1217
+ frontmatter: input.frontmatter
1218
+ });
1219
+ return created?.id ?? null;
1220
+ }
1221
+ function syncStayNote(settings, stay, place) {
1222
+ if (!settings || settings.publishMode === "no_publish") {
1223
+ return null;
1224
+ }
1225
+ const label = place?.label || stay.label || "Unlabeled stay";
1226
+ const durationSecondsValue = durationSeconds(stay.started_at, stay.ended_at);
1227
+ const live = stay.status.trim().toLowerCase() !== "completed" &&
1228
+ stay.status.trim().toLowerCase() !== "closed";
1229
+ const generatedTags = uniqStrings([
1230
+ "movement",
1231
+ "stay",
1232
+ ...(place ? safeJsonParse(place.category_tags_json, []) : [])
1233
+ ]);
1234
+ const content = [
1235
+ live ? `Currently staying at **${label}**.` : `Stayed at **${label}**.`,
1236
+ "",
1237
+ `- Started: ${stay.started_at}`,
1238
+ `- ${live ? "Current end" : "Ended"}: ${stay.ended_at}`,
1239
+ `- Duration: ${formatMovementDurationForNote(durationSecondsValue)}`,
1240
+ `- Radius: ${Math.round(stay.radius_meters)} m`,
1241
+ `- Classification: ${stay.classification || "stationary"}`
1242
+ ].join("\n");
1243
+ return syncMovementNote({
1244
+ userId: stay.user_id,
1245
+ publishedNoteId: stay.published_note_id,
1246
+ title: `Stay · ${label}`,
1247
+ contentMarkdown: content,
1248
+ generatedTags,
1249
+ frontmatter: {
1250
+ observedAt: stay.started_at,
1251
+ movement: {
1252
+ kind: "stay",
1253
+ state: live ? "live" : "closed",
1254
+ stayId: stay.id,
1255
+ publishMode: settings.publishMode,
1256
+ placeId: place?.id ?? null,
1257
+ placeLabel: label,
1258
+ startedAt: stay.started_at,
1259
+ endedAt: stay.ended_at,
1260
+ durationSeconds: durationSecondsValue,
1261
+ generatedTags
1262
+ }
1263
+ }
1264
+ });
1265
+ }
1266
+ function syncTripNote(settings, trip, startPlace, endPlace) {
1267
+ if (!settings || settings.publishMode === "no_publish") {
1268
+ return null;
1269
+ }
1270
+ const startLabel = startPlace?.label || "Unknown start";
1271
+ const endLabel = endPlace?.label || "Unknown end";
1272
+ const durationSecondsValue = durationSeconds(trip.started_at, trip.ended_at);
1273
+ const distanceKm = round(trip.distance_meters / 1000, 2);
1274
+ const live = trip.status.trim().toLowerCase() !== "completed" &&
1275
+ trip.status.trim().toLowerCase() !== "closed";
1276
+ const generatedTags = uniqStrings([
1277
+ "movement",
1278
+ "trip",
1279
+ ...safeJsonParse(trip.tags_json, [])
1280
+ ]);
1281
+ const content = [
1282
+ live
1283
+ ? `Currently moving from **${startLabel}** to **${endLabel}**.`
1284
+ : `Travelled from **${startLabel}** to **${endLabel}**.`,
1285
+ "",
1286
+ `- Started: ${trip.started_at}`,
1287
+ `- ${live ? "Current end" : "Ended"}: ${trip.ended_at}`,
1288
+ `- Duration: ${formatMovementDurationForNote(durationSecondsValue)}`,
1289
+ `- Distance: ${distanceKm} km`,
1290
+ `- Activity: ${trip.activity_type || trip.travel_mode}`
1291
+ ].join("\n");
1292
+ return syncMovementNote({
1293
+ userId: trip.user_id,
1294
+ publishedNoteId: trip.published_note_id,
1295
+ title: `Trip · ${startLabel} → ${endLabel}`,
1296
+ contentMarkdown: content,
1297
+ generatedTags,
1298
+ frontmatter: {
1299
+ observedAt: trip.started_at,
1300
+ movement: {
1301
+ kind: "trip",
1302
+ state: live ? "live" : "closed",
1303
+ tripId: trip.id,
1304
+ publishMode: settings.publishMode,
1305
+ startPlaceId: startPlace?.id ?? null,
1306
+ endPlaceId: endPlace?.id ?? null,
1307
+ startPlaceLabel: startLabel,
1308
+ endPlaceLabel: endLabel,
1309
+ startedAt: trip.started_at,
1310
+ endedAt: trip.ended_at,
1311
+ durationSeconds: durationSecondsValue,
1312
+ distanceMeters: trip.distance_meters,
1313
+ generatedTags
1314
+ }
1315
+ }
1316
+ });
1317
+ }
1318
+ function awardMovementXp(input) {
1319
+ const deltaXp = estimateMovementXp(input.categoryTags, input.distanceMeters);
1320
+ if (deltaXp <= 0) {
1321
+ return null;
1322
+ }
1323
+ return createManualRewardGrant({
1324
+ entityType: "system",
1325
+ entityId: input.entityId,
1326
+ deltaXp,
1327
+ reasonTitle: input.title,
1328
+ reasonSummary: `Movement activity reward for ${input.categoryTags.join(", ") || "general mobility"}.`,
1329
+ metadata: {}
1330
+ }, { actor: "Movement sync", source: "system" });
1331
+ }
1332
+ function upsertMovementSettings(userId, input) {
1333
+ const parsed = movementSettingsInputSchema.parse(input);
1334
+ const now = nowIso();
1335
+ getDatabase()
1336
+ .prepare(`INSERT INTO movement_settings (
1337
+ user_id, tracking_enabled, publish_mode, retention_mode,
1338
+ location_permission_status, motion_permission_status,
1339
+ background_tracking_ready, last_companion_sync_at, metadata_json,
1340
+ created_at, updated_at
1341
+ )
1342
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1343
+ ON CONFLICT(user_id) DO UPDATE SET
1344
+ tracking_enabled = excluded.tracking_enabled,
1345
+ publish_mode = excluded.publish_mode,
1346
+ retention_mode = excluded.retention_mode,
1347
+ location_permission_status = excluded.location_permission_status,
1348
+ motion_permission_status = excluded.motion_permission_status,
1349
+ background_tracking_ready = excluded.background_tracking_ready,
1350
+ last_companion_sync_at = excluded.last_companion_sync_at,
1351
+ metadata_json = excluded.metadata_json,
1352
+ updated_at = excluded.updated_at`)
1353
+ .run(userId, parsed.trackingEnabled ? 1 : 0, parsed.publishMode, parsed.retentionMode, parsed.locationPermissionStatus, parsed.motionPermissionStatus, parsed.backgroundTrackingReady ? 1 : 0, now, JSON.stringify(parsed.metadata), now, now);
1354
+ return mapMovementSettings(getMovementSettingsRow(userId));
1355
+ }
1356
+ function upsertMovementPlaceInternal(input) {
1357
+ const parsed = movementPlaceInputSchema.parse(input.place);
1358
+ const now = nowIso();
1359
+ const existing = input.id && input.id.trim().length > 0
1360
+ ? getDatabase()
1361
+ .prepare(`SELECT *
1362
+ FROM movement_places
1363
+ WHERE id = ?`)
1364
+ .get(input.id)
1365
+ : parsed.externalUid.trim().length > 0
1366
+ ? getDatabase()
1367
+ .prepare(`SELECT *
1368
+ FROM movement_places
1369
+ WHERE user_id = ?
1370
+ AND source = ?
1371
+ AND external_uid = ?`)
1372
+ .get(input.userId, input.source, parsed.externalUid)
1373
+ : undefined;
1374
+ const id = existing?.id ?? input.id ?? `mpl_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1375
+ getDatabase()
1376
+ .prepare(`INSERT INTO movement_places (
1377
+ id, external_uid, user_id, label, aliases_json, latitude, longitude,
1378
+ radius_meters, category_tags_json, visibility, wiki_note_id,
1379
+ linked_entities_json, linked_people_json, metadata_json, source,
1380
+ created_at, updated_at
1381
+ )
1382
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1383
+ ON CONFLICT(id) DO UPDATE SET
1384
+ external_uid = excluded.external_uid,
1385
+ label = excluded.label,
1386
+ aliases_json = excluded.aliases_json,
1387
+ latitude = excluded.latitude,
1388
+ longitude = excluded.longitude,
1389
+ radius_meters = excluded.radius_meters,
1390
+ category_tags_json = excluded.category_tags_json,
1391
+ visibility = excluded.visibility,
1392
+ wiki_note_id = excluded.wiki_note_id,
1393
+ linked_entities_json = excluded.linked_entities_json,
1394
+ linked_people_json = excluded.linked_people_json,
1395
+ metadata_json = excluded.metadata_json,
1396
+ source = excluded.source,
1397
+ updated_at = excluded.updated_at`)
1398
+ .run(id, parsed.externalUid, input.userId, parsed.label, JSON.stringify(uniqStrings(parsed.aliases)), parsed.latitude, parsed.longitude, parsed.radiusMeters, JSON.stringify(canonicalizeMovementCategoryTags(parsed.categoryTags)), parsed.visibility, parsed.wikiNoteId, JSON.stringify(parsed.linkedEntities), JSON.stringify(parsed.linkedPeople), JSON.stringify(parsed.metadata), input.source, existing?.created_at ?? now, now);
1399
+ syncPlaceWikiMetadata(id);
1400
+ return mapMovementPlace(getDatabase()
1401
+ .prepare(`SELECT * FROM movement_places WHERE id = ?`)
1402
+ .get(id));
1403
+ }
1404
+ function replaceTripChildren(tripId, tripExternalUid, points, stops, userId) {
1405
+ replaceTripPoints(tripId, tripExternalUid, points);
1406
+ getDatabase()
1407
+ .prepare(`DELETE FROM movement_trip_stops WHERE trip_id = ?`)
1408
+ .run(tripId);
1409
+ const stopInsert = getDatabase().prepare(`INSERT INTO movement_trip_stops (
1410
+ id, external_uid, trip_id, sequence_index, label, place_id,
1411
+ started_at, ended_at, duration_seconds, latitude, longitude,
1412
+ radius_meters, metadata_json, created_at, updated_at
1413
+ )
1414
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1415
+ const now = nowIso();
1416
+ stops.forEach((stop, index) => {
1417
+ const matchedPlace = resolvePlaceForCoordinates(userId, { latitude: stop.latitude, longitude: stop.longitude }, stop.placeExternalUid);
1418
+ stopInsert.run(`mts_${randomUUID().replaceAll("-", "").slice(0, 10)}`, stop.externalUid, tripId, index, stop.label, matchedPlace?.id ?? null, stop.startedAt, stop.endedAt, durationSeconds(stop.startedAt, stop.endedAt), stop.latitude, stop.longitude, stop.radiusMeters, JSON.stringify(stop.metadata), now, now);
1419
+ });
1420
+ }
1421
+ function cleanupRawTripPoints(userId) {
1422
+ const staleTripRows = getDatabase()
1423
+ .prepare(`SELECT id
1424
+ FROM movement_trips
1425
+ WHERE user_id = ?
1426
+ AND ended_at <= datetime('now', '-30 day')`)
1427
+ .all(userId);
1428
+ for (const row of staleTripRows) {
1429
+ getDatabase()
1430
+ .prepare(`DELETE FROM movement_trip_points
1431
+ WHERE trip_id = ?
1432
+ AND is_stop_anchor = 0
1433
+ AND sequence_index NOT IN (
1434
+ SELECT MIN(sequence_index) FROM movement_trip_points WHERE trip_id = ?
1435
+ UNION
1436
+ SELECT MAX(sequence_index) FROM movement_trip_points WHERE trip_id = ?
1437
+ )`)
1438
+ .run(row.id, row.id, row.id);
1439
+ }
1440
+ }
1441
+ function upsertMovementStay(pairing, settings, input) {
1442
+ const parsed = movementStayInputSchema.parse(input);
1443
+ const existing = getDatabase()
1444
+ .prepare(`SELECT *
1445
+ FROM movement_stays
1446
+ WHERE user_id = ?
1447
+ AND external_uid = ?`)
1448
+ .get(pairing.user_id, parsed.externalUid);
1449
+ const now = nowIso();
1450
+ const matchedPlace = resolvePlaceForCoordinates(pairing.user_id, {
1451
+ latitude: parsed.centerLatitude,
1452
+ longitude: parsed.centerLongitude
1453
+ }, parsed.placeExternalUid);
1454
+ const metrics = {
1455
+ tags: uniqStrings(parsed.tags),
1456
+ durationSeconds: durationSeconds(parsed.startedAt, parsed.endedAt)
1457
+ };
1458
+ const id = existing?.id ?? `mst_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1459
+ getDatabase()
1460
+ .prepare(`INSERT INTO movement_stays (
1461
+ id, external_uid, pairing_session_id, user_id, place_id, label,
1462
+ status, classification, started_at, ended_at, center_latitude,
1463
+ center_longitude, radius_meters, sample_count, weather_json,
1464
+ metrics_json, metadata_json, published_note_id, created_at, updated_at
1465
+ )
1466
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1467
+ ON CONFLICT(user_id, external_uid) DO UPDATE SET
1468
+ pairing_session_id = excluded.pairing_session_id,
1469
+ place_id = excluded.place_id,
1470
+ label = excluded.label,
1471
+ status = excluded.status,
1472
+ classification = excluded.classification,
1473
+ started_at = excluded.started_at,
1474
+ ended_at = excluded.ended_at,
1475
+ center_latitude = excluded.center_latitude,
1476
+ center_longitude = excluded.center_longitude,
1477
+ radius_meters = excluded.radius_meters,
1478
+ sample_count = excluded.sample_count,
1479
+ weather_json = excluded.weather_json,
1480
+ metrics_json = excluded.metrics_json,
1481
+ metadata_json = excluded.metadata_json,
1482
+ updated_at = excluded.updated_at`)
1483
+ .run(id, parsed.externalUid, pairing.id, pairing.user_id, matchedPlace?.id ?? null, parsed.label || parsed.placeLabel, parsed.status, parsed.classification, parsed.startedAt, parsed.endedAt, parsed.centerLatitude, parsed.centerLongitude, parsed.radiusMeters, parsed.sampleCount, JSON.stringify({}), JSON.stringify(metrics), JSON.stringify(parsed.metadata), existing?.published_note_id ?? null, existing?.created_at ?? now, now);
1484
+ reconcileMovementOverlapValidation(pairing.user_id);
1485
+ const fresh = getDatabase()
1486
+ .prepare(`SELECT * FROM movement_stays WHERE user_id = ? AND external_uid = ?`)
1487
+ .get(pairing.user_id, parsed.externalUid);
1488
+ const freshMetadata = safeJsonParse(fresh.metadata_json, {});
1489
+ if (settings?.publishMode === "auto_publish" && !hasInvalidMovementRecord(freshMetadata)) {
1490
+ const publishedNoteId = syncStayNote(settings, fresh, matchedPlace);
1491
+ if (publishedNoteId && publishedNoteId !== fresh.published_note_id) {
1492
+ getDatabase()
1493
+ .prepare(`UPDATE movement_stays
1494
+ SET published_note_id = ?, updated_at = ?
1495
+ WHERE id = ?`)
1496
+ .run(publishedNoteId, nowIso(), fresh.id);
1497
+ }
1498
+ }
1499
+ return {
1500
+ mode: existing ? "updated" : "created",
1501
+ stayId: fresh.id
1502
+ };
1503
+ }
1504
+ function upsertMovementTrip(pairing, settings, input) {
1505
+ const parsed = movementTripInputSchema.parse(input);
1506
+ const existing = getDatabase()
1507
+ .prepare(`SELECT *
1508
+ FROM movement_trips
1509
+ WHERE user_id = ?
1510
+ AND external_uid = ?`)
1511
+ .get(pairing.user_id, parsed.externalUid);
1512
+ const now = nowIso();
1513
+ const canonicalPoints = applyTripPointSyncDirectives({
1514
+ userId: pairing.user_id,
1515
+ tripExternalUid: parsed.externalUid,
1516
+ points: parsed.points
1517
+ });
1518
+ const firstPoint = canonicalPoints[0] ?? null;
1519
+ const lastPoint = canonicalPoints[canonicalPoints.length - 1] ?? null;
1520
+ const startPlace = resolvePlaceForCoordinates(pairing.user_id, firstPoint
1521
+ ? {
1522
+ latitude: firstPoint.latitude,
1523
+ longitude: firstPoint.longitude
1524
+ }
1525
+ : parsed.stops[0]
1526
+ ? {
1527
+ latitude: parsed.stops[0].latitude,
1528
+ longitude: parsed.stops[0].longitude
1529
+ }
1530
+ : {
1531
+ latitude: 0,
1532
+ longitude: 0
1533
+ }, parsed.startPlaceExternalUid) ?? undefined;
1534
+ const endPlace = resolvePlaceForCoordinates(pairing.user_id, lastPoint
1535
+ ? {
1536
+ latitude: lastPoint.latitude,
1537
+ longitude: lastPoint.longitude
1538
+ }
1539
+ : parsed.stops[parsed.stops.length - 1]
1540
+ ? {
1541
+ latitude: parsed.stops[parsed.stops.length - 1].latitude,
1542
+ longitude: parsed.stops[parsed.stops.length - 1].longitude
1543
+ }
1544
+ : {
1545
+ latitude: 0,
1546
+ longitude: 0
1547
+ }, parsed.endPlaceExternalUid) ?? undefined;
1548
+ const derivedMetrics = deriveTripMetricsFromPoints(canonicalPoints, {
1549
+ started_at: parsed.startedAt,
1550
+ ended_at: parsed.endedAt,
1551
+ distance_meters: parsed.distanceMeters,
1552
+ moving_seconds: parsed.movingSeconds,
1553
+ idle_seconds: parsed.idleSeconds,
1554
+ average_speed_mps: parsed.averageSpeedMps,
1555
+ max_speed_mps: parsed.maxSpeedMps
1556
+ });
1557
+ const effectiveExpectedMet = parsed.expectedMet ??
1558
+ inferExpectedMet(parsed.activityType, derivedMetrics.averageSpeedMps ?? parsed.averageSpeedMps);
1559
+ const id = existing?.id ?? `mtr_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1560
+ getDatabase()
1561
+ .prepare(`INSERT INTO movement_trips (
1562
+ id, external_uid, pairing_session_id, user_id, start_place_id,
1563
+ end_place_id, label, status, travel_mode, activity_type, started_at,
1564
+ ended_at, distance_meters, moving_seconds, idle_seconds,
1565
+ average_speed_mps, max_speed_mps, calories_kcal, expected_met,
1566
+ weather_json, tags_json, linked_entities_json, linked_people_json,
1567
+ metadata_json, published_note_id, created_at, updated_at
1568
+ )
1569
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1570
+ ON CONFLICT(user_id, external_uid) DO UPDATE SET
1571
+ pairing_session_id = excluded.pairing_session_id,
1572
+ start_place_id = excluded.start_place_id,
1573
+ end_place_id = excluded.end_place_id,
1574
+ label = excluded.label,
1575
+ status = excluded.status,
1576
+ travel_mode = excluded.travel_mode,
1577
+ activity_type = excluded.activity_type,
1578
+ started_at = excluded.started_at,
1579
+ ended_at = excluded.ended_at,
1580
+ distance_meters = excluded.distance_meters,
1581
+ moving_seconds = excluded.moving_seconds,
1582
+ idle_seconds = excluded.idle_seconds,
1583
+ average_speed_mps = excluded.average_speed_mps,
1584
+ max_speed_mps = excluded.max_speed_mps,
1585
+ calories_kcal = excluded.calories_kcal,
1586
+ expected_met = excluded.expected_met,
1587
+ weather_json = excluded.weather_json,
1588
+ tags_json = excluded.tags_json,
1589
+ linked_entities_json = excluded.linked_entities_json,
1590
+ linked_people_json = excluded.linked_people_json,
1591
+ metadata_json = excluded.metadata_json,
1592
+ updated_at = excluded.updated_at`)
1593
+ .run(id, parsed.externalUid, pairing.id, pairing.user_id, startPlace?.id ?? null, endPlace?.id ?? null, parsed.label, parsed.status, parsed.travelMode, parsed.activityType, derivedMetrics.startedAt, derivedMetrics.endedAt, derivedMetrics.distanceMeters, derivedMetrics.movingSeconds, derivedMetrics.idleSeconds, derivedMetrics.averageSpeedMps, derivedMetrics.maxSpeedMps, parsed.caloriesKcal, effectiveExpectedMet, JSON.stringify({}), JSON.stringify(uniqStrings(parsed.tags)), JSON.stringify(parsed.linkedEntities), JSON.stringify(parsed.linkedPeople), JSON.stringify(parsed.metadata), existing?.published_note_id ?? null, existing?.created_at ?? now, now);
1594
+ reconcileMovementOverlapValidation(pairing.user_id);
1595
+ const fresh = getDatabase()
1596
+ .prepare(`SELECT * FROM movement_trips WHERE user_id = ? AND external_uid = ?`)
1597
+ .get(pairing.user_id, parsed.externalUid);
1598
+ replaceTripChildren(fresh.id, parsed.externalUid, canonicalPoints, parsed.stops, pairing.user_id);
1599
+ reconcileMovementOverlapValidation(pairing.user_id);
1600
+ const refreshed = getDatabase()
1601
+ .prepare(`SELECT * FROM movement_trips WHERE id = ?`)
1602
+ .get(fresh.id);
1603
+ const freshMetadata = safeJsonParse(refreshed.metadata_json, {});
1604
+ if (settings?.publishMode === "auto_publish" && !hasInvalidMovementRecord(freshMetadata)) {
1605
+ const publishedNoteId = syncTripNote(settings, refreshed, startPlace, endPlace);
1606
+ if (publishedNoteId && publishedNoteId !== refreshed.published_note_id) {
1607
+ getDatabase()
1608
+ .prepare(`UPDATE movement_trips
1609
+ SET published_note_id = ?, updated_at = ?
1610
+ WHERE id = ?`)
1611
+ .run(publishedNoteId, nowIso(), refreshed.id);
1612
+ }
1613
+ }
1614
+ if (!existing && settings?.publishMode === "auto_publish" && !hasInvalidMovementRecord(freshMetadata)) {
1615
+ awardMovementXp({
1616
+ userId: pairing.user_id,
1617
+ entityId: refreshed.id,
1618
+ categoryTags: uniqStrings([
1619
+ ...(startPlace
1620
+ ? safeJsonParse(startPlace.category_tags_json, [])
1621
+ : []),
1622
+ ...(endPlace
1623
+ ? safeJsonParse(endPlace.category_tags_json, [])
1624
+ : []),
1625
+ ...parsed.tags
1626
+ ]),
1627
+ distanceMeters: refreshed.distance_meters,
1628
+ title: "Movement exploration"
1629
+ });
1630
+ }
1631
+ return {
1632
+ mode: existing ? "updated" : "created",
1633
+ tripId: refreshed.id
1634
+ };
1635
+ }
1636
+ export function ingestMovementSync(pairing, payload) {
1637
+ const parsed = movementSyncPayloadSchema.parse(payload);
1638
+ const settings = upsertMovementSettings(pairing.user_id, parsed.settings);
1639
+ let createdCount = 0;
1640
+ let updatedCount = 0;
1641
+ parsed.knownPlaces.forEach((place) => {
1642
+ const existing = place.externalUid
1643
+ ? getDatabase()
1644
+ .prepare(`SELECT id
1645
+ FROM movement_places
1646
+ WHERE user_id = ?
1647
+ AND source = 'companion'
1648
+ AND external_uid = ?`)
1649
+ .get(pairing.user_id, place.externalUid)
1650
+ : undefined;
1651
+ upsertMovementPlaceInternal({
1652
+ userId: pairing.user_id,
1653
+ source: "companion",
1654
+ id: existing?.id ?? null,
1655
+ place
1656
+ });
1657
+ if (existing) {
1658
+ updatedCount += 1;
1659
+ }
1660
+ else {
1661
+ createdCount += 1;
1662
+ }
1663
+ });
1664
+ parsed.stays.forEach((stay) => {
1665
+ const canonicalStay = applyMovementStaySyncDirectives(pairing.user_id, stay);
1666
+ if (!canonicalStay) {
1667
+ return;
1668
+ }
1669
+ const result = upsertMovementStay(pairing, settings, canonicalStay);
1670
+ if (result.mode === "created") {
1671
+ createdCount += 1;
1672
+ }
1673
+ else {
1674
+ updatedCount += 1;
1675
+ }
1676
+ });
1677
+ parsed.trips.forEach((trip) => {
1678
+ const canonicalTrip = applyMovementTripSyncDirectives(pairing.user_id, trip);
1679
+ if (!canonicalTrip) {
1680
+ return;
1681
+ }
1682
+ const result = upsertMovementTrip(pairing, settings, canonicalTrip);
1683
+ if (result.mode === "created") {
1684
+ createdCount += 1;
1685
+ }
1686
+ else {
1687
+ updatedCount += 1;
1688
+ }
1689
+ });
1690
+ cleanupRawTripPoints(pairing.user_id);
1691
+ recordActivityEvent({
1692
+ entityType: "system",
1693
+ entityId: pairing.id,
1694
+ eventType: "movement_sync_completed",
1695
+ title: "Movement sync completed",
1696
+ description: "Forge Companion synchronized passive movement stays, trips, and known places.",
1697
+ actor: "Forge Companion",
1698
+ source: "system",
1699
+ metadata: {
1700
+ knownPlaces: parsed.knownPlaces.length,
1701
+ stays: parsed.stays.length,
1702
+ trips: parsed.trips.length
1703
+ }
1704
+ });
1705
+ return {
1706
+ createdCount,
1707
+ updatedCount,
1708
+ knownPlaces: parsed.knownPlaces.length,
1709
+ stays: parsed.stays.length,
1710
+ trips: parsed.trips.length,
1711
+ settings
1712
+ };
1713
+ }
1714
+ export function listMovementPlaces(userIds) {
1715
+ return listMovementPlaceRows(userIds).map(mapMovementPlace);
1716
+ }
1717
+ export function createMovementPlace(input, context) {
1718
+ const parsed = movementPlaceMutationSchema.parse(input);
1719
+ const place = upsertMovementPlaceInternal({
1720
+ userId: parsed.userId ?? getDefaultUser().id,
1721
+ source: parsed.source,
1722
+ place: parsed
1723
+ });
1724
+ recordActivityEvent({
1725
+ entityType: "system",
1726
+ entityId: place.id,
1727
+ eventType: "movement_place_created",
1728
+ title: "Movement place added",
1729
+ description: `Added ${place.label} as a known place for movement reasoning.`,
1730
+ actor: context.actor ?? null,
1731
+ source: context.source,
1732
+ metadata: {
1733
+ label: place.label,
1734
+ categoryTags: place.categoryTags
1735
+ }
1736
+ });
1737
+ return place;
1738
+ }
1739
+ export function updateMovementPlace(placeId, patch, context) {
1740
+ const existing = getDatabase()
1741
+ .prepare(`SELECT *
1742
+ FROM movement_places
1743
+ WHERE id = ?`)
1744
+ .get(placeId);
1745
+ if (!existing) {
1746
+ return undefined;
1747
+ }
1748
+ const parsed = movementPlacePatchSchema.parse(patch);
1749
+ const place = upsertMovementPlaceInternal({
1750
+ userId: existing.user_id,
1751
+ source: existing.source,
1752
+ id: placeId,
1753
+ place: {
1754
+ externalUid: parsed.externalUid ?? existing.external_uid,
1755
+ label: parsed.label ?? existing.label,
1756
+ aliases: parsed.aliases ?? safeJsonParse(existing.aliases_json, []),
1757
+ latitude: parsed.latitude ?? existing.latitude,
1758
+ longitude: parsed.longitude ?? existing.longitude,
1759
+ radiusMeters: parsed.radiusMeters ?? existing.radius_meters,
1760
+ categoryTags: parsed.categoryTags ??
1761
+ safeJsonParse(existing.category_tags_json, []),
1762
+ visibility: parsed.visibility ?? existing.visibility,
1763
+ wikiNoteId: parsed.wikiNoteId === undefined ? existing.wiki_note_id : parsed.wikiNoteId,
1764
+ linkedEntities: parsed.linkedEntities ??
1765
+ safeJsonParse(existing.linked_entities_json, []),
1766
+ linkedPeople: parsed.linkedPeople ??
1767
+ safeJsonParse(existing.linked_people_json, []),
1768
+ metadata: parsed.metadata ??
1769
+ safeJsonParse(existing.metadata_json, {})
1770
+ }
1771
+ });
1772
+ recordActivityEvent({
1773
+ entityType: "system",
1774
+ entityId: place.id,
1775
+ eventType: "movement_place_updated",
1776
+ title: "Movement place updated",
1777
+ description: `Updated ${place.label}.`,
1778
+ actor: context.actor ?? null,
1779
+ source: context.source,
1780
+ metadata: {
1781
+ categoryTags: place.categoryTags
1782
+ }
1783
+ });
1784
+ return place;
1785
+ }
1786
+ function buildMovementTimelineTitleForStay(stay) {
1787
+ return stay.place?.label || stay.label || "Stay";
1788
+ }
1789
+ function buildMovementTimelineSubtitleForStay(stay) {
1790
+ const metricTags = Array.isArray(stay.metrics.tags)
1791
+ ? (stay.metrics.tags ?? [])
1792
+ : [];
1793
+ const metadataTags = Array.isArray(stay.metadata.tags)
1794
+ ? (stay.metadata.tags ?? [])
1795
+ : [];
1796
+ const tags = uniqStrings([
1797
+ ...(stay.place?.categoryTags ?? []),
1798
+ ...metricTags,
1799
+ ...metadataTags
1800
+ ]);
1801
+ if (tags.length > 0) {
1802
+ return tags.join(" · ");
1803
+ }
1804
+ return stay.classification === "stationary" ? "Stay" : stay.classification;
1805
+ }
1806
+ function buildMovementTimelineTitleForTrip(trip) {
1807
+ return (trip.label ||
1808
+ `${trip.startPlace?.label ?? "Unknown"} → ${trip.endPlace?.label ?? "Unknown"}`);
1809
+ }
1810
+ function buildMovementTimelineSubtitleForTrip(trip) {
1811
+ const parts = [
1812
+ trip.distanceMeters > 0 ? `${round(trip.distanceMeters / 1000, 1)} km` : "",
1813
+ trip.activityType || trip.travelMode,
1814
+ trip.stops.length > 0 ? `${trip.stops.length} stop${trip.stops.length === 1 ? "" : "s"}` : ""
1815
+ ].filter(Boolean);
1816
+ return parts.join(" · ");
1817
+ }
1818
+ function compareMovementTimelineDescending(left, right) {
1819
+ return (right.endedAt.localeCompare(left.endedAt) ||
1820
+ right.startedAt.localeCompare(left.startedAt) ||
1821
+ right.kind.localeCompare(left.kind) ||
1822
+ right.id.localeCompare(left.id));
1823
+ }
1824
+ export function getMovementTimeline(input) {
1825
+ const parsed = movementTimelineQuerySchema.parse(input);
1826
+ const userIds = parsed.userIds.length > 0 ? parsed.userIds : undefined;
1827
+ const initialStayRows = listMovementStayRows(userIds);
1828
+ const initialTripRows = listMovementTripRows(userIds);
1829
+ const scopedUserIds = new Set();
1830
+ for (const row of initialStayRows) {
1831
+ scopedUserIds.add(row.user_id);
1832
+ }
1833
+ for (const row of initialTripRows) {
1834
+ scopedUserIds.add(row.user_id);
1835
+ }
1836
+ for (const scopedUserId of scopedUserIds) {
1837
+ reconcileMovementOverlapValidation(scopedUserId);
1838
+ }
1839
+ const places = listMovementPlaceRows(userIds).map(mapMovementPlace);
1840
+ const placesById = new Map(places.map((place) => [place.id, place]));
1841
+ const stayRows = listMovementStayRows(userIds);
1842
+ const tripRows = listMovementTripRows(userIds);
1843
+ const tripIds = tripRows.map((row) => row.id);
1844
+ const pointsByTrip = new Map();
1845
+ listTripPoints(tripIds).forEach((point) => {
1846
+ pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
1847
+ });
1848
+ const stopsByTrip = new Map();
1849
+ listTripStops(tripIds).forEach((stop) => {
1850
+ stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
1851
+ });
1852
+ const chronological = [
1853
+ ...stayRows.map((row) => ({
1854
+ id: row.id,
1855
+ kind: "stay",
1856
+ startedAt: row.started_at,
1857
+ endedAt: row.ended_at,
1858
+ stay: mapMovementStay(row, placesById)
1859
+ })),
1860
+ ...tripRows.map((row) => ({
1861
+ id: row.id,
1862
+ kind: "trip",
1863
+ startedAt: row.started_at,
1864
+ endedAt: row.ended_at,
1865
+ trip: mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? [])
1866
+ }))
1867
+ ]
1868
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
1869
+ left.endedAt.localeCompare(right.endedAt) ||
1870
+ left.kind.localeCompare(right.kind) ||
1871
+ left.id.localeCompare(right.id));
1872
+ const validChronological = chronological.filter((segment) => segment.kind === "stay"
1873
+ ? !hasInvalidMovementRecord(segment.stay.metadata)
1874
+ : !hasInvalidMovementRecord(segment.trip.metadata));
1875
+ let nextStayLane = "left";
1876
+ const stayLaneById = new Map();
1877
+ for (const segment of validChronological) {
1878
+ if (segment.kind === "stay") {
1879
+ stayLaneById.set(segment.id, nextStayLane);
1880
+ nextStayLane = nextStayLane === "left" ? "right" : "left";
1881
+ }
1882
+ }
1883
+ const timelineSource = parsed.includeInvalid ? chronological : validChronological;
1884
+ const decorated = timelineSource.map((segment, index) => {
1885
+ const previousStayId = [...timelineSource.slice(0, index)]
1886
+ .reverse()
1887
+ .find((candidate) => candidate.kind === "stay")?.id;
1888
+ const previousStayLane = previousStayId
1889
+ ? stayLaneById.get(previousStayId)
1890
+ : undefined;
1891
+ const nextStayLaneId = timelineSource
1892
+ .slice(index + 1)
1893
+ .find((candidate) => candidate.kind === "stay")?.id;
1894
+ const nextStayLane = nextStayLaneId ? stayLaneById.get(nextStayLaneId) : undefined;
1895
+ const cursor = {
1896
+ id: segment.id,
1897
+ kind: segment.kind,
1898
+ startedAt: segment.startedAt,
1899
+ endedAt: segment.endedAt
1900
+ };
1901
+ if (segment.kind === "stay") {
1902
+ const invalid = hasInvalidMovementRecord(segment.stay.metadata);
1903
+ const laneSide = stayLaneById.get(segment.id) ?? "left";
1904
+ return {
1905
+ id: segment.id,
1906
+ kind: "stay",
1907
+ startedAt: segment.startedAt,
1908
+ endedAt: segment.endedAt,
1909
+ durationSeconds: segment.stay.durationSeconds,
1910
+ laneSide,
1911
+ connectorFromLane: laneSide,
1912
+ connectorToLane: laneSide,
1913
+ title: buildMovementTimelineTitleForStay(segment.stay),
1914
+ subtitle: buildMovementTimelineSubtitleForStay(segment.stay),
1915
+ placeLabel: segment.stay.place?.label ?? segment.stay.placeId ?? null,
1916
+ tags: uniqStrings([
1917
+ ...(segment.stay.place?.categoryTags ?? []),
1918
+ ...(Array.isArray(segment.stay.metrics.tags)
1919
+ ? (segment.stay.metrics.tags ?? [])
1920
+ : [])
1921
+ ]),
1922
+ isInvalid: invalid,
1923
+ syncSource: segment.stay.pairingSessionId ? "companion" : "forge",
1924
+ cursor: encodeMovementTimelineCursor(cursor),
1925
+ stay: segment.stay,
1926
+ trip: null
1927
+ };
1928
+ }
1929
+ const invalid = hasInvalidMovementRecord(segment.trip.metadata);
1930
+ const laneSide = nextStayLane ?? previousStayLane ?? "left";
1931
+ return {
1932
+ id: segment.id,
1933
+ kind: "trip",
1934
+ startedAt: segment.startedAt,
1935
+ endedAt: segment.endedAt,
1936
+ durationSeconds: segment.trip.durationSeconds,
1937
+ laneSide,
1938
+ connectorFromLane: previousStayLane ?? laneSide,
1939
+ connectorToLane: nextStayLane ?? laneSide,
1940
+ title: buildMovementTimelineTitleForTrip(segment.trip),
1941
+ subtitle: buildMovementTimelineSubtitleForTrip(segment.trip),
1942
+ placeLabel: segment.trip.endPlace?.label ??
1943
+ segment.trip.startPlace?.label ??
1944
+ null,
1945
+ tags: uniqStrings([
1946
+ ...segment.trip.tags,
1947
+ ...(segment.trip.startPlace?.categoryTags ?? []),
1948
+ ...(segment.trip.endPlace?.categoryTags ?? [])
1949
+ ]),
1950
+ isInvalid: invalid,
1951
+ syncSource: segment.trip.pairingSessionId ? "companion" : "forge",
1952
+ cursor: encodeMovementTimelineCursor(cursor),
1953
+ stay: null,
1954
+ trip: segment.trip
1955
+ };
1956
+ });
1957
+ const descending = [...decorated].sort((left, right) => compareMovementTimelineDescending({
1958
+ id: left.id,
1959
+ kind: left.kind,
1960
+ startedAt: left.startedAt,
1961
+ endedAt: left.endedAt
1962
+ }, {
1963
+ id: right.id,
1964
+ kind: right.kind,
1965
+ startedAt: right.startedAt,
1966
+ endedAt: right.endedAt
1967
+ }));
1968
+ const beforeCursor = decodeMovementTimelineCursor(parsed.before);
1969
+ const filtered = beforeCursor
1970
+ ? descending.filter((segment) => compareMovementTimelineDescending({
1971
+ id: segment.id,
1972
+ kind: segment.kind,
1973
+ startedAt: segment.startedAt,
1974
+ endedAt: segment.endedAt
1975
+ }, beforeCursor) > 0)
1976
+ : descending;
1977
+ const segments = filtered.slice(0, parsed.limit);
1978
+ const nextCursor = filtered.length > segments.length && segments.length > 0
1979
+ ? segments[segments.length - 1].cursor
1980
+ : null;
1981
+ return {
1982
+ segments,
1983
+ nextCursor,
1984
+ hasMore: nextCursor !== null,
1985
+ invalidSegmentCount: chronological.length - validChronological.length
1986
+ };
1987
+ }
1988
+ export function updateMovementStay(stayId, patch, context, options = {}) {
1989
+ const existing = getDatabase()
1990
+ .prepare(`SELECT *
1991
+ FROM movement_stays
1992
+ WHERE id = ?`)
1993
+ .get(stayId);
1994
+ if (!existing) {
1995
+ return undefined;
1996
+ }
1997
+ if (options.userId && existing.user_id !== options.userId) {
1998
+ return undefined;
1999
+ }
2000
+ const parsed = movementStayPatchSchema.parse(patch);
2001
+ const now = nowIso();
2002
+ getDatabase()
2003
+ .prepare(`INSERT INTO movement_stay_overrides (
2004
+ id, user_id, stay_external_uid, stay_json, created_at, updated_at
2005
+ )
2006
+ VALUES (?, ?, ?, ?, ?, ?)
2007
+ ON CONFLICT(user_id, stay_external_uid) DO UPDATE SET
2008
+ stay_json = excluded.stay_json,
2009
+ updated_at = excluded.updated_at`)
2010
+ .run(`msto_${randomUUID().replaceAll("-", "").slice(0, 10)}`, existing.user_id, existing.external_uid, JSON.stringify(parsed), now, now);
2011
+ getDatabase()
2012
+ .prepare(`DELETE FROM movement_stay_tombstones
2013
+ WHERE user_id = ?
2014
+ AND stay_external_uid = ?`)
2015
+ .run(existing.user_id, existing.external_uid);
2016
+ const startedAt = parsed.startedAt ?? existing.started_at;
2017
+ const endedAt = parsed.endedAt ?? existing.ended_at;
2018
+ if (Date.parse(endedAt) < Date.parse(startedAt)) {
2019
+ throw new HttpError(400, "invalid_movement_stay_range", "Movement stay end time must be after the start time.");
2020
+ }
2021
+ const resolvedPlace = resolvePlaceForPatch({
2022
+ userId: existing.user_id,
2023
+ explicitPlaceId: hasOwn(parsed, "placeId") ? parsed.placeId : undefined,
2024
+ explicitPlaceExternalUid: hasOwn(parsed, "placeExternalUid") ? parsed.placeExternalUid : undefined,
2025
+ fallbackCoordinates: {
2026
+ latitude: parsed.centerLatitude ?? existing.center_latitude,
2027
+ longitude: parsed.centerLongitude ?? existing.center_longitude
2028
+ }
2029
+ }) ?? (hasOwn(parsed, "placeId") || hasOwn(parsed, "placeExternalUid")
2030
+ ? null
2031
+ : resolvePlaceRowById(existing.user_id, existing.place_id));
2032
+ const tags = parsed.tags !== undefined
2033
+ ? uniqStrings(parsed.tags)
2034
+ : Array.isArray((safeJsonParse(existing.metrics_json, {}).tags))
2035
+ ? uniqStrings((safeJsonParse(existing.metrics_json, {}).tags ??
2036
+ []))
2037
+ : [];
2038
+ const metrics = {
2039
+ ...safeJsonParse(existing.metrics_json, {}),
2040
+ tags
2041
+ };
2042
+ const metadata = {
2043
+ ...safeJsonParse(existing.metadata_json, {}),
2044
+ ...(parsed.metadata ?? {})
2045
+ };
2046
+ getDatabase()
2047
+ .prepare(`UPDATE movement_stays
2048
+ SET place_id = ?,
2049
+ label = ?,
2050
+ status = ?,
2051
+ classification = ?,
2052
+ started_at = ?,
2053
+ ended_at = ?,
2054
+ center_latitude = ?,
2055
+ center_longitude = ?,
2056
+ radius_meters = ?,
2057
+ sample_count = ?,
2058
+ metrics_json = ?,
2059
+ metadata_json = ?,
2060
+ updated_at = ?
2061
+ WHERE id = ?`)
2062
+ .run(resolvedPlace?.id ?? null, parsed.label ?? parsed.placeLabel ?? existing.label, parsed.status ?? existing.status, parsed.classification ?? existing.classification, startedAt, endedAt, parsed.centerLatitude ?? existing.center_latitude, parsed.centerLongitude ?? existing.center_longitude, parsed.radiusMeters ?? existing.radius_meters, parsed.sampleCount ?? existing.sample_count, JSON.stringify(metrics), JSON.stringify(metadata), nowIso(), stayId);
2063
+ reconcileMovementOverlapValidation(existing.user_id);
2064
+ const places = listMovementPlaceRows([existing.user_id]).map(mapMovementPlace);
2065
+ const placesById = new Map(places.map((place) => [place.id, place]));
2066
+ const updated = mapMovementStay(getDatabase()
2067
+ .prepare(`SELECT *
2068
+ FROM movement_stays
2069
+ WHERE id = ?`)
2070
+ .get(stayId), placesById);
2071
+ recordActivityEvent({
2072
+ entityType: "system",
2073
+ entityId: updated.id,
2074
+ eventType: "movement_stay_updated",
2075
+ title: "Movement stay updated",
2076
+ description: `Updated ${updated.place?.label ?? (updated.label || "movement stay")}.`,
2077
+ actor: context.actor ?? null,
2078
+ source: context.source,
2079
+ metadata: {
2080
+ placeId: updated.placeId,
2081
+ durationSeconds: updated.durationSeconds
2082
+ }
2083
+ });
2084
+ return updated;
2085
+ }
2086
+ export function updateMovementTrip(tripId, patch, context, options = {}) {
2087
+ const existing = getDatabase()
2088
+ .prepare(`SELECT *
2089
+ FROM movement_trips
2090
+ WHERE id = ?`)
2091
+ .get(tripId);
2092
+ if (!existing) {
2093
+ return undefined;
2094
+ }
2095
+ if (options.userId && existing.user_id !== options.userId) {
2096
+ return undefined;
2097
+ }
2098
+ const parsed = movementTripPatchSchema.parse(patch);
2099
+ const now = nowIso();
2100
+ getDatabase()
2101
+ .prepare(`INSERT INTO movement_trip_overrides (
2102
+ id, user_id, trip_external_uid, trip_json, created_at, updated_at
2103
+ )
2104
+ VALUES (?, ?, ?, ?, ?, ?)
2105
+ ON CONFLICT(user_id, trip_external_uid) DO UPDATE SET
2106
+ trip_json = excluded.trip_json,
2107
+ updated_at = excluded.updated_at`)
2108
+ .run(`mtro_${randomUUID().replaceAll("-", "").slice(0, 10)}`, existing.user_id, existing.external_uid, JSON.stringify(parsed), now, now);
2109
+ getDatabase()
2110
+ .prepare(`DELETE FROM movement_trip_tombstones
2111
+ WHERE user_id = ?
2112
+ AND trip_external_uid = ?`)
2113
+ .run(existing.user_id, existing.external_uid);
2114
+ const startedAt = parsed.startedAt ?? existing.started_at;
2115
+ const endedAt = parsed.endedAt ?? existing.ended_at;
2116
+ if (Date.parse(endedAt) < Date.parse(startedAt)) {
2117
+ throw new HttpError(400, "invalid_movement_trip_range", "Movement trip end time must be after the start time.");
2118
+ }
2119
+ const tripPoints = listTripPoints([tripId]);
2120
+ const fallbackStartPoint = tripPoints[0]
2121
+ ? {
2122
+ latitude: tripPoints[0].latitude,
2123
+ longitude: tripPoints[0].longitude
2124
+ }
2125
+ : { latitude: 0, longitude: 0 };
2126
+ const fallbackEndPoint = tripPoints[tripPoints.length - 1]
2127
+ ? {
2128
+ latitude: tripPoints[tripPoints.length - 1].latitude,
2129
+ longitude: tripPoints[tripPoints.length - 1].longitude
2130
+ }
2131
+ : fallbackStartPoint;
2132
+ const startPlace = resolvePlaceForPatch({
2133
+ userId: existing.user_id,
2134
+ explicitPlaceId: hasOwn(parsed, "startPlaceId") ? parsed.startPlaceId : undefined,
2135
+ explicitPlaceExternalUid: hasOwn(parsed, "startPlaceExternalUid")
2136
+ ? parsed.startPlaceExternalUid
2137
+ : undefined,
2138
+ fallbackCoordinates: fallbackStartPoint
2139
+ }) ?? (hasOwn(parsed, "startPlaceId") || hasOwn(parsed, "startPlaceExternalUid")
2140
+ ? null
2141
+ : resolvePlaceRowById(existing.user_id, existing.start_place_id));
2142
+ const endPlace = resolvePlaceForPatch({
2143
+ userId: existing.user_id,
2144
+ explicitPlaceId: hasOwn(parsed, "endPlaceId") ? parsed.endPlaceId : undefined,
2145
+ explicitPlaceExternalUid: hasOwn(parsed, "endPlaceExternalUid") ? parsed.endPlaceExternalUid : undefined,
2146
+ fallbackCoordinates: fallbackEndPoint
2147
+ }) ?? (hasOwn(parsed, "endPlaceId") || hasOwn(parsed, "endPlaceExternalUid")
2148
+ ? null
2149
+ : resolvePlaceRowById(existing.user_id, existing.end_place_id));
2150
+ const metadata = {
2151
+ ...safeJsonParse(existing.metadata_json, {}),
2152
+ ...(parsed.metadata ?? {})
2153
+ };
2154
+ getDatabase()
2155
+ .prepare(`UPDATE movement_trips
2156
+ SET start_place_id = ?,
2157
+ end_place_id = ?,
2158
+ label = ?,
2159
+ status = ?,
2160
+ travel_mode = ?,
2161
+ activity_type = ?,
2162
+ started_at = ?,
2163
+ ended_at = ?,
2164
+ distance_meters = ?,
2165
+ moving_seconds = ?,
2166
+ idle_seconds = ?,
2167
+ average_speed_mps = ?,
2168
+ max_speed_mps = ?,
2169
+ calories_kcal = ?,
2170
+ expected_met = ?,
2171
+ tags_json = ?,
2172
+ metadata_json = ?,
2173
+ updated_at = ?
2174
+ WHERE id = ?`)
2175
+ .run(startPlace?.id ?? null, endPlace?.id ?? null, parsed.label ?? existing.label, parsed.status ?? existing.status, parsed.travelMode ?? existing.travel_mode, parsed.activityType ?? existing.activity_type, startedAt, endedAt, parsed.distanceMeters ?? existing.distance_meters, parsed.movingSeconds ?? existing.moving_seconds, parsed.idleSeconds ?? existing.idle_seconds, parsed.averageSpeedMps === undefined
2176
+ ? existing.average_speed_mps
2177
+ : parsed.averageSpeedMps, parsed.maxSpeedMps === undefined ? existing.max_speed_mps : parsed.maxSpeedMps, parsed.caloriesKcal === undefined ? existing.calories_kcal : parsed.caloriesKcal, parsed.expectedMet === undefined ? existing.expected_met : parsed.expectedMet, JSON.stringify(parsed.tags !== undefined ? uniqStrings(parsed.tags) : safeJsonParse(existing.tags_json, [])), JSON.stringify(metadata), nowIso(), tripId);
2178
+ reconcileMovementOverlapValidation(existing.user_id);
2179
+ const places = listMovementPlaceRows([existing.user_id]).map(mapMovementPlace);
2180
+ const placesById = new Map(places.map((place) => [place.id, place]));
2181
+ const updated = mapMovementTrip(getDatabase()
2182
+ .prepare(`SELECT *
2183
+ FROM movement_trips
2184
+ WHERE id = ?`)
2185
+ .get(tripId), placesById, tripPoints, listTripStops([tripId]));
2186
+ recordActivityEvent({
2187
+ entityType: "system",
2188
+ entityId: updated.id,
2189
+ eventType: "movement_trip_updated",
2190
+ title: "Movement trip updated",
2191
+ description: `Updated ${updated.label || "movement trip"}.`,
2192
+ actor: context.actor ?? null,
2193
+ source: context.source,
2194
+ metadata: {
2195
+ startPlaceId: updated.startPlaceId,
2196
+ endPlaceId: updated.endPlaceId,
2197
+ distanceMeters: updated.distanceMeters
2198
+ }
2199
+ });
2200
+ return updated;
2201
+ }
2202
+ export function deleteMovementStay(stayId, context, options = {}) {
2203
+ const existing = getDatabase()
2204
+ .prepare(`SELECT *
2205
+ FROM movement_stays
2206
+ WHERE id = ?`)
2207
+ .get(stayId);
2208
+ if (!existing) {
2209
+ return undefined;
2210
+ }
2211
+ if (options.userId && existing.user_id !== options.userId) {
2212
+ return undefined;
2213
+ }
2214
+ const now = nowIso();
2215
+ getDatabase()
2216
+ .prepare(`INSERT INTO movement_stay_tombstones (
2217
+ id, user_id, stay_external_uid, created_at, updated_at
2218
+ )
2219
+ VALUES (?, ?, ?, ?, ?)
2220
+ ON CONFLICT(user_id, stay_external_uid) DO UPDATE SET
2221
+ updated_at = excluded.updated_at`)
2222
+ .run(`mstt_${randomUUID().replaceAll("-", "").slice(0, 10)}`, existing.user_id, existing.external_uid, now, now);
2223
+ getDatabase()
2224
+ .prepare(`DELETE FROM movement_stay_overrides
2225
+ WHERE user_id = ?
2226
+ AND stay_external_uid = ?`)
2227
+ .run(existing.user_id, existing.external_uid);
2228
+ getDatabase()
2229
+ .prepare(`DELETE FROM movement_stays
2230
+ WHERE id = ?`)
2231
+ .run(stayId);
2232
+ reconcileMovementOverlapValidation(existing.user_id);
2233
+ recordActivityEvent({
2234
+ entityType: "system",
2235
+ entityId: stayId,
2236
+ eventType: "movement_stay_deleted",
2237
+ title: "Movement stay deleted",
2238
+ description: `Deleted ${existing.label || "movement stay"} and tombstoned it for sync.`,
2239
+ actor: context.actor ?? null,
2240
+ source: context.source,
2241
+ metadata: {
2242
+ stayExternalUid: existing.external_uid
2243
+ }
2244
+ });
2245
+ return {
2246
+ deletedStayId: stayId,
2247
+ deletedStayExternalUid: existing.external_uid
2248
+ };
2249
+ }
2250
+ export function deleteMovementTrip(tripId, context, options = {}) {
2251
+ const existing = getDatabase()
2252
+ .prepare(`SELECT *
2253
+ FROM movement_trips
2254
+ WHERE id = ?`)
2255
+ .get(tripId);
2256
+ if (!existing) {
2257
+ return undefined;
2258
+ }
2259
+ if (options.userId && existing.user_id !== options.userId) {
2260
+ return undefined;
2261
+ }
2262
+ const now = nowIso();
2263
+ getDatabase()
2264
+ .prepare(`INSERT INTO movement_trip_tombstones (
2265
+ id, user_id, trip_external_uid, created_at, updated_at
2266
+ )
2267
+ VALUES (?, ?, ?, ?, ?)
2268
+ ON CONFLICT(user_id, trip_external_uid) DO UPDATE SET
2269
+ updated_at = excluded.updated_at`)
2270
+ .run(`mtrt_${randomUUID().replaceAll("-", "").slice(0, 10)}`, existing.user_id, existing.external_uid, now, now);
2271
+ getDatabase()
2272
+ .prepare(`DELETE FROM movement_trip_overrides
2273
+ WHERE user_id = ?
2274
+ AND trip_external_uid = ?`)
2275
+ .run(existing.user_id, existing.external_uid);
2276
+ getDatabase()
2277
+ .prepare(`DELETE FROM movement_trip_point_tombstones
2278
+ WHERE user_id = ?
2279
+ AND trip_external_uid = ?`)
2280
+ .run(existing.user_id, existing.external_uid);
2281
+ getDatabase()
2282
+ .prepare(`DELETE FROM movement_trip_point_overrides
2283
+ WHERE user_id = ?
2284
+ AND trip_external_uid = ?`)
2285
+ .run(existing.user_id, existing.external_uid);
2286
+ getDatabase()
2287
+ .prepare(`DELETE FROM movement_trips
2288
+ WHERE id = ?`)
2289
+ .run(tripId);
2290
+ reconcileMovementOverlapValidation(existing.user_id);
2291
+ recordActivityEvent({
2292
+ entityType: "system",
2293
+ entityId: tripId,
2294
+ eventType: "movement_trip_deleted",
2295
+ title: "Movement trip deleted",
2296
+ description: `Deleted ${existing.label || "movement trip"} and tombstoned it for sync.`,
2297
+ actor: context.actor ?? null,
2298
+ source: context.source,
2299
+ metadata: {
2300
+ tripExternalUid: existing.external_uid
2301
+ }
2302
+ });
2303
+ return {
2304
+ deletedTripId: tripId,
2305
+ deletedTripExternalUid: existing.external_uid
2306
+ };
2307
+ }
2308
+ export function updateMovementTripPoint(tripId, pointId, patch, context, options = {}) {
2309
+ const trip = getDatabase()
2310
+ .prepare(`SELECT *
2311
+ FROM movement_trips
2312
+ WHERE id = ?`)
2313
+ .get(tripId);
2314
+ if (!trip) {
2315
+ return undefined;
2316
+ }
2317
+ if (options.userId && trip.user_id !== options.userId) {
2318
+ return undefined;
2319
+ }
2320
+ const point = getDatabase()
2321
+ .prepare(`SELECT *
2322
+ FROM movement_trip_points
2323
+ WHERE id = ?
2324
+ AND trip_id = ?`)
2325
+ .get(pointId, tripId);
2326
+ if (!point) {
2327
+ return undefined;
2328
+ }
2329
+ const parsed = movementTripPointPatchSchema.parse(patch);
2330
+ const nextPoint = movementTripPointInputSchema.parse({
2331
+ externalUid: point.external_uid,
2332
+ recordedAt: parsed.recordedAt ?? point.recorded_at,
2333
+ latitude: parsed.latitude ?? point.latitude,
2334
+ longitude: parsed.longitude ?? point.longitude,
2335
+ accuracyMeters: parsed.accuracyMeters === undefined
2336
+ ? point.accuracy_meters
2337
+ : parsed.accuracyMeters,
2338
+ altitudeMeters: parsed.altitudeMeters === undefined
2339
+ ? point.altitude_meters
2340
+ : parsed.altitudeMeters,
2341
+ speedMps: parsed.speedMps === undefined ? point.speed_mps : parsed.speedMps,
2342
+ isStopAnchor: parsed.isStopAnchor === undefined
2343
+ ? point.is_stop_anchor === 1
2344
+ : parsed.isStopAnchor
2345
+ });
2346
+ const now = nowIso();
2347
+ getDatabase()
2348
+ .prepare(`INSERT INTO movement_trip_point_overrides (
2349
+ id, user_id, trip_external_uid, point_external_uid, point_json, created_at, updated_at
2350
+ )
2351
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2352
+ ON CONFLICT(user_id, trip_external_uid, point_external_uid) DO UPDATE SET
2353
+ point_json = excluded.point_json,
2354
+ updated_at = excluded.updated_at`)
2355
+ .run(`mtpo_${randomUUID().replaceAll("-", "").slice(0, 10)}`, trip.user_id, trip.external_uid, point.external_uid, JSON.stringify(nextPoint), now, now);
2356
+ getDatabase()
2357
+ .prepare(`DELETE FROM movement_trip_point_tombstones
2358
+ WHERE user_id = ?
2359
+ AND trip_external_uid = ?
2360
+ AND point_external_uid = ?`)
2361
+ .run(trip.user_id, trip.external_uid, point.external_uid);
2362
+ const currentPoints = listTripPoints([tripId]).map((row) => ({
2363
+ externalUid: row.external_uid,
2364
+ recordedAt: row.recorded_at,
2365
+ latitude: row.latitude,
2366
+ longitude: row.longitude,
2367
+ accuracyMeters: row.accuracy_meters,
2368
+ altitudeMeters: row.altitude_meters,
2369
+ speedMps: row.speed_mps,
2370
+ isStopAnchor: row.is_stop_anchor === 1
2371
+ }));
2372
+ const nextPoints = currentPoints
2373
+ .map((current) => current.externalUid === point.external_uid ? nextPoint : current)
2374
+ .sort((left, right) => Date.parse(left.recordedAt) - Date.parse(right.recordedAt) ||
2375
+ left.externalUid.localeCompare(right.externalUid));
2376
+ replaceTripPoints(tripId, trip.external_uid, nextPoints);
2377
+ const refreshedTrip = refreshTripDerivedFields(tripId);
2378
+ if (!refreshedTrip) {
2379
+ return undefined;
2380
+ }
2381
+ const refreshedPoints = listTripPoints([tripId]);
2382
+ const updatedPoint = refreshedPoints.find((row) => row.external_uid === point.external_uid);
2383
+ if (!updatedPoint) {
2384
+ return undefined;
2385
+ }
2386
+ const places = listMovementPlaceRows([trip.user_id]).map(mapMovementPlace);
2387
+ const placesById = new Map(places.map((place) => [place.id, place]));
2388
+ const mappedTrip = mapMovementTrip(refreshedTrip, placesById, refreshedPoints, listTripStops([tripId]));
2389
+ recordActivityEvent({
2390
+ entityType: "system",
2391
+ entityId: tripId,
2392
+ eventType: "movement_trip_point_updated",
2393
+ title: "Movement datapoint updated",
2394
+ description: `Updated a raw movement datapoint inside ${refreshedTrip.label || "the selected trip"}.`,
2395
+ actor: context.actor ?? null,
2396
+ source: context.source,
2397
+ metadata: {
2398
+ tripId,
2399
+ pointId: updatedPoint.id,
2400
+ pointExternalUid: updatedPoint.external_uid
2401
+ }
2402
+ });
2403
+ return {
2404
+ point: {
2405
+ id: updatedPoint.id,
2406
+ externalUid: updatedPoint.external_uid,
2407
+ recordedAt: updatedPoint.recorded_at,
2408
+ latitude: updatedPoint.latitude,
2409
+ longitude: updatedPoint.longitude,
2410
+ accuracyMeters: updatedPoint.accuracy_meters,
2411
+ altitudeMeters: updatedPoint.altitude_meters,
2412
+ speedMps: updatedPoint.speed_mps,
2413
+ isStopAnchor: updatedPoint.is_stop_anchor === 1
2414
+ },
2415
+ trip: mappedTrip
2416
+ };
2417
+ }
2418
+ export function deleteMovementTripPoint(tripId, pointId, context, options = {}) {
2419
+ const trip = getDatabase()
2420
+ .prepare(`SELECT *
2421
+ FROM movement_trips
2422
+ WHERE id = ?`)
2423
+ .get(tripId);
2424
+ if (!trip) {
2425
+ return undefined;
2426
+ }
2427
+ if (options.userId && trip.user_id !== options.userId) {
2428
+ return undefined;
2429
+ }
2430
+ const point = getDatabase()
2431
+ .prepare(`SELECT *
2432
+ FROM movement_trip_points
2433
+ WHERE id = ?
2434
+ AND trip_id = ?`)
2435
+ .get(pointId, tripId);
2436
+ if (!point) {
2437
+ return undefined;
2438
+ }
2439
+ const now = nowIso();
2440
+ getDatabase()
2441
+ .prepare(`INSERT INTO movement_trip_point_tombstones (
2442
+ id, user_id, trip_external_uid, point_external_uid, created_at, updated_at
2443
+ )
2444
+ VALUES (?, ?, ?, ?, ?, ?)
2445
+ ON CONFLICT(user_id, trip_external_uid, point_external_uid) DO UPDATE SET
2446
+ updated_at = excluded.updated_at`)
2447
+ .run(`mtpt_${randomUUID().replaceAll("-", "").slice(0, 10)}`, trip.user_id, trip.external_uid, point.external_uid, now, now);
2448
+ getDatabase()
2449
+ .prepare(`DELETE FROM movement_trip_point_overrides
2450
+ WHERE user_id = ?
2451
+ AND trip_external_uid = ?
2452
+ AND point_external_uid = ?`)
2453
+ .run(trip.user_id, trip.external_uid, point.external_uid);
2454
+ const remainingPoints = listTripPoints([tripId])
2455
+ .filter((row) => row.id !== pointId)
2456
+ .map((row) => ({
2457
+ externalUid: row.external_uid,
2458
+ recordedAt: row.recorded_at,
2459
+ latitude: row.latitude,
2460
+ longitude: row.longitude,
2461
+ accuracyMeters: row.accuracy_meters,
2462
+ altitudeMeters: row.altitude_meters,
2463
+ speedMps: row.speed_mps,
2464
+ isStopAnchor: row.is_stop_anchor === 1
2465
+ }))
2466
+ .sort((left, right) => Date.parse(left.recordedAt) - Date.parse(right.recordedAt) ||
2467
+ left.externalUid.localeCompare(right.externalUid));
2468
+ replaceTripPoints(tripId, trip.external_uid, remainingPoints);
2469
+ const refreshedTrip = refreshTripDerivedFields(tripId);
2470
+ if (!refreshedTrip) {
2471
+ return undefined;
2472
+ }
2473
+ const places = listMovementPlaceRows([trip.user_id]).map(mapMovementPlace);
2474
+ const placesById = new Map(places.map((place) => [place.id, place]));
2475
+ const mappedTrip = mapMovementTrip(refreshedTrip, placesById, listTripPoints([tripId]), listTripStops([tripId]));
2476
+ recordActivityEvent({
2477
+ entityType: "system",
2478
+ entityId: tripId,
2479
+ eventType: "movement_trip_point_deleted",
2480
+ title: "Movement datapoint deleted",
2481
+ description: `Deleted a raw movement datapoint from ${refreshedTrip.label || "the selected trip"}.`,
2482
+ actor: context.actor ?? null,
2483
+ source: context.source,
2484
+ metadata: {
2485
+ tripId,
2486
+ pointExternalUid: point.external_uid
2487
+ }
2488
+ });
2489
+ return {
2490
+ deletedPointId: pointId,
2491
+ deletedPointExternalUid: point.external_uid,
2492
+ trip: mappedTrip
2493
+ };
2494
+ }
2495
+ export function getMovementSettings(userIds) {
2496
+ const effectiveUserId = userIds?.[0] ?? getDefaultUser().id;
2497
+ const row = ensureMovementSettings(effectiveUserId);
2498
+ const settings = mapMovementSettings(row) ?? defaultMovementSettings(effectiveUserId);
2499
+ return {
2500
+ ...settings,
2501
+ knownPlaceCount: listMovementPlaceRows([effectiveUserId]).length
2502
+ };
2503
+ }
2504
+ export function updateMovementSettings(userId, patch, context) {
2505
+ const existing = mapMovementSettings(ensureMovementSettings(userId)) ?? defaultMovementSettings(userId);
2506
+ const parsed = movementSettingsPatchSchema.parse(patch);
2507
+ const settings = upsertMovementSettings(userId, {
2508
+ trackingEnabled: parsed.trackingEnabled ?? existing.trackingEnabled,
2509
+ publishMode: parsed.publishMode ?? existing.publishMode,
2510
+ retentionMode: parsed.retentionMode ?? existing.retentionMode,
2511
+ locationPermissionStatus: parsed.locationPermissionStatus ?? existing.locationPermissionStatus,
2512
+ motionPermissionStatus: parsed.motionPermissionStatus ?? existing.motionPermissionStatus,
2513
+ backgroundTrackingReady: parsed.backgroundTrackingReady ?? existing.backgroundTrackingReady,
2514
+ metadata: {
2515
+ ...existing.metadata,
2516
+ ...(parsed.metadata ?? {})
2517
+ }
2518
+ });
2519
+ recordActivityEvent({
2520
+ entityType: "system",
2521
+ entityId: userId,
2522
+ eventType: "movement_settings_updated",
2523
+ title: "Movement settings updated",
2524
+ description: "Movement tracking behavior changed for the current Forge user.",
2525
+ actor: context.actor ?? null,
2526
+ source: context.source,
2527
+ metadata: settings ?? undefined
2528
+ });
2529
+ return settings;
2530
+ }
2531
+ function overlapSeconds(leftStart, leftEnd, rightStart, rightEnd) {
2532
+ const start = Math.max(Date.parse(leftStart), Date.parse(rightStart));
2533
+ const end = Math.min(Date.parse(leftEnd), Date.parse(rightEnd));
2534
+ return Math.max(0, Math.round((end - start) / 1000));
2535
+ }
2536
+ function computeSelectionAggregate(input) {
2537
+ const relevantTaskRuns = listTaskRuns({ userIds: input.userIds }).filter((run) => overlapSeconds(input.startedAt, input.endedAt, run.claimedAt, run.completedAt ?? run.updatedAt) > 0);
2538
+ const publishedNotes = [
2539
+ ...input.stays.map((stay) => stay.note).filter(Boolean),
2540
+ ...input.trips.map((trip) => trip.note).filter(Boolean)
2541
+ ];
2542
+ const selectionDuration = durationSeconds(input.startedAt, input.endedAt);
2543
+ const tripDistances = input.trips.reduce((sum, trip) => sum + trip.distanceMeters, 0);
2544
+ const calories = input.trips.reduce((sum, trip) => sum + (trip.caloriesKcal ?? 0), 0);
2545
+ const averageSpeedMps = average(input.trips
2546
+ .map((trip) => trip.averageSpeedMps)
2547
+ .filter((value) => typeof value === "number"));
2548
+ const placeLabels = uniqStrings(input.stays
2549
+ .map((stay) => stay.place?.label ?? stay.label)
2550
+ .filter(Boolean)
2551
+ .concat(input.trips.flatMap((trip) => [
2552
+ trip.startPlace?.label ?? "",
2553
+ trip.endPlace?.label ?? ""
2554
+ ])));
2555
+ const tags = uniqStrings(input.trips.flatMap((trip) => trip.tags).concat(input.stays.flatMap((stay) => Array.isArray(stay.metrics.tags)
2556
+ ? (stay.metrics.tags ?? [])
2557
+ : [])));
2558
+ return {
2559
+ startedAt: input.startedAt,
2560
+ endedAt: input.endedAt,
2561
+ durationSeconds: selectionDuration,
2562
+ distanceMeters: round(tripDistances),
2563
+ caloriesKcal: round(calories),
2564
+ averageSpeedMps: round(averageSpeedMps, 2),
2565
+ stayCount: input.stays.length,
2566
+ tripCount: input.trips.length,
2567
+ noteCount: publishedNotes.length,
2568
+ taskRunCount: relevantTaskRuns.length,
2569
+ trackedWorkSeconds: relevantTaskRuns.reduce((sum, run) => {
2570
+ const end = run.completedAt ?? run.updatedAt;
2571
+ return sum + overlapSeconds(input.startedAt, input.endedAt, run.claimedAt, end);
2572
+ }, 0),
2573
+ placeLabels,
2574
+ tags
2575
+ };
2576
+ }
2577
+ export function getMovementDayDetail(input) {
2578
+ const targetDate = input.date ?? dayKey(nowIso());
2579
+ const placeRows = listMovementPlaceRows(input.userIds);
2580
+ const places = placeRows.map(mapMovementPlace);
2581
+ const placesById = new Map(places.map((place) => [place.id, place]));
2582
+ const stays = listMovementStayRows(input.userIds, targetDate).map((row) => mapMovementStay(row, placesById));
2583
+ const tripRows = listMovementTripRows(input.userIds, { dateKey: targetDate });
2584
+ const tripIds = tripRows.map((row) => row.id);
2585
+ const pointsByTrip = new Map();
2586
+ const stopsByTrip = new Map();
2587
+ listTripPoints(tripIds).forEach((point) => {
2588
+ pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
2589
+ });
2590
+ listTripStops(tripIds).forEach((stop) => {
2591
+ stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
2592
+ });
2593
+ const trips = tripRows.map((row) => mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? []));
2594
+ const allSegments = [
2595
+ ...stays.map((stay) => ({
2596
+ id: stay.id,
2597
+ kind: "stay",
2598
+ startedAt: stay.startedAt,
2599
+ endedAt: stay.endedAt,
2600
+ durationSeconds: stay.durationSeconds,
2601
+ label: stay.place?.label ?? stay.label ?? "Stay",
2602
+ subtitle: stay.place?.categoryTags.join(" · ") ||
2603
+ (stay.classification === "stationary" ? "Stationary" : stay.classification),
2604
+ distanceMeters: 0,
2605
+ averageSpeedMps: 0,
2606
+ colorTone: stay.place?.categoryTags.includes("home")
2607
+ ? "from-sky-400/30 to-indigo-500/12"
2608
+ : "from-white/16 to-white/4",
2609
+ noteCount: stay.note ? 1 : 0
2610
+ })),
2611
+ ...trips.map((trip) => ({
2612
+ id: trip.id,
2613
+ kind: "trip",
2614
+ startedAt: trip.startedAt,
2615
+ endedAt: trip.endedAt,
2616
+ durationSeconds: trip.durationSeconds,
2617
+ label: trip.label ||
2618
+ `${trip.startPlace?.label ?? "Unknown"} → ${trip.endPlace?.label ?? "Unknown"}`,
2619
+ subtitle: `${round(trip.distanceMeters / 1000, 1)} km · ${trip.activityType || trip.travelMode}`,
2620
+ distanceMeters: trip.distanceMeters,
2621
+ averageSpeedMps: trip.averageSpeedMps ?? 0,
2622
+ colorTone: "from-emerald-300/26 to-cyan-400/12",
2623
+ noteCount: trip.note ? 1 : 0
2624
+ }))
2625
+ ].sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
2626
+ const dayStart = `${targetDate}T00:00:00.000Z`;
2627
+ const dayEnd = `${targetDate}T23:59:59.999Z`;
2628
+ const selectionAggregate = computeSelectionAggregate({
2629
+ startedAt: dayStart,
2630
+ endedAt: dayEnd,
2631
+ stays,
2632
+ trips,
2633
+ userIds: input.userIds
2634
+ });
2635
+ return {
2636
+ date: targetDate,
2637
+ settings: getMovementSettings(input.userIds),
2638
+ summary: {
2639
+ totalDistanceMeters: round(trips.reduce((sum, trip) => sum + trip.distanceMeters, 0)),
2640
+ totalMovingSeconds: trips.reduce((sum, trip) => sum + trip.movingSeconds, 0),
2641
+ totalIdleSeconds: stays.reduce((sum, stay) => sum + stay.durationSeconds, 0) +
2642
+ trips.reduce((sum, trip) => sum + trip.idleSeconds, 0),
2643
+ tripCount: trips.length,
2644
+ stayCount: stays.length,
2645
+ knownPlaceCount: places.length,
2646
+ caloriesKcal: round(trips.reduce((sum, trip) => sum + (trip.caloriesKcal ?? 0), 0)),
2647
+ averageSpeedMps: round(average(trips
2648
+ .map((trip) => trip.averageSpeedMps)
2649
+ .filter((value) => typeof value === "number")), 2)
2650
+ },
2651
+ segments: allSegments,
2652
+ stays,
2653
+ trips,
2654
+ places,
2655
+ selectionAggregate
2656
+ };
2657
+ }
2658
+ export function getMovementMonthSummary(input) {
2659
+ const targetMonth = input.month ?? monthKey(nowIso());
2660
+ const stays = listMovementStayRows(input.userIds).filter((row) => monthKey(row.started_at) === targetMonth);
2661
+ const trips = listMovementTripRows(input.userIds, { month: targetMonth });
2662
+ const byDay = new Map();
2663
+ for (const stay of stays) {
2664
+ const key = dayKey(stay.started_at);
2665
+ const current = byDay.get(key) ?? {
2666
+ distanceMeters: 0,
2667
+ movingSeconds: 0,
2668
+ idleSeconds: 0,
2669
+ caloriesKcal: 0,
2670
+ tripCount: 0,
2671
+ stayCount: 0,
2672
+ expectedMet: []
2673
+ };
2674
+ current.idleSeconds += durationSeconds(stay.started_at, stay.ended_at);
2675
+ current.stayCount += 1;
2676
+ byDay.set(key, current);
2677
+ }
2678
+ for (const trip of trips) {
2679
+ const key = dayKey(trip.started_at);
2680
+ const current = byDay.get(key) ?? {
2681
+ distanceMeters: 0,
2682
+ movingSeconds: 0,
2683
+ idleSeconds: 0,
2684
+ caloriesKcal: 0,
2685
+ tripCount: 0,
2686
+ stayCount: 0,
2687
+ expectedMet: []
2688
+ };
2689
+ current.distanceMeters += trip.distance_meters;
2690
+ current.movingSeconds += trip.moving_seconds;
2691
+ current.idleSeconds += trip.idle_seconds;
2692
+ current.caloriesKcal += trip.calories_kcal ?? 0;
2693
+ current.tripCount += 1;
2694
+ if (typeof trip.expected_met === "number") {
2695
+ current.expectedMet.push(trip.expected_met);
2696
+ }
2697
+ byDay.set(key, current);
2698
+ }
2699
+ return {
2700
+ month: targetMonth,
2701
+ days: [...byDay.entries()]
2702
+ .sort((left, right) => left[0].localeCompare(right[0]))
2703
+ .map(([dateKey, summary]) => ({
2704
+ dateKey,
2705
+ distanceMeters: round(summary.distanceMeters),
2706
+ movingSeconds: summary.movingSeconds,
2707
+ idleSeconds: summary.idleSeconds,
2708
+ caloriesKcal: round(summary.caloriesKcal),
2709
+ tripCount: summary.tripCount,
2710
+ stayCount: summary.stayCount,
2711
+ averageExpectedMet: round(average(summary.expectedMet), 2)
2712
+ })),
2713
+ totals: {
2714
+ distanceMeters: round(trips.reduce((sum, trip) => sum + trip.distance_meters, 0)),
2715
+ movingSeconds: trips.reduce((sum, trip) => sum + trip.moving_seconds, 0),
2716
+ idleSeconds: stays.reduce((sum, stay) => sum + durationSeconds(stay.started_at, stay.ended_at), 0) +
2717
+ trips.reduce((sum, trip) => sum + trip.idle_seconds, 0),
2718
+ tripCount: trips.length,
2719
+ stayCount: stays.length
2720
+ }
2721
+ };
2722
+ }
2723
+ export function getMovementAllTimeSummary(userIds) {
2724
+ const placeRows = listMovementPlaceRows(userIds);
2725
+ const stays = listMovementStayRows(userIds);
2726
+ const trips = listMovementTripRows(userIds);
2727
+ const tagBreakdown = new Map();
2728
+ placeRows.forEach((place) => {
2729
+ safeJsonParse(place.category_tags_json, []).forEach((tag) => {
2730
+ tagBreakdown.set(tag, (tagBreakdown.get(tag) ?? 0) + 1);
2731
+ });
2732
+ });
2733
+ return {
2734
+ summary: {
2735
+ knownPlaceCount: placeRows.length,
2736
+ stayCount: stays.length,
2737
+ tripCount: trips.length,
2738
+ totalDistanceMeters: round(trips.reduce((sum, trip) => sum + trip.distance_meters, 0)),
2739
+ totalMovingSeconds: trips.reduce((sum, trip) => sum + trip.moving_seconds, 0),
2740
+ totalIdleSeconds: stays.reduce((sum, stay) => sum + durationSeconds(stay.started_at, stay.ended_at), 0) +
2741
+ trips.reduce((sum, trip) => sum + trip.idle_seconds, 0),
2742
+ visitedCountries: new Set(placeRows
2743
+ .map((place) => safeJsonParse(place.metadata_json, {}))
2744
+ .map((metadata) => typeof metadata.countryCode === "string" ? metadata.countryCode : null)
2745
+ .filter((value) => Boolean(value))).size
2746
+ },
2747
+ categoryBreakdown: [...tagBreakdown.entries()]
2748
+ .map(([tag, count]) => ({ tag, count }))
2749
+ .sort((left, right) => right.count - left.count),
2750
+ recentTrips: trips
2751
+ .slice(0, 12)
2752
+ .map((trip) => ({
2753
+ id: trip.id,
2754
+ label: trip.label,
2755
+ startedAt: trip.started_at,
2756
+ distanceMeters: trip.distance_meters,
2757
+ activityType: trip.activity_type
2758
+ }))
2759
+ };
2760
+ }
2761
+ function buildStylizedCurve(points) {
2762
+ if (points.length <= 1) {
2763
+ return [];
2764
+ }
2765
+ const minLat = Math.min(...points.map((point) => point.latitude));
2766
+ const maxLat = Math.max(...points.map((point) => point.latitude));
2767
+ const minLng = Math.min(...points.map((point) => point.longitude));
2768
+ const maxLng = Math.max(...points.map((point) => point.longitude));
2769
+ const latRange = Math.max(0.0001, maxLat - minLat);
2770
+ const lngRange = Math.max(0.0001, maxLng - minLng);
2771
+ return points.map((point, index) => ({
2772
+ x: round((index / Math.max(1, points.length - 1)) * 100, 2),
2773
+ y: round(60 -
2774
+ (((point.latitude - minLat) / latRange) * 26 -
2775
+ ((point.longitude - minLng) / lngRange) * 8), 2)
2776
+ }));
2777
+ }
2778
+ export function getMovementTripDetail(tripId) {
2779
+ const tripRow = getDatabase()
2780
+ .prepare(`SELECT *
2781
+ FROM movement_trips
2782
+ WHERE id = ?`)
2783
+ .get(tripId);
2784
+ if (!tripRow) {
2785
+ return undefined;
2786
+ }
2787
+ const places = listMovementPlaceRows([tripRow.user_id]).map(mapMovementPlace);
2788
+ const placesById = new Map(places.map((place) => [place.id, place]));
2789
+ const points = listTripPoints([tripId]);
2790
+ const stops = listTripStops([tripId]);
2791
+ const trip = mapMovementTrip(tripRow, placesById, points, stops);
2792
+ const stylizedPath = buildStylizedCurve(trip.points.map((point) => ({
2793
+ latitude: point.latitude,
2794
+ longitude: point.longitude
2795
+ })));
2796
+ const selectionAggregate = computeSelectionAggregate({
2797
+ startedAt: trip.startedAt,
2798
+ endedAt: trip.endedAt,
2799
+ stays: [],
2800
+ trips: [trip],
2801
+ userIds: [trip.userId]
2802
+ });
2803
+ return {
2804
+ trip,
2805
+ stylizedPath: {
2806
+ curve: stylizedPath,
2807
+ startLabel: trip.startPlace?.label ?? "Start",
2808
+ endLabel: trip.endPlace?.label ?? "End",
2809
+ stops: trip.stops.map((stop, index) => ({
2810
+ id: stop.id,
2811
+ label: stop.label || stop.place?.label || `Stop ${index + 1}`,
2812
+ durationSeconds: stop.durationSeconds
2813
+ }))
2814
+ },
2815
+ selectionAggregate
2816
+ };
2817
+ }
2818
+ export function getMovementSelectionAggregate(input) {
2819
+ const parsed = movementSelectionAggregateSchema.parse(input);
2820
+ const placeRows = listMovementPlaceRows(parsed.userIds);
2821
+ const placesById = new Map(placeRows.map((row) => {
2822
+ const mapped = mapMovementPlace(row);
2823
+ return [mapped.id, mapped];
2824
+ }));
2825
+ const stayRows = parsed.stayIds.length > 0
2826
+ ? getDatabase()
2827
+ .prepare(`SELECT *
2828
+ FROM movement_stays
2829
+ WHERE id IN (${parsed.stayIds.map(() => "?").join(",")})`)
2830
+ .all(...parsed.stayIds)
2831
+ : [];
2832
+ const tripRows = parsed.tripIds.length > 0
2833
+ ? getDatabase()
2834
+ .prepare(`SELECT *
2835
+ FROM movement_trips
2836
+ WHERE id IN (${parsed.tripIds.map(() => "?").join(",")})`)
2837
+ .all(...parsed.tripIds)
2838
+ : [];
2839
+ const tripIds = tripRows.map((row) => row.id);
2840
+ const pointsByTrip = new Map();
2841
+ listTripPoints(tripIds).forEach((point) => {
2842
+ pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
2843
+ });
2844
+ const stopsByTrip = new Map();
2845
+ listTripStops(tripIds).forEach((stop) => {
2846
+ stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
2847
+ });
2848
+ const stays = stayRows.map((row) => mapMovementStay(row, placesById));
2849
+ const trips = tripRows.map((row) => mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? []));
2850
+ const startedAt = parsed.startedAt ??
2851
+ [...stays.map((stay) => stay.startedAt), ...trips.map((trip) => trip.startedAt)]
2852
+ .sort()[0] ??
2853
+ nowIso();
2854
+ const endedAt = parsed.endedAt ??
2855
+ [...stays.map((stay) => stay.endedAt), ...trips.map((trip) => trip.endedAt)]
2856
+ .sort()
2857
+ .slice(-1)[0] ??
2858
+ nowIso();
2859
+ return computeSelectionAggregate({
2860
+ startedAt,
2861
+ endedAt,
2862
+ stays,
2863
+ trips,
2864
+ userIds: parsed.userIds
2865
+ });
2866
+ }
2867
+ export function getMovementMobileBootstrap(pairing) {
2868
+ const canonicalTripExternalUids = new Set();
2869
+ const canonicalStayExternalUids = new Set();
2870
+ getDatabase()
2871
+ .prepare(`SELECT DISTINCT trip_external_uid
2872
+ FROM movement_trip_point_tombstones
2873
+ WHERE user_id = ?
2874
+ UNION
2875
+ SELECT DISTINCT trip_external_uid
2876
+ FROM movement_trip_point_overrides
2877
+ WHERE user_id = ?`)
2878
+ .all(pairing.user_id, pairing.user_id).forEach((row) => {
2879
+ if (row.trip_external_uid.trim().length > 0) {
2880
+ canonicalTripExternalUids.add(row.trip_external_uid);
2881
+ }
2882
+ });
2883
+ getDatabase()
2884
+ .prepare(`SELECT DISTINCT stay_external_uid
2885
+ FROM movement_stay_tombstones
2886
+ WHERE user_id = ?
2887
+ UNION
2888
+ SELECT DISTINCT stay_external_uid
2889
+ FROM movement_stay_overrides
2890
+ WHERE user_id = ?`)
2891
+ .all(pairing.user_id, pairing.user_id).forEach((row) => {
2892
+ if (row.stay_external_uid.trim().length > 0) {
2893
+ canonicalStayExternalUids.add(row.stay_external_uid);
2894
+ }
2895
+ });
2896
+ const tripRows = canonicalTripExternalUids.size > 0
2897
+ ? getDatabase()
2898
+ .prepare(`SELECT *
2899
+ FROM movement_trips
2900
+ WHERE user_id = ?
2901
+ AND external_uid IN (${[...canonicalTripExternalUids]
2902
+ .map(() => "?")
2903
+ .join(",")})`)
2904
+ .all(pairing.user_id, ...canonicalTripExternalUids)
2905
+ : [];
2906
+ const stayRows = canonicalStayExternalUids.size > 0
2907
+ ? getDatabase()
2908
+ .prepare(`SELECT *
2909
+ FROM movement_stays
2910
+ WHERE user_id = ?
2911
+ AND external_uid IN (${[...canonicalStayExternalUids]
2912
+ .map(() => "?")
2913
+ .join(",")})`)
2914
+ .all(pairing.user_id, ...canonicalStayExternalUids)
2915
+ : [];
2916
+ const placeRows = listMovementPlaceRows([pairing.user_id]);
2917
+ const places = placeRows.map(mapMovementPlace);
2918
+ const placesById = new Map(places.map((place) => [place.id, place]));
2919
+ const pointsByTrip = new Map();
2920
+ listTripPoints(tripRows.map((row) => row.id)).forEach((point) => {
2921
+ pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
2922
+ });
2923
+ const stopsByTrip = new Map();
2924
+ listTripStops(tripRows.map((row) => row.id)).forEach((stop) => {
2925
+ stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
2926
+ });
2927
+ return {
2928
+ settings: getMovementSettings([pairing.user_id]),
2929
+ places,
2930
+ stayOverrides: stayRows.map((stay) => mapMovementStay(stay, placesById)),
2931
+ tripOverrides: tripRows.map((trip) => mapMovementTrip(trip, placesById, pointsByTrip.get(trip.id) ?? [], stopsByTrip.get(trip.id) ?? [])),
2932
+ deletedStayExternalUids: listMovementStayTombstones(pairing.user_id).map((row) => row.stay_external_uid),
2933
+ deletedTripExternalUids: listMovementTripTombstones(pairing.user_id).map((row) => row.trip_external_uid)
2934
+ };
2935
+ }