Skip to content

fix(map): render every position fix + paginate full history (#3791)#3795

Merged
Yeraze merged 1 commit into
mainfrom
feature/position-history-full-pagination
Jun 26, 2026
Merged

fix(map): render every position fix + paginate full history (#3791)#3795
Yeraze merged 1 commit into
mainfrom
feature/position-history-full-pagination

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

Position-history points were being dropped on the map: the dots were subsampled to a hard cap of 30 markers, so lengthening the tail thinned them out (only the connecting line stayed dense), and the server only ever returned the newest 1500 telemetry rows (~300 fixes) in a single request, so older history was unreachable. This PR renders a marker at every recorded fix and progressively loads the entire history in bounded pages, keeping the per-request 1500-row cap intact.

Changes

  • generatePositionHistoryArrows (mapHelpers.tsx): render a CircleMarker (with hover tooltip + click popup) at every fix. Decorative heading triangles remain subsampled to maxArrows so dense/stationary trails don't become a wall of overlapping arrows — the dots underneath are the full-resolution interactive layer.
  • Server before cursor: GET /api/nodes/:nodeId/position-history accepts an optional before=<epoch ms> param returning only fixes strictly older than the cursor. Threaded through getPositionTelemetryByNodeAsync → repo getPositionTelemetryByNode as a lt(timestamp, before) condition. The 1500-row per-request cap is unchanged; the new param is optional and positional-last, so existing callers (incl. the v1 route) are unaffected.
  • Client pagination (App.tsx): walks the full history backward in bounded pages, using each page's oldest fix as the next before cursor and appending to state progressively. Strict < on the oldest complete fix re-assembles any boundary fix split by the row cap without producing duplicates. Includes a no-progress guard, MAX_PAGES safety bound, gentle inter-page pacing, and cancellation when the selected node changes.
  • Docs: documented the new before query param in docs/api/API.md.

Issues Resolved

Relates to #3791 (addresses Bug 1: position-history points vanish above a ~2-day tail). Bugs 2–4 in that issue (hover/click on points, implausible velocity, missing SNR on directs) are not addressed here.

Documentation Updates

  • docs/api/API.md: added the before cursor query parameter and a paginated-usage example.

Testing

  • Unit tests pass (full suite: 7649 passed, 0 failed, 0 suite failures)
  • TypeScript compiles cleanly (tsconfig.server.json; only pre-existing TelemetryChart.tsx noise remains)
  • New repo test: getPositionTelemetryByNode honors the beforeTimestamp cursor (strictly older)
  • New mapHelpers tests: a dot renders at every fix; heading triangles stay capped at maxArrows
  • Manual: select a mobile node with >2 days of history — confirm dots appear at every fix and the trail fills in progressively page-by-page

🤖 Generated with Claude Code

Position history dots were subsampled to 30 markers via maxArrows, so a
longer tail thinned the dots out (only the line stayed dense). And the
server returned only the newest 1500 telemetry rows (~300 fixes) in a
single request, so older history was never fetched.

- generatePositionHistoryArrows: render a CircleMarker at EVERY fix.
  Heading triangles stay subsampled to maxArrows to avoid clutter on
  dense/stationary trails; the dots underneath are the full-resolution
  interactive layer.
- Add an optional `before` cursor to GET /api/nodes/:nodeId/position-history
  (threaded through getPositionTelemetryByNodeAsync -> repo as a
  `lt(timestamp, before)` condition). The 1500-row per-request cap stays.
- App.tsx now walks the entire history backward in bounded pages, using
  the oldest fix of each page as the next `before` cursor and appending
  progressively. Strict `<` on the oldest complete fix re-assembles any
  boundary fix split by the row cap without duplicates. Includes a
  no-progress guard, MAX_PAGES bound, gentle pacing, and cancellation on
  node change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NnNR1gD45VxgB249xaTS4
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(map): render every position fix + paginate full history

  • Gather context and read changed files
  • Review backend changes (telemetry repo, server routes)
  • Review frontend changes (App.tsx pagination, mapHelpers.tsx)
  • Review test coverage
  • Post final review

Good fix for a real user-facing bug. The logic is correct and the test coverage is solid. A few things worth examining before merge:

Issues / Bugs

1. getPositionTelemetryByNodeAsync returns a non-Promise from a sync path on SQLite (src/services/database.ts:3919)

// @deprecated facade
async getPositionTelemetryByNodeAsync(...): Promise<DbTelemetry[]> {
  return this.telemetry.getPositionTelemetryByNode(...)  // ← returns Promise<DbTelemetry[]> (async repo method)
}

The underlying TelemetryRepository.getPositionTelemetryByNode is async and returns Promise<DbTelemetry[]>, so wrapping it in as unknown as Promise<DbTelemetry[]> is hiding a real double-awaiting issue. Fortunately it works in practice because await Promise<Promise<T>> auto-flattens, but the as unknown as cast is a code smell. This pre-existed the PR, but the PR now wires a new beforeTimestamp arg through this path — worth noting.

2. parseInt truncates float cursor values (src/server/server.ts:1340)

The before query param is parsed with parseInt, so a client sending before=1719412345678.5 will silently truncate to the integer. This is consistent with how hours is handled and probably fine in practice (timestamps are integers), but using Number(req.query.before) would be more semantically correct since parseInt stops at the first non-digit and can silently accept values like "123abc".

3. No-progress guard has an off-by-one edge case (src/App.tsx:1568)

if (beforeCursor !== undefined && oldest >= beforeCursor) break; // no-progress guard

The cursor for the next page is set to oldest (the first item in pageItems, which is the oldest fix in that page). If two consecutive pages both return a page where the oldest timestamp equals beforeCursor, this correctly breaks. However: the cursor is pageItems[0].timestamp (line 1567), and the server returns fixes oldest-first (pivotPositionHistory sorts ascending). If the oldest fix on two consecutive pages happens to share a timestamp (e.g., two simultaneous GPS fixes), the guard fires immediately and stops paginating early. This is a corner case, but worth noting.

Performance Considerations

4. unshift(...pageItems) on a growing array (src/App.tsx:1561)

accumulated.unshift(...pageItems);

For large histories (50 pages × 300 fixes = 15,000 items) unshift on an array is O(n) per call because it shifts every existing element right. After 50 pages this becomes O(n²) in total. Consider collecting pages in a separate pages array and merging at the end: [...collected.flat(), ...latestPage]. For typical deployments with a few hundred fixes this won't matter, but GPS trackers with years of history could hit this.

5. 150 ms inter-page delay × up to 50 pages = up to 7.5 seconds (src/App.tsx:1572)

The gentle pacing is a good idea to avoid hammering the server, but 7.5 seconds of background loading may be noticeable. This is a product decision rather than a bug — just flagging it. The state updates on each page mitigate the perceived latency.

Code Quality

6. sourceId delegation is missing in getPositionTelemetryByNodeAsync (src/services/database.ts:3917-3919)

async getPositionTelemetryByNodeAsync(nodeId: string, limit: number = 1500, sinceTimestamp?: number, beforeTimestamp?: number) {
  return this.telemetry.getPositionTelemetryByNode(nodeId, limit, sinceTimestamp, undefined, beforeTimestamp);

The fourth positional argument to TelemetryRepository.getPositionTelemetryByNode is sourceId (per the repo signature at telemetry.ts:272), and it's hardcoded to undefined here. This means sourceId filtering isn't applied in the paginated path when called from the route handler. The route handler does extract posHistSourceId (server.ts:1348-1350) but passes it as req.query.sourceId only to the privacy check — it does not pass it to getPositionTelemetryByNodeAsync. This is a pre-existing gap in the facade (the old single-page call also omitted it), so it's out of scope for this PR, but the new paginated path inherits the same gap.

7. MAX_PAGES comment may be slightly optimistic (src/App.tsx:1541)

const MAX_PAGES = 50; // safety bound (~15k fixes)

The comment says ~15k fixes but 50 pages × 1500 rows × ~1/5 ratio (5 telemetry types per fix) = 50 × 300 = 15,000 fixes. The math checks out — just be aware that 1500 telemetry rows is the server cap, not 1500 fixes, since each fix produces 5 rows (lat/lon/alt/speed/track).

Test Coverage

The new tests are well-written and exercise the right things:

  • getPositionTelemetryByNode beforeTimestamp cursor test in telemetry.extra.test.ts:337-346
  • generatePositionHistoryArrows dot-per-fix tests in mapHelpers.test.tsx:396-413
  • ✅ Subsampling of heading triangles is verified independently

Missing test:

  • The client-side pagination loop in fetchAllPositionHistory is not unit-tested. A test mocking authFetch to return two pages and asserting the merged result and cursor progression would be valuable for a future follow-up.

Summary

The core approach is sound — cursor-based backward pagination with a safety bound, and rendering a CircleMarker at every fix instead of subsampling dots. The most actionable items before merge:

  • Issue 3 (no-progress guard) — low risk but worth a comment acknowledging same-timestamp behavior
  • Issue 6 (sourceId not forwarded through async facade) — pre-existing but this PR adds a second call site that inherits the gap; consider noting in a follow-up issue

Everything else is minor style / future optimization.

@Yeraze Yeraze merged commit 8b30305 into main Jun 26, 2026
19 checks passed
@Yeraze Yeraze deleted the feature/position-history-full-pagination branch June 26, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant