Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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
wiedehopf
reviewed
Feb 27, 2026
| totalRows, | ||
| }); | ||
|
|
||
| // Free space for adsb.im users. Also anyone else. |
… 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
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
d00165e to
44c7822
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ACARS Hub v4.1.4
v4.1.4 Performance
@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.v4.1.4 New
v4.1.4 Improvements
v4.1.4 Notes