Skip to content

Minor tweaks#1638

Merged
fredclausen merged 56 commits intomainfrom
minor-tweaks
Mar 2, 2026
Merged

Minor tweaks#1638
fredclausen merged 56 commits intomainfrom
minor-tweaks

Conversation

@fredclausen
Copy link
Copy Markdown
Member

ACARS Hub v4.1.4

v4.1.4 Performance

  • Live Messages: The message list is now rendered as a virtual windowed list using @tanstack/react-virtual. Only the ~7 message cards visible in the viewport are mounted in the DOM at any time, down from ~90 fully-mounted trees previously. On busy stations this eliminates the UI lag that accumulated as the message list grew. Theme switching, which previously had to cascade CSS variable changes through every mounted card, is now instant.
  • Alerts: The alert message list (both live and historical modes) is now also virtualised, using the same architecture as Live Messages.
  • Search: Search results are now rendered as a virtual list. The search form itself remains fully scrollable on all screen sizes.

v4.1.4 New

  • Message rate widget: A rolling messages-per-minute counter is now displayed in the navigation bar. On desktop, hovering or focusing the widget expands a tooltip showing the rate broken down by decoder type (ACARS, HFDL, VDL-M2, etc.). On mobile devices with a screen width of 375 px or wider the total rate is shown directly in the nav bar.
  • Status page: A "Rolling Rate" row has been added to the Message Statistics card for each enabled decoder, showing the same rolling 60-second rate as the nav bar widget.
  • Search: The search form now auto-collapses when you scroll more than 80 px into the results. A sticky header pins to the top of the viewport while collapsed, showing a summary of the active search criteria and a button to expand the form again. Clicking the expand button scrolls back to the top and reopens the form.
  • Search: All text search inputs are now automatically normalised to uppercase as you type, matching the way messages are stored in the database and eliminating missed results caused by case differences.
  • Messages: Any portion of a message that the decoder recognised but could not fully decode is now shown as "Remaining Text" in the message detail view, rather than being silently discarded (1)

v4.1.4 Improvements

  • Navigation: The mobile and desktop navigation bars are now conditionally rendered — only the layout appropriate for the current screen size is mounted. Previously both trees were always present in the DOM with CSS toggling visibility, which meant React was maintaining two full navigation trees, their event listeners, and active-link tracking simultaneously.

v4.1.4 Notes

  1. Credit to @makrsmark for the remaining text feature in PR #1637.

The sdre.yml reusable workflow always pushes per-arch image digests
to ghcr.io regardless of push_enabled, which fails for fork PR tokens
that don't have write access to the org package registry.

Replace with a direct matrix build using docker/build-push-action
with outputs: type=cacheonly — builds fully but never pushes anything.
Uses native ubuntu-latest (amd64) and ubuntu-24.04-arm (arm64) runners,
matching the platform coverage of the original workflow without QEMU.
Backend:
- MessageQueue: add 12-bucket × 5-second rolling rate window per decoder
  (sum of all buckets = msgs in last ~60 seconds = msgs/min equivalent)
  - advanceRateBucket() — zeroes oldest slot, called by scheduler every 5s
  - getRollingRates() — returns MessageRateData for all decoders + total
  - clearRollingRates() — resets window (called by clearStats())
- BackgroundServices: add 'emit_message_rate' scheduled task (every 5s)
  that advances the bucket then broadcasts message_rate to /main
- 195 new tests for rolling rate window behaviour

Shared types:
- MessageRateData interface in system.ts
- message_rate socket event in SocketEvents

Frontend:
- useAppStore: messageRate state + setMessageRate action
- useSocketIO: wire message_rate event → setMessageRate
- Navigation: MessageRateWidget shows total rate (desktop only)
  with CSS hover/focus-within tooltip listing per-decoder rates
  (tooltip suppressed when only one decoder is enabled)
- StatusPage: Rolling Rate row added below Last Minute in
  Message Statistics card for each enabled decoder
- _navigation.scss: message-rate widget + tooltip styles with
  Catppuccin theming, tabular-nums, reduced-motion support
… conditional render

Both the mobile and desktop nav trees were always mounted. CSS toggled
display:none on whichever was off-screen, leaving React maintaining two
full nav trees at all times — refs, event listeners, NavLink active
tracking, and all.

- Add useMediaQuery hook (matchMedia-based, fires only at breakpoint
  boundary, SSR-safe, cleans up on unmount)
- Navigation now renders mobile OR desktop via ternary — never both
- Remove show_when_small / hide_when_small classes and their media-query
  blocks from _navigation.scss (CSS toggling no longer needed)
- 8 unit tests covering init state, change events, listener lifecycle,
  post-unmount safety, and query re-subscription
Reduces DOM nodes from ~90 fully-mounted MessageGroup trees down to
~7 (overscan=3 above/below viewport). Theme switching, which cascades
CSS variables through every mounted card, is now instant.

Core changes
------------
- Install @tanstack/react-virtual
- LiveMessagesPage: replace flat .map() render with useVirtualizer
  * Absolute-position items inside a single 'height: totalSize' container
  * measureElement ref lets virtualizer learn each card's true height
  * ESTIMATED_ITEM_HEIGHT=300 biased high so measurement corrections
    drift view slightly downward (imperceptible) rather than upward
- Scroll anchoring (useLayoutEffect, runs before paint):
  * Detects prepended items by comparing prevFirstKey + prevTotalSize
  * Adjusts scrollTop by (newTotalSize - prevTotalSize) so the user's
    current view does not move when new messages arrive at index 0
  * Only fires when scrollTop > 0; at the top the view auto-follows
- Dynamic scroll-container height via ResizeObserver:
  * Measures window.innerHeight - scrollEl.getBoundingClientRect().top
  * Adapts automatically to any nav/header/filter-bar height change
  * No hardcoded pixel values or CSS custom properties needed
