Skip to content

feat(map): position-history markers at every fix, points-only mode, hover tooltip (#3492)#3495

Merged
Yeraze merged 2 commits into
mainfrom
feature/3492-position-history-points
Jun 16, 2026
Merged

feat(map): position-history markers at every fix, points-only mode, hover tooltip (#3492)#3495
Yeraze merged 2 commits into
mainfrom
feature/3492-position-history-points

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Closes #3492 and #3494 (duplicate).

Three improvements to the position-history map layer.

1. Bug — a marker at every fix

Position history previously rendered a marker only where a fix's packet carried heading data (a rotated ); fixes without heading rendered nothing — the line connected invisible vertices. Now every fix renders a CircleMarker dot, and the heading triangle is overlaid (non-interactive) only when heading is present. The last no-heading point (which was skipped entirely) now shows too.

2. Feature — "Points only (no line)" toggle

A new Map Features checkbox (shown under Show Position History) hides the connecting line and shows only the per-fix dots. Persisted per user via a new user_map_preferences.position_history_points_only column (migration 090) wired through MapContext/api/user/map-preferences.

3. Feature — hover tooltip (timestamp, hops, SNR)

Each fix now has a hover tooltip with the timestamp, hop count, and SNR — only when the fix was heard directly (hopStart === hopLimit, i.e. 0 hops; relayed fixes omit SNR since it isn't meaningful).

This required capturing data that wasn't stored before: the telemetry table gains rxSnr / hopStart / hopLimit columns (migration 089), populated on the lat/lon rows at ingest from the receiving packet, and surfaced per fix by the position-history API and PositionHistoryItem. Capture-forward: only fixes received after upgrading have SNR/hops; older fixes show the timestamp only and the tooltip omits the missing parts gracefully.

Notes

  • drizzle-orm references all schema-defined columns on insert/select, so the manual CREATE TABLE telemetry statements in the repo unit tests were updated to include the new columns (verified the behavior with a minimal repro before mass-editing).
  • Two migrations (089 telemetry SNR/hops, 090 map-prefs toggle); both idempotent across SQLite/PostgreSQL/MySQL, no backfill.

Tests

  • mapHelpers: dot at every fix incl. no-heading, triangle only when heading, point cap, empty input.
  • misc.mapprefs: positionHistoryPointsOnlyposition_history_points_only column mapping (3 backends).
  • telemetry.extra: rxSnr/hopStart/hopLimit capture round-trip via getPositionTelemetryByNode.
  • migrations: registry count 90, last = add_position_points_only_to_map_prefs.

Validation

  • tsc --noEmit: clean
  • Full Vitest suite: 6509 pass, 0 fail, 0 suite failures

🤖 Generated with Claude Code

…over tooltip (#3492, #3494)

Three improvements to the position-history map layer (closes the duplicate
#3494 too):

1. Bug: a marker at EVERY fix. Previously a fix only rendered a marker when its
   packet carried heading data (a rotated triangle); fixes without heading drew
   nothing — the line connected invisible vertices. Now every fix renders a
   CircleMarker dot; the heading triangle is overlaid (non-interactive) only when
   heading is present.

2. Feature: "Points only (no line)" Map Features toggle. New per-user map
   preference (MapContext + user_map_preferences.position_history_points_only,
   migration 090) that, when on, omits the connecting Polyline and shows only the
   per-fix dots.

3. Feature: hover tooltip per fix showing timestamp, hop count, and SNR — SNR
   only when the fix was heard directly (hopStart === hopLimit, i.e. 0 hops). To
   support this, per-position-fix receive metadata is now captured going forward:
   telemetry gains rxSnr/hopStart/hopLimit columns (migration 089), populated on
   the lat/lon rows at ingest and surfaced per fix by the position-history API.
   Fixes received before upgrading show the timestamp only (capture-forward).

Note: drizzle-orm references all schema-defined columns on insert/select, so the
manual telemetry-table CREATE statements in repo tests were updated to include
the new columns.

Tests: every-fix dot + no-heading dot + cap (mapHelpers), points-only column
mapping (misc.mapprefs), SNR/hop capture round-trip (telemetry.extra), migration
registry count 90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Gather context and understand the PR
  • Review migrations (089, 090)
  • Review schema changes
  • Review repository changes
  • Review frontend changes (mapHelpers, MapContext, NodesTab)
  • Review API route changes
  • Review test coverage
  • Post comprehensive feedback

Overall this is a well-constructed PR. The architecture is sound, the migrations are idempotent across all three backends, and the feature is cleanly decomposed. A few things worth discussing before merge:

Potential Issues

1. positionMap grouping key: timestamp collisions

File: src/server/routes/v1/positionHistory.ts:117–160

The route groups telemetry rows by t.timestamp. A node that sends two position fixes within the same second (same millisecond timestamp) will collide into one map entry. Only the first lat/lon values are kept, and the second fix is silently dropped. The PR description doesn't indicate this is new behavior — I'd want to confirm the previous code had the same characteristic and this PR didn't regress it. If the prior code also grouped by timestamp, the existing bug isn't introduced here, but it may be worth a follow-up.

2. heardDirectly logic: hops === 0 is true when SNR is unknown

File: src/utils/mapHelpers.tsx:469–470

const heardDirectly = hops === 0;
const showSnr = heardDirectly && item.snr != null;

hops = Math.max(0, hopStart - hopLimit) can legitimately return 0 if both fields are zero (e.g. a device running old firmware that doesn't populate them), not only when hopStart === hopLimit. In that edge case, heardDirectly is true even though we can't verify the fix was actually heard directly. However, showSnr still requires item.snr != null, so the practical effect is only that the tooltip would say "Heard directly (0 hops)" for a node where both values happened to be default-zero — which is a mild display inaccuracy rather than a security concern.

Consider adding a guard: only compute hops when hopStart > 0 || hopLimit > 0 (or match the existing hopStart > 0 guard already used elsewhere in meshtasticManager.ts:5378).

3. Migration 089 SQLite down silently eats all errors

File: src/server/migrations/089_add_position_snr_hops.ts:40

try { db.exec(`ALTER TABLE ${TABLE} DROP COLUMN ${col.name}`); } catch { /* older SQLite lacks DROP COLUMN */ }

The down implementation swallows any error unconditionally. If there's a real schema issue (wrong table name, etc.) the rollback would silently no-op. In practice down is rarely called on this project, but the comment that masks the error is slightly misleading — "older SQLite" is only one possible error source. Low severity, but consider logging.

4. before filter applied in-memory but not to the DB query

File: src/server/routes/v1/positionHistory.ts:131–132

if (beforeTimestamp !== undefined && t.timestamp >= beforeTimestamp) return;

The before filter is applied after fetching from the database. For large datasets this means the DB may return rows that are immediately discarded. The internalLimit calculation ((offsetNum + maxLimit) * 5) can also over-count if many rows are filtered out by before. This is a pre-existing characteristic (not introduced by this PR), but the new SNR/hop metadata capture makes the query slightly heavier per row, so the inefficiency compounds slightly.


Minor Notes

packetId assignment: src/server/routes/v1/positionHistory.ts:151

pos.packetId = t.packetId ?? undefined;

t.packetId is number | null | undefined. If t.packetId is null, ?? undefined evaluates to undefined, and then the if (t.packetId != null) guard is never entered with a null value anyway — so the ?? undefined on line 151 is redundant but harmless.

SNR tooltip in hover vs popup: The hover <Tooltip> shows {dateStr} {timeStr} on one line (space-separated). On narrow map tiles this might render awkwardly, but this is a style preference rather than a bug.

showPolarGrid not persisted to server: MapContext.tsx:232–235setShowPolarGrid calls savePreferenceToServer({ showPolarGrid: value }) but there's no positionHistoryPointsOnly column in the schema for showPolarGrid. This appears to be pre-existing; the new positionHistoryPointsOnly setter at line 211–214 correctly calls savePreferenceToServer. No issue with the new code.


Code Quality

Migrations: Both 089 and 090 are well-structured — idempotent SQLite (try/catch on duplicate column), IF NOT EXISTS for PostgreSQL, information_schema check for MySQL. The eslint-disable-next-line comment in migration 090 for raw DDL is properly annotated.

Schema consistency: The new columns follow existing conventions — rxSnr as REAL/doublePrecision/DOUBLE, integer hop fields using the same int types as channel and precisionBits. The sourceId column ordering (at the end) matches the pattern.

Frontend: The fix in generatePositionHistoryArrows is clean — always emit a CircleMarker dot, then conditionally overlay the non-interactive Marker triangle. The interactive={false} prop on the triangle ensures hover/click hit-testing falls through to the dot.

Test coverage: Good coverage of the new behavior:

  • mapHelpers.test.tsx: dot at every fix, triangle only when heading, cap, empty
  • misc.mapprefs.test.ts: column mapping for all three backends
  • telemetry.extra.test.ts: round-trip SNR/hop capture via getPositionTelemetryByNode
  • migrations.test.ts: count and last migration name updated

Summary

The PR is functionally correct and well-tested. The main item worth addressing before merge is item #2 (the heardDirectly edge case with both-zero hop values showing an inaccurate "Heard directly" label). Items #1, #3, and #4 are pre-existing or low-severity and can reasonably be follow-up issues.

…b test table

The PG path of telemetry.multidb.test.ts (run in CI, skipped locally) creates
its own telemetry table; it needs the #3492 columns too since drizzle references
all schema columns on insert. SQLite path was already updated.
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Gather context and understand the PR
  • Review migrations (089, 090)
  • Review schema changes
  • Review repository and service changes
  • Review frontend changes (mapHelpers, MapContext, NodesTab)
  • Review API route changes
  • Review test coverage
  • Post comprehensive feedback

Overall this is a well-structured PR. The architecture is clean, the migrations are idempotent across all three backends, and the three features (bug fix + points-only mode + hover tooltip) are well-decomposed. Here's the full review:

Potential Issues

1. heardDirectly false positive when both hop fields are default-zero

File: src/utils/mapHelpers.tsx:466–470

if (item.hopStart != null && item.hopLimit != null) {
  hops = Math.max(0, item.hopStart - item.hopLimit);
}
const heardDirectly = hops === 0;
const showSnr = heardDirectly && item.snr != null;

hops === 0 is true both when hopStart === hopLimit (legitimately heard directly) and when both fields are 0 due to old firmware not populating them. The existing message-hops ingestion in meshtasticManager.ts:5378 guards against this with hopStart > 0, but the display path here has no such guard.

In practice, showSnr still requires item.snr != null, which limits the damage to the tooltip saying "Heard directly (0 hops)" for old-firmware nodes that happen to have an SNR stored. A minimal fix consistent with the rest of the codebase:

if (item.hopStart != null && item.hopLimit != null && (item.hopStart > 0 || item.hopLimit > 0)) {
  hops = Math.max(0, item.hopStart - item.hopLimit);
}

2. Redundant ?? undefined on line 151

File: src/server/routes/v1/positionHistory.ts:150–151

if (t.packetId != null && pos.packetId === undefined) {
  pos.packetId = t.packetId ?? undefined;
}

The if guard already ensures t.packetId is non-null before entering the block, so ?? undefined inside is a no-op. Minor but slightly misleading — t.packetId alone suffices here.

3. Migration 089 down swallows all errors unconditionally

File: src/server/migrations/089_add_position_snr_hops.ts:40

try { db.exec(`ALTER TABLE ${TABLE} DROP COLUMN ${col.name}`); } catch { /* older SQLite lacks DROP COLUMN */ }

The catch comment is accurate for the intended case but the empty handler also silences real errors (wrong table name, database locked, etc.). Migration 090's down is better — it explicitly logs and does nothing rather than attempting and silently failing. Low severity since down is rarely invoked, but worth noting.

4. before filter applied in-memory, not pushed to the DB query

File: src/server/routes/v1/positionHistory.ts:131

The before timestamp filter is applied after the DB fetch. The internalLimit calculation ((offsetNum + maxLimit) * 5) may not fetch enough rows if a large fraction falls after beforeTimestamp, causing silent under-counting in total. This is pre-existing behaviour, not introduced here, but the new per-row SNR/hop metadata makes the query slightly heavier, so the trade-off is worth noting for a future follow-up.


Code Quality

Migrations (089, 090): Both are idempotent. Migration 089 follows the try/catch-on-duplicate column pattern for SQLite, IF NOT EXISTS for PostgreSQL, and information_schema check for MySQL. Migration 090 does the same plus correctly logs the skip. The eslint-disable-next-line annotations are properly placed.

Schema: New columns follow existing conventions — rxSnr as REAL/doublePrecision/DOUBLE, integer hop fields matching the channel/precisionBits types, sourceId at the end. All three backend schemas are in sync.

Ingest (meshtasticManager.ts:5971–5988): SNR/hop capture is extracted cleanly before the two insertTelemetry calls and stored on both latitude and longitude rows. Using ?? undefined instead of ?? null is intentional — insertTelemetry skips the column entirely when the value is null | undefined (lines 114–116 of telemetry.ts), avoiding writes on old-firmware packets.

generatePositionHistoryArrows: The fix is clean — always emit a CircleMarker dot, then conditionally overlay a non-interactive Marker triangle. interactive={false} correctly lets hover/click fall through to the dot. The step-based cap logic correctly tracks rendered points (not React elements), so dots + triangles from the same fix don't both count against maxArrows.

positionHistoryPointsOnly toggle: The continue at NodesTab.tsx:550 skips only the Polyline push, not the segmentColors accumulation or the subsequent generatePositionHistoryArrows call, which is exactly correct — dots still need their color array.

MapContext.tsx: setPositionHistoryPointsOnly calls savePreferenceToServer exactly like all other preference setters. The schema column naming (camelCase in Drizzle, snake_case in the SQLite DDL, snake_case in the PG/MySQL migration) is consistent with the existing pattern.


Test Coverage

  • mapHelpers.test.tsx: covers dot-at-every-fix, triangle-only-on-heading, maxArrows cap, and empty input — solid.
  • misc.mapprefs.test.ts: column mapping for all three backends.
  • telemetry.extra.test.ts: round-trip SNR/hop capture via getPositionTelemetryByNode.
  • migrations.test.ts: count = 90, last = add_position_points_only_to_map_prefs.

One gap: no test explicitly covers the heardDirectly = hops === 0 branch with SNR present (both in the tooltip display and in the showSnr guard). A test case with hopStart: 2, hopLimit: 2, snr: 5 (direct) vs hopStart: 3, hopLimit: 2, snr: 5 (relayed) would pin the tooltip behaviour and catch the edge case in issue #1 above.


Summary

The PR is functionally correct and well-tested. The only item I'd recommend addressing before merge is issue #1 (the heardDirectly false positive for default-zero hop fields). It's a one-line fix and aligns the display path with the same guard already used in the ingest path. Items #2 and #3 are cosmetic/low-severity; item #4 is pre-existing.

@Yeraze Yeraze merged commit c84f4f5 into main Jun 16, 2026
20 checks passed
@Yeraze Yeraze deleted the feature/3492-position-history-points branch June 16, 2026 16:30
Yeraze added a commit that referenced this pull request Jun 16, 2026
…ection B2, part 1) (#3498)

* refactor(test): shared createTestDb helper from the migration registry (section B2, part 1)

~40 non-migration test files hand-roll `CREATE TABLE` per backend, duplicating
src/db/schema. That DDL drifts: a schema column-add breaks N tests, and the
PG/MySQL copies (only run in CI) diverge silently — exactly what broke #3495.

Add src/server/test-helpers/testDb.ts: `createTestDb()` runs the REAL SQLite
migration registry (baseline 001 + every ALTER) against a fresh `:memory:`
database, so each test gets the exact production schema and a schema change is a
single edit. Includes a smoke test asserting key tables and recent columns
(telemetry.rxSnr from migration 089) are present and a repo round-trip works.

Migrate the three SQLite-only telemetry repo tests — the ones whose hand-rolled
telemetry DDL had to be patched for #3492/#3495 — to use the helper:
telemetry.test.ts, telemetry.extra.test.ts, telemetry.badTimestamp.test.ts.

This is part 1: the helper is proven and the most-recently-fragile DDL is gone.
The remaining ~37 hand-rolled tables (nodes in ~20 files, etc.) will be migrated
incrementally in follow-ups using this now-proven helper. telemetry.multidb.test.ts
is intentionally left — it deliberately exercises per-backend DDL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(test): address review — drop redundant dedupe-index DDL, document cast

claude-pr review on #3498: the 'duplicate packet dedupe' block in
telemetry.test.ts still manually created telemetry_source_packet_type_uniq,
now dead DDL since createTestDb() applies migration 032. Removed it (test still
passes, confirming the index comes from the migration). Also documented the
as-any cast in testDb.ts per the review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Position history map: points-only toggle + hover tooltip for timestamp/hops

1 participant