forge-openclaw-plugin 0.2.26 → 0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +59 -3
  2. package/dist/assets/{board-ta0rUHOf.js → board-C6jCchjI.js} +2 -2
  3. package/dist/assets/{board-ta0rUHOf.js.map → board-C6jCchjI.js.map} +1 -1
  4. package/dist/assets/index-DVvS8iiU.css +1 -0
  5. package/dist/assets/index-zYB-9Dfo.js +85 -0
  6. package/dist/assets/index-zYB-9Dfo.js.map +1 -0
  7. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
  8. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
  9. package/dist/assets/{motion-fBKPB6yw.js → motion-DFHrH2rd.js} +2 -2
  10. package/dist/assets/{motion-fBKPB6yw.js.map → motion-DFHrH2rd.js.map} +1 -1
  11. package/dist/assets/{table-C-IGTQni.js → table-ZL7Di_u3.js} +2 -2
  12. package/dist/assets/{table-C-IGTQni.js.map → table-ZL7Di_u3.js.map} +1 -1
  13. package/dist/assets/{ui-DInOpaYF.js → ui-CKNPpz7q.js} +2 -2
  14. package/dist/assets/{ui-DInOpaYF.js.map → ui-CKNPpz7q.js.map} +1 -1
  15. package/dist/assets/vendor-DoNZuFhn.js +1247 -0
  16. package/dist/assets/vendor-DoNZuFhn.js.map +1 -0
  17. package/dist/index.html +7 -7
  18. package/dist/openclaw/local-runtime.js +16 -0
  19. package/dist/openclaw/routes.d.ts +27 -0
  20. package/dist/openclaw/routes.js +16 -12
  21. package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  22. package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
  23. package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
  24. package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
  25. package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
  26. package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
  27. package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  28. package/dist/server/server/src/app.js +1684 -117
  29. package/dist/server/server/src/connectors/box-registry.js +44 -9
  30. package/dist/server/server/src/data-management-types.js +107 -0
  31. package/dist/server/server/src/db.js +68 -4
  32. package/dist/server/server/src/demo-data.js +2 -2
  33. package/dist/server/server/src/health.js +702 -18
  34. package/dist/server/server/src/managers/platform/llm-manager.js +7 -4
  35. package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
  36. package/dist/server/server/src/managers/platform/secrets-manager.js +18 -1
  37. package/dist/server/server/src/managers/runtime.js +9 -0
  38. package/dist/server/server/src/movement.js +1971 -112
  39. package/dist/server/server/src/openapi.js +489 -1
  40. package/dist/server/server/src/psyche-types.js +9 -1
  41. package/dist/server/server/src/repositories/activity-events.js +8 -0
  42. package/dist/server/server/src/repositories/ai-connectors.js +522 -74
  43. package/dist/server/server/src/repositories/habits.js +37 -1
  44. package/dist/server/server/src/repositories/model-settings.js +13 -3
  45. package/dist/server/server/src/repositories/notes.js +3 -0
  46. package/dist/server/server/src/repositories/settings.js +380 -18
  47. package/dist/server/server/src/repositories/tasks.js +170 -10
  48. package/dist/server/server/src/runtime-data-root.js +82 -0
  49. package/dist/server/server/src/screen-time.js +802 -0
  50. package/dist/server/server/src/services/data-management.js +788 -0
  51. package/dist/server/server/src/services/entity-crud.js +205 -2
  52. package/dist/server/server/src/services/knowledge-graph.js +1455 -0
  53. package/dist/server/server/src/services/life-force-model.js +197 -0
  54. package/dist/server/server/src/services/life-force.js +1270 -0
  55. package/dist/server/server/src/services/psyche-observation-calendar.js +383 -16
  56. package/dist/server/server/src/types.js +286 -13
  57. package/dist/server/server/src/web.js +228 -13
  58. package/dist/server/src/components/customization/utility-widgets.js +136 -27
  59. package/dist/server/src/components/ui/info-tooltip.js +25 -0
  60. package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
  61. package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
  62. package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
  63. package/dist/server/src/components/workbench-boxes/health/health-boxes.js +63 -8
  64. package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
  65. package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +62 -54
  66. package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +18 -8
  67. package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +56 -38
  68. package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
  69. package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
  70. package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +35 -30
  71. package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
  72. package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
  73. package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
  74. package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +3 -1
  75. package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +39 -3
  76. package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
  77. package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
  78. package/dist/server/src/components/workbench-boxes/today/today-boxes.js +47 -32
  79. package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
  80. package/dist/server/src/lib/api.js +280 -21
  81. package/dist/server/src/lib/data-management-types.js +1 -0
  82. package/dist/server/src/lib/entity-visuals.js +279 -0
  83. package/dist/server/src/lib/knowledge-graph-types.js +276 -0
  84. package/dist/server/src/lib/knowledge-graph.js +470 -0
  85. package/dist/server/src/lib/schemas.js +4 -0
  86. package/dist/server/src/lib/snapshot-normalizer.js +43 -1
  87. package/dist/server/src/lib/workbench/contracts.js +229 -0
  88. package/dist/server/src/lib/workbench/nodes.js +200 -0
  89. package/dist/server/src/lib/workbench/registry.js +52 -5
  90. package/dist/server/src/lib/workbench/runtime.js +254 -38
  91. package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
  92. package/openclaw.plugin.json +1 -1
  93. package/package.json +1 -1
  94. package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  95. package/server/migrations/038_data_management_settings.sql +11 -0
  96. package/server/migrations/039_life_force_and_action_points.sql +114 -0
  97. package/server/migrations/040_screen_time_domain.sql +89 -0
  98. package/server/migrations/041_companion_source_states.sql +21 -0
  99. package/server/migrations/042_movement_boxes.sql +47 -0
  100. package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  101. package/skills/forge-openclaw/SKILL.md +24 -11
  102. package/skills/forge-openclaw/entity_conversation_playbooks.md +210 -34
  103. package/skills/forge-openclaw/psyche_entity_playbooks.md +113 -17
  104. package/dist/assets/index-Ro0ZF_az.css +0 -1
  105. package/dist/assets/index-ytlpSj23.js +0 -79
  106. package/dist/assets/index-ytlpSj23.js.map +0 -1
  107. package/dist/assets/vendor-lE3tZJcC.js +0 -876
  108. 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" && parsed.kind !== "trip") {
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
- export function getMovementTimeline(input) {
1825
- const parsed = movementTimelineQuerySchema.parse(input);
1826
- const userIds = parsed.userIds.length > 0 ? parsed.userIds : undefined;
1827
- const initialStayRows = listMovementStayRows(userIds);
1828
- const initialTripRows = listMovementTripRows(userIds);
1829
- const scopedUserIds = new Set();
1830
- for (const row of initialStayRows) {
1831
- scopedUserIds.add(row.user_id);
2106
+ function movementBoundaryDistanceMeters(left, right) {
2107
+ if (!left || !right) {
2108
+ return null;
1832
2109
  }
1833
- for (const row of initialTripRows) {
1834
- scopedUserIds.add(row.user_id);
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
- for (const scopedUserId of scopedUserIds) {
1837
- reconcileMovementOverlapValidation(scopedUserId);
2122
+ if (left.placeExternalUid &&
2123
+ right.placeExternalUid &&
2124
+ left.placeExternalUid === right.placeExternalUid) {
2125
+ return true;
1838
2126
  }
1839
- const places = listMovementPlaceRows(userIds).map(mapMovementPlace);
1840
- const placesById = new Map(places.map((place) => [place.id, place]));
1841
- const stayRows = listMovementStayRows(userIds);
1842
- const tripRows = listMovementTripRows(userIds);
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 chronological = [
1853
- ...stayRows.map((row) => ({
1854
- id: row.id,
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
- startedAt: row.started_at,
1857
- endedAt: row.ended_at,
1858
- stay: mapMovementStay(row, placesById)
1859
- })),
1860
- ...tripRows.map((row) => ({
1861
- id: row.id,
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
- startedAt: row.started_at,
1864
- endedAt: row.ended_at,
1865
- trip: mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? [])
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
- const validChronological = chronological.filter((segment) => segment.kind === "stay"
1873
- ? !hasInvalidMovementRecord(segment.stay.metadata)
1874
- : !hasInvalidMovementRecord(segment.trip.metadata));
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 segment of validChronological) {
1878
- if (segment.kind === "stay") {
1879
- stayLaneById.set(segment.id, nextStayLane);
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 timelineSource = parsed.includeInvalid ? chronological : validChronological;
1884
- const decorated = timelineSource.map((segment, index) => {
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 = timelineSource
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
- if (segment.kind === "stay") {
1902
- const invalid = hasInvalidMovementRecord(segment.stay.metadata);
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
- durationSeconds: segment.stay.durationSeconds,
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: buildMovementTimelineTitleForStay(segment.stay),
1914
- subtitle: buildMovementTimelineSubtitleForStay(segment.stay),
1915
- placeLabel: segment.stay.place?.label ?? segment.stay.placeId ?? null,
1916
- tags: uniqStrings([
1917
- ...(segment.stay.place?.categoryTags ?? []),
1918
- ...(Array.isArray(segment.stay.metrics.tags)
1919
- ? (segment.stay.metrics.tags ?? [])
1920
- : [])
1921
- ]),
3564
+ title: segment.title,
3565
+ subtitle: segment.subtitle,
3566
+ placeLabel: segment.placeLabel,
3567
+ tags: segment.tags,
1922
3568
  isInvalid: invalid,
1923
- syncSource: segment.stay.pairingSessionId ? "companion" : "forge",
3569
+ syncSource,
1924
3570
  cursor: encodeMovementTimelineCursor(cursor),
1925
- stay: segment.stay,
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
- const invalid = hasInvalidMovementRecord(segment.trip.metadata);
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
- durationSeconds: segment.trip.durationSeconds,
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: buildMovementTimelineTitleForTrip(segment.trip),
1941
- subtitle: buildMovementTimelineSubtitleForTrip(segment.trip),
1942
- placeLabel: segment.trip.endPlace?.label ??
1943
- segment.trip.startPlace?.label ??
1944
- null,
1945
- tags: uniqStrings([
1946
- ...segment.trip.tags,
1947
- ...(segment.trip.startPlace?.categoryTags ?? []),
1948
- ...(segment.trip.endPlace?.categoryTags ?? [])
1949
- ]),
3678
+ title: segment.title,
3679
+ subtitle: segment.subtitle,
3680
+ placeLabel: segment.placeLabel,
3681
+ tags: segment.tags,
1950
3682
  isInvalid: invalid,
1951
- syncSource: segment.trip.pairingSessionId ? "companion" : "forge",
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: segment.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: chronological.length - validChronological.length
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).map((row) => mapMovementStay(row, placesById));
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.map((row) => mapMovementTrip(row, placesById, pointsByTrip.get(row.id) ?? [], stopsByTrip.get(row.id) ?? []));
2594
- const allSegments = [
2595
- ...stays.map((stay) => ({
2596
- id: stay.id,
2597
- kind: "stay",
2598
- startedAt: stay.startedAt,
2599
- endedAt: stay.endedAt,
2600
- durationSeconds: stay.durationSeconds,
2601
- label: stay.place?.label ?? stay.label ?? "Stay",
2602
- subtitle: stay.place?.categoryTags.join(" · ") ||
2603
- (stay.classification === "stationary" ? "Stationary" : stay.classification),
2604
- distanceMeters: 0,
2605
- averageSpeedMps: 0,
2606
- colorTone: stay.place?.categoryTags.includes("home")
2607
- ? "from-sky-400/30 to-indigo-500/12"
2608
- : "from-white/16 to-white/4",
2609
- noteCount: stay.note ? 1 : 0
2610
- })),
2611
- ...trips.map((trip) => ({
2612
- id: trip.id,
2613
- kind: "trip",
2614
- startedAt: trip.startedAt,
2615
- endedAt: trip.endedAt,
2616
- durationSeconds: trip.durationSeconds,
2617
- label: trip.label ||
2618
- `${trip.startPlace?.label ?? "Unknown"} → ${trip.endPlace?.label ?? "Unknown"}`,
2619
- subtitle: `${round(trip.distanceMeters / 1000, 1)} km · ${trip.activityType || trip.travelMode}`,
2620
- distanceMeters: trip.distanceMeters,
2621
- averageSpeedMps: trip.averageSpeedMps ?? 0,
2622
- colorTone: "from-emerald-300/26 to-cyan-400/12",
2623
- noteCount: trip.note ? 1 : 0
2624
- }))
2625
- ].sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
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(trips.reduce((sum, trip) => sum + trip.distanceMeters, 0)),
2640
- totalMovingSeconds: trips.reduce((sum, trip) => sum + trip.movingSeconds, 0),
2641
- totalIdleSeconds: stays.reduce((sum, stay) => sum + stay.durationSeconds, 0) +
2642
- trips.reduce((sum, trip) => sum + trip.idleSeconds, 0),
2643
- tripCount: trips.length,
2644
- stayCount: stays.length,
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
  }