- Tab state lifted out of MessageGroup into useRef<Map<string,number>>
  * Tab selections survive virtualizer unmount/remount cycles
  * Ref mutations cause zero re-renders in sibling groups

MessageGroup changes
--------------------
- Accepts optional activeIndex + onActiveIndexChange props
- When supplied (Live Messages page): controlled mode, state lives in
  parent ref map
- When omitted (AlertsPage, AircraftMessagesModal): falls back to
  internal useState (uncontrolled mode, no breaking change)

CSS changes
-----------
- .live-messages-page: display:flex column; min-height:200px fallback
- .live-messages-page .page__content: flex:1; min-height:0; overflow:hidden
- .message-list: height:100% + overflow-y:auto (scroll container)
  inline height set by component after ResizeObserver measurement
- .message-list__item: padding-bottom replaces old flex gap

Tests
-----
- LiveMessagesPage test: mock @tanstack/react-virtual to render all
  items (jsdom has no layout engine, virtualizer would render nothing)
- All 18 existing LiveMessagesPage tests pass unchanged
- MessageGroup: use internal state for tab display; treat activeIndex
  as initial value only, call onActiveIndexChange as a side-effect
  callback. Fixes tab clicks not re-rendering in virtualizer controlled
  mode (parent persists state via a ref, so no parent re-render fires).

- LiveMessagesPage: remove 8 px height buffer now that
  .app-content:has(.live-messages-page) uses overflow:hidden —
  subpixel overshoot clips silently, no outer scrollbar can appear.

- LiveMessagesPage: add paddingStart:24 to the virtualizer so the first
  message card has breathing room below the filter bar at scroll-top.
  The space is virtual content, so it naturally scrolls away — once past
  24 px the card sits flush at the top with zero wasted space.

- _live-messages.scss: suppress outer app-content scrollbar via
  :has(.live-messages-page) { overflow:hidden }.

- _live-messages.scss: remove margin-top/bottom and border-radius from
  .live-messages-page so it occupies the full viewport below the nav
  with no gaps or rounded corners.

- _live-messages.scss: remove margin-bottom from .message-filters
  (the 24 px dead zone between the filter bar and the scroll container).

- _live-messages.scss: remove padding-top/bottom from .message-list;
  keep only horizontal padding. Bottom spacing comes from
  .message-list__item padding-bottom.

- Add MessageGroup.test.tsx with 15 tests covering tab navigation in
  both controlled and uncontrolled modes, arrow buttons, wrap-around,
  counter text, alert styling, and the regression case.
Two independent bugs, both in the virtual message list:

1. Item overlap / wrong spacing after prepend (getItemKey)

   The virtualizer's height cache was index-based by default. When a new
   message was prepended, every existing item shifted to a higher index.
   The virtualizer would then look up the *old* item's cached height for
   the *new* index — so the new message at index 0 inherited the previous
   first message's measured height. If the new message was taller than
   that stale value, it overflowed into the next item's slot (overlap);
   if shorter, it left excess empty space.

   Fix: add getItemKey so heights are cached by stable message uid. After
   a prepend, the new item at index 0 has no cache entry and correctly
   falls back to ESTIMATED_ITEM_HEIGHT (300 px); all existing items retain
   their correct heights under their own keys.

2. Compounding scroll drift when new messages arrive while scrolled

   The old anchor only fired when prevFirstKey changed (a prepend event),
   adjusting scrollTop by the estimated height (300 px). When the new item
   was later measured at its actual height (e.g. 150 px), the total size
   shrank by 150 px — and the anchor ignored this correction entirely.
   Every new message arriving while scrolled added ~150 px of uncorrected
   drift; after 10 messages the user was ~1500 px below their intended
   position.

   Fix: replace the prepend-only anchor with a general-purpose one that
   reacts to *any* change in rowVirtualizer.getTotalSize(). The math is
   correct for both prepends and remeasurements: the two-step adjustment
   (initial estimate + later measurement correction) always sums to the
   item's actual height. The threshold is scrollTop > MESSAGE_LIST_PADDING_START
   (24 px) rather than > 0 so users inside the top breathing-room gap see
   new messages flow in naturally instead of being anchored.

   Also removes the now-unused prevFirstKey ref.
AlertsPage:
- Replace .map() renders with @tanstack/react-virtual in both live
  and historical modes, sharing a single ResizeObserver-measured scroll
  container (matching the LiveMessagesPage architecture)
- Two virtualizers (liveVirtualizer / histVirtualizer) share the scroll
  container; the inactive one always has count=0
- Scroll anchoring (useLayoutEffect) fires only in live mode to keep
  the viewport stable when new alerts prepend or items are remeasured
- Historical mode scrolls to top on viewMode / selectedTerm /
  historicalPage changes
- Per-group active tab indices stored in a Map ref so MessageGroup tab
  state survives virtualizer unmount/remount cycles
- Pagination controls moved into .alerts-page__controls-bar (above the
  scroll container) so they remain accessible at all scroll positions
- .app-content:has(.alerts-page) { overflow: hidden } suppresses the
  outer scrollbar; page margins/border-radius removed (same treatment
  as LiveMessagesPage)
- Restored .alerts-page__result-card card-container styling for
  historical MessageCard items (surface0 bg, surface2 border, lg radius)

SearchPage:
- Replace .map() results render with @tanstack/react-virtual using
  .app-content as the scroll element so the large search form remains
  fully scrollable on all screen sizes (no fixed-height container)
- scrollMargin tracks the pixel offset of the virtual results container
  from the top of .app-content; measured in useLayoutEffect after every
  result change and on resize via ResizeObserver
- Items positioned with transform: translateY(start - scrollMargin) so
  they are correctly placed within their relative container
- Bottom pagination follows the virtual container naturally in DOM flow
- Restored .search-page__result-card with full card-container styling
  (surface0 background, surface2 border, lg border-radius, md padding)
  plus margin-bottom for virtual item gap spacing

