@meri-imperiumi/signalk-dead-reckoning 0.11.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,163 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-09-28
11
+
12
+ ### Changed
13
+ - **Notes plot by default.** The Notes overlay started detached — the
14
+ markers were fetched and cached, but the layer only mounted when the
15
+ layers-control checkbox was flipped, so chart annotations (often
16
+ navigational warnings, e.g. METAREA gale warnings delivered by
17
+ signalk-passage-briefing) never showed until manually enabled.
18
+ Notes now mount with the map like AIS traffic and the active route;
19
+ the checkbox remains for decluttering, and a previously stored
20
+ "off" pick is still honored.
21
+ - **Own-ship boat glyphs are bigger and bolder.** The GPS and DR boats
22
+ grew from 20 to 26 px, the hull fill is near-solid (0.92 opacity, was
23
+ 0.35) under a heavier stroke, and the hull rides on a dark casing
24
+ outline — the same cartographic trick as the ghost track — so own
25
+ ship stays legible over light raster charts and chart clutter. The
26
+ navigator's X inside the DR hull got a matching weight bump.
27
+
28
+ ### Fixed
29
+ - **Notes show their full text.** The note detail surface (and the
30
+ edit form) read only the v2 notes `body` field — provider-shaped
31
+ notes (metarea warnings via signalk-passage-briefing) carry their
32
+ text in `description`, with the title truncated by the source, so
33
+ they opened with a "…" headline and no text at all. The text now
34
+ falls back to `description`. Editing such a note seeds the form with
35
+ the real text, and a save merges the stored resource so provider
36
+ metadata (properties, synoptic-chart `url`) survives the PUT.
37
+ - **Notes now load for the area being viewed.** The webapp fetched the
38
+ notes collection exactly once (boot and stream reconnect) with no
39
+ viewport information; resource-provider backends filter the
40
+ collection themselves, so the answer could be limited to the
41
+ provider's default area — zooming out showed no notes at all. The
42
+ chart now re-fetches notes for the visible area whenever the view
43
+ moves beyond the already-fetched region: Freeboard-SK's wire format
44
+ (`GET /signalk/v2/api/resources/notes?position=[lon,lat]&distance=<m>`
45
+ — view center plus a covering radius to the view corner), debounced,
46
+ with follow-mode re-centers not re-issuing the request. Scoped
47
+ responses merge into the notes cache, so notes from previously
48
+ viewed areas stay on the chart.
49
+
50
+ ### Added
51
+ - **The layers control remembers your choices.** Base chart and
52
+ overlay toggles (AIS traffic, active route, vectors, range rings,
53
+ laylines, notes) persist in localStorage (`dr-layers`) and are
54
+ restored on the next session — remembered picks that still mount
55
+ outrank the defaults; un-flipped toggles keep their built-in state.
56
+
57
+ ## [0.12.0] - 2026-09-21
58
+
59
+ ### Added
60
+ - **Chartplotter UX (work doc #30, north-up phase):** the DR webapp
61
+ navigates like a plotter, not a map viewer:
62
+ - **Own-ship boat glyphs** replace the GPS dot and the bare DR X —
63
+ a pointed-hull vessel shape rotated to COG (GPS) and to the DR
64
+ course (DR), the navigator's X kept as a small detail inside the
65
+ DR hull. Dot/X fallbacks when course is unknown.
66
+ - **10-minute predictor vectors** (layers control, "Vectors"): a
67
+ dashed COG×SOG line from the GPS position and a DR-course×DR-speed
68
+ line from the DR position, with tick marks at 2-minute intervals;
69
+ the AIS velocity leader extends from the 6-minute to the same
70
+ 10-minute convention so every predictor on the chart reads as one
71
+ family.
72
+ - **Nautical scale bar** (bottom-left): a custom control snapped to
73
+ a 0.1–1000 NM ladder (80–200 px band), metric fallback at deep
74
+ zooms; no `1:x` numeric readout.
75
+ - **Range rings** (layers control, "Range rings"): three
76
+ concentric rings centered on the GPS position, spacing derived
77
+ from the zoom ladder, re-spaced on zoom.
78
+ - **Wind laylines** (layers control, "Laylines"): two rays from the
79
+ DR vessel at TWD ± beat/gybe angle (the polar performance plugin's
80
+ angles — the established feed), port tack red / starboard green
81
+ per the navigation-light convention, fixed screen-relative length.
82
+ Gated honestly: renders only while `navigation.state` is
83
+ `sailing`, and only when true wind and the angle this point of
84
+ sail needs are actually published.
85
+ - **Pick menu is now the target surface:** every pick shows bearing
86
+ & distance from BOTH own-ship references (DR and GPS — the
87
+ most-wanted helm readout, absent-source rows hidden); AIS picks
88
+ add the target details panel (type, flag, dimensions, destination
89
+ & ETA — Freeboard-SK's field set, new static paths in the AIS
90
+ subscription folded into the store) plus **CPA/TCPA for both
91
+ references** (DR-based = conservative, GPS-based = conventional);
92
+ a target closing inside the CPA watch limits (0.5 nm / 20 min)
93
+ turns its glyph red and carries the CPA figure in its tooltip.
94
+ - **Measure tool** ("Measure from here…" in the pick menu): tap
95
+ points, get true bearing + distance leg by leg with a running
96
+ total in a floating readout; double-click / right-click / Esc ends.
97
+ - **Signal K notes on the chart**: positioned notes from the v2
98
+ resources API render as orange pin markers on a toggleable
99
+ "Notes" layer; clicking/right-clicking opens the detail surface
100
+ (title, body rendered per mimeType with a minimal safe markdown
101
+ renderer, timestamp, DR/GPS bearings, all pick actions). The pick
102
+ menu gains "New note at…" — a one-handed hazard-marking form
103
+ (Hazard preset, position pre-seeded) that POSTs to the v2
104
+ resources API, with edit/delete from the note's menu and refetch
105
+ on stream reconnect. Failed writes surface in the form, never
106
+ silently.
107
+
108
+ - **Fixes over a day old carry their date on the chart** —
109
+ "Fix 18.9. 02:36Z" instead of the ambiguous "Fix 02:36Z". With the
110
+ new 7-day history window most fixes on the chart aren't from
111
+ today, so the time alone no longer identifies the fix. Under 24 h
112
+ the label is unchanged.
113
+ - **History-aware chart window: tracks, overlays and trip
114
+ boundaries.** The webapp now shows the last
115
+ `max(7 days, since trip start)` on the chart instead of nothing:
116
+ both the GPS track and the DR ghost track backfill from the Signal K
117
+ History API over the window (10-minute resolution — ~1000 points per
118
+ track on a full week), and the persisted chartwork overlays (fixes,
119
+ LOPs, CPLs, snap vectors) are window-filtered via a new `since`
120
+ query parameter on the REST endpoints (`/fixes`, `/observations`,
121
+ `/corrections`; ISO-8601 or epoch ms, invalid values ignored).
122
+ Two bugs fixed on the way: the DR track backfill silently never
123
+ worked — the history provider emits plugin-published positions as
124
+ `{latitude, longitude}` objects where the parser only accepted
125
+ GeoJSON `[lon, lat]` pairs — and the 6-hour fetch window was
126
+ replaced by the proper window.
127
+ - **Trip log now actually resets at trip boundaries (SPEC §9.2).**
128
+ `engine.resetTrip()` existed but nothing ever called it, so
129
+ `navigation.deadReckoning.trip.log` accumulated since install (the
130
+ production log read 703 nm — more than the last two trips
131
+ combined). A sustained `navigation.state` transition from
132
+ `anchored`/`moored` to underway (5-minute debounce, configurable
133
+ via `tripBoundary.sustainS`/`clearS`) now zeroes the trip log and
134
+ records the trip start, which is persisted across restarts
135
+ (mid-trip server restarts keep the boundary) and exposed via
136
+ GET /status as `tripStartMs` — the webapp uses it to anchor its
137
+ history window. A flapping autostate source can't zero the log:
138
+ blips shorter than the debounce are ignored.
139
+ - **The headline log figure is now the trip log.** The bottom-right
140
+ readout shows `navigation.deadReckoning.trip.log` ("Trip log")
141
+ instead of the cumulative water-track log — the watchkeeper's
142
+ glance figure is distance-since-departure; the cumulative total
143
+ rides along as the figure's hover tooltip. Falls back to the
144
+ cumulative log until a first trip boundary is ever observed.
145
+
146
+ ### Fixed
147
+ - **Hosted plotter widgets showed as empty black squares with no
148
+ data** (sea trial 2026-09-21). Two independent bugs, both in the
149
+ widget host, both reproducible with a locally served webapp against
150
+ a stub Signal K server:
151
+ - The bus port captured the widget iframe's `contentWindow` at
152
+ context creation — but a browser replaces that Window object when
153
+ the frame navigates from `about:blank` to its `src` (and the
154
+ frame is still detached, window `null`, when the context is
155
+ created). Every message from the loaded widget was silently
156
+ dropped: the handshake never completed and the tile sat on its
157
+ placeholder grid. The port now resolves the live window per
158
+ message (`liveWindowPort`).
159
+ - A placement restored from layout storage (page reload) rendered
160
+ an empty dark cell forever: the area element renders its cells
161
+ before discovery has created the widget contexts, and the iframe
162
+ was only adopted at cell creation. Adoption is now idempotent —
163
+ every render adopts a context's iframe into its cell unless it's
164
+ already home (never re-parenting a live iframe, which would
165
+ reload it).
166
+
10
167
  ## [0.11.2] - 2026-09-21
11
168
 
12
169
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/db.js CHANGED
@@ -654,10 +654,14 @@ function dequeueLogbookPending(db, pendingId) {
654
654
 
655
655
  /**
656
656
  * Lists recent confirmed fixes for the UI (SPEC §14.1 fix points).
657
+ * The webapp's history window filters via `sinceMs` (epoch ms): rows
658
+ * older than the boundary are left out so the chart shows current
659
+ * chartwork, not everything since install.
657
660
  *
658
661
  * @param {import("node:sqlite").DatabaseSync} db
659
662
  * @param {object} [q]
660
663
  * @param {number} [q.limit=100]
664
+ * @param {number|null} [q.sinceMs] - include rows at/after this epoch ms
661
665
  * @returns {Array<object>} newest-first
662
666
  */
663
667
  function listFixes(db, q = {}) {
@@ -666,9 +670,35 @@ function listFixes(db, q = {}) {
666
670
  `SELECT fix_id, timestamp, source_type, latitude, longitude,
667
671
  estimated_error_radius, confirmed_by, resets_dr_origin,
668
672
  derived_from_fix_id
669
- FROM fixes ORDER BY fix_id DESC LIMIT ?`,
673
+ FROM fixes ${sinceClause(q)} ORDER BY fix_id DESC LIMIT ?`,
670
674
  )
671
- .all(q.limit ?? 100);
675
+ .all(...sinceArgs(q), q.limit ?? 100);
676
+ }
677
+
678
+ /**
679
+ * WHERE fragment for the optional `sinceMs` window filter, shared by
680
+ * the overlay list queries — fixes, LOPs, CPLs and corrections all
681
+ * carry the same ISO-8601 `timestamp` column. Compares via julianday()
682
+ * rather than string comparison: stored timestamps vary in shape
683
+ * (`…00Z` vs `…00.001Z` vs `…00.000Z`), and lexicographic order is
684
+ * wrong across those shapes (a bare `Z` sorts AFTER a fraction).
685
+ *
686
+ * @param {object} q - the query options carrying `sinceMs`
687
+ * @returns {string} "" or the WHERE clause (args from sinceArgs)
688
+ */
689
+ function sinceClause(q) {
690
+ return q.sinceMs != null ? "WHERE julianday(timestamp) >= julianday(?)" : "";
691
+ }
692
+
693
+ /**
694
+ * Positional args matching {@link sinceClause}; callers pass them to
695
+ * .all() BEFORE the limit.
696
+ *
697
+ * @param {object} q
698
+ * @returns {Array<unknown>}
699
+ */
700
+ function sinceArgs(q) {
701
+ return q.sinceMs != null ? [new Date(q.sinceMs).toISOString()] : [];
672
702
  }
673
703
 
674
704
  /**
@@ -709,10 +739,12 @@ function getCircularPositionLine(db, id) {
709
739
 
710
740
  /**
711
741
  * Lists persisted lines of position for the UI (SPEC §14.1 LOP overlay).
742
+ * `sinceMs` window-filters as in listFixes.
712
743
  *
713
744
  * @param {import("node:sqlite").DatabaseSync} db
714
745
  * @param {object} [q]
715
746
  * @param {number} [q.limit=100]
747
+ * @param {number|null} [q.sinceMs]
716
748
  * @returns {Array<object>} newest-first
717
749
  */
718
750
  function listLinesOfPosition(db, q = {}) {
@@ -720,9 +752,9 @@ function listLinesOfPosition(db, q = {}) {
720
752
  .prepare(
721
753
  `SELECT lop_id, timestamp, lop_type, assumed_lat, assumed_lon,
722
754
  azimuth_true, intercept_nm, body_or_object, used_in_fix_id
723
- FROM lines_of_position ORDER BY lop_id DESC LIMIT ?`,
755
+ FROM lines_of_position ${sinceClause(q)} ORDER BY lop_id DESC LIMIT ?`,
724
756
  )
725
- .all(q.limit ?? 100);
757
+ .all(...sinceArgs(q), q.limit ?? 100);
726
758
  }
727
759
 
728
760
  /**
@@ -732,6 +764,7 @@ function listLinesOfPosition(db, q = {}) {
732
764
  * @param {import("node:sqlite").DatabaseSync} db
733
765
  * @param {object} [q]
734
766
  * @param {number} [q.limit=100]
767
+ * @param {number|null} [q.sinceMs]
735
768
  * @returns {Array<object>} newest-first
736
769
  */
737
770
  function listCircularPositionLines(db, q = {}) {
@@ -739,18 +772,20 @@ function listCircularPositionLines(db, q = {}) {
739
772
  .prepare(
740
773
  `SELECT cpl_id, timestamp, center_lat, center_lon, radius_nm,
741
774
  source_object, used_in_fix_id
742
- FROM circular_position_lines ORDER BY cpl_id DESC LIMIT ?`,
775
+ FROM circular_position_lines ${sinceClause(q)} ORDER BY cpl_id DESC LIMIT ?`,
743
776
  )
744
- .all(q.limit ?? 100);
777
+ .all(...sinceArgs(q), q.limit ?? 100);
745
778
  }
746
779
 
747
780
  /**
748
781
  * Lists recent snap-to-fix corrections for the UI (SPEC §9.3/§14.1 —
749
782
  * dashed vector from pre-snap ghost position to confirmed fix).
783
+ * `sinceMs` window-filters as in listFixes.
750
784
  *
751
785
  * @param {import("node:sqlite").DatabaseSync} db
752
786
  * @param {object} [q]
753
787
  * @param {number} [q.limit=20]
788
+ * @param {number|null} [q.sinceMs]
754
789
  * @returns {Array<object>} newest-first
755
790
  */
756
791
  function listCorrections(db, q = {}) {
@@ -759,9 +794,9 @@ function listCorrections(db, q = {}) {
759
794
  `SELECT correction_id, timestamp, dr_lat, dr_lon, fix_lat, fix_lon,
760
795
  deviation_nm, deviation_bearing, dr_elapsed_seconds,
761
796
  sail_state, sea_state
762
- FROM dr_corrections ORDER BY correction_id DESC LIMIT ?`,
797
+ FROM dr_corrections ${sinceClause(q)} ORDER BY correction_id DESC LIMIT ?`,
763
798
  )
764
- .all(q.limit ?? 20);
799
+ .all(...sinceArgs(q), q.limit ?? 20);
765
800
  }
766
801
 
767
802
  // -------------------------------------------------------------------------
package/plugin/index.js CHANGED
@@ -323,6 +323,18 @@ const DEFAULT_CONFIG = {
323
323
  sustainS: 10,
324
324
  clearS: 10,
325
325
  },
326
+ /**
327
+ * Trip-boundary hysteresis (SPEC §9.2): how long navigation.state
328
+ * must STAY underway (leaving moored/anchored) before the transition
329
+ * counts as a trip start and resets the trip log. Longer than the
330
+ * sensor-health windows on purpose — untying, anchor aweigh and
331
+ * lunch stops all bounce the state for a few minutes — long enough
332
+ * that only real departure zeroes the trip log.
333
+ */
334
+ tripBoundary: {
335
+ sustainS: 300,
336
+ clearS: 300,
337
+ },
326
338
  /**
327
339
  * §3.1 inertial-polar fallback (work doc #18): running-average window
328
340
  * and staleness cutoff for the polar speed — both the
@@ -610,6 +622,27 @@ module.exports = (app) => {
610
622
  /** @type {number|null} monotonic seconds counter for the training loop */
611
623
  let clockS = 0;
612
624
 
625
+ /**
626
+ * Trip boundary tracking (SPEC §9.2): the debounced moored/anchored
627
+ * → underway transition resets the trip log and records the trip
628
+ * start. Debounced symmetric hysteresis (createFlagState/flagTick)
629
+ * so a flapping autostate source can't zero the trip log spuriously.
630
+ * `raw` is this tick's underway verdict; the flag RAISES on the
631
+ * sustained underway edge and never drops mid-trip — the boundary
632
+ * event is the transition INTO a trip, not the trip's end.
633
+ * @type {ReturnType<typeof createFlagState>|null}
634
+ */
635
+ let tripFlag = null;
636
+
637
+ /**
638
+ * Trip start timestamp (ms) of the current/last trip — persisted in
639
+ * dr_state_store so a mid-trip restart keeps the boundary (and the
640
+ * webapp's history window: max(7 days, since trip start)). Null when
641
+ * no boundary has ever been observed.
642
+ * @type {number|null}
643
+ */
644
+ let tripStartMs = null;
645
+
613
646
  /** @type {ReturnType<typeof createDivergenceState>|null} §7.3 divergence monitor */
614
647
  let divergence = null;
615
648
 
@@ -859,6 +892,10 @@ module.exports = (app) => {
859
892
  ...DEFAULT_CONFIG.sensorHealth,
860
893
  ...(opts.sensorHealth ?? {}),
861
894
  };
895
+ config.tripBoundary = {
896
+ ...DEFAULT_CONFIG.tripBoundary,
897
+ ...(opts.tripBoundary ?? {}),
898
+ };
862
899
  config.logbook = {
863
900
  ...DEFAULT_CONFIG.logbook,
864
901
  ...(opts.logbook ?? {}),
@@ -955,6 +992,14 @@ module.exports = (app) => {
955
992
  shadowGate = deps.createPublishGate({
956
993
  everyTicks: config.publish.everyTicks,
957
994
  });
995
+ // Trip boundary tracking (SPEC §9.2): fresh debounce state per
996
+ // start; the trip start itself survives restarts via dr_state_store
997
+ // so the webapp's history window keeps the trip boundary across a
998
+ // mid-trip restart.
999
+ tripFlag = deps.createFlagState();
1000
+ const savedTripStartMs = Number(deps.getState(db, "dr_trip_start_ms"));
1001
+ tripStartMs = Number.isFinite(savedTripStartMs) ? savedTripStartMs : null;
1002
+
958
1003
  polarState = deps.createPolarSpeedState();
959
1004
  polarModel = null;
960
1005
  polarLoadedId = null;
@@ -1461,6 +1506,30 @@ module.exports = (app) => {
1461
1506
  const navState = deltaState.get("navigation.state");
1462
1507
  const underway = navState !== "anchored" && navState !== "moored";
1463
1508
 
1509
+ // Trip boundary (SPEC §9.2): a sustained moored/anchored → underway
1510
+ // transition starts a trip — resets the trip log and records the
1511
+ // start. The flag raises once on the sustained edge and never drops
1512
+ // (a trip ends at the NEXT raise, not on the underway→moored edge),
1513
+ // so an afternoon of anchoring for lunch doesn't zero the log. Only
1514
+ // a reset that actually moves the count is recorded — restarting
1515
+ // underway (no prior observed boundary) must not fabricate one.
1516
+ const tripEdge = deps.flagTick(
1517
+ tripFlag,
1518
+ underway,
1519
+ config.tickIntervalMs / 1000,
1520
+ config.tripBoundary,
1521
+ );
1522
+ if (tripEdge.transition === "raise") {
1523
+ if (engine.tripLogNm > 0) {
1524
+ engine.resetTrip();
1525
+ tripStartMs = Date.now();
1526
+ deps.setState(db, "dr_trip_start_ms", String(tripStartMs));
1527
+ app.debug(
1528
+ `Trip boundary: navigation.state sustained underway — trip log reset, trip started ${new Date(tripStartMs).toISOString()}`,
1529
+ );
1530
+ }
1531
+ }
1532
+
1464
1533
  // GPS-derived motion (independent of the water-track sensors):
1465
1534
  // updated every tick, used to detect "idle but making way" below.
1466
1535
  updateGpsMotion(gps);
@@ -2725,6 +2794,11 @@ module.exports = (app) => {
2725
2794
  }
2726
2795
  deps.setState(db, "dr_log_nm", String(engine.logNm));
2727
2796
  deps.setState(db, "dr_trip_log_nm", String(engine.tripLogNm));
2797
+ // Trip boundary (SPEC §9.2) survives restarts: persisted at the
2798
+ // boundary tick AND on every flush (the boundary itself can't be
2799
+ // re-derived — a restart mid-trip sees only "underway").
2800
+ if (tripStartMs != null)
2801
+ deps.setState(db, "dr_trip_start_ms", String(tripStartMs));
2728
2802
  deps.setState(db, "dr_log_since_origin", String(engine.logNmSinceOrigin));
2729
2803
  // Sea trial 2026-09-06: elapsed time drives the current-knowledge
2730
2804
  // term of the uncertainty cone — a restart must not zero it, or the
@@ -2746,6 +2820,25 @@ module.exports = (app) => {
2746
2820
 
2747
2821
  // --- REST API ----------------------------------------------------------
2748
2822
 
2823
+ /**
2824
+ * Parses the optional `since` query parameter (ISO-8601 string or
2825
+ * epoch ms) of the overlay endpoints into the db layer's `sinceMs`
2826
+ * option. Invalid values are ignored (unfiltered) — the window is a
2827
+ * display concern, never a reason to 4xx the chart.
2828
+ *
2829
+ * @param {object} req - Express request
2830
+ * @returns {{sinceMs?: number}}
2831
+ */
2832
+ function sinceOpt(req) {
2833
+ const raw = req.query?.since;
2834
+ if (raw == null || raw === "") return {};
2835
+ const n = Number(raw);
2836
+ if (Number.isFinite(n) && n > 0) return { sinceMs: n };
2837
+ const t = Date.parse(String(raw));
2838
+ if (Number.isFinite(t)) return { sinceMs: t };
2839
+ return {};
2840
+ }
2841
+
2749
2842
  /**
2750
2843
  * Registers REST routes on the plugin router.
2751
2844
  *
@@ -2766,6 +2859,9 @@ module.exports = (app) => {
2766
2859
  origin: engine.origin,
2767
2860
  logNm: engine.logNm,
2768
2861
  tripLogNm: engine.tripLogNm,
2862
+ // SPEC §9.2 trip boundary (null when none observed yet): the
2863
+ // webapp's history window is max(7 days, since trip start).
2864
+ tripStartMs,
2769
2865
  elapsedSinceOriginS: engine.elapsedSinceOriginS,
2770
2866
  underwaySinceOriginS: engine.underwaySinceOriginS,
2771
2867
  binCount: matrix.count(),
@@ -2794,8 +2890,9 @@ module.exports = (app) => {
2794
2890
  });
2795
2891
 
2796
2892
  /**
2797
- * GET /fixes — recent confirmed fixes for the map overlay (SPEC
2798
- * §14.1 fix points). `limit` caps the result (default 100).
2893
+ * GET /fixes — confirmed fixes for the map overlay (SPEC §14.1 fix
2894
+ * points). `limit` caps the result (default 100); `since` (ISO-8601
2895
+ * or epoch ms) window-filters to the webapp's history window.
2799
2896
  */
2800
2897
  router.get("/fixes", (req, res) => {
2801
2898
  if (!engine || !db) {
@@ -2806,13 +2903,14 @@ module.exports = (app) => {
2806
2903
  1,
2807
2904
  Math.min(1000, Number(req.query?.limit) || 100),
2808
2905
  );
2809
- res.json({ fixes: deps.listFixes(db, { limit }) });
2906
+ res.json({ fixes: deps.listFixes(db, { limit, ...sinceOpt(req) }) });
2810
2907
  });
2811
2908
 
2812
2909
  /**
2813
2910
  * GET /observations — persisted LOPs and CPLs for the map overlay
2814
2911
  * (SPEC §14.1 geometric primitives). Unused/unresolved observations
2815
2912
  * (used_in_fix_id IS NULL) are marked so the UI can emphasize them.
2913
+ * `since` window-filters to the webapp's history window.
2816
2914
  */
2817
2915
  router.get("/observations", (req, res) => {
2818
2916
  if (!engine || !db) {
@@ -2823,15 +2921,17 @@ module.exports = (app) => {
2823
2921
  1,
2824
2922
  Math.min(1000, Number(req.query?.limit) || 100),
2825
2923
  );
2924
+ const since = sinceOpt(req);
2826
2925
  res.json({
2827
- lops: deps.listLinesOfPosition(db, { limit }),
2828
- cpls: deps.listCircularPositionLines(db, { limit }),
2926
+ lops: deps.listLinesOfPosition(db, { limit, ...since }),
2927
+ cpls: deps.listCircularPositionLines(db, { limit, ...since }),
2829
2928
  });
2830
2929
  });
2831
2930
 
2832
2931
  /**
2833
2932
  * GET /corrections — recent snap-to-fix corrections for the dashed
2834
- * vector overlay (SPEC §9.3, §14.1).
2933
+ * vector overlay (SPEC §9.3, §14.1). `since` window-filters to the
2934
+ * webapp's history window.
2835
2935
  */
2836
2936
  router.get("/corrections", (req, res) => {
2837
2937
  if (!engine || !db) {
@@ -2839,7 +2939,9 @@ module.exports = (app) => {
2839
2939
  return;
2840
2940
  }
2841
2941
  const limit = Math.max(1, Math.min(200, Number(req.query?.limit) || 20));
2842
- res.json({ corrections: deps.listCorrections(db, { limit }) });
2942
+ res.json({
2943
+ corrections: deps.listCorrections(db, { limit, ...sinceOpt(req) }),
2944
+ });
2843
2945
  });
2844
2946
 
2845
2947
  /**