forge-openclaw-plugin 0.2.26 → 0.2.28
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 +60 -3
- package/dist/assets/{board-ta0rUHOf.js → board-DPFvZf-D.js} +2 -2
- package/dist/assets/{board-ta0rUHOf.js.map → board-DPFvZf-D.js.map} +1 -1
- package/dist/assets/index-Auw3JrdE.css +1 -0
- package/dist/assets/index-D1H7myQH.js +85 -0
- package/dist/assets/index-D1H7myQH.js.map +1 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
- package/dist/assets/{motion-fBKPB6yw.js → motion-Bvwc85ch.js} +2 -2
- package/dist/assets/{motion-fBKPB6yw.js.map → motion-Bvwc85ch.js.map} +1 -1
- package/dist/assets/{table-C-IGTQni.js → table-FJQTJvUR.js} +2 -2
- package/dist/assets/{table-C-IGTQni.js.map → table-FJQTJvUR.js.map} +1 -1
- package/dist/assets/{ui-DInOpaYF.js → ui-GXFcgvSw.js} +2 -2
- package/dist/assets/{ui-DInOpaYF.js.map → ui-GXFcgvSw.js.map} +1 -1
- package/dist/assets/vendor-Cwf49UMz.js +1247 -0
- package/dist/assets/vendor-Cwf49UMz.js.map +1 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/local-runtime.js +16 -0
- package/dist/openclaw/routes.d.ts +27 -0
- package/dist/openclaw/routes.js +16 -12
- package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
- package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
- package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
- package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
- package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/dist/server/server/src/app.js +1900 -91
- package/dist/server/server/src/connectors/box-registry.js +44 -9
- package/dist/server/server/src/data-management-types.js +107 -0
- package/dist/server/server/src/db.js +68 -4
- package/dist/server/server/src/demo-data.js +2 -2
- package/dist/server/server/src/health.js +702 -18
- package/dist/server/server/src/managers/platform/llm-manager.js +7 -4
- package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
- package/dist/server/server/src/managers/platform/secrets-manager.js +18 -1
- package/dist/server/server/src/managers/runtime.js +9 -0
- package/dist/server/server/src/movement.js +1971 -112
- package/dist/server/server/src/openapi.js +1390 -105
- package/dist/server/server/src/psyche-types.js +9 -1
- package/dist/server/server/src/repositories/activity-events.js +8 -0
- package/dist/server/server/src/repositories/ai-connectors.js +522 -74
- package/dist/server/server/src/repositories/calendar.js +151 -0
- package/dist/server/server/src/repositories/habits.js +37 -1
- package/dist/server/server/src/repositories/model-settings.js +13 -3
- package/dist/server/server/src/repositories/notes.js +3 -0
- package/dist/server/server/src/repositories/settings.js +380 -18
- package/dist/server/server/src/repositories/tasks.js +170 -10
- package/dist/server/server/src/runtime-data-root.js +82 -0
- package/dist/server/server/src/screen-time.js +802 -0
- package/dist/server/server/src/services/data-management.js +788 -0
- package/dist/server/server/src/services/entity-crud.js +205 -2
- package/dist/server/server/src/services/knowledge-graph.js +1455 -0
- package/dist/server/server/src/services/life-force-model.js +217 -0
- package/dist/server/server/src/services/life-force.js +2506 -0
- package/dist/server/server/src/services/psyche-observation-calendar.js +383 -16
- package/dist/server/server/src/types.js +307 -14
- package/dist/server/server/src/web.js +228 -13
- package/dist/server/src/components/customization/utility-widgets.js +136 -27
- package/dist/server/src/components/ui/info-tooltip.js +25 -0
- package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/health/health-boxes.js +63 -8
- package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
- package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +62 -54
- package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +18 -8
- package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +56 -38
- package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
- package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +35 -30
- package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
- package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
- package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
- package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +3 -1
- package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +39 -3
- package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
- package/dist/server/src/components/workbench-boxes/today/today-boxes.js +47 -32
- package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
- package/dist/server/src/lib/api.js +280 -21
- package/dist/server/src/lib/data-management-types.js +1 -0
- package/dist/server/src/lib/entity-visuals.js +279 -0
- package/dist/server/src/lib/knowledge-graph-types.js +276 -0
- package/dist/server/src/lib/knowledge-graph.js +470 -0
- package/dist/server/src/lib/schemas.js +4 -0
- package/dist/server/src/lib/snapshot-normalizer.js +45 -1
- package/dist/server/src/lib/workbench/contracts.js +229 -0
- package/dist/server/src/lib/workbench/nodes.js +200 -0
- package/dist/server/src/lib/workbench/registry.js +52 -5
- package/dist/server/src/lib/workbench/runtime.js +254 -38
- package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/server/migrations/038_data_management_settings.sql +11 -0
- package/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/server/migrations/040_screen_time_domain.sql +89 -0
- package/server/migrations/041_companion_source_states.sql +21 -0
- package/server/migrations/042_movement_boxes.sql +47 -0
- package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/skills/forge-openclaw/SKILL.md +41 -11
- package/skills/forge-openclaw/entity_conversation_playbooks.md +448 -34
- package/skills/forge-openclaw/psyche_entity_playbooks.md +170 -17
- package/dist/assets/index-Ro0ZF_az.css +0 -1
- package/dist/assets/index-ytlpSj23.js +0 -79
- package/dist/assets/index-ytlpSj23.js.map +0 -1
- package/dist/assets/vendor-lE3tZJcC.js +0 -876
- package/dist/assets/vendor-lE3tZJcC.js.map +0 -1
|
@@ -8,6 +8,7 @@ import { createManualRewardGrant } from "./repositories/rewards.js";
|
|
|
8
8
|
import { listTaskRuns } from "./repositories/task-runs.js";
|
|
9
9
|
import { getDefaultUser } from "./repositories/users.js";
|
|
10
10
|
import { listWikiSpaces } from "./repositories/wiki-memory.js";
|
|
11
|
+
import { getScreenTimeOverlapSummary } from "./screen-time.js";
|
|
11
12
|
const movementPublishModeSchema = z.enum([
|
|
12
13
|
"auto_publish",
|
|
13
14
|
"draft_review",
|
|
@@ -274,6 +275,61 @@ export const movementTripPatchSchema = z.object({
|
|
|
274
275
|
tags: z.array(z.string().trim()).optional(),
|
|
275
276
|
metadata: z.record(z.string(), z.unknown()).optional()
|
|
276
277
|
});
|
|
278
|
+
const movementBoxKindSchema = z.enum(["stay", "trip", "missing"]);
|
|
279
|
+
const movementBoxSourceKindSchema = z.enum(["automatic", "user_defined"]);
|
|
280
|
+
const movementBoxOriginSchema = z.enum([
|
|
281
|
+
"recorded",
|
|
282
|
+
"continued_stay",
|
|
283
|
+
"repaired_gap",
|
|
284
|
+
"missing",
|
|
285
|
+
"user_defined",
|
|
286
|
+
"user_invalidated"
|
|
287
|
+
]);
|
|
288
|
+
const movementUserBoxSchemaBase = z.object({
|
|
289
|
+
kind: movementBoxKindSchema,
|
|
290
|
+
startedAt: z.string().datetime(),
|
|
291
|
+
endedAt: z.string().datetime(),
|
|
292
|
+
title: z.string().trim().default(""),
|
|
293
|
+
subtitle: z.string().trim().default(""),
|
|
294
|
+
placeLabel: z.string().trim().nullable().default(null),
|
|
295
|
+
anchorExternalUid: z.string().trim().min(1).nullable().default(null),
|
|
296
|
+
tags: z.array(z.string().trim()).default([]),
|
|
297
|
+
distanceMeters: z.number().nonnegative().nullable().default(null),
|
|
298
|
+
averageSpeedMps: z.number().nonnegative().nullable().default(null),
|
|
299
|
+
metadata: z.record(z.string(), z.unknown()).default({})
|
|
300
|
+
});
|
|
301
|
+
export const movementUserBoxCreateSchema = movementUserBoxSchemaBase.superRefine((value, ctx) => {
|
|
302
|
+
if (Date.parse(value.endedAt) <= Date.parse(value.startedAt)) {
|
|
303
|
+
ctx.addIssue({
|
|
304
|
+
code: z.ZodIssueCode.custom,
|
|
305
|
+
path: ["endedAt"],
|
|
306
|
+
message: "Movement boxes must end after they start."
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
export const movementUserBoxPatchSchema = movementUserBoxSchemaBase.partial().superRefine((value, ctx) => {
|
|
311
|
+
if (value.startedAt && value.endedAt) {
|
|
312
|
+
if (Date.parse(value.endedAt) <= Date.parse(value.startedAt)) {
|
|
313
|
+
ctx.addIssue({
|
|
314
|
+
code: z.ZodIssueCode.custom,
|
|
315
|
+
path: ["endedAt"],
|
|
316
|
+
message: "Movement boxes must end after they start."
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
export const movementUserBoxPreflightSchema = movementUserBoxSchemaBase.extend({
|
|
322
|
+
excludeBoxId: z.string().trim().min(1).nullable().optional(),
|
|
323
|
+
rangeStart: z.string().datetime().nullable().optional(),
|
|
324
|
+
rangeEnd: z.string().datetime().nullable().optional()
|
|
325
|
+
});
|
|
326
|
+
export const movementAutomaticBoxInvalidateSchema = z.object({
|
|
327
|
+
startedAt: z.string().datetime().optional(),
|
|
328
|
+
endedAt: z.string().datetime().optional(),
|
|
329
|
+
title: z.string().trim().optional(),
|
|
330
|
+
subtitle: z.string().trim().optional(),
|
|
331
|
+
metadata: z.record(z.string(), z.unknown()).default({})
|
|
332
|
+
});
|
|
277
333
|
export const movementTripPointPatchSchema = z.object({
|
|
278
334
|
recordedAt: z.string().datetime().optional(),
|
|
279
335
|
latitude: z.number().finite().optional(),
|
|
@@ -294,12 +350,25 @@ export const movementMobileTimelineSchema = movementMobileBootstrapSchema.extend
|
|
|
294
350
|
export const movementMobilePlaceMutationSchema = movementMobileBootstrapSchema.extend({
|
|
295
351
|
place: movementPlaceMutationSchema.omit({ userId: true, source: true })
|
|
296
352
|
});
|
|
353
|
+
export const movementMobileUserBoxCreateSchema = movementMobileBootstrapSchema.extend({
|
|
354
|
+
box: movementUserBoxCreateSchema
|
|
355
|
+
});
|
|
356
|
+
export const movementMobileUserBoxPatchSchema = movementMobileBootstrapSchema.extend({
|
|
357
|
+
patch: movementUserBoxPatchSchema
|
|
358
|
+
});
|
|
359
|
+
export const movementMobileUserBoxPreflightSchema = movementMobileBootstrapSchema.extend({
|
|
360
|
+
draft: movementUserBoxPreflightSchema
|
|
361
|
+
});
|
|
362
|
+
export const movementMobileAutomaticBoxInvalidateSchema = movementMobileBootstrapSchema.extend({
|
|
363
|
+
invalidate: movementAutomaticBoxInvalidateSchema
|
|
364
|
+
});
|
|
297
365
|
export const movementMobileStayPatchSchema = movementMobileBootstrapSchema.extend({
|
|
298
366
|
patch: movementStayPatchSchema
|
|
299
367
|
});
|
|
300
368
|
export const movementMobileTripPatchSchema = movementMobileBootstrapSchema.extend({
|
|
301
369
|
patch: movementTripPatchSchema
|
|
302
370
|
});
|
|
371
|
+
const MISSING_MOVEMENT_DATA_THRESHOLD_SECONDS = 60 * 60;
|
|
303
372
|
function nowIso() {
|
|
304
373
|
return new Date().toISOString();
|
|
305
374
|
}
|
|
@@ -398,6 +467,206 @@ function listMovementTripOverrides(userId) {
|
|
|
398
467
|
WHERE user_id = ?`)
|
|
399
468
|
.all(userId);
|
|
400
469
|
}
|
|
470
|
+
function listMovementBoxRows(input) {
|
|
471
|
+
const clauses = [];
|
|
472
|
+
const values = [];
|
|
473
|
+
if (input.userIds && input.userIds.length > 0) {
|
|
474
|
+
clauses.push(`user_id IN (${input.userIds.map(() => "?").join(",")})`);
|
|
475
|
+
values.push(...input.userIds);
|
|
476
|
+
}
|
|
477
|
+
if (input.sourceKinds && input.sourceKinds.length > 0) {
|
|
478
|
+
clauses.push(`source_kind IN (${input.sourceKinds.map(() => "?").join(",")})`);
|
|
479
|
+
values.push(...input.sourceKinds);
|
|
480
|
+
}
|
|
481
|
+
if (!input.includeDeleted) {
|
|
482
|
+
clauses.push(`deleted_at IS NULL`);
|
|
483
|
+
}
|
|
484
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
485
|
+
return getDatabase()
|
|
486
|
+
.prepare(`SELECT *
|
|
487
|
+
FROM movement_boxes
|
|
488
|
+
${where}
|
|
489
|
+
ORDER BY started_at ASC, ended_at ASC, created_at ASC`)
|
|
490
|
+
.all(...values);
|
|
491
|
+
}
|
|
492
|
+
function getMovementBoxRow(id) {
|
|
493
|
+
return getDatabase()
|
|
494
|
+
.prepare(`SELECT *
|
|
495
|
+
FROM movement_boxes
|
|
496
|
+
WHERE id = ?`)
|
|
497
|
+
.get(id);
|
|
498
|
+
}
|
|
499
|
+
function movementBoxTags(row) {
|
|
500
|
+
return uniqStrings(safeJsonParse(row.tags_json, []));
|
|
501
|
+
}
|
|
502
|
+
function movementBoxMetadata(row) {
|
|
503
|
+
return safeJsonParse(row.metadata_json, {});
|
|
504
|
+
}
|
|
505
|
+
function movementBoxRawStayIds(row) {
|
|
506
|
+
return safeJsonParse(row.raw_stay_ids_json, []);
|
|
507
|
+
}
|
|
508
|
+
function movementBoxRawTripIds(row) {
|
|
509
|
+
return safeJsonParse(row.raw_trip_ids_json, []);
|
|
510
|
+
}
|
|
511
|
+
function movementBoxOverriddenAutomaticBoxIds(row) {
|
|
512
|
+
return safeJsonParse(row.overridden_automatic_box_ids_json, []);
|
|
513
|
+
}
|
|
514
|
+
function movementBoxOverriddenUserBoxIds(row) {
|
|
515
|
+
return safeJsonParse(row.overridden_user_box_ids_json, []);
|
|
516
|
+
}
|
|
517
|
+
function movementBoxOverrideRanges(row) {
|
|
518
|
+
return safeJsonParse(row.override_ranges_json, []);
|
|
519
|
+
}
|
|
520
|
+
function normalizeMovementBoxTitle(row) {
|
|
521
|
+
const title = row.title.trim();
|
|
522
|
+
if (row.kind !== "missing") {
|
|
523
|
+
return title;
|
|
524
|
+
}
|
|
525
|
+
if (row.source_kind === "automatic") {
|
|
526
|
+
return "Missing data";
|
|
527
|
+
}
|
|
528
|
+
if (title.length === 0) {
|
|
529
|
+
return row.origin === "user_invalidated"
|
|
530
|
+
? "User invalidated movement"
|
|
531
|
+
: "User-defined missing data";
|
|
532
|
+
}
|
|
533
|
+
const normalized = title.toLowerCase();
|
|
534
|
+
if (normalized === "stay" ||
|
|
535
|
+
normalized === "continued stay" ||
|
|
536
|
+
normalized === "repaired stay") {
|
|
537
|
+
return row.origin === "user_invalidated"
|
|
538
|
+
? "User invalidated movement"
|
|
539
|
+
: "User-defined missing data";
|
|
540
|
+
}
|
|
541
|
+
return title;
|
|
542
|
+
}
|
|
543
|
+
function normalizeMovementBoxSubtitle(row) {
|
|
544
|
+
const subtitle = row.subtitle.trim();
|
|
545
|
+
if (row.kind !== "missing") {
|
|
546
|
+
return subtitle;
|
|
547
|
+
}
|
|
548
|
+
if (row.source_kind === "automatic") {
|
|
549
|
+
return "No trusted movement signal for this period.";
|
|
550
|
+
}
|
|
551
|
+
if (subtitle.length > 0) {
|
|
552
|
+
return subtitle;
|
|
553
|
+
}
|
|
554
|
+
return row.origin === "user_invalidated"
|
|
555
|
+
? "Overrides the automatic movement box with missing data."
|
|
556
|
+
: "User-defined missing-data override.";
|
|
557
|
+
}
|
|
558
|
+
function mapMovementBoxRow(row) {
|
|
559
|
+
return {
|
|
560
|
+
id: row.id,
|
|
561
|
+
boxId: row.id,
|
|
562
|
+
kind: row.kind,
|
|
563
|
+
sourceKind: row.source_kind,
|
|
564
|
+
origin: row.origin,
|
|
565
|
+
editable: row.editable === 1,
|
|
566
|
+
startedAt: row.started_at,
|
|
567
|
+
endedAt: row.ended_at,
|
|
568
|
+
trueStartedAt: row.true_started_at ?? row.started_at,
|
|
569
|
+
trueEndedAt: row.true_ended_at ?? row.ended_at,
|
|
570
|
+
visibleStartedAt: row.started_at,
|
|
571
|
+
visibleEndedAt: row.ended_at,
|
|
572
|
+
durationSeconds: durationSeconds(row.started_at, row.ended_at),
|
|
573
|
+
title: normalizeMovementBoxTitle(row),
|
|
574
|
+
subtitle: normalizeMovementBoxSubtitle(row),
|
|
575
|
+
placeLabel: row.place_label,
|
|
576
|
+
anchorExternalUid: row.anchor_external_uid,
|
|
577
|
+
tags: movementBoxTags(row),
|
|
578
|
+
distanceMeters: row.distance_meters ?? 0,
|
|
579
|
+
averageSpeedMps: row.average_speed_mps ?? 0,
|
|
580
|
+
overrideCount: row.override_count,
|
|
581
|
+
overriddenAutomaticBoxIds: movementBoxOverriddenAutomaticBoxIds(row),
|
|
582
|
+
overriddenUserBoxIds: movementBoxOverriddenUserBoxIds(row),
|
|
583
|
+
isFullyHidden: row.is_fully_hidden === 1,
|
|
584
|
+
rawStayIds: movementBoxRawStayIds(row),
|
|
585
|
+
rawTripIds: movementBoxRawTripIds(row),
|
|
586
|
+
rawPointCount: row.raw_point_count,
|
|
587
|
+
hasLegacyCorrections: row.has_legacy_corrections === 1,
|
|
588
|
+
metadata: {
|
|
589
|
+
...movementBoxMetadata(row),
|
|
590
|
+
overrideRanges: movementBoxOverrideRanges(row),
|
|
591
|
+
overriddenStartedAt: row.overridden_started_at,
|
|
592
|
+
overriddenEndedAt: row.overridden_ended_at,
|
|
593
|
+
overriddenByBoxId: row.overridden_by_box_id,
|
|
594
|
+
isOverridden: row.is_overridden === 1
|
|
595
|
+
},
|
|
596
|
+
createdAt: row.created_at,
|
|
597
|
+
updatedAt: row.updated_at
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function insertMovementBox(input) {
|
|
601
|
+
const now = nowIso();
|
|
602
|
+
const existingByLegacyOriginKey = input.legacyOriginKey != null
|
|
603
|
+
? (getDatabase()
|
|
604
|
+
.prepare(`SELECT id
|
|
605
|
+
FROM movement_boxes
|
|
606
|
+
WHERE user_id = ?
|
|
607
|
+
AND legacy_origin_key = ?`)
|
|
608
|
+
.get(input.userId, input.legacyOriginKey) ??
|
|
609
|
+
null)
|
|
610
|
+
: null;
|
|
611
|
+
const id = existingByLegacyOriginKey?.id ??
|
|
612
|
+
input.id ??
|
|
613
|
+
`mbx_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
614
|
+
getDatabase()
|
|
615
|
+
.prepare(`INSERT INTO movement_boxes (
|
|
616
|
+
id, user_id, kind, source_kind, origin, started_at, ended_at, title, subtitle,
|
|
617
|
+
place_label, anchor_external_uid, tags_json, distance_meters, average_speed_mps,
|
|
618
|
+
editable, override_count, overridden_automatic_box_ids_json,
|
|
619
|
+
true_started_at, true_ended_at, overridden_started_at, overridden_ended_at,
|
|
620
|
+
overridden_by_box_id, overridden_user_box_ids_json, override_ranges_json,
|
|
621
|
+
is_overridden, is_fully_hidden, raw_stay_ids_json,
|
|
622
|
+
raw_trip_ids_json, raw_point_count, has_legacy_corrections, legacy_origin_key,
|
|
623
|
+
metadata_json, deleted_at, created_at, updated_at
|
|
624
|
+
)
|
|
625
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
626
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
627
|
+
kind = excluded.kind,
|
|
628
|
+
source_kind = excluded.source_kind,
|
|
629
|
+
origin = excluded.origin,
|
|
630
|
+
started_at = excluded.started_at,
|
|
631
|
+
ended_at = excluded.ended_at,
|
|
632
|
+
title = excluded.title,
|
|
633
|
+
subtitle = excluded.subtitle,
|
|
634
|
+
place_label = excluded.place_label,
|
|
635
|
+
anchor_external_uid = excluded.anchor_external_uid,
|
|
636
|
+
tags_json = excluded.tags_json,
|
|
637
|
+
distance_meters = excluded.distance_meters,
|
|
638
|
+
average_speed_mps = excluded.average_speed_mps,
|
|
639
|
+
editable = excluded.editable,
|
|
640
|
+
override_count = excluded.override_count,
|
|
641
|
+
overridden_automatic_box_ids_json = excluded.overridden_automatic_box_ids_json,
|
|
642
|
+
true_started_at = excluded.true_started_at,
|
|
643
|
+
true_ended_at = excluded.true_ended_at,
|
|
644
|
+
overridden_started_at = excluded.overridden_started_at,
|
|
645
|
+
overridden_ended_at = excluded.overridden_ended_at,
|
|
646
|
+
overridden_by_box_id = excluded.overridden_by_box_id,
|
|
647
|
+
overridden_user_box_ids_json = excluded.overridden_user_box_ids_json,
|
|
648
|
+
override_ranges_json = excluded.override_ranges_json,
|
|
649
|
+
is_overridden = excluded.is_overridden,
|
|
650
|
+
is_fully_hidden = excluded.is_fully_hidden,
|
|
651
|
+
raw_stay_ids_json = excluded.raw_stay_ids_json,
|
|
652
|
+
raw_trip_ids_json = excluded.raw_trip_ids_json,
|
|
653
|
+
raw_point_count = excluded.raw_point_count,
|
|
654
|
+
has_legacy_corrections = excluded.has_legacy_corrections,
|
|
655
|
+
legacy_origin_key = excluded.legacy_origin_key,
|
|
656
|
+
metadata_json = excluded.metadata_json,
|
|
657
|
+
deleted_at = excluded.deleted_at,
|
|
658
|
+
updated_at = excluded.updated_at`)
|
|
659
|
+
.run(id, input.userId, input.kind, input.sourceKind, input.origin, input.startedAt, input.endedAt, input.title ?? "", input.subtitle ?? "", input.placeLabel ?? null, input.anchorExternalUid ?? null, JSON.stringify(uniqStrings(input.tags ?? [])), input.distanceMeters ?? null, input.averageSpeedMps ?? null, input.editable ? 1 : 0, input.overrideCount ?? 0, JSON.stringify(input.overriddenAutomaticBoxIds ?? []), input.trueStartedAt ?? input.startedAt, input.trueEndedAt ?? input.endedAt, input.overriddenStartedAt ?? null, input.overriddenEndedAt ?? null, input.overriddenByBoxId ?? null, JSON.stringify(input.overriddenUserBoxIds ?? []), JSON.stringify(input.overrideRanges ?? []), input.isOverridden ? 1 : 0, input.isFullyHidden ? 1 : 0, JSON.stringify(input.rawStayIds ?? []), JSON.stringify(input.rawTripIds ?? []), input.rawPointCount ?? 0, input.hasLegacyCorrections ? 1 : 0, input.legacyOriginKey ?? null, JSON.stringify(input.metadata ?? {}), input.deletedAt ?? null, input.createdAt ?? now, input.updatedAt ?? now);
|
|
660
|
+
return getMovementBoxRow(id);
|
|
661
|
+
}
|
|
662
|
+
function softDeleteMovementBox(id) {
|
|
663
|
+
const now = nowIso();
|
|
664
|
+
getDatabase()
|
|
665
|
+
.prepare(`UPDATE movement_boxes
|
|
666
|
+
SET deleted_at = ?, updated_at = ?
|
|
667
|
+
WHERE id = ?`)
|
|
668
|
+
.run(now, now, id);
|
|
669
|
+
}
|
|
401
670
|
function applyMovementStaySyncDirectives(userId, stay) {
|
|
402
671
|
const tombstoned = new Set(listMovementStayTombstones(userId).map((row) => row.stay_external_uid));
|
|
403
672
|
if (tombstoned.has(stay.externalUid)) {
|
|
@@ -608,7 +877,9 @@ function decodeMovementTimelineCursor(rawValue) {
|
|
|
608
877
|
typeof parsed.endedAt !== "string") {
|
|
609
878
|
return null;
|
|
610
879
|
}
|
|
611
|
-
if (parsed.kind !== "stay" &&
|
|
880
|
+
if (parsed.kind !== "stay" &&
|
|
881
|
+
parsed.kind !== "trip" &&
|
|
882
|
+
parsed.kind !== "missing") {
|
|
612
883
|
return null;
|
|
613
884
|
}
|
|
614
885
|
return parsed;
|
|
@@ -842,6 +1113,16 @@ function mapMovementTrip(row, placesById, points = [], stops = []) {
|
|
|
842
1113
|
: null
|
|
843
1114
|
};
|
|
844
1115
|
}
|
|
1116
|
+
function enrichMovementSegmentWithScreenTime(segment, userIds) {
|
|
1117
|
+
return {
|
|
1118
|
+
...segment,
|
|
1119
|
+
...getScreenTimeOverlapSummary({
|
|
1120
|
+
startedAt: segment.startedAt,
|
|
1121
|
+
endedAt: segment.endedAt,
|
|
1122
|
+
userIds
|
|
1123
|
+
})
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
845
1126
|
function getMovementSettingsRow(userId) {
|
|
846
1127
|
return getDatabase()
|
|
847
1128
|
.prepare(`SELECT *
|
|
@@ -1688,6 +1969,7 @@ export function ingestMovementSync(pairing, payload) {
|
|
|
1688
1969
|
}
|
|
1689
1970
|
});
|
|
1690
1971
|
cleanupRawTripPoints(pairing.user_id);
|
|
1972
|
+
rebuildAutomaticMovementBoxes(pairing.user_id);
|
|
1691
1973
|
recordActivityEvent({
|
|
1692
1974
|
entityType: "system",
|
|
1693
1975
|
entityId: pairing.id,
|
|
@@ -1821,25 +2103,687 @@ function compareMovementTimelineDescending(left, right) {
|
|
|
1821
2103
|
right.kind.localeCompare(left.kind) ||
|
|
1822
2104
|
right.id.localeCompare(left.id));
|
|
1823
2105
|
}
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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);
|
|
2106
|
+
function movementBoundaryDistanceMeters(left, right) {
|
|
2107
|
+
if (!left || !right) {
|
|
2108
|
+
return null;
|
|
1832
2109
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
2110
|
+
return haversineDistanceMeters({
|
|
2111
|
+
latitude: left.latitude,
|
|
2112
|
+
longitude: left.longitude
|
|
2113
|
+
}, {
|
|
2114
|
+
latitude: right.latitude,
|
|
2115
|
+
longitude: right.longitude
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
function movementBoundariesShareAnchor(left, right) {
|
|
2119
|
+
if (!left || !right) {
|
|
2120
|
+
return false;
|
|
1835
2121
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
2122
|
+
if (left.placeExternalUid &&
|
|
2123
|
+
right.placeExternalUid &&
|
|
2124
|
+
left.placeExternalUid === right.placeExternalUid) {
|
|
2125
|
+
return true;
|
|
1838
2126
|
}
|
|
1839
|
-
const
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2127
|
+
const displacementMeters = movementBoundaryDistanceMeters(left, right);
|
|
2128
|
+
return displacementMeters !== null && displacementMeters <= 100;
|
|
2129
|
+
}
|
|
2130
|
+
function buildDerivedMovementGapSegment(input) {
|
|
2131
|
+
return {
|
|
2132
|
+
id: input.id,
|
|
2133
|
+
kind: input.kind,
|
|
2134
|
+
origin: input.origin,
|
|
2135
|
+
startedAt: input.startedAt,
|
|
2136
|
+
endedAt: input.endedAt,
|
|
2137
|
+
durationSeconds: durationSeconds(input.startedAt, input.endedAt),
|
|
2138
|
+
displacementMeters: input.displacementMeters ?? null,
|
|
2139
|
+
placeLabel: input.placeLabel ?? null,
|
|
2140
|
+
suppressedShortJump: input.suppressedShortJump ?? false,
|
|
2141
|
+
startBoundary: input.startBoundary ?? null,
|
|
2142
|
+
endBoundary: input.endBoundary ?? null
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
function coalesceMovementStayCoverageSegments(segments) {
|
|
2146
|
+
const coalesced = [];
|
|
2147
|
+
for (const segment of segments) {
|
|
2148
|
+
const previous = coalesced[coalesced.length - 1];
|
|
2149
|
+
const shouldMerge = previous &&
|
|
2150
|
+
previous.kind === "stay" &&
|
|
2151
|
+
segment.kind === "stay" &&
|
|
2152
|
+
(previous.origin !== "recorded" || segment.origin !== "recorded") &&
|
|
2153
|
+
durationSeconds(previous.endedAt, segment.startedAt) === 0 &&
|
|
2154
|
+
movementBoundariesShareAnchor(previous.endBoundary, segment.startBoundary);
|
|
2155
|
+
if (!shouldMerge || !previous) {
|
|
2156
|
+
coalesced.push(segment);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
const mergedOrigin = previous.origin === "continued_stay" || segment.origin === "continued_stay"
|
|
2160
|
+
? "continued_stay"
|
|
2161
|
+
: previous.origin === "repaired_gap" || segment.origin === "repaired_gap"
|
|
2162
|
+
? "repaired_gap"
|
|
2163
|
+
: "recorded";
|
|
2164
|
+
coalesced[coalesced.length - 1] = {
|
|
2165
|
+
id: `coalesced_stay_${previous.id}_${segment.id}`,
|
|
2166
|
+
kind: "stay",
|
|
2167
|
+
origin: mergedOrigin,
|
|
2168
|
+
startedAt: previous.startedAt,
|
|
2169
|
+
endedAt: segment.endedAt,
|
|
2170
|
+
durationSeconds: durationSeconds(previous.startedAt, segment.endedAt),
|
|
2171
|
+
payload: null,
|
|
2172
|
+
displacementMeters: null,
|
|
2173
|
+
placeLabel: previous.placeLabel ??
|
|
2174
|
+
segment.placeLabel ??
|
|
2175
|
+
previous.endBoundary?.placeLabel ??
|
|
2176
|
+
segment.startBoundary?.placeLabel ??
|
|
2177
|
+
null,
|
|
2178
|
+
suppressedShortJump: previous.suppressedShortJump || segment.suppressedShortJump,
|
|
2179
|
+
startBoundary: previous.startBoundary,
|
|
2180
|
+
endBoundary: segment.endBoundary
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
return coalesced;
|
|
2184
|
+
}
|
|
2185
|
+
// Movement repair rules are intentionally duplicated here and in the companion.
|
|
2186
|
+
// They are binding, and the tests are expected to enforce them:
|
|
2187
|
+
// 1. Every positive-duration interval must be labeled as stay, trip, or missing.
|
|
2188
|
+
// 2. Missing is never allowed for gaps under one hour.
|
|
2189
|
+
// 3. Any move with cumulative distance under 100m is invalid and must be repaired into stay.
|
|
2190
|
+
// 4. Any move with duration under 5 minutes is invalid and must be repaired into stay.
|
|
2191
|
+
// 5. For gaps under one hour:
|
|
2192
|
+
// - same place / same anchor => continue stay
|
|
2193
|
+
// - different place => repaired trip only when boundary displacement is >100m
|
|
2194
|
+
// and the gap lasts at least 5 minutes
|
|
2195
|
+
// - otherwise => repaired stay
|
|
2196
|
+
function normalizeMovementCoverageSegments(segments, options = {}) {
|
|
2197
|
+
const sorted = [...segments]
|
|
2198
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
|
|
2199
|
+
left.endedAt.localeCompare(right.endedAt))
|
|
2200
|
+
.map((segment) => ({
|
|
2201
|
+
id: segment.id,
|
|
2202
|
+
kind: segment.kind,
|
|
2203
|
+
origin: "recorded",
|
|
2204
|
+
startedAt: segment.startedAt,
|
|
2205
|
+
endedAt: segment.endedAt,
|
|
2206
|
+
durationSeconds: durationSeconds(segment.startedAt, segment.endedAt),
|
|
2207
|
+
payload: segment.payload,
|
|
2208
|
+
displacementMeters: null,
|
|
2209
|
+
placeLabel: segment.kind === "stay"
|
|
2210
|
+
? segment.startBoundary?.placeLabel ?? segment.endBoundary?.placeLabel ?? null
|
|
2211
|
+
: segment.endBoundary?.placeLabel ?? segment.startBoundary?.placeLabel ?? null,
|
|
2212
|
+
suppressedShortJump: false,
|
|
2213
|
+
startBoundary: segment.startBoundary,
|
|
2214
|
+
endBoundary: segment.endBoundary
|
|
2215
|
+
}));
|
|
2216
|
+
const classifyGap = (previous, next) => {
|
|
2217
|
+
const gapSeconds = durationSeconds(previous.endedAt, next.startedAt);
|
|
2218
|
+
if (gapSeconds <= 0) {
|
|
2219
|
+
return null;
|
|
2220
|
+
}
|
|
2221
|
+
if (gapSeconds > MISSING_MOVEMENT_DATA_THRESHOLD_SECONDS) {
|
|
2222
|
+
return buildDerivedMovementGapSegment({
|
|
2223
|
+
id: `missing_${previous.endedAt}_${next.startedAt}`,
|
|
2224
|
+
kind: "missing",
|
|
2225
|
+
origin: "missing",
|
|
2226
|
+
startedAt: previous.endedAt,
|
|
2227
|
+
endedAt: next.startedAt
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
if (movementBoundariesShareAnchor(previous.endBoundary, next.startBoundary)) {
|
|
2231
|
+
return buildDerivedMovementGapSegment({
|
|
2232
|
+
id: `continued_stay_${previous.id}_${next.id}`,
|
|
2233
|
+
kind: "stay",
|
|
2234
|
+
origin: "repaired_gap",
|
|
2235
|
+
startedAt: previous.endedAt,
|
|
2236
|
+
endedAt: next.startedAt,
|
|
2237
|
+
displacementMeters: movementBoundaryDistanceMeters(previous.endBoundary, next.startBoundary) ?? 0,
|
|
2238
|
+
placeLabel: previous.endBoundary?.placeLabel ?? next.startBoundary?.placeLabel ?? null,
|
|
2239
|
+
startBoundary: previous.endBoundary,
|
|
2240
|
+
endBoundary: next.startBoundary
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
const displacementMeters = movementBoundaryDistanceMeters(previous.endBoundary, next.startBoundary);
|
|
2244
|
+
if (gapSeconds < 5 * 60) {
|
|
2245
|
+
return buildDerivedMovementGapSegment({
|
|
2246
|
+
id: `repaired_stay_short_jump_${previous.id}_${next.id}`,
|
|
2247
|
+
kind: "stay",
|
|
2248
|
+
origin: "repaired_gap",
|
|
2249
|
+
startedAt: previous.endedAt,
|
|
2250
|
+
endedAt: next.startedAt,
|
|
2251
|
+
displacementMeters,
|
|
2252
|
+
placeLabel: previous.endBoundary?.placeLabel ?? next.startBoundary?.placeLabel ?? null,
|
|
2253
|
+
suppressedShortJump: true,
|
|
2254
|
+
startBoundary: previous.endBoundary,
|
|
2255
|
+
endBoundary: next.startBoundary
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
if (displacementMeters === null || displacementMeters <= 100) {
|
|
2259
|
+
return buildDerivedMovementGapSegment({
|
|
2260
|
+
id: `repaired_stay_short_distance_${previous.id}_${next.id}`,
|
|
2261
|
+
kind: "stay",
|
|
2262
|
+
origin: "repaired_gap",
|
|
2263
|
+
startedAt: previous.endedAt,
|
|
2264
|
+
endedAt: next.startedAt,
|
|
2265
|
+
displacementMeters: displacementMeters ?? null,
|
|
2266
|
+
placeLabel: previous.endBoundary?.placeLabel ?? next.startBoundary?.placeLabel ?? null,
|
|
2267
|
+
startBoundary: previous.endBoundary,
|
|
2268
|
+
endBoundary: next.startBoundary
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
return buildDerivedMovementGapSegment({
|
|
2272
|
+
id: `repaired_trip_${previous.id}_${next.id}`,
|
|
2273
|
+
kind: "trip",
|
|
2274
|
+
origin: "repaired_gap",
|
|
2275
|
+
startedAt: previous.endedAt,
|
|
2276
|
+
endedAt: next.startedAt,
|
|
2277
|
+
displacementMeters,
|
|
2278
|
+
placeLabel: next.startBoundary?.placeLabel ?? previous.endBoundary?.placeLabel ?? null,
|
|
2279
|
+
startBoundary: previous.endBoundary,
|
|
2280
|
+
endBoundary: next.startBoundary
|
|
2281
|
+
});
|
|
2282
|
+
};
|
|
2283
|
+
const coverage = [];
|
|
2284
|
+
if (sorted.length === 0 && options.rangeStart && options.rangeEnd) {
|
|
2285
|
+
const uncoveredSeconds = durationSeconds(options.rangeStart, options.rangeEnd);
|
|
2286
|
+
return [
|
|
2287
|
+
uncoveredSeconds > MISSING_MOVEMENT_DATA_THRESHOLD_SECONDS
|
|
2288
|
+
? buildDerivedMovementGapSegment({
|
|
2289
|
+
id: `missing_${options.rangeStart}_${options.rangeEnd}`,
|
|
2290
|
+
kind: "missing",
|
|
2291
|
+
origin: "missing",
|
|
2292
|
+
startedAt: options.rangeStart,
|
|
2293
|
+
endedAt: options.rangeEnd
|
|
2294
|
+
})
|
|
2295
|
+
: buildDerivedMovementGapSegment({
|
|
2296
|
+
id: `empty_stay_${options.rangeStart}_${options.rangeEnd}`,
|
|
2297
|
+
kind: "stay",
|
|
2298
|
+
origin: "repaired_gap",
|
|
2299
|
+
startedAt: options.rangeStart,
|
|
2300
|
+
endedAt: options.rangeEnd
|
|
2301
|
+
})
|
|
2302
|
+
];
|
|
2303
|
+
}
|
|
2304
|
+
if (options.rangeStart && sorted[0]) {
|
|
2305
|
+
const first = sorted[0];
|
|
2306
|
+
const leadingGapSeconds = durationSeconds(options.rangeStart, first.startedAt);
|
|
2307
|
+
if (leadingGapSeconds > 0) {
|
|
2308
|
+
coverage.push(leadingGapSeconds > MISSING_MOVEMENT_DATA_THRESHOLD_SECONDS
|
|
2309
|
+
? buildDerivedMovementGapSegment({
|
|
2310
|
+
id: `missing_${options.rangeStart}_${first.startedAt}`,
|
|
2311
|
+
kind: "missing",
|
|
2312
|
+
origin: "missing",
|
|
2313
|
+
startedAt: options.rangeStart,
|
|
2314
|
+
endedAt: first.startedAt,
|
|
2315
|
+
placeLabel: first.startBoundary?.placeLabel ?? null,
|
|
2316
|
+
endBoundary: first.startBoundary
|
|
2317
|
+
})
|
|
2318
|
+
: buildDerivedMovementGapSegment({
|
|
2319
|
+
id: `leading_stay_${options.rangeStart}_${first.id}`,
|
|
2320
|
+
kind: "stay",
|
|
2321
|
+
origin: "repaired_gap",
|
|
2322
|
+
startedAt: options.rangeStart,
|
|
2323
|
+
endedAt: first.startedAt,
|
|
2324
|
+
placeLabel: first.startBoundary?.placeLabel ?? null,
|
|
2325
|
+
endBoundary: first.startBoundary
|
|
2326
|
+
}));
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
for (const [index, segment] of sorted.entries()) {
|
|
2330
|
+
if (index > 0) {
|
|
2331
|
+
const derivedGap = classifyGap(sorted[index - 1], segment);
|
|
2332
|
+
if (derivedGap) {
|
|
2333
|
+
coverage.push(derivedGap);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
coverage.push(segment);
|
|
2337
|
+
}
|
|
2338
|
+
if (options.rangeEnd && sorted.length > 0) {
|
|
2339
|
+
const last = sorted[sorted.length - 1];
|
|
2340
|
+
const trailingGapSeconds = durationSeconds(last.endedAt, options.rangeEnd);
|
|
2341
|
+
if (trailingGapSeconds > 0) {
|
|
2342
|
+
if (trailingGapSeconds <= MISSING_MOVEMENT_DATA_THRESHOLD_SECONDS) {
|
|
2343
|
+
coverage.push(buildDerivedMovementGapSegment({
|
|
2344
|
+
id: `continued_stay_${last.id}_${options.rangeEnd}`,
|
|
2345
|
+
kind: "stay",
|
|
2346
|
+
origin: last.kind === "stay" ? "continued_stay" : "repaired_gap",
|
|
2347
|
+
startedAt: last.endedAt,
|
|
2348
|
+
endedAt: options.rangeEnd,
|
|
2349
|
+
displacementMeters: 0,
|
|
2350
|
+
placeLabel: last.endBoundary?.placeLabel ?? null,
|
|
2351
|
+
startBoundary: last.endBoundary,
|
|
2352
|
+
endBoundary: last.endBoundary
|
|
2353
|
+
}));
|
|
2354
|
+
}
|
|
2355
|
+
else {
|
|
2356
|
+
coverage.push(buildDerivedMovementGapSegment({
|
|
2357
|
+
id: `missing_${last.endedAt}_${options.rangeEnd}`,
|
|
2358
|
+
kind: "missing",
|
|
2359
|
+
origin: "missing",
|
|
2360
|
+
startedAt: last.endedAt,
|
|
2361
|
+
endedAt: options.rangeEnd,
|
|
2362
|
+
placeLabel: last.endBoundary?.placeLabel ?? null,
|
|
2363
|
+
startBoundary: last.endBoundary
|
|
2364
|
+
}));
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
return options.allowStayCoalescing === false
|
|
2369
|
+
? coverage
|
|
2370
|
+
: coalesceMovementStayCoverageSegments(coverage);
|
|
2371
|
+
}
|
|
2372
|
+
function projectedBoundariesShareAnchor(left, right) {
|
|
2373
|
+
if (!left || !right) {
|
|
2374
|
+
return false;
|
|
2375
|
+
}
|
|
2376
|
+
if (left.placeExternalUid &&
|
|
2377
|
+
right.placeExternalUid &&
|
|
2378
|
+
left.placeExternalUid === right.placeExternalUid) {
|
|
2379
|
+
return true;
|
|
2380
|
+
}
|
|
2381
|
+
if (left.placeLabel &&
|
|
2382
|
+
right.placeLabel &&
|
|
2383
|
+
left.placeLabel.trim().length > 0 &&
|
|
2384
|
+
left.placeLabel === right.placeLabel) {
|
|
2385
|
+
return true;
|
|
2386
|
+
}
|
|
2387
|
+
if (left.latitude == null ||
|
|
2388
|
+
left.longitude == null ||
|
|
2389
|
+
right.latitude == null ||
|
|
2390
|
+
right.longitude == null) {
|
|
2391
|
+
return false;
|
|
2392
|
+
}
|
|
2393
|
+
return (haversineDistanceMeters({ latitude: left.latitude, longitude: left.longitude }, { latitude: right.latitude, longitude: right.longitude }) <= 100);
|
|
2394
|
+
}
|
|
2395
|
+
function projectedBoundaryDistanceMeters(left, right) {
|
|
2396
|
+
if (!left || !right) {
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
if (left.latitude == null ||
|
|
2400
|
+
left.longitude == null ||
|
|
2401
|
+
right.latitude == null ||
|
|
2402
|
+
right.longitude == null) {
|
|
2403
|
+
return null;
|
|
2404
|
+
}
|
|
2405
|
+
return haversineDistanceMeters({ latitude: left.latitude, longitude: left.longitude }, { latitude: right.latitude, longitude: right.longitude });
|
|
2406
|
+
}
|
|
2407
|
+
function mergeProjectedBoxArrays(left, right) {
|
|
2408
|
+
return uniqStrings([...left, ...right]);
|
|
2409
|
+
}
|
|
2410
|
+
function ensureProjectedMovementBoxCoverage(segments, options) {
|
|
2411
|
+
const sorted = [...segments].sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
|
|
2412
|
+
left.endedAt.localeCompare(right.endedAt) ||
|
|
2413
|
+
left.kind.localeCompare(right.kind) ||
|
|
2414
|
+
left.id.localeCompare(right.id));
|
|
2415
|
+
const deriveGapSegment = (startedAt, endedAt, previous, next) => {
|
|
2416
|
+
const gapSeconds = durationSeconds(startedAt, endedAt);
|
|
2417
|
+
if (gapSeconds <= 0) {
|
|
2418
|
+
return null;
|
|
2419
|
+
}
|
|
2420
|
+
const previousBoundary = previous
|
|
2421
|
+
? options.resolveBoundary(previous, "end")
|
|
2422
|
+
: null;
|
|
2423
|
+
const nextBoundary = next ? options.resolveBoundary(next, "start") : null;
|
|
2424
|
+
if (gapSeconds > MISSING_MOVEMENT_DATA_THRESHOLD_SECONDS) {
|
|
2425
|
+
return {
|
|
2426
|
+
id: `projected_missing_${startedAt}_${endedAt}`,
|
|
2427
|
+
boxId: `projected_missing_${startedAt}_${endedAt}`,
|
|
2428
|
+
kind: "missing",
|
|
2429
|
+
sourceKind: "automatic",
|
|
2430
|
+
origin: "missing",
|
|
2431
|
+
editable: false,
|
|
2432
|
+
startedAt,
|
|
2433
|
+
endedAt,
|
|
2434
|
+
trueStartedAt: startedAt,
|
|
2435
|
+
trueEndedAt: endedAt,
|
|
2436
|
+
visibleStartedAt: startedAt,
|
|
2437
|
+
visibleEndedAt: endedAt,
|
|
2438
|
+
durationSeconds: gapSeconds,
|
|
2439
|
+
title: "Missing data",
|
|
2440
|
+
subtitle: "No trusted movement signal for this period.",
|
|
2441
|
+
placeLabel: previousBoundary?.placeLabel ?? nextBoundary?.placeLabel ?? null,
|
|
2442
|
+
tags: ["missing-data"],
|
|
2443
|
+
distanceMeters: null,
|
|
2444
|
+
averageSpeedMps: null,
|
|
2445
|
+
overrideCount: 0,
|
|
2446
|
+
overriddenAutomaticBoxIds: [],
|
|
2447
|
+
overriddenUserBoxIds: [],
|
|
2448
|
+
isFullyHidden: false,
|
|
2449
|
+
rawStayIds: [],
|
|
2450
|
+
rawTripIds: [],
|
|
2451
|
+
rawPointCount: 0,
|
|
2452
|
+
hasLegacyCorrections: false,
|
|
2453
|
+
metadata: { syncSource: "automatic" }
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
if (projectedBoundariesShareAnchor(previousBoundary, nextBoundary)) {
|
|
2457
|
+
return {
|
|
2458
|
+
id: `projected_continued_stay_${startedAt}_${endedAt}`,
|
|
2459
|
+
boxId: `projected_continued_stay_${startedAt}_${endedAt}`,
|
|
2460
|
+
kind: "stay",
|
|
2461
|
+
sourceKind: "automatic",
|
|
2462
|
+
origin: previous?.kind === "stay" || next?.kind === "stay"
|
|
2463
|
+
? "continued_stay"
|
|
2464
|
+
: "repaired_gap",
|
|
2465
|
+
editable: false,
|
|
2466
|
+
startedAt,
|
|
2467
|
+
endedAt,
|
|
2468
|
+
trueStartedAt: startedAt,
|
|
2469
|
+
trueEndedAt: endedAt,
|
|
2470
|
+
visibleStartedAt: startedAt,
|
|
2471
|
+
visibleEndedAt: endedAt,
|
|
2472
|
+
durationSeconds: gapSeconds,
|
|
2473
|
+
title: previousBoundary?.placeLabel ??
|
|
2474
|
+
nextBoundary?.placeLabel ??
|
|
2475
|
+
"Continued stay",
|
|
2476
|
+
subtitle: "Short stationary gap carried forward into one continuous stay.",
|
|
2477
|
+
placeLabel: previousBoundary?.placeLabel ?? nextBoundary?.placeLabel ?? null,
|
|
2478
|
+
tags: ["continued_stay"],
|
|
2479
|
+
distanceMeters: null,
|
|
2480
|
+
averageSpeedMps: null,
|
|
2481
|
+
overrideCount: 0,
|
|
2482
|
+
overriddenAutomaticBoxIds: [],
|
|
2483
|
+
overriddenUserBoxIds: [],
|
|
2484
|
+
isFullyHidden: false,
|
|
2485
|
+
rawStayIds: [],
|
|
2486
|
+
rawTripIds: [],
|
|
2487
|
+
rawPointCount: 0,
|
|
2488
|
+
hasLegacyCorrections: false,
|
|
2489
|
+
metadata: { syncSource: "automatic" }
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
const displacementMeters = projectedBoundaryDistanceMeters(previousBoundary, nextBoundary);
|
|
2493
|
+
if (gapSeconds < 5 * 60 || displacementMeters == null || displacementMeters <= 100) {
|
|
2494
|
+
return {
|
|
2495
|
+
id: `projected_repaired_stay_${startedAt}_${endedAt}`,
|
|
2496
|
+
boxId: `projected_repaired_stay_${startedAt}_${endedAt}`,
|
|
2497
|
+
kind: "stay",
|
|
2498
|
+
sourceKind: "automatic",
|
|
2499
|
+
origin: "repaired_gap",
|
|
2500
|
+
editable: false,
|
|
2501
|
+
startedAt,
|
|
2502
|
+
endedAt,
|
|
2503
|
+
trueStartedAt: startedAt,
|
|
2504
|
+
trueEndedAt: endedAt,
|
|
2505
|
+
visibleStartedAt: startedAt,
|
|
2506
|
+
visibleEndedAt: endedAt,
|
|
2507
|
+
durationSeconds: gapSeconds,
|
|
2508
|
+
title: previousBoundary?.placeLabel ??
|
|
2509
|
+
nextBoundary?.placeLabel ??
|
|
2510
|
+
"Repaired stay",
|
|
2511
|
+
subtitle: gapSeconds < 5 * 60
|
|
2512
|
+
? "Short jump under five minutes suppressed into stay continuity."
|
|
2513
|
+
: "Short gap repaired as a stay between known anchors.",
|
|
2514
|
+
placeLabel: previousBoundary?.placeLabel ?? nextBoundary?.placeLabel ?? null,
|
|
2515
|
+
tags: gapSeconds < 5 * 60
|
|
2516
|
+
? ["repaired_gap", "suppressed-short-jump"]
|
|
2517
|
+
: ["repaired_gap"],
|
|
2518
|
+
distanceMeters: null,
|
|
2519
|
+
averageSpeedMps: null,
|
|
2520
|
+
overrideCount: 0,
|
|
2521
|
+
overriddenAutomaticBoxIds: [],
|
|
2522
|
+
overriddenUserBoxIds: [],
|
|
2523
|
+
isFullyHidden: false,
|
|
2524
|
+
rawStayIds: [],
|
|
2525
|
+
rawTripIds: [],
|
|
2526
|
+
rawPointCount: 0,
|
|
2527
|
+
hasLegacyCorrections: false,
|
|
2528
|
+
metadata: { syncSource: "automatic" }
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
return {
|
|
2532
|
+
id: `projected_repaired_trip_${startedAt}_${endedAt}`,
|
|
2533
|
+
boxId: `projected_repaired_trip_${startedAt}_${endedAt}`,
|
|
2534
|
+
kind: "trip",
|
|
2535
|
+
sourceKind: "automatic",
|
|
2536
|
+
origin: "repaired_gap",
|
|
2537
|
+
editable: false,
|
|
2538
|
+
startedAt,
|
|
2539
|
+
endedAt,
|
|
2540
|
+
trueStartedAt: startedAt,
|
|
2541
|
+
trueEndedAt: endedAt,
|
|
2542
|
+
visibleStartedAt: startedAt,
|
|
2543
|
+
visibleEndedAt: endedAt,
|
|
2544
|
+
durationSeconds: gapSeconds,
|
|
2545
|
+
title: nextBoundary?.placeLabel ??
|
|
2546
|
+
previousBoundary?.placeLabel ??
|
|
2547
|
+
"Repaired move",
|
|
2548
|
+
subtitle: "Short gap repaired as a move between known anchors.",
|
|
2549
|
+
placeLabel: nextBoundary?.placeLabel ?? previousBoundary?.placeLabel ?? null,
|
|
2550
|
+
tags: ["repaired_gap"],
|
|
2551
|
+
distanceMeters: displacementMeters,
|
|
2552
|
+
averageSpeedMps: displacementMeters / Math.max(1, gapSeconds),
|
|
2553
|
+
overrideCount: 0,
|
|
2554
|
+
overriddenAutomaticBoxIds: [],
|
|
2555
|
+
overriddenUserBoxIds: [],
|
|
2556
|
+
isFullyHidden: false,
|
|
2557
|
+
rawStayIds: [],
|
|
2558
|
+
rawTripIds: [],
|
|
2559
|
+
rawPointCount: 0,
|
|
2560
|
+
hasLegacyCorrections: false,
|
|
2561
|
+
metadata: { syncSource: "automatic" }
|
|
2562
|
+
};
|
|
2563
|
+
};
|
|
2564
|
+
const withCoverage = [];
|
|
2565
|
+
if (options.rangeStart) {
|
|
2566
|
+
const first = sorted[0] ?? null;
|
|
2567
|
+
const leading = deriveGapSegment(options.rangeStart, first?.startedAt ?? options.rangeEnd ?? options.rangeStart, null, first);
|
|
2568
|
+
if (leading) {
|
|
2569
|
+
withCoverage.push(leading);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
for (const [index, segment] of sorted.entries()) {
|
|
2573
|
+
if (index > 0) {
|
|
2574
|
+
const previous = sorted[index - 1];
|
|
2575
|
+
const derived = deriveGapSegment(previous.endedAt, segment.startedAt, previous, segment);
|
|
2576
|
+
if (derived) {
|
|
2577
|
+
withCoverage.push(derived);
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
withCoverage.push(segment);
|
|
2581
|
+
}
|
|
2582
|
+
if (options.rangeEnd) {
|
|
2583
|
+
const last = sorted[sorted.length - 1] ?? null;
|
|
2584
|
+
const trailing = deriveGapSegment(last?.endedAt ?? options.rangeStart ?? options.rangeEnd, options.rangeEnd, last, null);
|
|
2585
|
+
if (trailing) {
|
|
2586
|
+
withCoverage.push(trailing);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const coalesced = [];
|
|
2590
|
+
for (const segment of withCoverage) {
|
|
2591
|
+
const previous = coalesced[coalesced.length - 1];
|
|
2592
|
+
const shouldMerge = previous &&
|
|
2593
|
+
previous.kind === "stay" &&
|
|
2594
|
+
segment.kind === "stay" &&
|
|
2595
|
+
previous.sourceKind === "automatic" &&
|
|
2596
|
+
segment.sourceKind === "automatic" &&
|
|
2597
|
+
previous.endedAt === segment.startedAt &&
|
|
2598
|
+
projectedBoundariesShareAnchor(options.resolveBoundary(previous, "end"), options.resolveBoundary(segment, "start"));
|
|
2599
|
+
if (!shouldMerge || !previous) {
|
|
2600
|
+
coalesced.push(segment);
|
|
2601
|
+
continue;
|
|
2602
|
+
}
|
|
2603
|
+
coalesced[coalesced.length - 1] = {
|
|
2604
|
+
...previous,
|
|
2605
|
+
id: `projected_coalesced_${previous.id}_${segment.id}`,
|
|
2606
|
+
boxId: previous.boxId,
|
|
2607
|
+
origin: previous.origin === "continued_stay" || segment.origin === "continued_stay"
|
|
2608
|
+
? "continued_stay"
|
|
2609
|
+
: previous.origin === "repaired_gap" || segment.origin === "repaired_gap"
|
|
2610
|
+
? "repaired_gap"
|
|
2611
|
+
: previous.origin,
|
|
2612
|
+
endedAt: segment.endedAt,
|
|
2613
|
+
visibleEndedAt: segment.endedAt,
|
|
2614
|
+
durationSeconds: durationSeconds(previous.startedAt, segment.endedAt),
|
|
2615
|
+
title: previous.title || segment.title,
|
|
2616
|
+
subtitle: previous.origin === "continued_stay" || segment.origin === "continued_stay"
|
|
2617
|
+
? "Short stationary gap carried forward into one continuous stay."
|
|
2618
|
+
: previous.subtitle,
|
|
2619
|
+
placeLabel: previous.placeLabel ?? segment.placeLabel ?? null,
|
|
2620
|
+
tags: mergeProjectedBoxArrays(previous.tags, segment.tags),
|
|
2621
|
+
overrideCount: previous.overrideCount + segment.overrideCount,
|
|
2622
|
+
overriddenAutomaticBoxIds: mergeProjectedBoxArrays(previous.overriddenAutomaticBoxIds, segment.overriddenAutomaticBoxIds),
|
|
2623
|
+
overriddenUserBoxIds: mergeProjectedBoxArrays(previous.overriddenUserBoxIds, segment.overriddenUserBoxIds),
|
|
2624
|
+
isFullyHidden: false,
|
|
2625
|
+
rawStayIds: mergeProjectedBoxArrays(previous.rawStayIds, segment.rawStayIds),
|
|
2626
|
+
rawTripIds: mergeProjectedBoxArrays(previous.rawTripIds, segment.rawTripIds),
|
|
2627
|
+
rawPointCount: previous.rawPointCount + segment.rawPointCount,
|
|
2628
|
+
hasLegacyCorrections: previous.hasLegacyCorrections || segment.hasLegacyCorrections,
|
|
2629
|
+
metadata: {
|
|
2630
|
+
...previous.metadata,
|
|
2631
|
+
coalescedSegmentIds: [
|
|
2632
|
+
...(Array.isArray(previous.metadata.coalescedSegmentIds)
|
|
2633
|
+
? previous.metadata.coalescedSegmentIds
|
|
2634
|
+
: [previous.id]),
|
|
2635
|
+
...(Array.isArray(segment.metadata.coalescedSegmentIds)
|
|
2636
|
+
? segment.metadata.coalescedSegmentIds
|
|
2637
|
+
: [segment.id])
|
|
2638
|
+
]
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
return coalesced.sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
|
|
2643
|
+
left.endedAt.localeCompare(right.endedAt) ||
|
|
2644
|
+
left.kind.localeCompare(right.kind) ||
|
|
2645
|
+
left.id.localeCompare(right.id));
|
|
2646
|
+
}
|
|
2647
|
+
function movementBoxOverlapsRange(row, startedAt, endedAt) {
|
|
2648
|
+
const rowStartedAt = row.true_started_at ?? row.started_at;
|
|
2649
|
+
const rowEndedAt = row.true_ended_at ?? row.ended_at;
|
|
2650
|
+
return rowStartedAt < endedAt && rowEndedAt > startedAt;
|
|
2651
|
+
}
|
|
2652
|
+
function mergeMovementOverrideRanges(ranges) {
|
|
2653
|
+
const sorted = [...ranges]
|
|
2654
|
+
.filter((range) => range.startedAt < range.endedAt)
|
|
2655
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
|
|
2656
|
+
left.endedAt.localeCompare(right.endedAt));
|
|
2657
|
+
const merged = [];
|
|
2658
|
+
for (const range of sorted) {
|
|
2659
|
+
const previous = merged[merged.length - 1];
|
|
2660
|
+
if (!previous || range.startedAt > previous.endedAt) {
|
|
2661
|
+
merged.push({ ...range });
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
previous.endedAt =
|
|
2665
|
+
range.endedAt > previous.endedAt ? range.endedAt : previous.endedAt;
|
|
2666
|
+
}
|
|
2667
|
+
return merged;
|
|
2668
|
+
}
|
|
2669
|
+
function subtractMovementOverrideRanges(startedAt, endedAt, ranges) {
|
|
2670
|
+
let fragments = [{ startedAt, endedAt }];
|
|
2671
|
+
for (const range of mergeMovementOverrideRanges(ranges)) {
|
|
2672
|
+
fragments = fragments.flatMap((fragment) => {
|
|
2673
|
+
if (range.startedAt >= fragment.endedAt || range.endedAt <= fragment.startedAt) {
|
|
2674
|
+
return [fragment];
|
|
2675
|
+
}
|
|
2676
|
+
const nextFragments = [];
|
|
2677
|
+
if (fragment.startedAt < range.startedAt) {
|
|
2678
|
+
nextFragments.push({
|
|
2679
|
+
startedAt: fragment.startedAt,
|
|
2680
|
+
endedAt: range.startedAt
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
if (fragment.endedAt > range.endedAt) {
|
|
2684
|
+
nextFragments.push({
|
|
2685
|
+
startedAt: range.endedAt,
|
|
2686
|
+
endedAt: fragment.endedAt
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
return nextFragments;
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
return fragments.filter((fragment) => fragment.startedAt < fragment.endedAt);
|
|
2693
|
+
}
|
|
2694
|
+
function updateMovementBoxOverrideState(id, input) {
|
|
2695
|
+
getDatabase()
|
|
2696
|
+
.prepare(`UPDATE movement_boxes
|
|
2697
|
+
SET override_count = ?,
|
|
2698
|
+
overridden_automatic_box_ids_json = ?,
|
|
2699
|
+
true_started_at = ?,
|
|
2700
|
+
true_ended_at = ?,
|
|
2701
|
+
overridden_started_at = ?,
|
|
2702
|
+
overridden_ended_at = ?,
|
|
2703
|
+
overridden_by_box_id = ?,
|
|
2704
|
+
overridden_user_box_ids_json = ?,
|
|
2705
|
+
override_ranges_json = ?,
|
|
2706
|
+
is_overridden = ?,
|
|
2707
|
+
is_fully_hidden = ?,
|
|
2708
|
+
updated_at = ?
|
|
2709
|
+
WHERE id = ?`)
|
|
2710
|
+
.run(input.overrideCount, JSON.stringify(input.overriddenAutomaticBoxIds), input.trueStartedAt, input.trueEndedAt, input.overriddenStartedAt, input.overriddenEndedAt, input.overriddenByBoxId, JSON.stringify(input.overriddenUserBoxIds), JSON.stringify(input.overrideRanges), input.isOverridden ? 1 : 0, input.isFullyHidden ? 1 : 0, nowIso(), id);
|
|
2711
|
+
}
|
|
2712
|
+
function recomputeMovementBoxOverrideState(userId) {
|
|
2713
|
+
const rows = listMovementBoxRows({ userIds: [userId] });
|
|
2714
|
+
const automaticRows = rows.filter((row) => row.source_kind === "automatic");
|
|
2715
|
+
const userRows = rows.filter((row) => row.source_kind === "user_defined");
|
|
2716
|
+
const orderedUserRows = [...userRows].sort((left, right) => right.updated_at.localeCompare(left.updated_at) ||
|
|
2717
|
+
right.created_at.localeCompare(left.created_at) ||
|
|
2718
|
+
right.id.localeCompare(left.id));
|
|
2719
|
+
const newerRows = [];
|
|
2720
|
+
for (const row of automaticRows) {
|
|
2721
|
+
updateMovementBoxOverrideState(row.id, {
|
|
2722
|
+
overrideCount: 0,
|
|
2723
|
+
overriddenAutomaticBoxIds: [],
|
|
2724
|
+
overriddenUserBoxIds: [],
|
|
2725
|
+
trueStartedAt: row.started_at,
|
|
2726
|
+
trueEndedAt: row.ended_at,
|
|
2727
|
+
overriddenStartedAt: null,
|
|
2728
|
+
overriddenEndedAt: null,
|
|
2729
|
+
overriddenByBoxId: null,
|
|
2730
|
+
overrideRanges: [],
|
|
2731
|
+
isOverridden: false,
|
|
2732
|
+
isFullyHidden: false
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
for (const row of orderedUserRows) {
|
|
2736
|
+
const trueStartedAt = row.started_at;
|
|
2737
|
+
const trueEndedAt = row.ended_at;
|
|
2738
|
+
const overlappingAutomaticBoxIds = automaticRows
|
|
2739
|
+
.filter((automatic) => movementBoxOverlapsRange(automatic, trueStartedAt, trueEndedAt))
|
|
2740
|
+
.map((automatic) => automatic.id);
|
|
2741
|
+
const overridingRows = newerRows.filter((candidate) => movementBoxOverlapsRange(candidate, trueStartedAt, trueEndedAt));
|
|
2742
|
+
const overrideRanges = mergeMovementOverrideRanges(overridingRows.map((candidate) => ({
|
|
2743
|
+
startedAt: (candidate.true_started_at ?? candidate.started_at) > trueStartedAt
|
|
2744
|
+
? (candidate.true_started_at ?? candidate.started_at)
|
|
2745
|
+
: trueStartedAt,
|
|
2746
|
+
endedAt: (candidate.true_ended_at ?? candidate.ended_at) < trueEndedAt
|
|
2747
|
+
? (candidate.true_ended_at ?? candidate.ended_at)
|
|
2748
|
+
: trueEndedAt
|
|
2749
|
+
})));
|
|
2750
|
+
const visibleFragments = subtractMovementOverrideRanges(trueStartedAt, trueEndedAt, overrideRanges);
|
|
2751
|
+
updateMovementBoxOverrideState(row.id, {
|
|
2752
|
+
overrideCount: overlappingAutomaticBoxIds.length + overridingRows.length,
|
|
2753
|
+
overriddenAutomaticBoxIds: overlappingAutomaticBoxIds,
|
|
2754
|
+
overriddenUserBoxIds: overridingRows.map((candidate) => candidate.id),
|
|
2755
|
+
trueStartedAt,
|
|
2756
|
+
trueEndedAt,
|
|
2757
|
+
overriddenStartedAt: overrideRanges[0]?.startedAt ?? null,
|
|
2758
|
+
overriddenEndedAt: overrideRanges.length > 0
|
|
2759
|
+
? overrideRanges[overrideRanges.length - 1].endedAt
|
|
2760
|
+
: null,
|
|
2761
|
+
overriddenByBoxId: overridingRows[0]?.id ?? null,
|
|
2762
|
+
overrideRanges,
|
|
2763
|
+
isOverridden: overrideRanges.length > 0,
|
|
2764
|
+
isFullyHidden: visibleFragments.length === 0
|
|
2765
|
+
});
|
|
2766
|
+
newerRows.push(row);
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
function legacyTripHasCorrections(userId, tripExternalUid) {
|
|
2770
|
+
return (listMovementTripTombstones(userId).some((row) => row.trip_external_uid === tripExternalUid) ||
|
|
2771
|
+
listMovementTripOverrides(userId).some((row) => row.trip_external_uid === tripExternalUid) ||
|
|
2772
|
+
listMovementTripPointTombstones(userId, tripExternalUid).length > 0 ||
|
|
2773
|
+
listMovementTripPointOverrides(userId, tripExternalUid).length > 0);
|
|
2774
|
+
}
|
|
2775
|
+
function legacyStayHasCorrections(userId, stayExternalUid) {
|
|
2776
|
+
return (listMovementStayTombstones(userId).some((row) => row.stay_external_uid === stayExternalUid) ||
|
|
2777
|
+
listMovementStayOverrides(userId).some((row) => row.stay_external_uid === stayExternalUid));
|
|
2778
|
+
}
|
|
2779
|
+
function migrateLegacyMovementCorrectionsToUserBoxes(userId) {
|
|
2780
|
+
const placeRows = listMovementPlaceRows([userId]);
|
|
2781
|
+
const placesById = new Map(placeRows.map((row) => {
|
|
2782
|
+
const mapped = mapMovementPlace(row);
|
|
2783
|
+
return [mapped.id, mapped];
|
|
2784
|
+
}));
|
|
2785
|
+
const stayRows = listMovementStayRows([userId]);
|
|
2786
|
+
const tripRows = listMovementTripRows([userId]);
|
|
1843
2787
|
const tripIds = tripRows.map((row) => row.id);
|
|
1844
2788
|
const pointsByTrip = new Map();
|
|
1845
2789
|
listTripPoints(tripIds).forEach((point) => {
|
|
@@ -1849,46 +2793,704 @@ export function getMovementTimeline(input) {
|
|
|
1849
2793
|
listTripStops(tripIds).forEach((stop) => {
|
|
1850
2794
|
stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
|
|
1851
2795
|
});
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
|
|
2796
|
+
const stayByExternalUid = new Map(stayRows.map((row) => [row.external_uid, row]));
|
|
2797
|
+
const tripByExternalUid = new Map(tripRows.map((row) => [row.external_uid, row]));
|
|
2798
|
+
for (const row of listMovementStayTombstones(userId)) {
|
|
2799
|
+
const stayRow = stayByExternalUid.get(row.stay_external_uid);
|
|
2800
|
+
if (!stayRow) {
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2803
|
+
const stay = mapMovementStay(stayRow, placesById);
|
|
2804
|
+
insertMovementBox({
|
|
2805
|
+
userId,
|
|
2806
|
+
kind: "missing",
|
|
2807
|
+
sourceKind: "user_defined",
|
|
2808
|
+
origin: "user_invalidated",
|
|
2809
|
+
startedAt: stay.startedAt,
|
|
2810
|
+
endedAt: stay.endedAt,
|
|
2811
|
+
title: "User invalidated stay",
|
|
2812
|
+
subtitle: `Replaces ${stay.place?.label ?? stay.label ?? "the automatic stay"} with missing data.`,
|
|
2813
|
+
editable: true,
|
|
2814
|
+
tags: ["user-invalidated", "legacy-migration"],
|
|
2815
|
+
legacyOriginKey: `stay-tombstone:${row.stay_external_uid}`,
|
|
2816
|
+
metadata: {
|
|
2817
|
+
migratedFrom: "movement_stay_tombstones",
|
|
2818
|
+
stayExternalUid: row.stay_external_uid
|
|
2819
|
+
}
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
for (const row of listMovementTripTombstones(userId)) {
|
|
2823
|
+
const tripRow = tripByExternalUid.get(row.trip_external_uid);
|
|
2824
|
+
if (!tripRow) {
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
const trip = mapMovementTrip(tripRow, placesById, pointsByTrip.get(tripRow.id) ?? [], stopsByTrip.get(tripRow.id) ?? []);
|
|
2828
|
+
insertMovementBox({
|
|
2829
|
+
userId,
|
|
2830
|
+
kind: "missing",
|
|
2831
|
+
sourceKind: "user_defined",
|
|
2832
|
+
origin: "user_invalidated",
|
|
2833
|
+
startedAt: trip.startedAt,
|
|
2834
|
+
endedAt: trip.endedAt,
|
|
2835
|
+
title: "User invalidated move",
|
|
2836
|
+
subtitle: `Replaces ${trip.label || "the automatic move"} with missing data.`,
|
|
2837
|
+
editable: true,
|
|
2838
|
+
tags: ["user-invalidated", "legacy-migration"],
|
|
2839
|
+
legacyOriginKey: `trip-tombstone:${row.trip_external_uid}`,
|
|
2840
|
+
metadata: {
|
|
2841
|
+
migratedFrom: "movement_trip_tombstones",
|
|
2842
|
+
tripExternalUid: row.trip_external_uid
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
for (const row of listMovementStayOverrides(userId)) {
|
|
2847
|
+
const stayRow = stayByExternalUid.get(row.stay_external_uid);
|
|
2848
|
+
if (!stayRow) {
|
|
2849
|
+
continue;
|
|
2850
|
+
}
|
|
2851
|
+
const stay = mapMovementStay(stayRow, placesById);
|
|
2852
|
+
const patch = safeJsonParse(row.stay_json, {});
|
|
2853
|
+
insertMovementBox({
|
|
2854
|
+
userId,
|
|
1855
2855
|
kind: "stay",
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2856
|
+
sourceKind: "user_defined",
|
|
2857
|
+
origin: "user_defined",
|
|
2858
|
+
startedAt: patch.startedAt ?? stay.startedAt,
|
|
2859
|
+
endedAt: patch.endedAt ?? stay.endedAt,
|
|
2860
|
+
title: patch.placeLabel ?? patch.label ?? stay.place?.label ?? stay.label ?? "Manual stay",
|
|
2861
|
+
subtitle: "Migrated user-defined stay correction.",
|
|
2862
|
+
placeLabel: patch.placeLabel ?? stay.place?.label ?? stay.label ?? null,
|
|
2863
|
+
anchorExternalUid: patch.placeExternalUid ?? stay.place?.externalUid ?? null,
|
|
2864
|
+
tags: patch.tags ?? stay.tags,
|
|
2865
|
+
editable: true,
|
|
2866
|
+
legacyOriginKey: `stay-override:${row.stay_external_uid}`,
|
|
2867
|
+
metadata: {
|
|
2868
|
+
migratedFrom: "movement_stay_overrides",
|
|
2869
|
+
stayExternalUid: row.stay_external_uid
|
|
2870
|
+
}
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
for (const row of listMovementTripOverrides(userId)) {
|
|
2874
|
+
const tripRow = tripByExternalUid.get(row.trip_external_uid);
|
|
2875
|
+
if (!tripRow) {
|
|
2876
|
+
continue;
|
|
2877
|
+
}
|
|
2878
|
+
const trip = mapMovementTrip(tripRow, placesById, pointsByTrip.get(tripRow.id) ?? [], stopsByTrip.get(tripRow.id) ?? []);
|
|
2879
|
+
const patch = safeJsonParse(row.trip_json, {});
|
|
2880
|
+
insertMovementBox({
|
|
2881
|
+
userId,
|
|
1862
2882
|
kind: "trip",
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2883
|
+
sourceKind: "user_defined",
|
|
2884
|
+
origin: "user_defined",
|
|
2885
|
+
startedAt: patch.startedAt ?? trip.startedAt,
|
|
2886
|
+
endedAt: patch.endedAt ?? trip.endedAt,
|
|
2887
|
+
title: patch.label ??
|
|
2888
|
+
trip.label ??
|
|
2889
|
+
`${trip.startPlace?.label ?? "Unknown"} → ${trip.endPlace?.label ?? "Unknown"}`,
|
|
2890
|
+
subtitle: "Migrated user-defined move correction.",
|
|
2891
|
+
placeLabel: trip.endPlace?.label ?? trip.startPlace?.label ?? null,
|
|
2892
|
+
tags: patch.tags ?? trip.tags,
|
|
2893
|
+
distanceMeters: patch.distanceMeters ?? trip.distanceMeters,
|
|
2894
|
+
averageSpeedMps: patch.averageSpeedMps ?? trip.averageSpeedMps ?? null,
|
|
2895
|
+
editable: true,
|
|
2896
|
+
legacyOriginKey: `trip-override:${row.trip_external_uid}`,
|
|
2897
|
+
metadata: {
|
|
2898
|
+
migratedFrom: "movement_trip_overrides",
|
|
2899
|
+
tripExternalUid: row.trip_external_uid
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
function rebuildAutomaticMovementBoxes(userId) {
|
|
2905
|
+
migrateLegacyMovementCorrectionsToUserBoxes(userId);
|
|
2906
|
+
reconcileMovementOverlapValidation(userId);
|
|
2907
|
+
const placeRows = listMovementPlaceRows([userId]);
|
|
2908
|
+
const placesById = new Map(placeRows.map((row) => {
|
|
2909
|
+
const mapped = mapMovementPlace(row);
|
|
2910
|
+
return [mapped.id, mapped];
|
|
2911
|
+
}));
|
|
2912
|
+
const stayRows = listMovementStayRows([userId]);
|
|
2913
|
+
const tripRows = listMovementTripRows([userId]);
|
|
2914
|
+
const tripIds = tripRows.map((row) => row.id);
|
|
2915
|
+
const pointsByTrip = new Map();
|
|
2916
|
+
listTripPoints(tripIds).forEach((point) => {
|
|
2917
|
+
pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
|
|
2918
|
+
});
|
|
2919
|
+
const stopsByTrip = new Map();
|
|
2920
|
+
listTripStops(tripIds).forEach((stop) => {
|
|
2921
|
+
stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
|
|
2922
|
+
});
|
|
2923
|
+
const rawSegments = [
|
|
2924
|
+
...stayRows.map((row) => {
|
|
2925
|
+
const stay = mapMovementStay(row, placesById);
|
|
2926
|
+
return {
|
|
2927
|
+
id: row.id,
|
|
2928
|
+
kind: "stay",
|
|
2929
|
+
startedAt: stay.startedAt,
|
|
2930
|
+
endedAt: stay.endedAt,
|
|
2931
|
+
payload: stay,
|
|
2932
|
+
startBoundary: {
|
|
2933
|
+
latitude: stay.centerLatitude,
|
|
2934
|
+
longitude: stay.centerLongitude,
|
|
2935
|
+
placeLabel: stay.place?.label ?? stay.label ?? null,
|
|
2936
|
+
placeExternalUid: stay.place?.externalUid ?? null
|
|
2937
|
+
},
|
|
2938
|
+
endBoundary: {
|
|
2939
|
+
latitude: stay.centerLatitude,
|
|
2940
|
+
longitude: stay.centerLongitude,
|
|
2941
|
+
placeLabel: stay.place?.label ?? stay.label ?? null,
|
|
2942
|
+
placeExternalUid: stay.place?.externalUid ?? null
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
}),
|
|
2946
|
+
...tripRows.map((row) => {
|
|
2947
|
+
const trip = mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? []);
|
|
2948
|
+
return {
|
|
2949
|
+
id: row.id,
|
|
2950
|
+
kind: "trip",
|
|
2951
|
+
startedAt: trip.startedAt,
|
|
2952
|
+
endedAt: trip.endedAt,
|
|
2953
|
+
payload: trip,
|
|
2954
|
+
startBoundary: trip.points[0] || trip.startPlace
|
|
2955
|
+
? {
|
|
2956
|
+
latitude: trip.points[0]?.latitude ?? trip.startPlace?.latitude ?? 0,
|
|
2957
|
+
longitude: trip.points[0]?.longitude ?? trip.startPlace?.longitude ?? 0,
|
|
2958
|
+
placeLabel: trip.startPlace?.label ?? null,
|
|
2959
|
+
placeExternalUid: trip.startPlace?.externalUid ?? null
|
|
2960
|
+
}
|
|
2961
|
+
: null,
|
|
2962
|
+
endBoundary: trip.points[trip.points.length - 1] || trip.endPlace
|
|
2963
|
+
? {
|
|
2964
|
+
latitude: trip.points[trip.points.length - 1]?.latitude ??
|
|
2965
|
+
trip.endPlace?.latitude ??
|
|
2966
|
+
0,
|
|
2967
|
+
longitude: trip.points[trip.points.length - 1]?.longitude ??
|
|
2968
|
+
trip.endPlace?.longitude ??
|
|
2969
|
+
0,
|
|
2970
|
+
placeLabel: trip.endPlace?.label ?? null,
|
|
2971
|
+
placeExternalUid: trip.endPlace?.externalUid ?? null
|
|
2972
|
+
}
|
|
2973
|
+
: null
|
|
2974
|
+
};
|
|
2975
|
+
})
|
|
1867
2976
|
]
|
|
2977
|
+
.filter((segment) => segment.kind === "stay"
|
|
2978
|
+
? !hasInvalidMovementRecord(segment.payload.metadata)
|
|
2979
|
+
: !hasInvalidMovementRecord(segment.payload.metadata))
|
|
2980
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
|
|
2981
|
+
left.endedAt.localeCompare(right.endedAt));
|
|
2982
|
+
const automaticSegments = normalizeMovementCoverageSegments(rawSegments, {
|
|
2983
|
+
rangeEnd: nowIso()
|
|
2984
|
+
});
|
|
2985
|
+
getDatabase()
|
|
2986
|
+
.prepare(`DELETE FROM movement_boxes
|
|
2987
|
+
WHERE user_id = ?
|
|
2988
|
+
AND source_kind = 'automatic'`)
|
|
2989
|
+
.run(userId);
|
|
2990
|
+
for (const segment of automaticSegments) {
|
|
2991
|
+
if (segment.durationSeconds <= 0) {
|
|
2992
|
+
continue;
|
|
2993
|
+
}
|
|
2994
|
+
if (segment.kind === "stay" && segment.payload) {
|
|
2995
|
+
const stay = segment.payload;
|
|
2996
|
+
const rawStayId = stay.id;
|
|
2997
|
+
insertMovementBox({
|
|
2998
|
+
id: `mba_${rawStayId}`,
|
|
2999
|
+
userId,
|
|
3000
|
+
kind: "stay",
|
|
3001
|
+
sourceKind: "automatic",
|
|
3002
|
+
origin: segment.origin,
|
|
3003
|
+
startedAt: segment.startedAt,
|
|
3004
|
+
endedAt: segment.endedAt,
|
|
3005
|
+
title: stay.place?.label ?? stay.label ?? "Stay",
|
|
3006
|
+
subtitle: stay.place?.categoryTags.join(" · ") ||
|
|
3007
|
+
(stay.classification === "stationary" ? "Stationary" : stay.classification),
|
|
3008
|
+
placeLabel: stay.place?.label ?? stay.label ?? null,
|
|
3009
|
+
anchorExternalUid: stay.place?.externalUid ?? null,
|
|
3010
|
+
tags: uniqStrings([
|
|
3011
|
+
...(stay.place?.categoryTags ?? []),
|
|
3012
|
+
...(Array.isArray(stay.metrics.tags)
|
|
3013
|
+
? (stay.metrics.tags ?? [])
|
|
3014
|
+
: [])
|
|
3015
|
+
]),
|
|
3016
|
+
editable: false,
|
|
3017
|
+
rawStayIds: [rawStayId],
|
|
3018
|
+
hasLegacyCorrections: legacyStayHasCorrections(userId, stay.externalUid),
|
|
3019
|
+
metadata: {
|
|
3020
|
+
syncSource: stay.pairingSessionId ? "companion" : "forge"
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
continue;
|
|
3024
|
+
}
|
|
3025
|
+
if (segment.kind === "trip" && segment.payload) {
|
|
3026
|
+
const trip = segment.payload;
|
|
3027
|
+
insertMovementBox({
|
|
3028
|
+
id: `mba_${trip.id}`,
|
|
3029
|
+
userId,
|
|
3030
|
+
kind: "trip",
|
|
3031
|
+
sourceKind: "automatic",
|
|
3032
|
+
origin: segment.origin,
|
|
3033
|
+
startedAt: segment.startedAt,
|
|
3034
|
+
endedAt: segment.endedAt,
|
|
3035
|
+
title: trip.label ||
|
|
3036
|
+
`${trip.startPlace?.label ?? "Unknown"} → ${trip.endPlace?.label ?? "Unknown"}`,
|
|
3037
|
+
subtitle: `${round(trip.distanceMeters / 1000, 1)} km · ${trip.activityType || trip.travelMode}`,
|
|
3038
|
+
placeLabel: trip.endPlace?.label ?? trip.startPlace?.label ?? null,
|
|
3039
|
+
tags: uniqStrings([
|
|
3040
|
+
...trip.tags,
|
|
3041
|
+
...(trip.startPlace?.categoryTags ?? []),
|
|
3042
|
+
...(trip.endPlace?.categoryTags ?? [])
|
|
3043
|
+
]),
|
|
3044
|
+
distanceMeters: trip.distanceMeters,
|
|
3045
|
+
averageSpeedMps: trip.averageSpeedMps ?? null,
|
|
3046
|
+
editable: false,
|
|
3047
|
+
rawTripIds: [trip.id],
|
|
3048
|
+
rawPointCount: trip.points.length,
|
|
3049
|
+
hasLegacyCorrections: legacyTripHasCorrections(userId, trip.externalUid),
|
|
3050
|
+
metadata: {
|
|
3051
|
+
syncSource: trip.pairingSessionId ? "companion" : "forge"
|
|
3052
|
+
}
|
|
3053
|
+
});
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
insertMovementBox({
|
|
3057
|
+
id: `mba_${segment.id}`,
|
|
3058
|
+
userId,
|
|
3059
|
+
kind: segment.kind,
|
|
3060
|
+
sourceKind: "automatic",
|
|
3061
|
+
origin: segment.origin,
|
|
3062
|
+
startedAt: segment.startedAt,
|
|
3063
|
+
endedAt: segment.endedAt,
|
|
3064
|
+
title: segment.kind === "missing"
|
|
3065
|
+
? "Missing data"
|
|
3066
|
+
: segment.origin === "continued_stay"
|
|
3067
|
+
? segment.placeLabel ?? "Continued stay"
|
|
3068
|
+
: segment.kind === "stay"
|
|
3069
|
+
? segment.placeLabel ?? "Repaired stay"
|
|
3070
|
+
: segment.placeLabel ?? "Repaired move",
|
|
3071
|
+
subtitle: segment.kind === "missing"
|
|
3072
|
+
? "No trusted movement signal for this period."
|
|
3073
|
+
: segment.kind === "stay"
|
|
3074
|
+
? segment.origin === "continued_stay"
|
|
3075
|
+
? "Short stationary gap carried forward into one continuous stay."
|
|
3076
|
+
: segment.suppressedShortJump
|
|
3077
|
+
? "Short jump under five minutes suppressed into stay continuity."
|
|
3078
|
+
: "Short gap repaired as a stay between known anchors."
|
|
3079
|
+
: "Short gap repaired as a move between known anchors.",
|
|
3080
|
+
placeLabel: segment.placeLabel ?? null,
|
|
3081
|
+
anchorExternalUid: segment.startBoundary?.placeExternalUid ??
|
|
3082
|
+
segment.endBoundary?.placeExternalUid ??
|
|
3083
|
+
null,
|
|
3084
|
+
tags: uniqStrings([
|
|
3085
|
+
segment.origin,
|
|
3086
|
+
...(segment.suppressedShortJump ? ["suppressed-short-jump"] : []),
|
|
3087
|
+
...(segment.kind === "missing" ? ["missing-data"] : [])
|
|
3088
|
+
]),
|
|
3089
|
+
distanceMeters: segment.kind === "trip" ? segment.displacementMeters ?? null : null,
|
|
3090
|
+
averageSpeedMps: segment.kind === "trip" && segment.displacementMeters != null
|
|
3091
|
+
? segment.displacementMeters / Math.max(1, segment.durationSeconds)
|
|
3092
|
+
: null,
|
|
3093
|
+
editable: false,
|
|
3094
|
+
metadata: {
|
|
3095
|
+
syncSource: "automatic"
|
|
3096
|
+
}
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
recomputeMovementBoxOverrideState(userId);
|
|
3100
|
+
}
|
|
3101
|
+
function ensureAutomaticMovementBoxes(userIds) {
|
|
3102
|
+
for (const userId of userIds) {
|
|
3103
|
+
migrateLegacyMovementCorrectionsToUserBoxes(userId);
|
|
3104
|
+
const hasAutomatic = listMovementBoxRows({
|
|
3105
|
+
userIds: [userId],
|
|
3106
|
+
sourceKinds: ["automatic"]
|
|
3107
|
+
}).length > 0;
|
|
3108
|
+
if (!hasAutomatic) {
|
|
3109
|
+
rebuildAutomaticMovementBoxes(userId);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
function projectMovementBoxes(input) {
|
|
3114
|
+
ensureAutomaticMovementBoxes(input.userIds);
|
|
3115
|
+
input.userIds.forEach(recomputeMovementBoxOverrideState);
|
|
3116
|
+
const placeRows = listMovementPlaceRows(input.userIds);
|
|
3117
|
+
const placesById = new Map(placeRows
|
|
3118
|
+
.map((row) => mapMovementPlace(row))
|
|
3119
|
+
.map((place) => [place.id, place]));
|
|
3120
|
+
const placesByExternalUid = new Map(placeRows
|
|
3121
|
+
.map((row) => mapMovementPlace(row))
|
|
3122
|
+
.map((place) => [place.externalUid, place]));
|
|
3123
|
+
const stayRows = listMovementStayRows(input.userIds);
|
|
3124
|
+
const rawStayById = new Map(stayRows.map((row) => [row.id, row]));
|
|
3125
|
+
const tripRows = listMovementTripRows(input.userIds);
|
|
3126
|
+
const tripIds = tripRows.map((row) => row.id);
|
|
3127
|
+
const pointsByTrip = new Map();
|
|
3128
|
+
listTripPoints(tripIds).forEach((point) => {
|
|
3129
|
+
pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
|
|
3130
|
+
});
|
|
3131
|
+
const rawTripById = new Map(tripRows.map((row) => [row.id, row]));
|
|
3132
|
+
const allRows = listMovementBoxRows({ userIds: input.userIds });
|
|
3133
|
+
const inRange = allRows.filter((row) => {
|
|
3134
|
+
const rowStartedAt = row.true_started_at ?? row.started_at;
|
|
3135
|
+
const rowEndedAt = row.true_ended_at ?? row.ended_at;
|
|
3136
|
+
if (input.rangeStart && rowEndedAt <= input.rangeStart) {
|
|
3137
|
+
return false;
|
|
3138
|
+
}
|
|
3139
|
+
if (input.rangeEnd && rowStartedAt >= input.rangeEnd) {
|
|
3140
|
+
return false;
|
|
3141
|
+
}
|
|
3142
|
+
return true;
|
|
3143
|
+
});
|
|
3144
|
+
const automaticRows = inRange.filter((row) => row.source_kind === "automatic");
|
|
3145
|
+
const userRows = inRange.filter((row) => row.source_kind === "user_defined");
|
|
3146
|
+
const projectedAutomatic = [];
|
|
3147
|
+
const userProjected = userRows.flatMap((row) => {
|
|
3148
|
+
const base = mapMovementBoxRow(row);
|
|
3149
|
+
const trueStartedAt = base.trueStartedAt;
|
|
3150
|
+
const trueEndedAt = base.trueEndedAt;
|
|
3151
|
+
const fragments = subtractMovementOverrideRanges(trueStartedAt, trueEndedAt, movementBoxOverrideRanges(row));
|
|
3152
|
+
return fragments.map((fragment, index) => ({
|
|
3153
|
+
...base,
|
|
3154
|
+
id: fragments.length === 1
|
|
3155
|
+
? row.id
|
|
3156
|
+
: `${row.id}::fragment:${index}`,
|
|
3157
|
+
startedAt: fragment.startedAt,
|
|
3158
|
+
endedAt: fragment.endedAt,
|
|
3159
|
+
visibleStartedAt: fragment.startedAt,
|
|
3160
|
+
visibleEndedAt: fragment.endedAt,
|
|
3161
|
+
durationSeconds: durationSeconds(fragment.startedAt, fragment.endedAt),
|
|
3162
|
+
metadata: {
|
|
3163
|
+
...base.metadata,
|
|
3164
|
+
projectedFromBoxId: row.id,
|
|
3165
|
+
projectedFragmentIndex: index
|
|
3166
|
+
}
|
|
3167
|
+
}));
|
|
3168
|
+
});
|
|
3169
|
+
for (const automatic of automaticRows) {
|
|
3170
|
+
let fragments = [
|
|
3171
|
+
{
|
|
3172
|
+
startedAt: automatic.true_started_at ?? automatic.started_at,
|
|
3173
|
+
endedAt: automatic.true_ended_at ?? automatic.ended_at
|
|
3174
|
+
}
|
|
3175
|
+
];
|
|
3176
|
+
const overlappingUsers = userRows.filter((user) => movementBoxOverlapsRange(user, automatic.true_started_at ?? automatic.started_at, automatic.true_ended_at ?? automatic.ended_at));
|
|
3177
|
+
for (const user of overlappingUsers) {
|
|
3178
|
+
const userStartedAt = user.true_started_at ?? user.started_at;
|
|
3179
|
+
const userEndedAt = user.true_ended_at ?? user.ended_at;
|
|
3180
|
+
fragments = fragments.flatMap((fragment) => {
|
|
3181
|
+
if (userStartedAt >= fragment.endedAt || userEndedAt <= fragment.startedAt) {
|
|
3182
|
+
return [fragment];
|
|
3183
|
+
}
|
|
3184
|
+
const nextFragments = [];
|
|
3185
|
+
if (fragment.startedAt < userStartedAt) {
|
|
3186
|
+
nextFragments.push({
|
|
3187
|
+
startedAt: fragment.startedAt,
|
|
3188
|
+
endedAt: userStartedAt
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
if (fragment.endedAt > userEndedAt) {
|
|
3192
|
+
nextFragments.push({
|
|
3193
|
+
startedAt: userEndedAt,
|
|
3194
|
+
endedAt: fragment.endedAt
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
return nextFragments;
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
for (const [index, fragment] of fragments.entries()) {
|
|
3201
|
+
if (durationSeconds(fragment.startedAt, fragment.endedAt) <= 0) {
|
|
3202
|
+
continue;
|
|
3203
|
+
}
|
|
3204
|
+
projectedAutomatic.push({
|
|
3205
|
+
...mapMovementBoxRow(automatic),
|
|
3206
|
+
id: fragment.startedAt === (automatic.true_started_at ?? automatic.started_at) &&
|
|
3207
|
+
fragment.endedAt === (automatic.true_ended_at ?? automatic.ended_at)
|
|
3208
|
+
? automatic.id
|
|
3209
|
+
: `${automatic.id}::fragment:${index}`,
|
|
3210
|
+
startedAt: fragment.startedAt,
|
|
3211
|
+
endedAt: fragment.endedAt,
|
|
3212
|
+
visibleStartedAt: fragment.startedAt,
|
|
3213
|
+
visibleEndedAt: fragment.endedAt,
|
|
3214
|
+
durationSeconds: durationSeconds(fragment.startedAt, fragment.endedAt)
|
|
3215
|
+
});
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
const projected = [...projectedAutomatic, ...userProjected]
|
|
3219
|
+
.map((segment) => {
|
|
3220
|
+
const startedAt = input.rangeStart && segment.startedAt < input.rangeStart
|
|
3221
|
+
? input.rangeStart
|
|
3222
|
+
: segment.startedAt;
|
|
3223
|
+
const endedAt = input.rangeEnd && segment.endedAt > input.rangeEnd
|
|
3224
|
+
? input.rangeEnd
|
|
3225
|
+
: segment.endedAt;
|
|
3226
|
+
return {
|
|
3227
|
+
...segment,
|
|
3228
|
+
startedAt,
|
|
3229
|
+
endedAt,
|
|
3230
|
+
visibleStartedAt: startedAt,
|
|
3231
|
+
visibleEndedAt: endedAt,
|
|
3232
|
+
durationSeconds: durationSeconds(startedAt, endedAt)
|
|
3233
|
+
};
|
|
3234
|
+
})
|
|
3235
|
+
.filter((segment) => segment.durationSeconds > 0)
|
|
1868
3236
|
.sort((left, right) => left.startedAt.localeCompare(right.startedAt) ||
|
|
1869
3237
|
left.endedAt.localeCompare(right.endedAt) ||
|
|
1870
3238
|
left.kind.localeCompare(right.kind) ||
|
|
1871
3239
|
left.id.localeCompare(right.id));
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
:
|
|
3240
|
+
return ensureProjectedMovementBoxCoverage(projected, {
|
|
3241
|
+
rangeStart: input.rangeStart,
|
|
3242
|
+
rangeEnd: input.rangeEnd,
|
|
3243
|
+
resolveBoundary: (segment, kind) => {
|
|
3244
|
+
const rawStayId = segment.rawStayIds[0] ?? null;
|
|
3245
|
+
if (rawStayId) {
|
|
3246
|
+
const rawStay = rawStayById.get(rawStayId);
|
|
3247
|
+
if (rawStay) {
|
|
3248
|
+
const mappedPlace = rawStay.place_id != null ? placesById.get(rawStay.place_id) ?? null : null;
|
|
3249
|
+
return {
|
|
3250
|
+
latitude: rawStay.center_latitude,
|
|
3251
|
+
longitude: rawStay.center_longitude,
|
|
3252
|
+
placeLabel: mappedPlace?.label ?? segment.placeLabel ?? null,
|
|
3253
|
+
placeExternalUid: mappedPlace?.externalUid ?? segment.anchorExternalUid ?? null
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
const rawTripId = segment.rawTripIds[0] ?? null;
|
|
3258
|
+
if (rawTripId) {
|
|
3259
|
+
const rawTrip = rawTripById.get(rawTripId);
|
|
3260
|
+
if (rawTrip) {
|
|
3261
|
+
const points = pointsByTrip.get(rawTripId) ?? [];
|
|
3262
|
+
const point = kind === "start" ? points[0] ?? null : points[points.length - 1] ?? null;
|
|
3263
|
+
const placeId = kind === "start" ? rawTrip.start_place_id : rawTrip.end_place_id;
|
|
3264
|
+
const placeRow = placeId
|
|
3265
|
+
? placeRows.find((row) => row.id === placeId) ?? null
|
|
3266
|
+
: null;
|
|
3267
|
+
const mappedPlace = placeRow ? mapMovementPlace(placeRow) : null;
|
|
3268
|
+
return {
|
|
3269
|
+
latitude: point?.latitude ?? mappedPlace?.latitude ?? null,
|
|
3270
|
+
longitude: point?.longitude ?? mappedPlace?.longitude ?? null,
|
|
3271
|
+
placeLabel: mappedPlace?.label ?? segment.placeLabel ?? null,
|
|
3272
|
+
placeExternalUid: mappedPlace?.externalUid ?? null
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
const mappedPlace = segment.anchorExternalUid
|
|
3277
|
+
? placesByExternalUid.get(segment.anchorExternalUid) ?? null
|
|
3278
|
+
: null;
|
|
3279
|
+
return {
|
|
3280
|
+
latitude: mappedPlace?.latitude ?? null,
|
|
3281
|
+
longitude: mappedPlace?.longitude ?? null,
|
|
3282
|
+
placeLabel: mappedPlace?.label ?? segment.placeLabel ?? null,
|
|
3283
|
+
placeExternalUid: mappedPlace?.externalUid ?? segment.anchorExternalUid ?? null
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
export function createMovementUserBox(input, context) {
|
|
3289
|
+
const parsed = movementUserBoxCreateSchema.parse(input);
|
|
3290
|
+
const row = insertMovementBox({
|
|
3291
|
+
userId: input.userId,
|
|
3292
|
+
kind: parsed.kind,
|
|
3293
|
+
sourceKind: "user_defined",
|
|
3294
|
+
origin: parsed.kind === "missing" ? "user_invalidated" : "user_defined",
|
|
3295
|
+
startedAt: parsed.startedAt,
|
|
3296
|
+
endedAt: parsed.endedAt,
|
|
3297
|
+
title: parsed.title ||
|
|
3298
|
+
(parsed.kind === "missing"
|
|
3299
|
+
? "User-defined missing data"
|
|
3300
|
+
: parsed.kind === "stay"
|
|
3301
|
+
? parsed.placeLabel ?? "Manual stay"
|
|
3302
|
+
: "Manual move"),
|
|
3303
|
+
subtitle: parsed.subtitle ||
|
|
3304
|
+
(parsed.kind === "missing"
|
|
3305
|
+
? "User invalidated automatic movement here."
|
|
3306
|
+
: "User-defined movement box."),
|
|
3307
|
+
placeLabel: parsed.placeLabel,
|
|
3308
|
+
anchorExternalUid: parsed.anchorExternalUid,
|
|
3309
|
+
tags: parsed.tags,
|
|
3310
|
+
distanceMeters: parsed.distanceMeters,
|
|
3311
|
+
averageSpeedMps: parsed.averageSpeedMps,
|
|
3312
|
+
editable: true,
|
|
3313
|
+
metadata: parsed.metadata
|
|
3314
|
+
});
|
|
3315
|
+
recordActivityEvent({
|
|
3316
|
+
entityType: "system",
|
|
3317
|
+
entityId: row.id,
|
|
3318
|
+
eventType: "movement_user_box_created",
|
|
3319
|
+
title: "Movement box created",
|
|
3320
|
+
description: `Created a ${row.kind} movement box.`,
|
|
3321
|
+
actor: context.actor ?? null,
|
|
3322
|
+
source: context.source,
|
|
3323
|
+
metadata: {
|
|
3324
|
+
sourceKind: row.source_kind,
|
|
3325
|
+
origin: row.origin
|
|
3326
|
+
}
|
|
3327
|
+
});
|
|
3328
|
+
rebuildAutomaticMovementBoxes(input.userId);
|
|
3329
|
+
return mapMovementBoxRow(row);
|
|
3330
|
+
}
|
|
3331
|
+
export function updateMovementUserBox(boxId, patch, context, options = {}) {
|
|
3332
|
+
const existing = getMovementBoxRow(boxId);
|
|
3333
|
+
if (!existing || existing.source_kind !== "user_defined" || existing.deleted_at) {
|
|
3334
|
+
return undefined;
|
|
3335
|
+
}
|
|
3336
|
+
if (options.userId && existing.user_id !== options.userId) {
|
|
3337
|
+
return undefined;
|
|
3338
|
+
}
|
|
3339
|
+
const parsed = movementUserBoxPatchSchema.parse(patch);
|
|
3340
|
+
const nextStartedAt = parsed.startedAt ?? existing.started_at;
|
|
3341
|
+
const nextEndedAt = parsed.endedAt ?? existing.ended_at;
|
|
3342
|
+
const row = insertMovementBox({
|
|
3343
|
+
id: existing.id,
|
|
3344
|
+
userId: existing.user_id,
|
|
3345
|
+
kind: parsed.kind ?? existing.kind,
|
|
3346
|
+
sourceKind: "user_defined",
|
|
3347
|
+
origin: (parsed.kind ?? existing.kind) === "missing"
|
|
3348
|
+
? "user_invalidated"
|
|
3349
|
+
: "user_defined",
|
|
3350
|
+
startedAt: nextStartedAt,
|
|
3351
|
+
endedAt: nextEndedAt,
|
|
3352
|
+
title: parsed.title ?? existing.title,
|
|
3353
|
+
subtitle: parsed.subtitle ?? existing.subtitle,
|
|
3354
|
+
placeLabel: parsed.placeLabel === undefined ? existing.place_label : parsed.placeLabel,
|
|
3355
|
+
anchorExternalUid: parsed.anchorExternalUid === undefined
|
|
3356
|
+
? existing.anchor_external_uid
|
|
3357
|
+
: parsed.anchorExternalUid,
|
|
3358
|
+
tags: parsed.tags ?? movementBoxTags(existing),
|
|
3359
|
+
distanceMeters: parsed.distanceMeters === undefined
|
|
3360
|
+
? existing.distance_meters
|
|
3361
|
+
: parsed.distanceMeters,
|
|
3362
|
+
averageSpeedMps: parsed.averageSpeedMps === undefined
|
|
3363
|
+
? existing.average_speed_mps
|
|
3364
|
+
: parsed.averageSpeedMps,
|
|
3365
|
+
editable: true,
|
|
3366
|
+
overrideCount: existing.override_count,
|
|
3367
|
+
overriddenAutomaticBoxIds: movementBoxOverriddenAutomaticBoxIds(existing),
|
|
3368
|
+
rawStayIds: movementBoxRawStayIds(existing),
|
|
3369
|
+
rawTripIds: movementBoxRawTripIds(existing),
|
|
3370
|
+
rawPointCount: existing.raw_point_count,
|
|
3371
|
+
hasLegacyCorrections: existing.has_legacy_corrections === 1,
|
|
3372
|
+
legacyOriginKey: existing.legacy_origin_key,
|
|
3373
|
+
metadata: {
|
|
3374
|
+
...movementBoxMetadata(existing),
|
|
3375
|
+
...(parsed.metadata ?? {})
|
|
3376
|
+
},
|
|
3377
|
+
deletedAt: existing.deleted_at,
|
|
3378
|
+
createdAt: existing.created_at,
|
|
3379
|
+
updatedAt: nowIso()
|
|
3380
|
+
});
|
|
3381
|
+
recordActivityEvent({
|
|
3382
|
+
entityType: "system",
|
|
3383
|
+
entityId: row.id,
|
|
3384
|
+
eventType: "movement_user_box_updated",
|
|
3385
|
+
title: "Movement box updated",
|
|
3386
|
+
description: `Updated a user-defined ${row.kind} box.`,
|
|
3387
|
+
actor: context.actor ?? null,
|
|
3388
|
+
source: context.source,
|
|
3389
|
+
metadata: {
|
|
3390
|
+
sourceKind: row.source_kind,
|
|
3391
|
+
origin: row.origin
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
rebuildAutomaticMovementBoxes(existing.user_id);
|
|
3395
|
+
return mapMovementBoxRow(row);
|
|
3396
|
+
}
|
|
3397
|
+
export function deleteMovementUserBox(boxId, context, options = {}) {
|
|
3398
|
+
const existing = getMovementBoxRow(boxId);
|
|
3399
|
+
if (!existing || existing.source_kind !== "user_defined" || existing.deleted_at) {
|
|
3400
|
+
return undefined;
|
|
3401
|
+
}
|
|
3402
|
+
if (options.userId && existing.user_id !== options.userId) {
|
|
3403
|
+
return undefined;
|
|
3404
|
+
}
|
|
3405
|
+
softDeleteMovementBox(boxId);
|
|
3406
|
+
recordActivityEvent({
|
|
3407
|
+
entityType: "system",
|
|
3408
|
+
entityId: boxId,
|
|
3409
|
+
eventType: "movement_user_box_deleted",
|
|
3410
|
+
title: "Movement box deleted",
|
|
3411
|
+
description: `Deleted a user-defined ${existing.kind} box.`,
|
|
3412
|
+
actor: context.actor ?? null,
|
|
3413
|
+
source: context.source,
|
|
3414
|
+
metadata: {
|
|
3415
|
+
sourceKind: existing.source_kind,
|
|
3416
|
+
origin: existing.origin
|
|
3417
|
+
}
|
|
3418
|
+
});
|
|
3419
|
+
rebuildAutomaticMovementBoxes(existing.user_id);
|
|
3420
|
+
return {
|
|
3421
|
+
deletedBoxId: boxId
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
export function invalidateAutomaticMovementBox(boxId, input, context, options = {}) {
|
|
3425
|
+
const automatic = getMovementBoxRow(boxId);
|
|
3426
|
+
if (!automatic || automatic.source_kind !== "automatic" || automatic.deleted_at) {
|
|
3427
|
+
return undefined;
|
|
3428
|
+
}
|
|
3429
|
+
if (options.userId && automatic.user_id !== options.userId) {
|
|
3430
|
+
return undefined;
|
|
3431
|
+
}
|
|
3432
|
+
const parsed = movementAutomaticBoxInvalidateSchema.parse(input);
|
|
3433
|
+
const startedAt = parsed.startedAt ?? automatic.started_at;
|
|
3434
|
+
const endedAt = parsed.endedAt ?? automatic.ended_at;
|
|
3435
|
+
const created = createMovementUserBox({
|
|
3436
|
+
userId: automatic.user_id,
|
|
3437
|
+
kind: "missing",
|
|
3438
|
+
startedAt,
|
|
3439
|
+
endedAt,
|
|
3440
|
+
title: parsed.title ?? "User invalidated automatic movement",
|
|
3441
|
+
subtitle: parsed.subtitle ?? `Overrides ${automatic.title || automatic.kind} with missing data.`,
|
|
3442
|
+
placeLabel: automatic.place_label,
|
|
3443
|
+
anchorExternalUid: automatic.anchor_external_uid,
|
|
3444
|
+
tags: uniqStrings(["user-invalidated", ...movementBoxTags(automatic)]),
|
|
3445
|
+
metadata: {
|
|
3446
|
+
...parsed.metadata,
|
|
3447
|
+
invalidatedAutomaticBoxId: automatic.id
|
|
3448
|
+
}
|
|
3449
|
+
}, context);
|
|
3450
|
+
rebuildAutomaticMovementBoxes(automatic.user_id);
|
|
3451
|
+
return {
|
|
3452
|
+
box: created
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
function buildProjectedMovementTimelineSegments(userIds) {
|
|
3456
|
+
const places = listMovementPlaceRows(userIds).map(mapMovementPlace);
|
|
3457
|
+
const placesById = new Map(places.map((place) => [place.id, place]));
|
|
3458
|
+
const stayRows = listMovementStayRows(userIds);
|
|
3459
|
+
const tripRows = listMovementTripRows(userIds);
|
|
3460
|
+
const tripIds = tripRows.map((row) => row.id);
|
|
3461
|
+
const pointsByTrip = new Map();
|
|
3462
|
+
listTripPoints(tripIds).forEach((point) => {
|
|
3463
|
+
pointsByTrip.set(point.trip_id, [...(pointsByTrip.get(point.trip_id) ?? []), point]);
|
|
3464
|
+
});
|
|
3465
|
+
const stopsByTrip = new Map();
|
|
3466
|
+
listTripStops(tripIds).forEach((stop) => {
|
|
3467
|
+
stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
|
|
3468
|
+
});
|
|
3469
|
+
const stayPayloadById = new Map(stayRows.map((row) => {
|
|
3470
|
+
const stay = mapMovementStay(row, placesById);
|
|
3471
|
+
return [stay.id, stay];
|
|
3472
|
+
}));
|
|
3473
|
+
const tripPayloadById = new Map(tripRows.map((row) => {
|
|
3474
|
+
const trip = mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? []);
|
|
3475
|
+
return [trip.id, trip];
|
|
3476
|
+
}));
|
|
3477
|
+
const projected = projectMovementBoxes({ userIds });
|
|
1875
3478
|
let nextStayLane = "left";
|
|
1876
3479
|
const stayLaneById = new Map();
|
|
1877
|
-
for (const
|
|
1878
|
-
if (
|
|
1879
|
-
stayLaneById.set(
|
|
3480
|
+
for (const box of projected) {
|
|
3481
|
+
if (box.kind === "stay") {
|
|
3482
|
+
stayLaneById.set(box.id, nextStayLane);
|
|
1880
3483
|
nextStayLane = nextStayLane === "left" ? "right" : "left";
|
|
1881
3484
|
}
|
|
1882
3485
|
}
|
|
1883
|
-
const
|
|
1884
|
-
|
|
1885
|
-
const previousStayId = [...timelineSource.slice(0, index)]
|
|
3486
|
+
const decorated = projected.map((segment, index) => {
|
|
3487
|
+
const previousStayId = [...projected.slice(0, index)]
|
|
1886
3488
|
.reverse()
|
|
1887
3489
|
.find((candidate) => candidate.kind === "stay")?.id;
|
|
1888
3490
|
const previousStayLane = previousStayId
|
|
1889
3491
|
? stayLaneById.get(previousStayId)
|
|
1890
3492
|
: undefined;
|
|
1891
|
-
const nextStayLaneId =
|
|
3493
|
+
const nextStayLaneId = projected
|
|
1892
3494
|
.slice(index + 1)
|
|
1893
3495
|
.find((candidate) => candidate.kind === "stay")?.id;
|
|
1894
3496
|
const nextStayLane = nextStayLaneId ? stayLaneById.get(nextStayLaneId) : undefined;
|
|
@@ -1898,62 +3500,266 @@ export function getMovementTimeline(input) {
|
|
|
1898
3500
|
startedAt: segment.startedAt,
|
|
1899
3501
|
endedAt: segment.endedAt
|
|
1900
3502
|
};
|
|
1901
|
-
|
|
1902
|
-
|
|
3503
|
+
const rawStay = segment.rawStayIds.length > 0 ? stayPayloadById.get(segment.rawStayIds[0]) ?? null : null;
|
|
3504
|
+
const rawTrip = segment.rawTripIds.length > 0 ? tripPayloadById.get(segment.rawTripIds[0]) ?? null : null;
|
|
3505
|
+
const syncSource = String(segment.metadata.syncSource ?? segment.sourceKind);
|
|
3506
|
+
if (segment.kind === "missing") {
|
|
3507
|
+
const laneSide = previousStayLane ?? nextStayLane ?? "left";
|
|
3508
|
+
return {
|
|
3509
|
+
id: segment.id,
|
|
3510
|
+
boxId: segment.boxId,
|
|
3511
|
+
kind: "missing",
|
|
3512
|
+
sourceKind: segment.sourceKind,
|
|
3513
|
+
origin: segment.origin,
|
|
3514
|
+
editable: segment.editable,
|
|
3515
|
+
startedAt: segment.startedAt,
|
|
3516
|
+
endedAt: segment.endedAt,
|
|
3517
|
+
trueStartedAt: segment.trueStartedAt,
|
|
3518
|
+
trueEndedAt: segment.trueEndedAt,
|
|
3519
|
+
visibleStartedAt: segment.visibleStartedAt,
|
|
3520
|
+
visibleEndedAt: segment.visibleEndedAt,
|
|
3521
|
+
durationSeconds: segment.durationSeconds,
|
|
3522
|
+
laneSide,
|
|
3523
|
+
connectorFromLane: previousStayLane ?? laneSide,
|
|
3524
|
+
connectorToLane: nextStayLane ?? laneSide,
|
|
3525
|
+
title: segment.title,
|
|
3526
|
+
subtitle: segment.subtitle,
|
|
3527
|
+
placeLabel: segment.placeLabel,
|
|
3528
|
+
tags: segment.tags,
|
|
3529
|
+
isInvalid: false,
|
|
3530
|
+
syncSource,
|
|
3531
|
+
cursor: encodeMovementTimelineCursor(cursor),
|
|
3532
|
+
overrideCount: segment.overrideCount,
|
|
3533
|
+
overriddenAutomaticBoxIds: segment.overriddenAutomaticBoxIds,
|
|
3534
|
+
overriddenUserBoxIds: segment.overriddenUserBoxIds,
|
|
3535
|
+
isFullyHidden: segment.isFullyHidden,
|
|
3536
|
+
rawStayIds: segment.rawStayIds,
|
|
3537
|
+
rawTripIds: segment.rawTripIds,
|
|
3538
|
+
rawPointCount: segment.rawPointCount,
|
|
3539
|
+
hasLegacyCorrections: segment.hasLegacyCorrections,
|
|
3540
|
+
stay: null,
|
|
3541
|
+
trip: null
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
if (segment.kind === "stay" && rawStay && segment.sourceKind === "automatic" && segment.origin === "recorded") {
|
|
3545
|
+
const invalid = hasInvalidMovementRecord(rawStay.metadata);
|
|
1903
3546
|
const laneSide = stayLaneById.get(segment.id) ?? "left";
|
|
1904
3547
|
return {
|
|
1905
3548
|
id: segment.id,
|
|
3549
|
+
boxId: segment.boxId,
|
|
1906
3550
|
kind: "stay",
|
|
3551
|
+
sourceKind: segment.sourceKind,
|
|
3552
|
+
origin: segment.origin,
|
|
3553
|
+
editable: segment.editable,
|
|
1907
3554
|
startedAt: segment.startedAt,
|
|
1908
3555
|
endedAt: segment.endedAt,
|
|
1909
|
-
|
|
3556
|
+
trueStartedAt: segment.trueStartedAt,
|
|
3557
|
+
trueEndedAt: segment.trueEndedAt,
|
|
3558
|
+
visibleStartedAt: segment.visibleStartedAt,
|
|
3559
|
+
visibleEndedAt: segment.visibleEndedAt,
|
|
3560
|
+
durationSeconds: segment.durationSeconds,
|
|
1910
3561
|
laneSide,
|
|
1911
3562
|
connectorFromLane: laneSide,
|
|
1912
3563
|
connectorToLane: laneSide,
|
|
1913
|
-
title:
|
|
1914
|
-
subtitle:
|
|
1915
|
-
placeLabel: segment.
|
|
1916
|
-
tags:
|
|
1917
|
-
...(segment.stay.place?.categoryTags ?? []),
|
|
1918
|
-
...(Array.isArray(segment.stay.metrics.tags)
|
|
1919
|
-
? (segment.stay.metrics.tags ?? [])
|
|
1920
|
-
: [])
|
|
1921
|
-
]),
|
|
3564
|
+
title: segment.title,
|
|
3565
|
+
subtitle: segment.subtitle,
|
|
3566
|
+
placeLabel: segment.placeLabel,
|
|
3567
|
+
tags: segment.tags,
|
|
1922
3568
|
isInvalid: invalid,
|
|
1923
|
-
syncSource
|
|
3569
|
+
syncSource,
|
|
1924
3570
|
cursor: encodeMovementTimelineCursor(cursor),
|
|
1925
|
-
|
|
3571
|
+
overrideCount: segment.overrideCount,
|
|
3572
|
+
overriddenAutomaticBoxIds: segment.overriddenAutomaticBoxIds,
|
|
3573
|
+
overriddenUserBoxIds: segment.overriddenUserBoxIds,
|
|
3574
|
+
isFullyHidden: segment.isFullyHidden,
|
|
3575
|
+
rawStayIds: segment.rawStayIds,
|
|
3576
|
+
rawTripIds: segment.rawTripIds,
|
|
3577
|
+
rawPointCount: segment.rawPointCount,
|
|
3578
|
+
hasLegacyCorrections: segment.hasLegacyCorrections,
|
|
3579
|
+
stay: rawStay,
|
|
1926
3580
|
trip: null
|
|
1927
3581
|
};
|
|
1928
3582
|
}
|
|
1929
|
-
|
|
3583
|
+
if (segment.kind === "stay") {
|
|
3584
|
+
const laneSide = previousStayLane ?? nextStayLane ?? "left";
|
|
3585
|
+
return {
|
|
3586
|
+
id: segment.id,
|
|
3587
|
+
boxId: segment.boxId,
|
|
3588
|
+
kind: "stay",
|
|
3589
|
+
sourceKind: segment.sourceKind,
|
|
3590
|
+
origin: segment.origin,
|
|
3591
|
+
editable: segment.editable,
|
|
3592
|
+
startedAt: segment.startedAt,
|
|
3593
|
+
endedAt: segment.endedAt,
|
|
3594
|
+
trueStartedAt: segment.trueStartedAt,
|
|
3595
|
+
trueEndedAt: segment.trueEndedAt,
|
|
3596
|
+
visibleStartedAt: segment.visibleStartedAt,
|
|
3597
|
+
visibleEndedAt: segment.visibleEndedAt,
|
|
3598
|
+
durationSeconds: segment.durationSeconds,
|
|
3599
|
+
laneSide,
|
|
3600
|
+
connectorFromLane: previousStayLane ?? laneSide,
|
|
3601
|
+
connectorToLane: nextStayLane ?? laneSide,
|
|
3602
|
+
title: segment.title,
|
|
3603
|
+
subtitle: segment.subtitle,
|
|
3604
|
+
placeLabel: segment.placeLabel,
|
|
3605
|
+
tags: segment.tags,
|
|
3606
|
+
isInvalid: false,
|
|
3607
|
+
syncSource,
|
|
3608
|
+
cursor: encodeMovementTimelineCursor(cursor),
|
|
3609
|
+
overrideCount: segment.overrideCount,
|
|
3610
|
+
overriddenAutomaticBoxIds: segment.overriddenAutomaticBoxIds,
|
|
3611
|
+
overriddenUserBoxIds: segment.overriddenUserBoxIds,
|
|
3612
|
+
isFullyHidden: segment.isFullyHidden,
|
|
3613
|
+
rawStayIds: segment.rawStayIds,
|
|
3614
|
+
rawTripIds: segment.rawTripIds,
|
|
3615
|
+
rawPointCount: segment.rawPointCount,
|
|
3616
|
+
hasLegacyCorrections: segment.hasLegacyCorrections,
|
|
3617
|
+
stay: null,
|
|
3618
|
+
trip: null
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
if (!rawTrip || segment.sourceKind !== "automatic" || segment.origin !== "recorded") {
|
|
3622
|
+
const laneSide = nextStayLane ?? previousStayLane ?? "left";
|
|
3623
|
+
return {
|
|
3624
|
+
id: segment.id,
|
|
3625
|
+
boxId: segment.boxId,
|
|
3626
|
+
kind: "trip",
|
|
3627
|
+
sourceKind: segment.sourceKind,
|
|
3628
|
+
origin: segment.origin,
|
|
3629
|
+
editable: segment.editable,
|
|
3630
|
+
startedAt: segment.startedAt,
|
|
3631
|
+
endedAt: segment.endedAt,
|
|
3632
|
+
trueStartedAt: segment.trueStartedAt,
|
|
3633
|
+
trueEndedAt: segment.trueEndedAt,
|
|
3634
|
+
visibleStartedAt: segment.visibleStartedAt,
|
|
3635
|
+
visibleEndedAt: segment.visibleEndedAt,
|
|
3636
|
+
durationSeconds: segment.durationSeconds,
|
|
3637
|
+
laneSide,
|
|
3638
|
+
connectorFromLane: previousStayLane ?? laneSide,
|
|
3639
|
+
connectorToLane: nextStayLane ?? laneSide,
|
|
3640
|
+
title: segment.title,
|
|
3641
|
+
subtitle: segment.subtitle,
|
|
3642
|
+
placeLabel: segment.placeLabel,
|
|
3643
|
+
tags: segment.tags,
|
|
3644
|
+
isInvalid: false,
|
|
3645
|
+
syncSource,
|
|
3646
|
+
cursor: encodeMovementTimelineCursor(cursor),
|
|
3647
|
+
overrideCount: segment.overrideCount,
|
|
3648
|
+
overriddenAutomaticBoxIds: segment.overriddenAutomaticBoxIds,
|
|
3649
|
+
overriddenUserBoxIds: segment.overriddenUserBoxIds,
|
|
3650
|
+
isFullyHidden: segment.isFullyHidden,
|
|
3651
|
+
rawStayIds: segment.rawStayIds,
|
|
3652
|
+
rawTripIds: segment.rawTripIds,
|
|
3653
|
+
rawPointCount: segment.rawPointCount,
|
|
3654
|
+
hasLegacyCorrections: segment.hasLegacyCorrections,
|
|
3655
|
+
stay: null,
|
|
3656
|
+
trip: null
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
const invalid = hasInvalidMovementRecord(rawTrip.metadata);
|
|
1930
3660
|
const laneSide = nextStayLane ?? previousStayLane ?? "left";
|
|
1931
3661
|
return {
|
|
1932
3662
|
id: segment.id,
|
|
3663
|
+
boxId: segment.boxId,
|
|
1933
3664
|
kind: "trip",
|
|
3665
|
+
sourceKind: segment.sourceKind,
|
|
3666
|
+
origin: segment.origin,
|
|
3667
|
+
editable: segment.editable,
|
|
1934
3668
|
startedAt: segment.startedAt,
|
|
1935
3669
|
endedAt: segment.endedAt,
|
|
1936
|
-
|
|
3670
|
+
trueStartedAt: segment.trueStartedAt,
|
|
3671
|
+
trueEndedAt: segment.trueEndedAt,
|
|
3672
|
+
visibleStartedAt: segment.visibleStartedAt,
|
|
3673
|
+
visibleEndedAt: segment.visibleEndedAt,
|
|
3674
|
+
durationSeconds: segment.durationSeconds,
|
|
1937
3675
|
laneSide,
|
|
1938
3676
|
connectorFromLane: previousStayLane ?? laneSide,
|
|
1939
3677
|
connectorToLane: nextStayLane ?? laneSide,
|
|
1940
|
-
title:
|
|
1941
|
-
subtitle:
|
|
1942
|
-
placeLabel: segment.
|
|
1943
|
-
|
|
1944
|
-
null,
|
|
1945
|
-
tags: uniqStrings([
|
|
1946
|
-
...segment.trip.tags,
|
|
1947
|
-
...(segment.trip.startPlace?.categoryTags ?? []),
|
|
1948
|
-
...(segment.trip.endPlace?.categoryTags ?? [])
|
|
1949
|
-
]),
|
|
3678
|
+
title: segment.title,
|
|
3679
|
+
subtitle: segment.subtitle,
|
|
3680
|
+
placeLabel: segment.placeLabel,
|
|
3681
|
+
tags: segment.tags,
|
|
1950
3682
|
isInvalid: invalid,
|
|
1951
|
-
syncSource
|
|
3683
|
+
syncSource,
|
|
1952
3684
|
cursor: encodeMovementTimelineCursor(cursor),
|
|
3685
|
+
overrideCount: segment.overrideCount,
|
|
3686
|
+
overriddenAutomaticBoxIds: segment.overriddenAutomaticBoxIds,
|
|
3687
|
+
overriddenUserBoxIds: segment.overriddenUserBoxIds,
|
|
3688
|
+
isFullyHidden: segment.isFullyHidden,
|
|
3689
|
+
rawStayIds: segment.rawStayIds,
|
|
3690
|
+
rawTripIds: segment.rawTripIds,
|
|
3691
|
+
rawPointCount: segment.rawPointCount,
|
|
3692
|
+
hasLegacyCorrections: segment.hasLegacyCorrections,
|
|
1953
3693
|
stay: null,
|
|
1954
|
-
trip:
|
|
3694
|
+
trip: rawTrip
|
|
1955
3695
|
};
|
|
1956
3696
|
});
|
|
3697
|
+
return decorated;
|
|
3698
|
+
}
|
|
3699
|
+
export function resolveMovementTimelineSegmentForBox(userId, boxId) {
|
|
3700
|
+
return buildProjectedMovementTimelineSegments([userId]).find((segment) => segment.boxId === boxId);
|
|
3701
|
+
}
|
|
3702
|
+
export function analyzeMovementUserBoxPreflight(input) {
|
|
3703
|
+
const parsed = movementUserBoxPreflightSchema.parse(input);
|
|
3704
|
+
const visibleRangeStart = parsed.rangeStart ?? null;
|
|
3705
|
+
const visibleRangeEnd = parsed.rangeEnd ?? null;
|
|
3706
|
+
const allRows = listMovementBoxRows({ userIds: [input.userId] });
|
|
3707
|
+
const automaticRows = allRows.filter((row) => row.source_kind === "automatic");
|
|
3708
|
+
const userRows = allRows.filter((row) => row.source_kind === "user_defined" &&
|
|
3709
|
+
row.id !== (parsed.excludeBoxId ?? null));
|
|
3710
|
+
const affectedAutomaticBoxIds = automaticRows
|
|
3711
|
+
.filter((row) => movementBoxOverlapsRange(row, parsed.startedAt, parsed.endedAt))
|
|
3712
|
+
.map((row) => row.id);
|
|
3713
|
+
const affectedUserRows = userRows.filter((row) => movementBoxOverlapsRange(row, parsed.startedAt, parsed.endedAt));
|
|
3714
|
+
const fullyOverriddenUserBoxIds = [];
|
|
3715
|
+
const trimmedUserBoxIds = [];
|
|
3716
|
+
for (const row of affectedUserRows) {
|
|
3717
|
+
const fragments = subtractMovementOverrideRanges(row.started_at, row.ended_at, [
|
|
3718
|
+
{
|
|
3719
|
+
startedAt: parsed.startedAt > row.started_at ? parsed.startedAt : row.started_at,
|
|
3720
|
+
endedAt: parsed.endedAt < row.ended_at ? parsed.endedAt : row.ended_at
|
|
3721
|
+
}
|
|
3722
|
+
]);
|
|
3723
|
+
if (fragments.length === 0) {
|
|
3724
|
+
fullyOverriddenUserBoxIds.push(row.id);
|
|
3725
|
+
}
|
|
3726
|
+
else {
|
|
3727
|
+
trimmedUserBoxIds.push(row.id);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
const projected = buildProjectedMovementTimelineSegments([input.userId]).filter((segment) => {
|
|
3731
|
+
if (visibleRangeStart && segment.endedAt <= visibleRangeStart) {
|
|
3732
|
+
return false;
|
|
3733
|
+
}
|
|
3734
|
+
if (visibleRangeEnd && segment.startedAt >= visibleRangeEnd) {
|
|
3735
|
+
return false;
|
|
3736
|
+
}
|
|
3737
|
+
return true;
|
|
3738
|
+
});
|
|
3739
|
+
const missingSegments = projected.filter((segment) => segment.kind === "missing");
|
|
3740
|
+
const nearestMissing = [...missingSegments].sort((left, right) => {
|
|
3741
|
+
const leftDistance = Math.min(Math.abs(Date.parse(left.startedAt) - Date.parse(parsed.startedAt)), Math.abs(Date.parse(left.endedAt) - Date.parse(parsed.endedAt)));
|
|
3742
|
+
const rightDistance = Math.min(Math.abs(Date.parse(right.startedAt) - Date.parse(parsed.startedAt)), Math.abs(Date.parse(right.endedAt) - Date.parse(parsed.endedAt)));
|
|
3743
|
+
return leftDistance - rightDistance;
|
|
3744
|
+
})[0] ?? null;
|
|
3745
|
+
return {
|
|
3746
|
+
overlapsAnything: affectedAutomaticBoxIds.length > 0 || affectedUserRows.length > 0,
|
|
3747
|
+
visibleRangeStart: visibleRangeStart ?? projected[0]?.startedAt ?? parsed.startedAt,
|
|
3748
|
+
visibleRangeEnd: visibleRangeEnd ?? projected[projected.length - 1]?.endedAt ?? parsed.endedAt,
|
|
3749
|
+
suggestedStartedAt: nearestMissing?.startedAt ?? null,
|
|
3750
|
+
suggestedEndedAt: nearestMissing?.endedAt ?? null,
|
|
3751
|
+
nearestMissingStartedAt: nearestMissing?.startedAt ?? null,
|
|
3752
|
+
nearestMissingEndedAt: nearestMissing?.endedAt ?? null,
|
|
3753
|
+
affectedAutomaticBoxIds,
|
|
3754
|
+
affectedUserBoxIds: affectedUserRows.map((row) => row.id),
|
|
3755
|
+
fullyOverriddenUserBoxIds,
|
|
3756
|
+
trimmedUserBoxIds
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
export function getMovementTimeline(input) {
|
|
3760
|
+
const parsed = movementTimelineQuerySchema.parse(input);
|
|
3761
|
+
const userIds = parsed.userIds.length > 0 ? parsed.userIds : [getDefaultUser().id];
|
|
3762
|
+
const decorated = buildProjectedMovementTimelineSegments(userIds);
|
|
1957
3763
|
const descending = [...decorated].sort((left, right) => compareMovementTimelineDescending({
|
|
1958
3764
|
id: left.id,
|
|
1959
3765
|
kind: left.kind,
|
|
@@ -1982,7 +3788,7 @@ export function getMovementTimeline(input) {
|
|
|
1982
3788
|
segments,
|
|
1983
3789
|
nextCursor,
|
|
1984
3790
|
hasMore: nextCursor !== null,
|
|
1985
|
-
invalidSegmentCount:
|
|
3791
|
+
invalidSegmentCount: 0
|
|
1986
3792
|
};
|
|
1987
3793
|
}
|
|
1988
3794
|
export function updateMovementStay(stayId, patch, context, options = {}) {
|
|
@@ -2555,6 +4361,11 @@ function computeSelectionAggregate(input) {
|
|
|
2555
4361
|
const tags = uniqStrings(input.trips.flatMap((trip) => trip.tags).concat(input.stays.flatMap((stay) => Array.isArray(stay.metrics.tags)
|
|
2556
4362
|
? (stay.metrics.tags ?? [])
|
|
2557
4363
|
: [])));
|
|
4364
|
+
const screenTime = getScreenTimeOverlapSummary({
|
|
4365
|
+
startedAt: input.startedAt,
|
|
4366
|
+
endedAt: input.endedAt,
|
|
4367
|
+
userIds: input.userIds
|
|
4368
|
+
});
|
|
2558
4369
|
return {
|
|
2559
4370
|
startedAt: input.startedAt,
|
|
2560
4371
|
endedAt: input.endedAt,
|
|
@@ -2571,15 +4382,23 @@ function computeSelectionAggregate(input) {
|
|
|
2571
4382
|
return sum + overlapSeconds(input.startedAt, input.endedAt, run.claimedAt, end);
|
|
2572
4383
|
}, 0),
|
|
2573
4384
|
placeLabels,
|
|
2574
|
-
tags
|
|
4385
|
+
tags,
|
|
4386
|
+
estimatedScreenTimeSeconds: screenTime.estimatedScreenTimeSeconds,
|
|
4387
|
+
pickupCount: screenTime.pickupCount,
|
|
4388
|
+
notificationCount: screenTime.notificationCount,
|
|
4389
|
+
topApps: screenTime.topApps,
|
|
4390
|
+
topCategories: screenTime.topCategories
|
|
2575
4391
|
};
|
|
2576
4392
|
}
|
|
2577
4393
|
export function getMovementDayDetail(input) {
|
|
2578
4394
|
const targetDate = input.date ?? dayKey(nowIso());
|
|
4395
|
+
const userIds = input.userIds && input.userIds.length > 0 ? input.userIds : [getDefaultUser().id];
|
|
2579
4396
|
const placeRows = listMovementPlaceRows(input.userIds);
|
|
2580
4397
|
const places = placeRows.map(mapMovementPlace);
|
|
2581
4398
|
const placesById = new Map(places.map((place) => [place.id, place]));
|
|
2582
|
-
const stays = listMovementStayRows(input.userIds, targetDate)
|
|
4399
|
+
const stays = listMovementStayRows(input.userIds, targetDate)
|
|
4400
|
+
.map((row) => mapMovementStay(row, placesById))
|
|
4401
|
+
.map((stay) => enrichMovementSegmentWithScreenTime(stay, input.userIds));
|
|
2583
4402
|
const tripRows = listMovementTripRows(input.userIds, { dateKey: targetDate });
|
|
2584
4403
|
const tripIds = tripRows.map((row) => row.id);
|
|
2585
4404
|
const pointsByTrip = new Map();
|
|
@@ -2590,41 +4409,64 @@ export function getMovementDayDetail(input) {
|
|
|
2590
4409
|
listTripStops(tripIds).forEach((stop) => {
|
|
2591
4410
|
stopsByTrip.set(stop.trip_id, [...(stopsByTrip.get(stop.trip_id) ?? []), stop]);
|
|
2592
4411
|
});
|
|
2593
|
-
const trips = tripRows
|
|
2594
|
-
|
|
2595
|
-
|
|
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));
|
|
4412
|
+
const trips = tripRows
|
|
4413
|
+
.map((row) => mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? []))
|
|
4414
|
+
.map((trip) => enrichMovementSegmentWithScreenTime(trip, input.userIds));
|
|
2626
4415
|
const dayStart = `${targetDate}T00:00:00.000Z`;
|
|
2627
4416
|
const dayEnd = `${targetDate}T23:59:59.999Z`;
|
|
4417
|
+
const projectedBoxes = projectMovementBoxes({
|
|
4418
|
+
userIds,
|
|
4419
|
+
rangeStart: dayStart,
|
|
4420
|
+
rangeEnd: dayEnd
|
|
4421
|
+
});
|
|
4422
|
+
const stayById = new Map(stays.map((stay) => [stay.id, stay]));
|
|
4423
|
+
const tripById = new Map(trips.map((trip) => [trip.id, trip]));
|
|
4424
|
+
const allSegments = projectedBoxes.map((segment) => {
|
|
4425
|
+
const rawStay = segment.rawStayIds.length > 0 ? stayById.get(segment.rawStayIds[0]) ?? null : null;
|
|
4426
|
+
const rawTrip = segment.rawTripIds.length > 0 ? tripById.get(segment.rawTripIds[0]) ?? null : null;
|
|
4427
|
+
const estimatedScreenTimeSeconds = rawStay?.estimatedScreenTimeSeconds ?? rawTrip?.estimatedScreenTimeSeconds ?? 0;
|
|
4428
|
+
const pickupCount = rawStay?.pickupCount ?? rawTrip?.pickupCount ?? 0;
|
|
4429
|
+
const noteCount = rawStay?.note ? 1 : rawTrip?.note ? 1 : 0;
|
|
4430
|
+
return {
|
|
4431
|
+
id: segment.id,
|
|
4432
|
+
boxId: segment.boxId,
|
|
4433
|
+
kind: segment.kind,
|
|
4434
|
+
sourceKind: segment.sourceKind,
|
|
4435
|
+
origin: segment.origin,
|
|
4436
|
+
editable: segment.editable,
|
|
4437
|
+
startedAt: segment.startedAt,
|
|
4438
|
+
endedAt: segment.endedAt,
|
|
4439
|
+
trueStartedAt: segment.trueStartedAt,
|
|
4440
|
+
trueEndedAt: segment.trueEndedAt,
|
|
4441
|
+
visibleStartedAt: segment.visibleStartedAt,
|
|
4442
|
+
visibleEndedAt: segment.visibleEndedAt,
|
|
4443
|
+
durationSeconds: segment.durationSeconds,
|
|
4444
|
+
label: segment.title,
|
|
4445
|
+
subtitle: segment.subtitle,
|
|
4446
|
+
distanceMeters: segment.distanceMeters,
|
|
4447
|
+
averageSpeedMps: segment.averageSpeedMps,
|
|
4448
|
+
estimatedScreenTimeSeconds,
|
|
4449
|
+
pickupCount,
|
|
4450
|
+
colorTone: segment.kind === "missing"
|
|
4451
|
+
? "from-slate-400/18 to-slate-600/10"
|
|
4452
|
+
: segment.sourceKind === "user_defined"
|
|
4453
|
+
? "from-rose-300/20 to-fuchsia-300/10"
|
|
4454
|
+
: segment.kind === "trip"
|
|
4455
|
+
? "from-emerald-300/26 to-cyan-400/12"
|
|
4456
|
+
: segment.origin === "continued_stay"
|
|
4457
|
+
? "from-sky-400/22 to-indigo-400/10"
|
|
4458
|
+
: "from-white/16 to-white/4",
|
|
4459
|
+
noteCount,
|
|
4460
|
+
overrideCount: segment.overrideCount,
|
|
4461
|
+
overriddenAutomaticBoxIds: segment.overriddenAutomaticBoxIds,
|
|
4462
|
+
overriddenUserBoxIds: segment.overriddenUserBoxIds,
|
|
4463
|
+
isFullyHidden: segment.isFullyHidden,
|
|
4464
|
+
rawStayIds: segment.rawStayIds,
|
|
4465
|
+
rawTripIds: segment.rawTripIds,
|
|
4466
|
+
rawPointCount: segment.rawPointCount,
|
|
4467
|
+
hasLegacyCorrections: segment.hasLegacyCorrections
|
|
4468
|
+
};
|
|
4469
|
+
});
|
|
2628
4470
|
const selectionAggregate = computeSelectionAggregate({
|
|
2629
4471
|
startedAt: dayStart,
|
|
2630
4472
|
endedAt: dayEnd,
|
|
@@ -2636,14 +4478,27 @@ export function getMovementDayDetail(input) {
|
|
|
2636
4478
|
date: targetDate,
|
|
2637
4479
|
settings: getMovementSettings(input.userIds),
|
|
2638
4480
|
summary: {
|
|
2639
|
-
totalDistanceMeters: round(
|
|
2640
|
-
totalMovingSeconds:
|
|
2641
|
-
totalIdleSeconds:
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
4481
|
+
totalDistanceMeters: round(allSegments.reduce((sum, segment) => sum + (segment.kind === "trip" ? segment.distanceMeters : 0), 0)),
|
|
4482
|
+
totalMovingSeconds: allSegments.reduce((sum, segment) => sum + (segment.kind === "trip" ? segment.durationSeconds : 0), 0),
|
|
4483
|
+
totalIdleSeconds: allSegments.reduce((sum, segment) => sum + (segment.kind === "stay" ? segment.durationSeconds : 0), 0),
|
|
4484
|
+
tripCount: allSegments.filter((segment) => segment.kind === "trip").length,
|
|
4485
|
+
stayCount: allSegments.filter((segment) => segment.kind === "stay").length,
|
|
4486
|
+
missingCount: allSegments.filter((segment) => segment.kind === "missing").length,
|
|
4487
|
+
missingDurationSeconds: allSegments
|
|
4488
|
+
.filter((segment) => segment.kind === "missing")
|
|
4489
|
+
.reduce((sum, segment) => sum + segment.durationSeconds, 0),
|
|
4490
|
+
repairedGapCount: allSegments.filter((segment) => segment.origin === "repaired_gap").length,
|
|
4491
|
+
repairedGapDurationSeconds: allSegments
|
|
4492
|
+
.filter((segment) => segment.origin === "repaired_gap")
|
|
4493
|
+
.reduce((sum, segment) => sum + segment.durationSeconds, 0),
|
|
4494
|
+
continuedStayCount: allSegments.filter((segment) => segment.origin === "continued_stay").length,
|
|
4495
|
+
continuedStayDurationSeconds: allSegments
|
|
4496
|
+
.filter((segment) => segment.origin === "continued_stay")
|
|
4497
|
+
.reduce((sum, segment) => sum + segment.durationSeconds, 0),
|
|
2645
4498
|
knownPlaceCount: places.length,
|
|
2646
4499
|
caloriesKcal: round(trips.reduce((sum, trip) => sum + (trip.caloriesKcal ?? 0), 0)),
|
|
4500
|
+
estimatedScreenTimeSeconds: selectionAggregate.estimatedScreenTimeSeconds,
|
|
4501
|
+
pickupCount: selectionAggregate.pickupCount,
|
|
2647
4502
|
averageSpeedMps: round(average(trips
|
|
2648
4503
|
.map((trip) => trip.averageSpeedMps)
|
|
2649
4504
|
.filter((value) => typeof value === "number")), 2)
|
|
@@ -2788,7 +4643,7 @@ export function getMovementTripDetail(tripId) {
|
|
|
2788
4643
|
const placesById = new Map(places.map((place) => [place.id, place]));
|
|
2789
4644
|
const points = listTripPoints([tripId]);
|
|
2790
4645
|
const stops = listTripStops([tripId]);
|
|
2791
|
-
const trip = mapMovementTrip(tripRow, placesById, points, stops);
|
|
4646
|
+
const trip = enrichMovementSegmentWithScreenTime(mapMovementTrip(tripRow, placesById, points, stops), [tripRow.user_id]);
|
|
2792
4647
|
const stylizedPath = buildStylizedCurve(trip.points.map((point) => ({
|
|
2793
4648
|
latitude: point.latitude,
|
|
2794
4649
|
longitude: point.longitude
|
|
@@ -2930,6 +4785,10 @@ export function getMovementMobileBootstrap(pairing) {
|
|
|
2930
4785
|
stayOverrides: stayRows.map((stay) => mapMovementStay(stay, placesById)),
|
|
2931
4786
|
tripOverrides: tripRows.map((trip) => mapMovementTrip(trip, placesById, pointsByTrip.get(trip.id) ?? [], stopsByTrip.get(trip.id) ?? [])),
|
|
2932
4787
|
deletedStayExternalUids: listMovementStayTombstones(pairing.user_id).map((row) => row.stay_external_uid),
|
|
2933
|
-
deletedTripExternalUids: listMovementTripTombstones(pairing.user_id).map((row) => row.trip_external_uid)
|
|
4788
|
+
deletedTripExternalUids: listMovementTripTombstones(pairing.user_id).map((row) => row.trip_external_uid),
|
|
4789
|
+
projectedBoxes: getMovementTimeline({
|
|
4790
|
+
userIds: [pairing.user_id],
|
|
4791
|
+
limit: 80
|
|
4792
|
+
}).segments
|
|
2934
4793
|
};
|
|
2935
4794
|
}
|