Tests:
- Add @tanstack/react-virtual mock to AlertsPage.test.tsx and
  SearchPage.test.tsx (same pattern as LiveMessagesPage) so jsdom
  renders all items deterministically
- 9 new regression tests across both pages covering: virtual list
  renders all items, mode switching clears the correct list, historical
  pagination controls are present above the scroll container, page
  changes replace virtual items, clearing results empties the DOM
Auto-collapses when scrolling past 80px threshold; sticky header
pins to top of viewport when collapsed so it's always reachable.
Clicking the expand chevron scrolls back to top and opens the form.
No manual collapse — only scroll-driven auto-collapse.

- overflow:clip on .page so position:sticky works through it
- Form header (with expand button + active-search summary) only
  rendered when collapsed; no collapse affordance when expanded
- Reduced form top padding: var(--spacing-sm) vs var(--spacing-md)
- 34 tests (8 new, 2 updated for exact button-name matching)
All strings stored in the database are uppercase. Normalising on input
means search terms match the DB regardless of how the user types.

Implemented in handleInputChange via value.toUpperCase() — applied
uniformly to all fields; a no-op for digits/punctuation (e.g. freq).

11 regression tests added covering every text field plus a verify that
the normalised value reaches the emitted socket payload.
The max-height trick produced a two-stage effect: the content would
fade/compress for a moment with no visible result (animating through
the 2000px→actual-height dead zone), then visually compress.

Replace with grid-template-rows: 1fr → 0fr on __form-body (the grid
container) and a new __form-body-inner wrapper (overflow:hidden,
min-height:0). This animates height proportionally to the real content
size — compression starts immediately and finishes at exactly the same
time as the opacity fade, giving a true single-stage transition.
Copilot AI review requested due to automatic review settings February 27, 2026 05:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements version 4.1.4 of ACARS Hub, focusing on performance improvements through virtualization, a new message rate widget, and search form enhancements.

Changes:

  • Virtualized the Live Messages, Alerts (live and historical), and Search results lists using @tanstack/react-virtual, reducing DOM nodes from ~90 to ~7 visible items and eliminating UI lag on busy stations
  • Added a rolling 60-second message rate widget to the navigation bar with per-decoder breakdown tooltip, backed by a new rolling bucket system in the backend message queue
  • Implemented auto-collapsing search form with sticky header, uppercase normalization for all text inputs, and conditional rendering of mobile/desktop navigation

Reviewed changes

