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.
- package/README.md +13 -0
- package/dist/assets/{board-_C6oMy5w.js → board-VmF4FAfr.js} +3 -3
- package/dist/assets/{board-_C6oMy5w.js.map → board-VmF4FAfr.js.map} +1 -1
- package/dist/assets/index-CFCKDIMH.js +67 -0
- package/dist/assets/index-CFCKDIMH.js.map +1 -0
- package/dist/assets/index-ZPY6U1TU.css +1 -0
- package/dist/assets/{motion-D4sZgCHd.js → motion-DvkU14p-.js} +3 -3
- package/dist/assets/motion-DvkU14p-.js.map +1 -0
- package/dist/assets/{table-BWzTaky1.js → table-DgiPof9E.js} +2 -2
- package/dist/assets/{table-BWzTaky1.js.map → table-DgiPof9E.js.map} +1 -1
- package/dist/assets/{ui-BzK4azQb.js → ui-nYfoC0Gq.js} +2 -2
- package/dist/assets/{ui-BzK4azQb.js.map → ui-nYfoC0Gq.js.map} +1 -1
- package/dist/assets/vendor-D9PTEPSB.js +824 -0
- package/dist/assets/vendor-D9PTEPSB.js.map +1 -0
- package/dist/assets/viz-Cqb6s--o.js +34 -0
- package/dist/assets/viz-Cqb6s--o.js.map +1 -0
- package/dist/index.html +8 -8
- package/dist/openclaw/parity.d.ts +1 -1
- package/dist/openclaw/parity.js +29 -0
- package/dist/openclaw/plugin-entry-shared.d.ts +1 -0
- package/dist/openclaw/plugin-entry-shared.js +7 -4
- package/dist/openclaw/plugin-sdk-types.d.ts +12 -0
- package/dist/openclaw/routes.js +236 -0
- package/dist/openclaw/session-bootstrap.d.ts +78 -0
- package/dist/openclaw/session-bootstrap.js +240 -0
- package/dist/openclaw/tools.js +279 -3
- package/dist/server/app.js +855 -19
- package/dist/server/connectors/box-registry.js +257 -0
- package/dist/server/db.js +2 -0
- package/dist/server/discovery-advertiser.js +114 -0
- package/dist/server/health.js +39 -11
- package/dist/server/index.js +4 -0
- package/dist/server/managers/platform/llm-manager.js +40 -4
- package/dist/server/managers/platform/openai-responses-provider.js +129 -19
- package/dist/server/movement.js +2935 -0
- package/dist/server/openapi.js +628 -5
- package/dist/server/psyche-types.js +15 -1
- package/dist/server/questionnaire-flow.js +552 -0
- package/dist/server/questionnaire-seeds.js +853 -0
- package/dist/server/questionnaire-types.js +340 -0
- package/dist/server/repositories/ai-connectors.js +944 -0
- package/dist/server/repositories/ai-processors.js +547 -0
- package/dist/server/repositories/diagnostic-logs.js +57 -4
- package/dist/server/repositories/entity-ownership.js +9 -1
- package/dist/server/repositories/habits.js +77 -9
- package/dist/server/repositories/model-settings.js +216 -0
- package/dist/server/repositories/notes.js +57 -15
- package/dist/server/repositories/preferences.js +124 -0
- package/dist/server/repositories/questionnaires.js +1338 -0
- package/dist/server/repositories/rewards.js +2 -2
- package/dist/server/repositories/settings.js +108 -12
- package/dist/server/repositories/surface-layouts.js +76 -0
- package/dist/server/repositories/wiki-memory.js +5 -1
- package/dist/server/services/entity-crud.js +81 -2
- package/dist/server/services/openai-codex-oauth.js +153 -0
- package/dist/server/services/psyche-observation-calendar.js +46 -0
- package/dist/server/types.js +492 -3
- package/dist/server/watch-mobile.js +562 -0
- package/dist/server/web.js +9 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -1
- package/server/migrations/024_questionnaires.sql +96 -0
- package/server/migrations/025_ai_model_connections.sql +26 -0
- package/server/migrations/026_custom_theme_settings.sql +2 -0
- package/server/migrations/027_ai_processors.sql +31 -0
- package/server/migrations/028_movement_domain.sql +136 -0
- package/server/migrations/029_watch_micro_capture.sql +23 -0
- package/server/migrations/030_surface_layouts.sql +5 -0
- package/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
- package/server/migrations/032_ai_connectors.sql +44 -0
- package/server/migrations/033_movement_trip_point_sync.sql +36 -0
- package/server/migrations/034_movement_segment_sync.sql +49 -0
- package/skills/forge-openclaw/SKILL.md +12 -1
- package/skills/forge-openclaw/entity_conversation_playbooks.md +331 -84
- package/skills/forge-openclaw/psyche_entity_playbooks.md +252 -221
- package/dist/assets/index-Ch_xeZ2u.js +0 -63
- package/dist/assets/index-Ch_xeZ2u.js.map +0 -1
- package/dist/assets/index-DvVM7K6j.css +0 -1
- package/dist/assets/motion-D4sZgCHd.js.map +0 -1
- package/dist/assets/vendor-De38P6YR.js +0 -729
- package/dist/assets/vendor-De38P6YR.js.map +0 -1
- package/dist/assets/viz-C6hfyqzu.js +0 -34
- package/dist/assets/viz-C6hfyqzu.js.map +0 -1
- 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
|
+
}
|