Copilot reviewed 33 out of 34 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
package.json Version bump to 4.1.4-beta.1
package-lock.json Added @tanstack/react-virtual ^3.13.19 dependency
acarshub-types/src/system.ts Added MessageRateData interface for rolling rate tracking
acarshub-types/src/socket.ts Added message_rate socket event
acarshub-backend/src/services/message-queue.ts Implemented rolling rate window with 12×5-second buckets
acarshub-backend/src/services/index.ts Added scheduler task to emit message_rate every 5 seconds
acarshub-react/src/components/Navigation.tsx Added MessageRateWidget and conditional mobile/desktop rendering using useMediaQuery
acarshub-react/src/components/MessageGroup.tsx Converted to controlled/uncontrolled mode to survive virtualizer remounts
acarshub-react/src/pages/LiveMessagesPage.tsx Implemented virtual list with scroll anchoring for prepends
acarshub-react/src/pages/AlertsPage.tsx Dual virtualizers for live (groups) and historical (cards) modes
acarshub-react/src/pages/SearchPage.tsx Virtual list with scrollMargin for outer scroll container, collapsible form, uppercase normalization
acarshub-react/src/pages/StatusPage.tsx Display rolling rate per decoder
acarshub-react/src/hooks/useMediaQuery.ts New hook for responsive conditional rendering
acarshub-react/src/styles/* Updated styles for virtual lists, navigation widget, collapsible search form
.github/workflows/test-build.yml Switched from reusable workflow to matrix build strategy
CHANGELOG.md Documented all v4.1.4 changes

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dockerfile.e2e:
- New self-contained E2E image based on mcr.microsoft.com/playwright:v1.58.2-noble
- Builds types + React frontend (VITE_E2E=true) inside the container
- Sets PLAYWRIGHT_DOCKER=true so all 5 browser projects are enabled
- playwright.config.ts webServer config starts vite preview automatically
  inside the container — no host-side server or node_modules mount needed
- npm ci layer is cached independently of source for fast rebuilds

justfile:
- test-e2e-docker / test-e2e-docker-debug: replace the host-server +
  volume-mount approach with docker build -f Dockerfile.e2e + docker run
- Eliminates pkill MainThread (unreliable on Linux), background vite preview,
  named volume juggling, and --network=host

CI (e2e.yml):
- Drop Set up Node.js and Install npm workspace dependencies steps —
  everything now happens inside the Docker image

Navigation.tsx:
- Change mobile nav container from <div> to <nav> so
  document.querySelector('nav') returns a result on mobile viewports
- Fixes accessibility E2E test: 'Navigation should have proper ARIA landmarks'
  which was failing on Mobile Chrome and Mobile Safari

e2e/smoke.spec.ts:
- Update 'should be responsive on mobile viewport' to look for
  nav.mobile_nav_container instead of div.show_when_small — the old class
  was removed in the useMediaQuery conditional-render refactor

e2e/search.spec.ts:
- Test 4 (displays results): remove post-results 'Search button visible'
  assertion — on mobile, the submit handler scrolls to the results section
  which crosses the 80px threshold and auto-collapses the form, hiding the
  button. Asserting results-info is the correct signal that loading cleared.
- Test 6 (clear button): expand the form before clicking Clear if the
  expand chevron is visible (form was auto-collapsed by the scroll above)
The Status page was showing a coarse per-minute counter (reset at each
minute boundary) instead of the rolling 60-second rate, and the value
only refreshed on the 10-second request_status poll rather than the
5-second message_rate emit.

Changes:
- handlers.ts: getSystemStatus() now reads getRollingRates() for each
  decoder's LastMinute field instead of getStats().lastMinute, so the
  initial system_status response already carries the rolling rate
- handlers.ts: handleRequestStatus also emits message_rate alongside
  system_status so the Status page has a live value immediately on load
- StatusPage.tsx: re-subscribes to messageRate from the store; uses it
  as the live source for the Rate (1 min) display (updated every 5s via
  the scheduler's message_rate broadcast), falling back to the
  system_status LastMinute value until the first message_rate event
- handlers.test.ts: two regression tests — one verifying message_rate
  is emitted on request_status, one verifying system_status.global
  LastMinute comes from getRollingRates() not the coarse counter
…op resolution/id/created_at

The timeseries_stats table carried three dead-weight columns and two
redundant indexes that accumulated since the original multi-resolution
design was abandoned.

WHAT CHANGED
============

Columns removed:
  - id (INTEGER PRIMARY KEY AUTOINCREMENT, ~8 bytes/row) — superseded by
    timestamp as the rowid alias
  - resolution (TEXT NOT NULL, ~4-8 bytes/row) — was a non-nullable constant
    ('1min') on every row; the RRD importer's expandCoarseDataToOneMinute()
    normalises all archive data to 1-minute buckets before inserting, and the
    live stats-writer only ever writes resolution='1min'
  - created_at (INTEGER NOT NULL, ~8 bytes/row) — set on insert and never
    read by any query, handler, or API response; timestamp IS the time of
    the measurement

Indexes removed (implicitly by DROP TABLE in the rebuild transaction):
  - idx_timeseries_resolution — indexed a constant column; no query in the
    application ever filtered by resolution alone
  - idx_timeseries_timestamp_resolution (UNIQUE) — superseded by the new
    INTEGER PRIMARY KEY B-tree, which is the rowid itself and requires no
    separate index structure

New primary key:
  - timestamp INTEGER PRIMARY KEY — in SQLite an INTEGER PRIMARY KEY is the
    rowid alias, the most storage-efficient key possible with zero index
    overhead beyond the table B-tree itself

MIGRATION SAFETY
================

Migration 12 runs after migration 11, which already deduplicated rows on
(timestamp, resolution). The rebuild uses:

  INSERT OR IGNORE INTO timeseries_stats_new ... FROM timeseries_stats
  ORDER BY CASE WHEN resolution = '1min' THEN 0 ELSE 1 END, id

This ensures that if the same timestamp exists under two different resolution
values (an edge case on DBs that pre-date the RRD expansion logic), the
'1min' row wins. Non-'1min' losers are logged as a warning and discarded.
A VACUUM follows outside the transaction to reclaim freed pages.

SCOPE
=====

  - src/db/schema.ts — new lean schema (timestamp PK only, no extra indexes)
  - src/db/migrate.ts — migration12_dropResolutionPromoteTimestampPk(),
    LATEST_REVISION bumped to b6c7d8e9f0a1
  - src/services/rrd-migration.ts — resolution removed from
    TimeseriesDataPoint; RrdArchive.resolution kept for logging only;
    queryTimeseriesData/_resolution param accepted but ignored;
    batchInsertDataPoints no longer sets resolution/createdAt
  - src/services/stats-writer.ts — resolution/createdAt removed from insert
  - src/socket/handlers.ts — WHERE resolution = '1min' removed from raw SQL
    (column no longer exists)
  - All test files — DDL, inserts, and mock objects updated to match new schema
  - scripts/seed-test-db.ts — TIMESERIES_RESOLUTIONS reduced to 1min only
Remove per-migration VACUUM and ANALYZE calls. Both now run exactly
once, at the conclusion of runMigrations(), if and only if at least
one migration step executed or the FTS startup repair rebuilt the
virtual table.

Previously, running several migrations in sequence triggered multiple
multi-hour VACUUM stalls on large databases (migrations 08, 10, 11, 12
each called db.exec('VACUUM') independently). ANALYZE likewise only
ever ran during migration 08, leaving query planner statistics stale
after the later migrations that add tables and indexes.

Changes:
- Remove db.exec('VACUUM') from migration08, migration10, migration11,
  migration12, and verifyAndRepairFtsIfNeeded
- Remove db.exec('ANALYZE') from migration08
- Change verifyAndRepairFtsIfNeeded return type void -> boolean;
  returns true when a rebuild was performed
- Add migrationsRan flag (startIndex < MIGRATIONS.length) in
  runMigrations to track whether any step executed
- Run VACUUM then ANALYZE once in runMigrations when
  migrationsRan || ftsRepaired
- ANALYZE runs after VACUUM so the query planner sees the final
  compacted page layout rather than the pre-VACUUM fragmented state
- Update doc comments in migration 10/11/12 and the file header to
  reflect the new consolidated behaviour
Copy link
Copy Markdown
Contributor

@wiedehopf wiedehopf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wo, so much code to change!

i've skimmed "migration 12" committ and looks good to me.

totalRows,
});

// Free space for adsb.im users. Also anyone else.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yay!

… clients

Start the HTTP and Socket.IO servers BEFORE running database migrations
so clients can connect immediately and receive a meaningful status
message instead of seeing a silent connection-refused.

Backend changes:
- startup-state.ts (new): singleton module tracking whether migrations
  are running and holding a queue of Socket.IO clients that connected
  during the migration window.  Exports setMigrationRunning,
  isMigrationRunning, registerPendingSocket, drainPendingSockets, and
  resetStartupState (test helper).
- server.ts: reorder startup sequence — Fastify and Socket.IO start
  first, then setMigrationRunning(true) / runMigrations() /
  initDatabase() / setMigrationRunning(false), then the rest of init.
  After all background services are started, drainPendingSockets() is
  called and handleConnect() is delivered to any still-connected
  deferred clients.
- handlers.ts: export handleConnect so server.ts can call it for
  deferred sockets; check isMigrationRunning() on each connection —
  if true, emit migration_status { running: true } and register the
  socket in the pending queue instead of calling handleConnect.
  All per-socket event handlers are registered immediately regardless
  of migration state.
- acarshub-types/src/socket.ts: add migration_status event to
  SocketEvents.

Frontend changes:
- socket.ts service: add migration_status to ServerToClientEvents.
- useAppStore.ts: add migrationInProgress boolean state +
  setMigrationInProgress action.
- useSocketIO.ts: handle migration_status event (sets
  migrationInProgress); also clear migrationInProgress on
  features_enabled to handle reconnects where the client timed out
  during a long migration and never received { running: false }.
- MigrationStatus.tsx (new): banner component shown while
  migrationInProgress is true; uses semantic <output> element,
  aria-live='polite', spinning CSS indicator.
- _migration-status.scss (new): Catppuccin-themed info banner using
  --color-accent-sapphire border on surface0 background; mobile-first,
  prefers-reduced-motion safe.
- main.scss: import migration-status component styles.
- App.tsx: render <MigrationStatus /> below <ConnectionStatus />.

Tests:
- startup-state.test.ts (new): 16 tests covering isMigrationRunning,
  registerPendingSocket auto-prune on disconnect, drainPendingSockets
  connected-only filter, and resetStartupState.
- handlers.test.ts: 7 new migration-aware connection tests verifying
  migration_status emission, pending queue registration, normal connect
  suppression during migration, and event handler registration.
- MigrationStatus.test.tsx (new): 13 tests covering hidden state,
  banner rendering, ARIA attributes, reactive show/hide, and
  regression guard against re-appearing after clear.
The 500ms debounce in handleInputChange called executeSearch() with
submitIntent=false. That path unconditionally called setIsSearching(true),
which disabled the Search button and showed 'Searching...' even for silent
background searches triggered while the user was still typing.

On slow CI runners (GitHub Actions webkit/Mobile Safari) Playwright's
element-stability check on the button takes longer than 500ms. The debounce
fired during that check, disabling the button before the test could click it.
The mock socket never delivers a response, so isSearching never reset to
false — the button stayed disabled for the full 10s action timeout.

Fix: only call setIsSearching(true) when submitIntent=true (explicit form
submit or pagination). Debounce-triggered background searches now run
silently without touching the button state. This is also better UX — the
button remains clickable while incremental results update in the background.

Regression test added: advances fake timers past the 500ms debounce window
and asserts the button is still enabled after the background search fires.
- SearchPage: change expandForm() scroll from smooth to instant to
  eliminate a race condition on Mobile Safari where the concurrent
  scroll animation moved the Clear button outside the viewport
  mid-click.  Instant scroll also prevents the handleScroll listener
  from re-collapsing the form during the animation.

- AlertsPage: set tabIndex=0 on the virtual-list container
  programmatically in useEffect so axe's scrollable-region-focusable
  rule is satisfied without triggering Biome's noNoninteractiveTabindex
  (JSX expression comments cannot suppress attribute-level lint nodes
  in Biome's AST).

- search.spec.ts: update comment to explain why expandForm uses instant
  scroll (cross-references the component change).
The SVG icon and text label inside each button were flush against each
other.  Give the buttons inside __form-actions inline-flex layout with
gap: var(--spacing-xs) so the icon and label have consistent breathing
room.  svg dimensions are clamped to 1em so the icon scales with the
button's font-size.
…afari

The behavior:instant scroll fix landed but the 0.3s grid-template-rows
CSS expand animation on __form-body was still causing the test to fail
on Mobile Safari (iPhone 12, 390x664 effective viewport):

  1. Mid-animation: expanding form inputs intercept pointer events at
     the Clear button's position due to animation-frame layout overlap.
  2. Post-animation: the fully-expanded form pushes Clear below the
     viewport on the narrow Mobile Safari viewport.

Fix: after clicking the expand button, poll waitForFunction until
__form-body.getBoundingClientRect().height > 100 (animation complete),
then use el.scrollIntoView({ block:'nearest', behavior:'instant' }) to
bring Clear into view before Playwright's click action.
…diately

The [webkit] Live Map accessibility test was flaky because in the Docker
E2E container (no internet), when the default provider is a vector tile
URL (e.g. OpenFreeMap Liberty), MapLibre must fetch that URL to parse
the style.  The fetch never completes, so MapLibre's onLoad event never
fires.  LiveMapPage has a 10-second fallback that forces isMapLoaded=true,
but 10s + CI runner overhead regularly bumps over the 12-second test ceiling.

Fix: when VITE_E2E=true (set by Dockerfile.e2e), return a minimal inline
style { version:8, sources:{}, layers:[] }.  MapLibre can process this
with zero network requests and fires onLoad immediately, making the
isMapLoaded=true path deterministic.  Production behavior is unchanged.
Collapsing on submit immediately hid the form body, making the
Searching... button invisible and breaking every test that waits for
it.  Move setIsFormCollapsed(true) into handleSearchResults so the
form stays expanded (and the Searching... state visible) while the
query is in-flight, then collapses once results arrive.
Tests 2 and 5 checked that the Search button was visible after results
arrived, which was valid when the form stayed expanded after a search.
Now that the form collapses when handleSearchResults fires (so results
get the full viewport), the button is hidden and those assertions fail
on all browsers.

Replace the Search-button-visible checks with assertions on the results
or empty-state elements that are always visible regardless of form
state -- matching the pattern already used by test 4.
From makrsmark:feature/acars-decoder-1.8.8
#1639
…-driven collapse

- acarshub-backend: use dot notation for onceHandlers.disconnect access
  (biome lint/complexity/useLiteralKeys)

- acarshub-react: update SearchPage tests to reflect that form collapse is
  triggered by search results arriving (handleSearchResults), not by scroll
  events (which were never implemented)

  - 'clears all fields' and 'clearing results removes virtual items' tests:
    expand the form after results arrive before clicking Clear, since
    handleSearchResults collapses the form body (aria-hidden=true)

  - collapsible form header suite: replace simulateScrollPast() calls with
    emitSearchResults() to trigger collapse via the actual code path;
    remove the now-unused simulateScrollPast/simulateScrollToTop helpers
    from renderSearchPageWithScrollContainer
fredclausen and others added 16 commits February 28, 2026 08:59
The debounce (500 ms) could fire a query while the user was still typing.
If the backend responded before focus left the form, handleSearchResults
would call setIsFormCollapsed(true) mid-keystroke, yanking the form away.

Fix:
- Add formRef (<form> ref) and pendingCollapseRef (boolean ref).
- In handleSearchResults, check formRef.current?.contains(document.activeElement).
  - Focus outside the form → collapse immediately (unchanged behaviour).
  - Focus inside the form → set pendingCollapseRef=true, skip collapse.
- Add onBlur on <form> that fires when focus leaves the subtree.
  Uses e.relatedTarget to distinguish 'tabbed to another field' (stay open)
  from 'truly left the form' (apply the pending collapse if set).
- Clear pendingCollapseRef in expandForm() and handleClear() so stale
  deferred collapses cannot fire after the user has reset or reopened.

Update SearchPage tests:
- 'clears all fields' and 'clearing results removes virtual items': remove
  the expand-button step added in the previous commit — the form no longer
  collapses while focus is on the Search button.
- 'shows active search summary when results-collapsed': blur the form after
  emitSearchResults to trigger the deferred collapse, then assert summary.
…threshold

The fixed 80 px SCROLL_COLLAPSE_THRESHOLD was wrong on mobile: the expanded
form is taller than 80 px, so simply scrolling to reach the Search button
inside the form was enough to collapse it.  The results-arrival and
deferred-blur approaches that followed were worse — scrolling away from the
form (without ever clicking elsewhere) left the form expanded until the user
moused out, and then it vanished with no easy way back.

Replace the fixed threshold with a live geometry check:
  - Collapse when formRect.bottom - containerRect.top <= 0, i.e. when the
    form has completely scrolled above the top edge of .app-content.
  - Auto-expand when scrollTop returns to 0 so the fields reappear without
    requiring a manual click on the expand button.
  - suppressAutoCollapse ref (restored from pre-016145a9) prevents the
    programmatic scroll-to-top in expandForm() from immediately re-collapsing.

In jsdom all getBoundingClientRect values are zero, so (0 - 0) = 0 <= 0 is
trivially true.  A scrollTop > 0 guard ensures collapse only fires when a
test explicitly dispatches a scroll event, not at mount.

Update tests: restore simulateScrollPast / simulateScrollToTop helpers in
renderSearchPageWithScrollContainer and switch all collapsible form header
tests back to scroll-event-driven collapse.
… Over Time

- Add Y-axis labels to time-series charts reflecting actual bucket size
  per period (msg/min for 1/6/12hr, msg/5min for 24hr, msg/30min for
  1wk, msg/hr for 30day, msg/6hr for 6mon, msg/12hr for 1yr)

- Fix Chart.js crash 'too far apart with stepSize of 1 minute':
  Root cause was two stale-state paths when switching time periods.
  Path A: stale timeRange as min/max — getMaxSpanMs() guard in options
  useMemo discards any timeRange whose span exceeds 2x the nominal
  period window before it reaches Chart.js.
  Path B: stale data auto-scales x-axis — dataSpanOk guard blocks
  <Line> from rendering entirely when data timestamps span more than
  getMaxSpanMs(timePeriod), showing a loading state instead.
  Hook-level reset (setTimeRange/setData/setLoading on period change)
  is also added for correctness, though it cannot prevent the crash
  on its own since Chart.js's child useEffect fires before the hook's
  parent useEffect in the same passive-effects flush.

- Fix chart height on mobile: chart__canvas-wrapper now has explicit
  heights (280px mobile, 340px tablet, 380px desktop) so Chart.js
  has adequate vertical space with maintainAspectRatio: false

- Add tests: 56 tests for TimeSeriesChart (getYAxisLabel, getMaxSpanMs,
  timeRange guard, dataSpanOk guard) and 14 tests for
  useRRDTimeSeriesData (including 5 regression tests for the stale
  timeRange crash scenario)
… on short bars

Value labels on the Alert Terms and Frequency Distribution horizontal bar
charts now use dynamic alignment based on bar width relative to the axis max:

- value / axisMax < 0.15  → align 'end'  (label outside bar, right of tip)
                             no pill background, theme text colour
- value / axisMax >= 0.15 → align 'start' (label inside bar at right end)
                             coloured pill, dark text (unchanged behaviour)

This eliminates the overlap between value labels and Y-axis row labels on
low-count bars while preserving the original intent of keeping large-value
bar labels inside the chart boundary.

Both charts import Context from chartjs-plugin-datalabels for proper
callback typing (replaces the previous partial inline type annotation).
At tablet viewport widths (768 px–1023 px) a min-width: 280px rule applied
to .live-map-page__sidebar overrode the JS-driven --map-sidebar-width: 40px
CSS custom property set when the sidebar was collapsed.  The result was a
~280 px wide stub instead of the intended 40 px collapsed strip.

Fix: add a &--collapsed override inside the md→lg breakpoint block that
resets min-width to 0 and max-width to none.  The rule compiles after its
parent in source order so it wins the specificity tie and allows the CSS
variable to take full effect.

Affected device: Surface Pro 7 (912 px viewport width, between 768 and 1023).
At viewport widths between 768 px and 850 px the nav bar contains enough
elements that the "ACARS Hub" text beside the logo image word-wraps.  Add
a targeted media query on .logo-text to set display:none in that range.
The image logo alone is sufficient to identify the app; the text reappears
at 851 px and above where there is room for it.
normalizeMessageType("IMSL") returns "IMS-L" so that the message_type
stored in the messages table matches Python's getQueType() output.  However
updateFrequencies() in helpers.ts only had "IMSL" as a lookup key in its
freqTableMap — not "IMS-L".  Every IMSL message therefore silently hit the
unknown-type warn branch and returned without writing to freqs_imsl, leaving
the Status page IMSL Frequency Distribution chart permanently empty.

Fix: add "IMS-L": freqsImsl to freqTableMap, mirroring the existing
"VDL-M2" / "VDLM2" dual-entry pattern that already handles VDLM2 correctly.

Regression test added to helpers.test.ts that:
  - passes "IMS-L" (the normalized form) and asserts a row appears in freqs_imsl
  - covers all other decoder types and the VDLM2/VDL-M2 aliases
  - verifies unknown types write nothing

The test failed before the fix and passes after.
Backend
- Add src/utils/timeseries.ts: shared TimePeriod type, PERIOD_CONFIG,
  ALL_TIME_PERIODS, isValidTimePeriod, zeroFillBuckets, and
  getNextWallClockBoundary (wall-clock-aligned setTimeout math)
- Add src/services/timeseries-cache.ts: in-memory Map<TimePeriod,
  TimeSeriesResponse> warmed at startup; per-period refresh timers
  aligned to UTC clock marks (1 min for 1hr/6hr/12hr, up to 12 hr for
  1yr); each refresh broadcasts rrd_timeseries_data to /main namespace
- server.ts: call initTimeSeriesCache(broadcaster) after DB init;
  call stopTimeSeriesCache() on graceful shutdown
- handlers.ts: handleRRDTimeseries now returns getCachedTimeSeries()
  for time_period requests (zero DB hits on hot path); the legacy
  explicit start/end/downsample path is preserved for debug use
- Add tests: utils/timeseries.test.ts, services/timeseries-cache.test.ts
  (37 tests); update handlers and integration tests

Frontend
- Add src/types/timeseries.ts: TimePeriod, ALL_TIME_PERIODS,
  isValidTimePeriod, TimeSeriesDataPoint, TimeSeriesCacheEntry,
  TimeSeriesTimeRange — single source of truth for both hook and store
- store/useAppStore.ts: add timeSeriesCache: Map<TimePeriod,
  TimeSeriesCacheEntry> (non-persistent) and setTimeSeriesData()
- hooks/useSocketIO.ts: on every connect event emit rrd_timeseries for
  all 8 periods to pre-warm the cache; listen for rrd_timeseries_data
  and write each push to setTimeSeriesData — ADS-B style push model
- hooks/useRRDTimeSeriesData.ts: rewrite as a thin Zustand selector;
  reads timeSeriesCache.get(timePeriod) — no socket emit, no useEffect,
  no local state; period switches are instant once cache is warm;
  autoRefresh/refreshInterval params accepted but ignored for compat
- pages/StatsPage.tsx: drop the now-meaningless autoRefresh argument
- Add/update tests: useRRDTimeSeriesData (selector semantics, no-emit
  regression), useAppStore (timeSeriesCache CRUD), useSocketIO
  (rrd_timeseries_data handler + on-connect warm-up, 18 new tests)

All 2232 tests pass; just ci green.
Three bugs were preventing the time-series charts on the Stats page
from honouring their parent container's width:

1. Missing width: 100% on .page__content in the flex chain
   The element had max-width: 1400px and margin: 0 auto but no explicit
   width.  In a flex-column context margin: auto sizes the item at its
   content width rather than the container width, so the max-width guard
   was bounding an undefined value.  Adding width: 100% gives the item a
   definite cross-axis size before max-width clamps it.

2. contain: layout style on .card was never overridden for single-chart
   sections.  The previous fix removed contain: layout from .chart-wrapper
   but the card itself still had it.  contain: layout creates a layout
   containment boundary that in some browsers prevents flex cross-axis
   (width) sizing from propagating correctly, letting charts escape the
   1400 px constraint.  The > .card:only-child selector now overrides this
   to contain: style, which provides style isolation without breaking the
   width chain.

3. Missing min-width: 0 throughout the flex chain.
   Every flex item defaults to min-width: auto, meaning it refuses to
   shrink below its content's intrinsic minimum width.  Chart.js axis
   labels and tick text all carry intrinsic widths; without min-width: 0
   at each level (.page__content, &__section-content, .card:only-child,
   .card__content, .chart-wrapper, .chart-container, .chart-container__canvas,
   .chart__canvas-wrapper) the label text could push containers wider than
   their parents, eventually causing a horizontal scrollbar in .app-content.

max-width: 100% added as belt-and-suspenders on .chart-wrapper and its
descendants so no child can be laid out wider than its parent even before
overflow: hidden clips painted overflow.

All 1 303 tests pass; just ci is green.
…uture timestamps; fix y-axis label

Three bugs in the RRD import pipeline caused the spike reported by users:

1. parseRrdOutput: all-NaN rows (time slots with no RRD source data) were
   converted to zero-count rows and inserted into the DB. This polluted
   timeseries_stats with false zeros for the entire gap between the RRD's
   last_update and the time of import, and pre-occupied future timestamps
   that the live stats-writer should own.
   Fix: skip rows where every column is -nan/nan. Partial-NaN rows still
   map the NaN columns to 0 (unchanged).

2. expandCoarseDataToOneMinute: rrdtool returns the timestamp at the END
   of each consolidation period, so a 5-min point at T covers [T-300, T].
   The old code expanded forward (T, T+60, ..., T+240), shifting all
   coarse-archive historical data into the future and creating rows far
   beyond the RRD's last_update — blocking the live stats-writer.
   Fix: expand backward using T - (intervalCount - 1 - i) * 60 so the
   five 1-minute sub-rows land at T-240, T-180, T-120, T-60, T.

3. Future-timestamp clamp: after expansion, any row with timestamp >
   Date.now() is dropped. Belt-and-suspenders guard against clock skew or
   edge cases surviving the above two fixes.

4. Frontend Y-axis label: downsampled periods (24hr, 1wk, 30day, 6mon,
   1yr) use ROUND(AVG(total_count)) in the SQL query — an average
   messages-per-minute, not a per-bucket total. The old labels
   ('Messages / 5 min', 'Messages / hr', 'Messages / 12 hr', etc.)
   implied a per-bucket total, which is bucket_minutes × the displayed
   value — far larger. A user reading '130' with label 'Messages / 12 hr'
   could compute 130 × 60 × 12 = 93 600 and report a ~90 k spike. All
   downsampled periods now show 'Avg messages / min'.

Four regression tests added to rrd-migration.test.ts:
- all-NaN rows are skipped and do not produce zero-count DB rows
- migration still succeeds when input contains only NaN rows
- 5-minute archive points expand BACKWARD to cover historical period
  (asserts exact timestamps T-240, T-180, T-120, T-60, T; confirms T+60
  is absent)
- future-timestamp rows are clamped and not inserted
…N rows

## Problem

Three bugs in the RRD → SQLite import pipeline corrupted timeseries_stats:

1. **NaN→0 inflation**: parseRrdOutput converted -nan/nan to 0 and inserted
   all-NaN rows as real zero-count rows. This filled the gap between
   rrd_last_update and import time with false zeros, blocking the live
   stats-writer via INSERT OR IGNORE.

2. **Forward expansion**: expandCoarseDataToOneMinute expanded coarse archive
   points *forward* from the returned timestamp T (T, T+60, …, T+(N-1)×60).
   rrdtool timestamps are end-of-period, so expansion must go *backward*
   (T-(N-1)×60, …, T-60, T). The bug shifted historical data to wrong future
   timestamps that permanently occupied live-writer slots.

3. **Future timestamps**: combined effect produced rows with timestamps beyond
   rrd_last_update that the live stats-writer could never overwrite.

## Fixes

- **parseRrdOutput**: skip rows where *all* columns are NaN. Partial NaN
  columns are still converted to 0 (a data point exists, some fields unknown).

- **expandCoarseDataToOneMinute**: expand backward from T. A 5-min point at T
  produces rows at T-240, T-180, T-120, T-60, T.

- **Future-timestamp clamp**: filter expanded rows to timestamp <= now before
  inserting.

## Automatic repair when .rrd.back exists

Added repairBadImport() to clean up databases produced by the old importer:

  Class 1 — timestamp <= rrd_last_update
    All historically-imported rows. Safe to delete unconditionally; the fixed
    re-import will repopulate correctly.

  Class 3 — rrd_last_update < timestamp <= rrd_last_update + 21600
    Forward-expansion overflow (up to 6 h of squatted live-writer slots).
    Deleted unconditionally; worst-case trades 6 h of live data for correct
    future writes.

  Class 2 — rrd_last_update + 21600 < timestamp <= imported_at, all counts 0
    NaN-derived zeros only. Zero-filtered to preserve legitimate quiet periods.

Sentinel logic:
  - .rrd.back2 exists + DB has rows → skip (already migrated with fixed code)
  - .rrd.back exists → run repair → rename .back → .rrd → re-import
  - Fresh import → import .rrd → rename .rrd → .rrd.back2

New helpers: getRrdLastUpdate, getRegistryImportedAt, clearRegistryEntry,
repairBadImport.

## Frontend label fix

Downsampled chart periods (24hr, 1wk, 30day, 6mon, 1yr) now label the Y-axis
as "Avg messages / min" instead of "Messages / <bucket>", correctly conveying
that the SQL AVG produces a per-minute rate, not a per-bucket total.

## Tests

- NaN-skipping behaviour verified
- Backward expansion correctness (5-min and 6-hour examples)
- Future-timestamp clamping
- Sentinel skip (.back2 + rows → return null)
- Repair flow (.back → delete classes → clear registry → restore → reimport)
- .back2 empty DB → restore for fresh import
- All existing tests updated to expect .back2 as the post-import sentinel
…inator

Remove the .rrd.back2 filesystem sentinel and use the existing
rrd_import_registry.rrd_path column as the sole discriminator
between old and new imports.

State machine:
  .rrd.back present, rrd_path ends with '.back'  → old import → repair + re-import
  .rrd.back present, rrd_path does NOT end '.back' → new import → skip
  .rrd.back present, no registry entry            → fresh/legacy → rename .back → .rrd → import
  .rrd present (no .back)                        → normal import path

Changes:
- Remove back2Path variable and the entire step-1 .back2 block
- Remove tryRegisterBackupHash (replaced by registry discriminator)
- Add isOldImportPath(registeredPath) helper (exported for tests)
- Add getRegistryRrdPath(fileHash) helper to read rrd_path from registry
- Replace step-2 .back handler with registry-aware three-branch logic
- Step 9/10: rename post-import to .back (was .back2)
- Hash guard (step 4): rename to .back (was .back2) on hash-match skip

Tests:
- Remove .back2-based orchestration tests
- Add: registry rrd_path ends .back  → repair runs
- Add: registry rrd_path no .back    → skip
- Add: no registry entry             → fresh import
- Add: isOldImportPath unit tests (4 cases)
- Integration: lifecycle fresh→import→.back→second-startup→skip
- Integration: old-import detection (seeded old-path registry entry → repair)
- Update all .back2 rename assertions to .back
@fredclausen fredclausen merged commit 795bce7 into main Mar 2, 2026
8 checks passed
@fredclausen fredclausen deleted the minor-tweaks branch March 4, 2026 16:44
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.

5 participants