Skip to content

Latest commit

 

History

History
497 lines (344 loc) · 127 KB

File metadata and controls

497 lines (344 loc) · 127 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project uses Calendar Versioning with the format YYYY.0M.MICRO.

[Unreleased]

Nothing yet.

[2026.07.4] — 2026-07-09

Added

  • A ⌘K / Ctrl+K command palette for keyboard-first navigation and actions. Press ⌘K (macOS) or Ctrl+K (Windows / Linux) — or click the ⌘K button in the header — from anywhere to open a single search box that fuzzy-matches three kinds of result: Sections (jump to Dashboard, Sessions, Trends, Explore, Reports, Data, Settings, or Help), Sessions (type a date such as 2026-07-04 to list and open the nights recorded on it), and Actions (start a data import, switch the theme to light / dark / system, or set the global time window to a preset). It is fully keyboard-operable (↑/↓ wrap through results, Home/End jump to the ends, Enter runs the highlighted row, Esc or a click outside closes it) and accessible (focus-trapped modal dialog, focus restored to the invoker on close, a politely-announced result count, and reduced-motion honored). The date lookup reads only lightweight per-night metadata — never the underlying 25–50 Hz signal samples — and, like everything in the app, runs entirely in your browser.
  • A single global time-window control in the header command strip. The window control offers five presets — 7D / 30D / 90D / 6M / 12M — plus a Custom popover with Start/End date fields (bounded by your data extent and today) and one-click quick ranges (Last 14 days, Last 30 days, Last 6 months, All data). It is now the single range control for the whole application: choosing a window re-scopes every view that follows it at once. The highlighted preset is derived from the range actually in effect, so a range restored from a bookmarked URL or set from the command palette is reflected correctly. On narrow (mobile) screens the same presets and Custom range are available through a compact time-window menu.
  • A Compare entry point on the Sessions list. A Compare button in the Sessions toolbar, beside the Table/Calendar switch, opens the multi-session comparison view (/sessions/compare).

Changed

  • The whole application adopts a single "command surface" visual language. A dense, monospace-forward, panel-based redesign is applied across every surface — the app shell, Dashboard, Sessions list and detail, Trends, the Explore hub and its five analysis views, Reports, Data Management, the Import wizard, Settings, Help, and the AI Insights drawer — formalizing the direction begun by the Signal Deck dashboard and the Session Detail redesign so navigating between views no longer crosses a visual seam. This is a chrome-and-layout refresh only: every chart keeps its type, y-axis range and scale, axis labels, clinical reference lines and severity zones, and units, and the tuned signal-viewer and Trends renderers are untouched — so no clinical reading changes. Both light and dark themes are supported, the system default is preserved, and all computation stays in your browser with no new network egress or bundled fonts. (See ADR 0032.)
  • The global time window replaces the per-view date pickers. The header window control is now the single source of truth for the analysis range; individual views follow it instead of each carrying its own date selector with independently-drifting state. The Dashboard follows the global window too (its former standalone 30D/90D toggle is removed), while the Dashboard's 12-month AHI calendar spine and TECSA trajectory still always cover a trailing 12 months as a fixed longitudinal reference. Deep-linked and URL-restored range state continues to work, and Reports keeps its own export date range (seeded from the global window, then set independently on the Reports page).
  • The Import wizard now opens as a header-launched modal. The Import button in the command strip opens the full import wizard as a modal over whatever screen you are on, so you can start an import from anywhere without navigating away; its full richness is unchanged (CPAP SD card and Google Health / Fitbit sources, staged progress, background import dock). The /data/import route is retained for the first-run empty-state prompt and deep links.
  • Typography moves to a monospace-forward system for labels, numerals, and chrome; narrative and explanatory prose stays sans-serif for readability.

Fixed

  • WCAG AA contrast on primary and accent controls. Text sitting on primary/accent fills (the segmented controls, the Import button, and the custom-range Apply button, among others) previously used hardcoded white and fell below the AA contrast threshold in dark theme; it now uses a theme-aware inverse-text token, raising on-primary and on-accent contrast above AA in both themes. Separately, small status-coloured text on neutral surfaces — the LOCAL · NO UPLOAD indicator, the AHI-severity pills, and the dashboard alert tags — now uses dedicated on-surface status-text tokens so it clears AA in light theme as well, and the header coverage string moves off the muted colour when it is the sole statement of the active range.
  • Correlation-matrix and statistical-chart text/line contrast. The cross-source correlation matrix and the d3 correlation heatmap now render cell text with theme-aware, luminance-aware ink (dark text on light cells) over tokenized fills, and the statistical-analysis confidence-interval reference lines use a theme-flipping token — all meeting AA contrast in both light and dark themes. No chart encoding, axis, threshold, or type changed.
  • Trends charts now repaint on a light/dark theme toggle. The Trends Canvas2D charts could retain stale colours after switching theme because the theme attribute was applied asynchronously; it is now applied synchronously (and at cold boot from the rehydrated preference), so the canvas charts recolour immediately.
  • A crafted URL no longer white-screens the app. A path whose first segment named a JavaScript prototype-chain member (for example /__proto__ or /toString) could make the header resolve a non-string section title and throw outside the route error boundary, blanking the whole app. The section-title lookup is now restricted to own properties and falls back to the app name for any such path.

[2026.07.3] — 2026-07-06

Changed

  • The Session Details page — the canonical view of a single night — is redesigned around a per-night "Night assessment" verdict, a KPI grid with trailing-baseline context, and an embedded signal viewer, replacing the earlier flat statistics layout. The hero is a Night assessment card that reports a night through two independent, canonically-sourced gates rather than one composite score (see ADR 0031): Effective — the night's residual AHI was below 5 events/h, the American Academy of Sleep Medicine (AASM) normal/mild boundary — and Adherent — mask-on usage was at least 4 h, the U.S. CMS per-night compliance floor. Each gate shows an independent pass/fail; the two together resolve to a heuristic verdict word (Good / Fair / Partial / Rough night), never a number, and a missing metric (an AHI below the rate-validity floor) reads as an explicit gap that cannot pass a gate, never as a passing zero. The verdict is a plain-language summary, not a diagnosis. Beside it a KPI grid shows AHI, usage, median leak, 95th-percentile pressure, SpO₂ nadir, and resting heart rate, each with a trailing 30-night baseline delta ("▲/▼ vs 30-night", suppressed to "baseline building" until enough prior nights exist) and a small sparkline of the recent trajectory. Below the hero sits an embedded compact signal viewer — Flow, Pressure, Leak, and SpO₂ as stacked lanes, reusing the app's optimized Canvas renderer, with a whole-night minimap, zoom presets, channel toggles, and a "Full explorer" link to the full-page signal viewer for deep inspection. An events row adds a respiratory-event breakdown (per-type rate and count, plus longest apnea, central fraction, RERA and flow-limitation counts) and an expandable event clusters list ranked by an in-night relative intensity (cluster duration × event density), each cluster openable in the signal viewer. A full Session statistics panel groups pressure, ventilation, leak, and oxygenation figures, and a gated physiology row surfaces wearable sleep stages and physiology (resting HR, HRV, wearable SpO₂) and a weather/environment card only when those opt-in integrations are connected — otherwise those cards are omitted, never faked. The redesign holds a strict honesty stance: any metric with no computed source is omitted rather than approximated — the page deliberately does not invent an I:E ratio, inspiratory time, a flow-limitation median, a largest-leak timestamp, T90 in minutes, an awakenings count, or a pollen figure, because none of those are computed from the imported data. Every value maps to real recorded data; nulls render as an em dash. All processing stays in your browser. The in-app Sessions Guide gains detailed sections on the two-gate verdict, the KPI baseline deltas, the embedded viewer, the events breakdown and clusters, the statistics panel, and the gated wearable/weather cards, and the glossary adds entries for leak P95, central fraction, and event cluster.

[2026.07.2] — 2026-07-05

Changed

  • The home dashboard is redesigned as the "Signal Deck" — a dense, single-surface therapy overview — replacing the previous "Control Room" dashboard. The deck opens with a good-night rate verdict: the percentage of recorded nights in the window that were both effective (AHI < 5 events/h — the AASM "normal" residual-AHI ceiling) and adherent (usage ≥ 4 h/night — the CMS compliance floor), shown as a ring with a qualitative band (Excellent ≥ 85 / Good ≥ 70 / Fair ≥ 50 / Low < 50) and two supporting gate readouts — the share of nights meeting AHI < 5, and the share meeting ≥ 4 h. The denominator is every recorded night in the window, so a short, aborted, or null-AHI night counts as a not-good night rather than being dropped. Unlike the previous Therapy Index it replaces, the good-night rate invents no weights and blends nothing into an opaque composite — it simply counts the nights that cleared two established clinical thresholds (AASM AHI < 5, CMS 4 h); the band cut-offs are a heuristic for the qualitative label/colour only (the 70 % cut loosely mirrors the CMS "≥ 70 % of nights" adherence convention), and the metric is explicitly not a diagnosis. Surrounding the verdict are: a 12-month nightly-AHI calendar heatmap with a monthly-mean strip (a longitudinal spine that always spans a trailing 12 months, independent of the window toggle); app-computed alert cards summarizing notable trends; a signal small-multiples rail of sparklines for the core CPAP metrics (and resting heart rate and HRV when a wearable is connected); distribution plots (an AHI histogram, a nightly-median-leak spread box plot, and a per-night event mix); wearable correlation lanes (resting HR, SpO₂, and HRV, shown only when a wearable is connected); a TECSA trajectory (treatment-emergent central sleep apnea indicator) over a trailing-12-month window; a weather summary card (shown when the opt-in weather integration is enabled); and a session log. The deck is fully theme-aware and offers a 30D / 90D analysis-window toggle; all computation stays in your browser and no data leaves the device. The in-app Dashboard Guide is rewritten to document every panel and to explain the good-night rate — its two gates, their clinical sources, the qualitative bands, the two supporting gate readouts, and its non-diagnostic nature — in full.

[2026.07.1] — 2026-07-01

Fixed

  • Wearable heart-rate and SpO₂ overlays now use your Fitbit account's timezone (from Profile.csv) for nights without CPAP overlap, instead of your browser's timezone. The UTC→local offset for the intraday heart-rate and SpO₂ lanes is derived per night from the overlapping CPAP session; only for dates with no CPAP session does the app fall back to a whole-zone estimate. That fallback now prefers the IANA timezone recorded in your Fitbit export's Profile.csv (e.g. America/Los_Angeles), which is captured locally at import time, so viewing data recorded in a different zone than the browser you review it in lines up correctly. When no profile timezone is available it still falls back to the browser's zone. This is a DST-aware, per-date estimate and reflects your account's current timezone, not a per-night travel history; CPAP-anchored nights are unchanged. The captured timezone is stored only in your browser, never transmitted, and is removed by "delete all data".

Changed

  • Building the wearable timezone-offset table no longer deserializes full heart-rate sample blobs for nights already covered by SpO₂. The offset estimator anchors on the compact, sleep-only SpO₂ series and only falls back to heart rate for nights with no SpO₂. Previously it loaded every wearable record — including full-resolution intraday heart-rate nights (~17k samples each, across years) — even when SpO₂ already resolved the night. It now loads SpO₂ records in full, enumerates heart-rate record dates with a keys-only index cursor (no sample blobs), and fetches a single heart-rate night by key only when SpO₂ did not cover that date. The resulting offsets are identical; the load is substantially cheaper for large multi-year datasets.

[2026.07.0] — 2026-07-01

Fixed

  • Wearable heart-rate and SpO₂ overlays now render at your local time instead of ~7–8 hours off. Fitbit stamps the intraday heart-rate and SpO₂ series in UTC, but the app previously treated every wearable timestamp as local wall-clock — so for a user west of Greenwich those two lanes were shifted by the full UTC offset (a 1 AM event drew at 8 AM for US-Pacific), which made the heart-rate lane look wrong and pushed the SpO₂ lane onto the wrong part of the night. Both lanes are now converted to local time using a per-night offset derived from your own data: the overlapping CPAP session is treated as local-time ground truth and the wearable sleep period is aligned to it (with a DST-aware fallback to the browser's timezone for any night without CPAP overlap). The offset is estimated once and shared everywhere the two lanes appear — the per-session signal viewer and the Event Explorer's autonomic (event-triggered heart-rate) view — so they always agree. The other intraday lanes (HRV, snoring, sleep stages) were already in local time and are unchanged, and date-keyed correlations against daily wearable summaries are unaffected. Everything is computed in your browser; no data leaves the device and no timezone is requested from you.

[2026.06.22] — 2026-06-26

Added

  • The session signal viewer's Measure overlay now has five interchangeable statistical lenses on the measured region. In addition to the original Statistics (average / median / minimum / maximum), the overlay can now show Variability (sample standard deviation, coefficient of variation as a percentage, and interquartile range — CV is shown only where it is meaningful, i.e. for strictly-positive ratio-scale signals, and dashes for zero-mean signals like raw flow), Trend (ordinary-least-squares slope per minute, net change over the region, percent change relative to the region mean, direction — rising/falling/flat — and the regression R² as a goodness-of-fit note; rate-of-change is the robust whole-region regression slope, not a noisy endpoint difference, and percent change uses the region mean as a noise-stable base rather than a single endpoint), Distribution (the five-number percentile summary p5/p25/p50/p75/p95, for a sense of shape and tails a single mean cannot convey), and Selection (the lane's sample rate — nominal for CPAP, mean cadence for irregularly-sampled wearables — its in-region sample count, and the region span; the footer additionally shows the region's precise start, duration, and end to the millisecond with a one-click Copy button). Switch lenses with the footer's segmented control or by pressing . (forward) and , (back), which cycle through the modes and wrap around; the chosen mode is remembered per session. Only the active lens is computed, and only when you finish panning or zooming, so the hot path stays lean. Every lens has full keyboard and screen-reader support: the segmented control is a standard radio group (Arrow keys to move, Space/Enter to select), the active mode is announced politely, and the dedicated "Region statistics" table re-labels its columns and cells for each lens. Statistical convention follows the data-literate audience: sample (n−1) estimators for spread, signed figures with an explicit leading + / − for trend, and an explicit "—" wherever a metric is undefined or there are too few samples — a value is never fabricated. All computation stays in your browser; no data leaves the device.

Fixed

  • Holding Alt(Option) to peek at the Measure overlay now works every time, not just the first. Pressing Alt while the pointer is over the plot is meant to momentarily reveal the region statistics, but a lone Alt press is also the browser's "focus the menu bar" shortcut. Because the viewer did not suppress that shortcut, the first Alt press quietly shifted focus to the browser's chrome and blurred the page — which cleared the peek and stopped any further Alt presses from reaching the viewer until you clicked back into the page. The viewer now suppresses the menu-bar shortcut only when it is actually capturing Alt for the peek (pointer over the plot), so you can hold Alt, release, and hold it again to peek repeatedly without any intervening click. Alt elsewhere — including Alt-Tab to switch windows and Alt+drag to pin a measure region — is unaffected.

[2026.06.21] — 2026-06-26

Changed

  • The Sessions calendar now stacks one labelled panel per calendar year, so long date ranges stay legible. The calendar previously rendered a single horizontal week-grid scaled to fit the page width, which broke for ranges longer than about a year — the All time preset (spanning 2000 → today) collapsed into an unreadable sliver — and made short ranges look oversized. It now draws a GitHub-style panel per calendar year, stacked vertically with the oldest year at the top and each panel labelled with its year, at a fixed cell size that does not stretch: short ranges show a small neat grid and multi-year ranges stack cleanly. All time now shows only the years that actually contain data (empty leading and trailing years are trimmed), while interior empty years are kept because a multi-year therapy gap is itself meaningful. Each year panel scrolls horizontally on narrow or mobile viewports with the year and weekday labels held in view. The colour bands, legend, tooltip, the three cell states (data / gap / partial), and keyboard navigation are unchanged; arrow keys now move across year boundaries.

[2026.06.20] — 2026-06-26

Added

  • Measure a region in the session signal viewer to get its summary statistics across every lane. A new Measure toggle in the toolbar (or the M key) turns on a statistics overlay: each lane gains a small chip showing the region's average, median, minimum, and maximum (continuous CPAP and wearable lanes), the per-stage time breakdown (the hypnogram), or "— no data" where a lane has no meaningful samples — and a footer reports the region's clock span and total sample count. By default the region is the visible viewport, recomputed once each time you finish panning or zooming (never mid-gesture, so scrolling stays smooth). To pin a fixed region, Alt(Option)+drag horizontally across the plot — this draws a neutral, dashed measure band (distinct from the blue Shift+drag zoom band) and locks the statistics to that time span even as you pan and zoom away; a footer chevron scrolls an off-screen pinned region back into view. Press Esc to clear a pinned region (then again to turn Measure off), and hold Alt to momentarily peek at the viewport's statistics without toggling the mode on. The median is computed exactly for any realistic region (a whole night is well within an interaction-latency budget). The statistics are computed entirely in your browser; no data leaves the device. Keyboard and screen-reader users can define a region with [ and ] at the data cursor and read all per-lane figures from a dedicated, focusable "Region statistics" table; a concise spoken summary is announced whenever the region changes. Whether Measure is on is remembered per session; a drawn region is transient and never persisted.

Fixed

  • The hovered-region readout in the session signal viewer's pinned legend bar is no longer prematurely truncated. The right-aligned readout (showing the device event or detection region under your pointer) previously had a fixed width cap that cut off all but the shortest labels with an ellipsis, even when there was plenty of empty space in the row. It now expands to fit its content and uses whatever width is left over after the left-aligned legend items, only truncating with an ellipsis when the legend and readout together would exceed the row — keeping the full text visible far more often (and still available in full via hover tooltip when it does have to shorten).

[2026.06.19] — 2026-06-26

Added

  • The Sessions page has a new Calendar view, alongside the existing table. A Table ⇄ Calendar toggle switches the Sessions index between the sortable table and a GitHub-contribution-style grid with one cell per night, coloured by a metric you choose — AHI, Usage hours, or Leak median. Colours use discrete clinical severity bands (green → amber → orange → red) rather than a relative gradient, so a cell speaks the same colour language as the table's AHI badges and the clinically meaningful boundaries stay fixed and comparable across the whole grid: AHI at the AASM/ICSD-3 cutoffs (Normal <5, Mild 5–<15, Moderate 15–<30, Severe ≥30 events/h); usage against the CMS 4 h compliance floor and the 6 h recommended-adherence target (worst <2h, then 2–<4h, 4–<6h, ≥6h, with higher being better); and leak median against the ResMed 24 L/min large-leak threshold (<6, 6–<12, 12–<24, ≥24 L/min, lower being better). Each cell has a non-colour cue for its state so colour is never the only signal (WCAG 1.4.1): a filled cell is a night with data, a dashed/empty cell is a gap (a missed night with no recorded session — made visible because adherence holes matter), and a neutral cell with a small glyph is a partial night (a session that exists but whose metric is unavailable — for example AHI on a too-short recording, which is shown as unavailable and never as 0). A legend explains the bands and the no-data/partial markers, clicking a night opens its session detail, and the grid is keyboard-navigable. The leak metric colours by the night's median leak, so brief high-leak spikes that do not move the median are not reflected by the cell colour — the per-session leak chart, P95, and time-in-large-leak remain the place to find spikes. The Sessions Guide help article gains a "Calendar view" section documenting the views, every band and its clinical thresholds, the gap/partial states, and the leak-median caveat.
  • A page-size toggle (25 / 50 / 100 rows) on the Sessions table. Choose how many nights to show per page.
  • The Sessions view, calendar metric, and page size are now part of the URL, so a specific view is shareable and bookmarkable — for example /sessions?view=calendar&metric=leak opens straight into the calendar coloured by leak median. The selections survive reloads and browser back/forward, mirroring the existing ?page convention.

[2026.06.18] — 2026-06-26

Fixed

  • The crosshair time readout on the session signal viewer now stays visible while you scroll through the stacked graphs. Previously the hover time/clock readout was drawn once at the very top of the graph stack, so after scrolling down to inspect a lower signal it scrolled out of view — leaving no way to read the exact time of an event on that lane without scrolling back up. The readout is now pinned to the top of the visible area and stays aligned with the crosshair as you move and scroll, so the precise timestamp (and duration into the session) is always in view above the cursor.

[2026.06.17] — 2026-06-26

Fixed

  • Google Health (Fitbit) intraday data is no longer silently truncated each day at the export's file boundary (~7–8 AM for a US user). Fitbit splits the intraday export into per-day files whose 24-hour window is offset from your local midnight by your UTC offset, so each calendar day's samples are spread across two files (e.g. a night's pre-midnight samples land in one file and the post-midnight samples in the next). The importer grouped samples by their own local calendar date but then treated the second file's contribution to an already-seen date as a duplicate and dropped it — so every day's intraday record was cut off at the file boundary. On the session signal viewer this showed up most visibly as the heart-rate lane stopping at 7 AM in summer / 8 AM in winter (the cutoff tracks daylight-saving time because it equals your UTC offset); the other intraday types (SpO₂, HRV detail, snoring, sleep stages) were affected in principle too but rarely visibly, since they are recorded only during sleep and usually fall within a single file. The two partial-day chunks are now merged into one full-day record instead of one being discarded. If you imported Fitbit/Google Health data previously, please re-import to restore the missing samples; re-importing is safe — overlapping samples are de-duplicated by timestamp, so nothing is doubled.

[2026.06.16] — 2026-06-23

Performance

  • CPAP (ResMed) imports are faster, especially for full-length nights. The importer now parses upcoming days in the background while the current day is being saved — keeping the parsing workers busy instead of idle during the storage step — and writes each session's signal chunks in parallel rather than one at a time. In benchmarks, importing a set of long (8-hour) nights completed roughly twice as fast; imports made up of many short days see a smaller gain because they are limited by per-record storage rather than parsing. Imported data, duplicate detection, and on-disk results are unchanged; peak memory stays bounded by a fixed in-flight budget. (See ADR 0029.)
  • Google Health (Fitbit) imports of the heavy intraday data types are faster. While each parsed file's records are being saved, the next file is now parsed in the background (off the main thread) instead of waiting — so the worker is no longer idle during storage. On a representative intraday heart-rate import this cut total time by roughly a quarter (and scales with the number of files, so multi-year exports benefit most). Stored data and duplicate detection are unchanged, and peak memory stays bounded by a small fixed look-ahead. (See ADR 0030.)

[2026.06.15] — 2026-06-23

Fixed

  • Google Health (Fitbit) imports no longer silently drop the heavy intraday data types — intraday heart rate, intraday SpO₂, HRV detail, and snoring. On affected builds these full-resolution types imported zero records while the import still reported success; daily-resolution data and every other source were unaffected, which is why the gap went unnoticed. The cause was a defect in the Web Worker call wrapper that dropped the progress callback across the worker boundary, so every heavy-intraday file errored and was skipped — and because the lighter data still imported, the overall import looked successful. These intraday types now import correctly again. As a safeguard, this class of internal worker-serialisation failure now fails the import loudly rather than being silently skipped, so a future regression of this kind can no longer masquerade as success. If you imported Fitbit/Google Health data on an affected build, please re-import to pick up the missing intraday heart-rate, SpO₂, HRV, and snoring data; re-importing is safe — existing data is de-duplicated, so nothing is doubled.

[2026.06.14] — 2026-06-23

Fixed

  • Google Health (Fitbit) imports no longer fail when an export contains more than one record for the same data type and calendar date. An export carrying, say, two temperature readings dated 2025-09-30 previously aborted with a storage error (Storage failed: ... Unable to add key to index 'source_dataType_date': at least one key does not satisfy the uniqueness requirements), because the per-(source, data type, date) uniqueness index rejected the second write. Such same-date duplicates within a single import are now de-duplicated automatically — the first occurrence is kept — so the import completes. Genuine uniqueness or constraint violations are now correctly counted as skipped in the import summary rather than surfaced as import errors.

[2026.06.13] — 2026-06-23

Added

  • A "Data persistence" indicator in Settings → Privacy & Storage → Storage Usage. It tells you, in plain language, whether your browser has marked CPAP Analyzer's local data as Protected (persistent — the browser will not silently discard it to reclaim space) or Not protected (best-effort — eligible for automatic eviction under storage pressure). When it shows "Not protected," a Protect my data button asks the browser to grant persistent storage, and the indicator updates to reflect the result. This is purely local: requesting persistence sends nothing anywhere and contacts no network. A new "Keeping your data safe" help section and a new "Persistent Storage" glossary entry explain what eviction is, why a browser might discard app data, and how to satisfy the browser's heuristic so persistence is granted.

Fixed

  • The browser can no longer silently discard your imported data. CPAP Analyzer stored everything (sessions, signals, settings) in the browser's default best-effort storage, which the browser is allowed to evict automatically when disk space runs low, when "clear data on exit" is configured, or during routine disk cleanup — most often on Chrome on Windows. The symptom was intermittent, total data loss: opening the app to an empty database, or a "database connection is closing" error partway through a session. The app now requests persistent storage when it first opens your local data, which marks your data as protected from automatic eviction, and the database connection now reconnects automatically if the browser force-closes it mid-session rather than failing. If the browser declines to grant persistence (it weighs signals such as whether you have bookmarked or installed the app and how regularly you use it), the new Settings indicator shows "Not protected" and a one-click Protect my data button lets you ask again; the "Keeping your data safe" help section explains how to make a grant more likely and recommends periodic exports as a backup. No data leaves your device at any point.

[2026.06.12] — 2026-06-23

Performance

  • Large Google Health (Fitbit) imports use far less memory. Importing the big intraday data types (heart rate, SpO₂, HRV, snoring) previously held every parsed record for a whole data type in memory at once before writing it, so a multi-year wearable export could consume hundreds of megabytes — enough to exhaust memory and crash the tab on lower-RAM devices. Each file's records are now stored and released as it is parsed, so peak memory stays roughly flat regardless of how many years you import. Imported data, de-duplication, and progress are unchanged.

[2026.06.11] — 2026-06-23

Added

  • Imports now run in the background, with a persistent progress indicator on every screen. You no longer have to sit on the import wizard and wait. Once an import starts, it continues running while you navigate anywhere in the app — the Dashboard, Sessions, Trends, or Settings. A small progress pill appears bottom-left on every screen showing the live percentage; click it to expand a detail panel that lists each stage and its state, and gives you a Cancel button. When the import finishes, a completion toast announces how many sessions or records were added (and how many duplicates were skipped). The indicator is keyboard-accessible and announces progress to screen readers via a polite live region. Everything still runs entirely client-side; nothing leaves your browser. (See ADR 0026.)
  • Multi-stage import progress. Instead of a single bar whose meaning shifted as the import moved through its phases, every stage is now shown from the start, each with its own state (pending → running → done, or failed/cancelled). A CPAP SD-card import shows: scanning files, parsing, building sessions, and storing. A Google Health (Fitbit) import shows: scanning, then a determinate sub-progress row per discovered data type (sleep, intraday heart rate, SpO₂, HRV, snoring, …) as each is imported. You can see at a glance exactly where a long import is and which data types remain. New in-app help section "How the import runs" and two new glossary entries (Background Import, Intraday). (See ADRs 0026 and 0027.)

Changed

  • Cancelling an import is now immediate and safe. Pressing Cancel stops the import promptly rather than after the current phase finishes. Any data already written to your local database is kept — cancellation never rolls back or corrupts what was already stored — and because import is incremental with duplicate detection, simply re-importing later resumes where you left off: already-stored nights and records are recognised and skipped, and only the genuinely new data is added.

Performance

  • Google Health (Fitbit) intraday import no longer freezes the UI. Parsing the large, full-resolution intraday files (heart rate at ~5-second cadence, plus SpO₂, HRV, and snoring) used to run on the main thread, so the app could lock up for seconds during a big wearable import. That work now runs off the main thread (in Web Workers), and reports granular, determinate progress as it goes, so the interface stays responsive and the per-data-type progress rows advance smoothly. (See ADR 0027.)

Fixed

  • Closing the browser tab during an import no longer corrupts data. If you close the tab (or the browser) while an import is in progress, the import simply ends — there is no background process to leave running. Nothing is corrupted: data is written durably and incrementally as the import proceeds, so the nights and records already stored remain valid and usable. Re-importing the same source later transparently resumes via duplicate detection, skipping what was already saved and importing only the remainder. The "Importing Data" help article documents this tab-close behaviour.

[2026.06.10] — 2026-06-23

Fixed

  • On-device AI now shows clear model-download progress, and Stop works during the download. The first time you use an on-device backend (in-browser WebLLM), the model weights (~1–2 GB) are downloaded once; previously this happened with almost no feedback, so a long first-use wait looked like the app had frozen, and the Stop button could not interrupt it. There is now a distinct "Preparing the on-device model" state with a real progress bar, the one-time download size, and clear "runs on your device — nothing is uploaded" context (with separate copy for downloading vs. warming up). Settings → Integrations → AI Insights now also offers an explicit Download model button so you can fetch the weights ahead of time and make the first summary instant. Cancel/Stop now genuinely aborts an in-flight download and returns cleanly to the starting state, and the weights stay cached for next time.
  • AI Insights no longer falls back to the plain template when a summary restates a duration in mixed units. The numeral-validation backstop rejected correct summaries that wrote an hours value (e.g. "6.7 h") as "6 hours 42 minutes" or "402 minutes", because the minute components weren't recognised as the same computed value. Such restatements of the app's own computed durations are now accepted; the safety guarantee is unchanged (a fabricated duration that doesn't match the computed value is still caught and never shown).

[2026.06.9] — 2026-06-22

Added

  • The Trends AHI chart's faint raw per-night line now shows a per-pixel min–max envelope on dense date ranges, so a single bad night can no longer visually vanish. When a selected date range packs more nights than the chart has horizontal pixel columns, several nights must share each one-pixel column. A plain polyline through one sampled value per column can then skip straight over a lone spike, hiding it between the points it happens to draw. The faint individual-nights line is now replaced in that regime by a per-pixel-column min–max envelope: each column spans the full range from the lowest to the highest AHI among the nights that fall in it, so a single-night spike is always drawn even when its neighbours are calm. Nights with no valid AHI remain gaps and are never plotted as zero. This changes only how the faint raw series is drawn on dense ranges — the rolling-median line, the typical-nightly-range (P25–P75) band, the severity zones, and every computed value are unchanged. The "Understanding Measurement Uncertainty" help article notes the envelope behaviour.

Changed

  • The Trends charts (AHI, Leak, Pressure, Usage, Settings, and Event Breakdown) are now drawn through a faster Canvas2D rendering pipeline for smoother panning, hovering, and crosshair tracking. The charts were migrated from the previous Recharts/SVG implementation to the same Canvas2D + HTML-chrome + overlay-crosshair architecture that made the Session Signals page fluid: the data marks render on a Canvas2D layer, the axes / labels / legend remain HTML, and the synced crosshair draws on a lightweight transparent overlay so moving the pointer repaints only that overlay rather than every chart. The result is markedly more responsive interaction on long, multi-year date ranges. Appearance and features are intended to be identical — the same severity zones, lines, bands, markers, tooltips, settings-change markers, and the synchronized crosshair across all six charts — with one deliberate honesty improvement to the AHI chart's faint raw line, noted under Added. Rendering remains entirely client-side; nothing leaves the browser. (See ADR 0025.)

[2026.06.8] — 2026-06-22

Fixed

  • AI Insights no longer falls back to the plain template when the summary mentions a date. The numeral-validation backstop only recognised ISO dates, so a model that wrote the date in long form (e.g. "Jun 9, 2026") had the year flagged as an un-computed number, failing validation and substituting the deterministic template even though the AI summary was correct. The validator now recognises the snapshot's own dates in long form; the safety guarantee is unchanged (a genuinely fabricated number is still caught and never shown).
  • AI Insights fallback notice is now single and accurate. When the deterministic template is shown, it no longer prepends a misleading "AI narration is unavailable" line into the summary body; a single calm notice ("To stay accurate, this is the app's own computed summary rather than AI-written text.") appears once, in the panel.
  • "Summarize range" button now aligns with the date-range control on the Dashboard and Trends headers.

[2026.06.7] — 2026-06-22

Added

  • AI Insights — opt-in, off-by-default plain-language summaries of your already-computed metrics. A new optional integration (Settings → Integrations → AI Insights, sitting after Weather) that turns the numbers the app has already computed — AHI and its sub-indices, leak, pressure, usage, trends — into a few sentences of plain-language context. The architecture is compute-then-narrate: the deterministic analysis pipeline does every calculation, and the language model only phrases the finished figures. The model never computes, averages, derives, classifies, or diagnoses; it is a narrator, not a calculator (ADR 0024). The feature ships disabled, and while disabled no AI surface appears anywhere in the app — discovery is only via Settings and a new help article. You choose one of four interchangeable backends along a privacy/quality curve. Two are on-device and send nothing off your device: in-browser WebLLM (model weights downloaded once and run on the GPU via WebGPU — zero data egress after that one-time download) and Chrome's built-in AI (Gemini Nano, which ships with the browser — zero egress, no app-side download). Two are bring-your-own-key cloud backends for higher-quality wording: Claude (Anthropic) browser-direct and any OpenAI-compatible endpoint (OpenAI, OpenRouter, Together, and local servers such as Ollama and LM Studio via a base-URL field — a loopback URL stays on-device). On-device backends require no consent dialog because nothing leaves the device; cloud backends are gated by the same explicit two-gate consent as the weather integration — before any request leaves the browser you see, in plain language, exactly what is sent (a compact, aggregate metric snapshot — values like AHI, leak, usage, pressure, event counts, and trend direction, at the precision shown on screen, plus the calendar dates you asked about) and exactly what is not (never the raw 25–50 Hz signals, EDF files, exact event/clock times, machine serial number, your notes/tags, your location, or any account identifier; there is no CPAP Analyzer account, so a request carries only your own provider API key). The moment of consent is recorded so a future change to what is sent can re-prompt you. Correctness is protected end to end: every generated block carries an inseparable "AI-generated — may be inaccurate; verify against the numbers" caveat, a "Based on these numbers" panel shows the exact computed values the prose was built from, and a deterministic numeral-validation backstop checks every number in the output against the app's own computed figures and refuses to show text that invents a value — falling back to a plain templated summary instead. The wording is deliberately descriptive and non-diagnostic: it never says "you have…" or "you should…", and where relevant it notes a pattern "may be worth discussing with your clinician." This is not medical advice, and the app does not diagnose. API keys are never persisted to disk by default — they live in session-scoped memory (cleared when the tab closes) and travel only as the auth header to your chosen provider, never in the metric snapshot and never logged. New in-app help article "AI Insights" and new glossary entries (AI Insights, Grounding [compute-then-narrate], On-device LLM / WebGPU, Large Language Model, Hallucination, WebLLM, Chrome built-in AI, and the bring-your-own-key cloud backends).

[2026.06.6] — 2026-06-21

Added

  • The Event Explorer can now be scoped to a single session. A sessions URL parameter (/explore/events?sessions=<id>) restricts the Explorer's matched set to one night's events, and the Session Detail Events list's "View all in Event Explorer" link now pre-scopes the Explorer to that night rather than opening it across your whole history. When a scope is active, a removable Session scope chip (labelled with the session's date) appears in the filter panel; clearing it returns the Explorer to its global, cross-session set. A scoped session loads even when its date falls outside the Explorer's current global date-range selection, so following the link from an out-of-range night always works. The scope is part of the serialized URL state, so it is bookmarkable and survives back/forward navigation.

Changed

  • The Event Explorer's event table now shows each event's wall-clock time — the recording device's then-current local clock — rather than the time re-expressed in the viewer's machine timezone. This converges the table onto the same wall-clock-as-UTC convention already used by the Session Detail Events list and the Signal Viewer (axis, crosshair, and event readout), so all three surfaces report an identical clock time for the same event. Previously, for a session whose timestamps were recorded under a timezone offset different from the viewer's, the Explorer table could display a clock time that disagreed with those other surfaces; that divergence is resolved. The Event Explorer's time-of-night filter remains viewer-local for now (a tracked follow-up).

[2026.06.5] — 2026-06-21

Added

  • Session Detail now lists each respiratory event with its wall-clock time. The Session Detail page gains an Events list that enumerates the night's individual respiratory events — each row shows the event's wall-clock time (the recording device's then-current local clock), its type (obstructive / central / mixed / unclassified apnea, hypopnea, RERA, large leak, …), and its duration — so you can see exactly when each event occurred during the night rather than only its nightly count or rate. Each row deep-links into the Signal Viewer framed on that event. The list shows the first 50 events earliest-first, with a link to the full Event Explorer (/explore/events) for nights that have more, and shows a positive "clean night — no events were recorded" message when the night had none.
  • The session's Event Timeline tooltip now also shows each event's wall-clock time, alongside the type and duration it already reported, matching the new Events list and the Signal Viewer's clock-time axis.

[2026.06.4] — 2026-06-21

Fixed

  • Collapsed navigation sidebar icons now keep their colour, vertical alignment, and centring, and the expanded collapse/expand toggle is fully clickable. When the desktop sidebar (shipped in 2026.06.3) was collapsed to the icon-only rail, its navigation icons regressed in three ways: they changed colour, jumped upward and lost the vertical alignment they had when expanded, and hugged the left edge of the rail instead of being centred. Separately, the collapse/expand toggle button's expanded (full-width) form was not clickable across its whole row, unlike the other navigation items. All four are now fixed: in the collapsed rail the icons keep the same colour and vertical position as in the expanded sidebar and are horizontally centred, and the expanded toggle's entire row is clickable like every other navigation item. Presentation/navigation-only; no data or behaviour changed.

[2026.06.3] — 2026-06-21

Added

  • Collapsible navigation sidebar (desktop). The left navigation sidebar can now be collapsed from its full 240px width to a narrow, icon-only "rail" and expanded back, reclaiming horizontal space for the analysis and signal views on wide screens. A toggle button is pinned in the sidebar footer (below Help and Settings); its accessible name reflects state — "Collapse sidebar" when expanded, "Expand sidebar" when collapsed — and you can also toggle it from the keyboard with the [ shortcut (desktop only; ignored while you are typing in a text field, so the bracket key still types normally). In the collapsed rail, navigation items show only their icons, and hovering or keyboard-focusing an icon reveals a tooltip with its label; the active item remains marked by an accent bar, so it is identifiable without relying on colour alone (WCAG AA). Your collapsed/expanded choice is remembered across reloads, stored locally in your browser alongside the theme preference — consistent with the app's privacy-first, no-server design; nothing about this preference leaves your device. Mobile (< 768px) continues to use the existing slide-in navigation drawer, which is unchanged.

[2026.06.2] — 2026-06-21

Changed

  • Breathing Patterns → Episode Catalog now analyzes your whole date range, with persistent local caching and streaming progress. The catalog previously hard-capped analysis at 60 nights and showed "(truncated to keep the page responsive)" for larger or "all time" ranges, silently discarding the bulk of a multi-year history. That cap is removed: the catalog now analyzes every night in the selected range. To make full-range analysis fast and repeatable, each night's periodic-breathing / Cheyne-Stokes detection result is now persisted in a local on-device cache (a new IndexedDB store inside your browser) — this is purely local, exactly like the rest of your data, and nothing is uploaded. A first cold run over years of history computes the uncached nights in parallel and streams them into the table as they finish, behind a two-phase progress indicator — "Reading saved analysis" (cache reads) then "Analyzing N new nights" (fresh computation) — with a determinate progress bar that also notes how many nights came from the cache; the page is filterable, sortable, and clickable the moment the first rows land. Revisiting the same range, or reloading the page, is then near-instant because the saved results are read back instead of recomputed. A Cancel button stops a long run while keeping every night already analyzed, and a Resume action continues from where it stopped, computing only the still-missing nights. Cached results are kept honest automatically: each is tagged with a version hash derived from the detector's algorithm and parameters, so changing the algorithm or any detection threshold invalidates the stale entries and transparently recomputes the affected nights — you never see results from an old algorithm or superseded parameters. Per-night analysis failures (e.g. an unreadable signal or a missing flow channel) are no longer silently skipped: the catalog reports "N nights could not be analyzed" with a Details list of the specific dates and reasons, kept distinct from nights that were analyzed and simply had no episodes. The clinical contract is unchanged — detections remain statistical candidates, not diagnoses (ADR 0017), sub-threshold candidates are still shown, and caching changes only when detection runs, never what it asserts (ADR 0023). The "Breathing Patterns" help article gains a new "Using the episode catalog" section explaining the local cache and streaming behaviour (in plain language for non-experts), why a first long-range analysis takes a while and is then fast, what Cancel and Resume do, and how to read every column (confidence, cycle length, modulation, duration, sub-threshold).

[2026.06.1] — 2026-06-21

Added

  • Weather & environmental data integration (opt-in, Open-Meteo). A new optional integration that fetches local weather and air-quality data for your recorded nights and correlates it against your CPAP therapy — built to explore seasonal and weather-dependent variation, with barometric (atmospheric) pressure versus apnea and central events as the headline hypothesis. Data comes from Open-Meteo, which needs no account and no API key, so requests carry no identifier. The integration ships off by default and is the first feature in the app that makes any outbound network request: enabling it requires passing through an explicit consent dialog that states, in plain language, exactly what is sent and what is not. Per sync, only your coordinates rounded to ~1.1 km, the calendar dates you sync, and — if you use the optional city "Find" — a typed city name ever leave the device; your therapy/health data, any identifier, and precise GPS never leave the device, and the moment of consent is recorded so any future change to what is sent can re-prompt. Historical backfill covers past nights (the provider's weather archive lags roughly 5 days, with recent nights served from the forecast "recent past" window so there is no gap; air-quality history is shallower and region-dependent, so older or non-European nights may legitimately show "No data available" — stored distinctly from a request error, and never shown as a fabricated zero). Surfaces: a Dashboard "Weather Overview" panel (overnight-low temperature, humidity, barometric pressure, AQI with category word, dewpoint, wind, each with a 7-day trend and an "as of" date stamp); Signal-Viewer weather lanes (a conditions ribbon, a pressure/temperature line lane, and an AQI ribbon, aligned to recording hours, with severity conveyed by word + value + pattern, never colour alone); and cross-source correlation in Explore → Correlations, where Weather & Environment joins Wearable as a "Compare against" source and the existing Pearson/Spearman, Bland–Altman, and lagged cross-correlation tooling applies unchanged (lagged correlation is apt because an environmental change can precede a bad night). Disabling stops all requests and keeps your fetched data by default, with a one-click option to delete it; auto-syncing newly imported nights is a separate opt-in, off by default. Pollen is not yet included (it is forecast-only, Europe-only, and has no historical archive, so it could not be backfilled). New in-app help article "Weather & Environment" and nine new glossary entries (Barometric Pressure, Relative Humidity, Dewpoint, AQI [US and European], PM2.5, PM10, Ozone, Nitrogen Dioxide, and the Overnight Aggregation Window). This tool informs and helps you frame questions for your clinician; it does not diagnose.
  • "Sleep stages & cycles" event analysis. A new Event Explorer lens correlates machine-scored apnea/hypopnea events with imported wearable (Fitbit / Google Health) sleep-stage data and intraday heart rate, entirely client-side. It reports the per-stage event rate (Wake / REM / Light = N1–N2 / Deep = N3) with a χ² goodness-of-fit test against time-in-stage expectations (with a Cochran expected-count caveat); REM-predominant OSA via AHI_REM, AHI_NREM, and the AHI_REM/AHI_NREM ≥ 2 ratio (plus the stricter AHI_NREM < 15, ≥ 30 min REM, ≥ 15 min NREM floors) and an across-nights Wilcoxon signed-rank test; a per-cycle event load using sleep cycles derived heuristically from the wearable hypnogram (REM episodes as maximal REM runs merged across gaps ≤ 15 min, cycle boundaries at successive REM-episode ends, trailing NREM as an incomplete final cycle); and the cyclic variation of heart rate (CVHR) as an event-triggered average. New in-app help article ("Analysing Events by Sleep Stage & Cycle", slug events-by-sleep-stage) documents the methods, literature (Berry 2012 AASM, Feinberg & Floyd 1979, Guilleminault 1984, Conwell 2012, Koo 2008, Mokhlesi & Punjabi 2012, Pearson 1900, Cochran 1954, Wilcoxon 1945), and limitations, with the explicit non-diagnostic framing that consumer-wearable staging is approximate and findings should be discussed with a clinician. New glossary entries: Sleep Stage, REM Sleep, Sleep Cycle (NREM–REM ultradian cycle), REM-Predominant / REM-Related OSA, CVHR (Cyclic Variation of Heart Rate), Chi-Square Goodness-of-Fit Test, and Wilcoxon Signed-Rank Test, cross-linked with the existing AHI, arousal, and sleep-fragmentation terms.

Fixed

  • Signal Viewer sleep-stage (hypnogram) ribbon now colour-codes each stage. The per-session hypnogram lane previously painted every stage band in a single solid colour (most visible in dark theme): the band builder requested colours by bare custom-property name (--color-hypno-wake) instead of the var(--color-hypno-wake) form the shared colour resolver requires, so the names fell through unresolved, became invalid canvas fill values, and each band kept the prior band's colour. Wake, REM, Light (N1–2), and Deep (N3) now render as distinct colours again. While fixing it, the hypnogram palette was tuned for better contrast with the lane background and between stages: in dark theme the Deep band moves from blue to indigo so it is no longer hard to tell apart from the cyan Light band, and in light theme the Wake and Light bands were deepened for stronger contrast against white (Wake now clears the 3:1 graphical-contrast guideline). The REM band keeps its redundant hatch pattern so colour is never the only cue.

[2026.06.0] — 2026-06-18

Added

  • App chrome redesign (Phase 1: sidebar, header, status bar). The persistent application shell was refreshed end to end — no data, privacy, or analysis behaviour changed, only the UI shell. Navigation icons are now crisp inline SVG (replacing emoji), so they render consistently across platforms and follow the active theme. The sidebar gains a brand mark + wordmark, groups navigation into "Analysis" and "Data" sections, pins Help and Settings to a sidebar footer, and modernizes the active / hover / focus treatment. The header now shows the current section's title instead of repeating the app name. Theme switching is now a labeled Light / Dark / System menu — replacing the previous click-to-cycle button whose "System" state was invisible — and the System option indicates which theme it currently resolves to. The status bar now shows real information (session count, date coverage, last import, and storage usage with a quota meter) in place of placeholders. Accessibility: a skip-to-content link, improved mobile navigation-drawer focus handling (focus trap, Escape to close, focus restore on close), and larger touch targets on mobile. No new runtime dependencies.
  • Brand favicon and web manifest. The app now ships a real favicon — the brand "waveform" mark on a rounded blue tile — as a scalable SVG, so browser tabs and bookmarks show the CPAP Analyzer identity instead of a blank/default icon. A web app manifest (name, theme color, scalable icon) is included so the app can be installed to a device home screen with proper branding. Same-origin assets only; no new runtime dependencies. (A raster apple-touch-icon for iOS home screens remains a follow-up.)
  • Measurement reliability shown alongside the numbers. The app now admits how trustworthy each value is, quietly and only where it changes a decision. Metrics that are essentially direct measurements (delivered pressure, usage/mask-on time) are shown plainly, as before. Metrics that are algorithmically detected or modeled carry a small, non-color reliability chip — "Estimate" (outline triangle) for moderate-reliability values, "Modeled" (hexagon) for low-reliability ones — and a "Leak-affected" caveat appears when a night's leak crosses the device's red-zone convention. The AHI trend chart now leads with a rolling-median line and a "typical nightly range" (25th–75th percentile) band rather than a noisy single-night number — explicitly labelled as a spread, not a 95% confidence interval — because a single night's AHI is statistically unreliable. Numbers are shown at honest precision (AHI to one decimal, leak as whole L/min, etc.). Crucially, a rising central (clear-airway) apnea trend always surfaces a visible "discuss with your clinician" prompt, even though the central/obstructive split is labelled low-reliability — the reliability caveat lowers the precision claim, never the visibility of a potentially important trend. Reliability is communicated on a distinct violet/neutral visual axis so it never collides with clinical-severity colors, with redundant icon + text cues for WCAG-AA. A new in-app article, "Understanding Measurement Uncertainty," and per-metric/per-term help notes explain what each tier means and why trends beat single nights. All computation is client-side; nothing about this feature contacts the network.
  • Measurement-accuracy reference documentation (docs/accuracy/). A new four-document, regulatory-grade reference set on how accurate each measurement is, why, and how the application chooses to display it — written for a technically sophisticated patient audience. The set comprises an overview/index (README.md) with the app's measurement-uncertainty philosophy (quiet by default, trends over single nights, surface-don't-diagnose) and a one-screen per-metric reliability summary; cpap-devices.md (how a PAP device senses pressure and derives flow/Vt/MV/RR, the Forced Oscillation Technique and the central/obstructive split, why device event definitions differ from AASM, leak as a data-quality gate, the device-vs-PSG validation literature, and the per-metric reliability ranking); wearables.md (consumer wearables and home oximeters — PPG heart rate, reflectance vs transmissive SpO₂, the skin-pigmentation bias, sleep-stage estimation, derived respiratory rate, separating manufacturer claims from independent peer-reviewed findings); and measurement-uncertainty.md (the statistics — error taxonomy, GUM propagation laws, AHI as a Poisson process with exact and normal-approximation intervals, biological vs measurement variability, an agreement-statistics primer, and display-precision rules). All clinical and statistical claims carry primary-source citations with DOIs/PMIDs, manufacturer claims are tagged separately from independent findings, and unverified figures are quarantined behind a [?] provenance convention rather than asserted. Formulas render via KaTeX. The set documents the locked statistical conventions used by the reliability feature: the apnea count is moderate reliability (algorithmically detected, undercounts vs PSG, mask-on denominator) rather than high; the two-sided rule-of-three for zero observed events is 3.689 counts (not the one-sided 3.0); the per-night Poisson interval is labelled a lower bound on uncertainty (apnea events over-disperse); the split leak gate uses a user notice at 24 L/min and flow-metric suppression at 30 L/min, both documented as ResMed device conventions (mask-dependent, ~36 L/min oronasal), not AASM standards; T90 is displayed in integer minutes; and pressure display precision is justified by ISO 80601-2-70, not an unverified resolution figure.
  • Signal Viewer shows what you're hovering. Moving the pointer over a charted region that contains a device event (apnea, hypopnea, RERA, large leak, …) or an app-computed breathing-pattern candidate (PB / CSR) now surfaces a compact one-line readout in the pinned legend bar — a colour swatch plus the event/candidate type, its start time, duration, and a key stat (e.g. leak for a large-leak event, or confidence / cycle length / duration and a "candidate" label for a detection). The readout sits in the always-visible legend bar rather than a tooltip over the trace, so it never obstructs the waveform you're inspecting; when an event and a candidate overlap, both are shown. Keyboard users get the same context folded into the existing arrow-key data-cursor announcement (one polite live region, no extra screen-reader chatter), and colour is never the sole signal.
  • Explore → Machine Configurations. A new view at /explore/configs for comparing therapy outcomes across periods where machine settings stayed the same — answering "which max pressure gave me the lowest AHI?" without lab equipment. Consecutive nights sharing the same configured min pressure, max pressure, and EPR level are grouped into config periods (mode/ramp/mask intentionally not part of the segmentation key; nights with no recorded settings are bucketed into an honest unknown period rather than silently merged, and the legacy ResMed -1 sentinel row — max pressure ≤ 1 cmH₂O — is partitioned off as sentinel and never aggregated). A selectable period table previews AHI, central index, leak, and usage per period with severity dots and an "n < 7 nights" warning badge; up to six periods can be compared at once. The Compare sub-mode renders per-config box-plot distributions for AHI, central index, leak, and usage using identity colours drawn from --color-chart-1..8, plus a diff readout for exactly-two selection with redundant ▲/▼ glyph, explicit sign, percent delta, and muted favourability colour (colour is never the sole signal). The Optimize sub-mode plots the chosen outcome against a chosen setting axis (min / max pressure or EPR level), with point area proportional to the number of nights per period. The persistent confounding caveat sits under the comparison: periods differ in season, weight, illness, and adherence — observed differences are associations, not proven effects. Settings-change boundaries detected by detectSettingsChanges now also render as faint dashed vertical markers across the synced AHI / leak / pressure / usage / event-breakdown charts in Trends (not just the settings chart), each with a native SVG <title> hover summary of the change (e.g. "max 12.0 → 15.0").
  • App-computed breathing-pattern detection surfaced across the app. The periodic-breathing / Cheyne-Stokes detector and four-class TECSA trajectory classifier (ADR 0017) are now wired into the UI as candidate flags, never diagnoses. Surfaces:
    • Signal Viewer. Per-session candidate episodes are detected in a background worker after the canvas first-paints (so detection never blocks signals), then rendered on the airflow lane as the existing hatched / dashed-border / confidence-modulated wash. Each episode carries an HTML overlay confidence chip anchored to its start — low / moderate / high tier label plus a numeric percentage and a "PB" or "CSR" short label so colour is never the sole signal. Clicking the chip opens a popover with cycle length, modulation depth, duration, nadir character, a sub-threshold flag, and the persistent "candidate, not diagnosis" disclaimer. A new "Detections" group in the lane legend with a hatched violet swatch toggles the overlay on or off; the toggle persists per session alongside other lane preferences. A matching switch lives in the Lanes drawer for keyboard discoverability.
    • Explore → Breathing Patterns. A new view at /explore/breathing with three sections. A longitudinal TECSA overview shows the trajectory class (badge + non-colour shape ●◆▲■ + label drawn from the calm cyan→indigo→violet token ramp — never status-severe), early-vs-late CAI, usable-night fraction, a classifier confidence bar, the threshold, an inline CAI-over-time sparkline with a candidate-night marker, and a plain-language explainer keyed off the class. Honest "insufficient history — needs more usable nights" state when the classifier returns available: false. A filterable, sortable per-night episode catalog (by pattern, minimum confidence, date / cycle / duration / confidence) streams in night-by-night with a progress counter; each row deep-links into the Signal Viewer centred on the episode. A selected-episode detail panel shows full features with the standing disclaimer banner.
    • Dashboard. A new "Breathing stability" insight card surfaces the TECSA trajectory class for the active date range in the calm cyan→indigo→violet detection register (never red — TECSA is a trajectory, not an alarm), with early / late CAI, the threshold, a plain-language explainer, and a deep link to /explore/breathing. Honestly reports "insufficient history" when the range is too short or too leak-corrupted to classify.
    • All surfaces share the standard "Detected patterns are statistical candidates, not clinical diagnoses. Discuss with your clinician." disclaimer, the --color-detection-* / --color-tecsa-* design tokens (themed in light and dark), and the redundant non-colour cues required for WCAG-AA (shape + label + colour, numeric label alongside every confidence bar).
  • Event Explorer. A new ad-hoc query tool for respiratory-event characteristics at Explore → Event Explorer (/explore/events), replacing the former fixed-section Event Analysis. A left-rail query builder combines filters with logical AND — event type(s) (color-dot chips, with a distinct hatched marker for sustained "detection" patterns like Periodic Breathing), and duration / pressure / leak / SpO₂ ranges (dual-thumb sliders always paired with numeric min/max inputs; range filters disable with an explanatory chip when the underlying field has no data in the matched set). A time-of-night window filters by local clock time and can wrap past midnight. A prominent matched-count "trust strip" ("N of M events match K filters") with a proportion bar updates live and is announced via aria-live. The full filter state is serialized to the URL (bookmarkable, back/forward-able), and named queries persist to localStorage (with four shipped examples). The same matched set drives five swappable views — a duration histogram (adjustable bin width, optional split-by-type stacking), a scatter of duration vs pressure/leak/SpO₂/time-of-night, per-type box/violin small-multiples, an inter-event-interval histogram, and FLG-bridged clustering (strict/balanced/lenient) — beneath a summary-stats strip. A virtualized, sortable event table (windowed for large sets, with a "showing N of M" note) lets you click any row to deep-link into the Signal Viewer centered on that event. The matched set can be exported to CSV or JSON entirely in-browser (with a warning before very large exports); no data leaves the device.
  • Google Health (Fitbit) data import. Import sleep, heart rate, SpO₂, HRV, respiratory rate, activity, and more from a Google Takeout "Google Health" export. The import wizard now offers two source cards (CPAP SD Card / Google Health), with a scan-and-preview step that shows discovered data types grouped by tier, record counts, and date ranges. Incremental import with duplicate detection is supported. All parsing runs client-side — no data leaves the browser.
  • Cross-Source Analysis view. New analysis view at Analysis → Integration Analysis with three tabs: Correlation Explorer (Pearson/Spearman with confidence intervals), Correlation Matrix (heatmap of CPAP × wearable metrics with significance highlighting), and Metric Comparison (Bland-Altman agreement analysis and lagged cross-correlation). Includes statistical interpretation text and caveats about correlation vs. causation.
  • Wearable data on the Dashboard. When wearable data is available, a new panel shows key metrics (Sleep Score, HRV, Resting HR, SpO₂, Readiness, Steps) with 7-day trend indicators, plus a link to the Cross-Source Analysis view.
  • Help documentation for integrations. Updated the "Importing Data" help article with Google Health instructions and supported data types. Added a new "Cross-Source Analysis" help article explaining correlation methods, interpretation guidance, and statistical caveats.
  • IndexedDB schema v3. New integration_timeseries and integration_import_history object stores, plus compound indexes for efficient date-range and type-filtered queries. Automatic migration from v2.
  • Intraday heart-rate import. Full-resolution (~5-second cadence) heart-rate samples from a Google Health export's Global Export Data/heart_rate-*.json files are now parsed and stored per night, in addition to the existing daily resting heart rate. This is the foundation for overlaying heart rate alongside CPAP airflow in the signal viewer and for within-night correlation. Stored at full resolution to preserve short-timescale features (≈0.4–0.6 MB per day); all parsing remains client-side.
  • Wearable health overlays in the signal viewer. The per-session signal viewer (Sessions → a session → Signals) now overlays intraday wearable health signals alongside the CPAP channels on a shared time axis, so cardiac, respiratory, and sleep-architecture context can be read against airflow and pressure within the same night. New lanes: heart rate (the hero lane, ~5 s resolution), wearable SpO₂, HRV (5-min, step-rendered with sample markers), snoring, and a sleep-stage hypnogram (Wake/REM/Light/Deep as a categorical ribbon). A reorderable, toggleable, collapsible lane stack — accessed via a "Lanes" drawer (keyboard shortcut L) — offers presets (Respiratory focus, Cardio focus, Sleep architecture, Everything); lane visibility, order, and collapse state persist per session. A keyboard data cursor (arrow keys) announces a synchronized multi-lane readout at the cursor for screen-reader and keyboard users, and lanes are reorderable from the keyboard. Graceful fallbacks cover both the no-integration case (a hint linking to import) and nights that simply lack wearable coverage. Wearable signals load asynchronously so they never delay the CPAP signal's first paint. Requires a Google Health/Fitbit import containing intraday data for the overlays to appear.
  • In-app help articles for breathing patterns, intraday overlays, and the Event Explorer. Three new help articles cover the new functionality at regulatory-grade depth: "Breathing Patterns: Periodic Breathing, Cheyne-Stokes & TECSA" documents the airflow-envelope detection method (autocorrelation periodicity, Guyot-style modulation index, harmonic-ratio crescendo-decrescendo morphology, AASM-criteria CSR scoring, Liu-2017 four-class TECSA trajectory), the literature it is grounded in (Berry 2012, Weinreich 2009, Javed 2018, Midelet 2023, Guyot 2019, Liu 2017, Nigam 2016, Kwok 2022), and the non-diagnostic framing (CSR ↔ heart-failure candidate signal, TECSA's ~60–80% spontaneous resolution, the SERVE-HF / Somers 2018 ASV contraindication in HFrEF); "Intraday Health Signals & Overlays" explains the wearable lane stack, the wall-clock-as-UTC alignment convention, how to read sparse vs. dense lanes (step rendering, dashed connectors), and what the overlays physiologically reveal around respiratory events; "Event Explorer" documents the AND-combined filters, the null-field semantics (bounded ranges exclude null fields, unbounded ranges pass nulls), every lens (duration histogram with overflow bin, decimated scatter, per-type box/violin, inter-event intervals, FLG-bridged clusters), URL-serialized and saved queries, and CSV/JSON export. New glossary entries: Periodic Breathing, TECSA, CAI (Central Apnea Index), Forced Oscillation Technique, Hypoxic Burden, Loop Gain, Apneic Threshold, Modulation Index, and Harmonic Ratio.

Changed

  • Signal Viewer time axis now shows clock time, not duration into the session. The x-axis previously labelled ticks with elapsed time since the session start (e.g. 01:23:45). It now shows the recording device's then-current local wall-clock time (24-hour HH:MM, or HH:MM:SS when zoomed in far enough that ticks are finer than a minute), because knowing the actual time of night an event happened is critical. Day boundaries are marked: each midnight inside the visible window draws a solid full-height vertical rule with a Mon DD date label, so a recording that crosses midnight reads unambiguously. Duration into the session stays discoverable — the crosshair/keyboard cursor readout now leads with clock time and appends the elapsed offset (e.g. 23:16:42 (+1:12:08)), and the "Showing" status now reads as a clock range plus span (e.g. 23:14 – 23:19 · 5m). The crosshair time and the axis labels are guaranteed to agree (same wall-clock epoch and conversion). Screen-reader announcements lead with clock time, spell the elapsed duration naturally, and append the date once the cursor crosses into a later calendar day. If a session's start time is unparseable the axis falls back to the previous duration labels. Clock time is timezone-independent for a given import (it reflects the machine's local clock at recording time); cross-timezone viewing is out of scope. All client-side; nothing leaves the browser.
  • Signal Viewer zoom is gentler and gains drag-to-zoom-into-a-range. Wheel/pinch zoom was far too sensitive — a single notch jumped the view span by 50%, so it was easy to overshoot and hunt. Zoom is now device-aware and much gentler: a mouse-wheel notch changes the span by only a few percent, and trackpad pinch (which streams many tiny deltas) is normalized to the same feel, with a per-event cap so one outsized delta can't teleport the zoom. The time under the cursor stays fixed while zooming. A new Shift+drag rubber-band lets you select a time range directly: hold Shift and drag horizontally to draw a semi-transparent selection band, then release to zoom the viewport to exactly that range (a Shift-click or a tiny drag is ignored, and the selection never zooms below the maximum zoom-in limit). While Shift is held over the plot the cursor switches to a horizontal-range affordance for discoverability. Keyboard users continue to zoom via the existing preset buttons. The wheel-zoom sensitivity lives in a single tunable constant (WHEEL_ZOOM_RATE).
  • Signal Viewer waveforms are now GPU-rendered for much smoother panning and zooming. Scrolling and zooming a whole-night recording were limited not by computation but by the browser having to re-upload the entire waveform canvas to the GPU on every frame — so on long sessions the chart could only repaint a few times per second during a drag, regardless of how little had actually changed. The dense CPAP waveform lanes (flow, pressure, leak, …) now render through WebGL2: their geometry lives on the GPU, so panning and zooming become a lightweight transform rather than a full re-upload, and the frame rate during interaction is dramatically higher. The rest of the chart — axis labels, grid, event markers, the hypnogram, sparse/step lanes, and the crosshair — continues to render on Canvas2D, composited beneath the same crosshair overlay as before. The displayed waveform is intended to be visually identical, including the more-faithful zoomed-out min/max envelope and the exact zoomed-in per-sample line; this is enforced by an automated fidelity test that renders the same data through both paths and compares them pixel-for-pixel (with spike-survival and gap-break checks) at full device-pixel resolution. If a browser does not support WebGL2, or the GPU drops the rendering context, the viewer automatically falls back to the original Canvas2D renderer with no loss of function. Rendering remains entirely client-side; nothing leaves the browser. (See ADR 0019.)
  • Signal Viewer y-axes now use clinically sensible default ranges that expand to fit, never clip. Each waveform lane previously scaled its vertical axis to the EDF file's declared physical range (physicalMin/physicalMax). Those are decode calibration anchors — the physical values that map to the digital encoding's extremes — not display bounds, so they were the wrong thing to scale to. Each lane now starts from a clinical default display range and expands only outward to cover whatever the session's data actually needs, but never shrinks below the default. This keeps axes stable and directly comparable from night to night while guaranteeing no data is drawn off-lane. Per-signal special cases: Flow stays symmetric about zero; SpO₂ pins its top at 100% and expands only downward to reveal desaturations; the flow-limitation index is fixed at 0–1. Extreme or corrupt samples are clamped to per-signal plausibility ceilings so one bad reading cannot blow out the axis. Wearable lanes (heart rate, SpO₂, HRV, snoring) get the same expand-only-against-a-sensible-floor treatment. Clinical default ranges now in use: Flow ±60 L/min; Pressure / EPAP / EPR 0–25 and IPAP 0–30 cmH₂O; Leak 0–60 L/min; respiratory rate 0–30 br/min; tidal volume 0–1000 mL; minute ventilation 0–20 L/min; SpO₂ 85–100%; pulse 40–120 bpm; snore 0–1; flow limitation 0–1 (fixed). Channels with no clinical entry (unknown or future-machine signals) keep the previous declared-range behaviour. The EDF decode path is unchanged and the plotted waveform itself is unchanged — only the vertical extent of each lane changes.
  • Leak rate displays consistently as a whole number. The Session Detail Leak Rate card (median, 95th percentile, and max) now uses the same integer-L/min display precision as the dashboard, closing a small inconsistency where the session view showed one decimal. Stored values are unchanged; this is a presentation-only fix.
  • All in-app help raised to the same scholarly bar as the breathing-pattern articles. Every help article and glossary entry that makes a clinical or statistical claim now carries primary-source citations (with DOIs) and a formal "References" section, and a fact-check against the literature corrected a number of overstatements. Help articles clinical-reference, statistical-analysis, event-analysis, pressure-analysis, and cross-source-analysis gained References sections (AASM Manual 2012, Epstein 2009, Kapur 2017, Weaver 2007, CMS LCD L33718, SERVE-HF/Cowie 2015, Kaplan-Meier 1958, Cleveland 1979, Killick 2012, Bland-Altman 1986/1999, Schober 2018, Fisher 1915, and others); the Granger article now cites Akaike (1974) for the AIC it relies on. The glossary gained an optional references field, rendered at the "Detailed" depth, populated for 49 clinical and statistical terms. Corrections of substance: the leak threshold (24 L/min) is now labelled a ResMed device convention rather than an AASM standard; CPAP-use dose-response figures are attributed to Weaver (2007) functional outcomes rather than a hard "cardiovascular" cutoff; the statistical-analysis article no longer claims a Mann-Kendall test the app does not run (it describes the OLS + LOESS it actually computes) and no longer claims slope confidence intervals or variance/trend change-point detection it does not produce; the event-analysis article no longer advertises a retired Kaplan-Meier survival-curve view or a non-existent hazard-rate plot; the ODI entry no longer claims a configurable 4% threshold the code does not implement; correlation-strength bands are presented as CPAP Analyzer's arbitrary rule-of-thumb (Schober 2018), not a "standard" or "Cohen" scheme; and the unsupported "device AHI differs from PSG by 10–30%" figure was replaced with a cited, accurate statement (Kapur 2017). The central-ai metric tooltip now carries the SERVE-HF / LVEF ≤ 45% ASV caveat.
  • The Analysis Hub's "Integration Analysis" card is no longer disabled/coming-soon.
  • Reorganized the "Analysis" section into an intent-oriented "Explore" hub (/explore) with three explorations: Event Explorer (/explore/events), Correlations (/explore/correlations), and Pressure Optimization (/explore/pressure). The former Statistical Analysis and Cross-Source (Integration) Analysis are now combined under Correlations as deep-linkable tabs (?tab=cross-source). Old /analysis/* links redirect to their new locations.
  • The Signal Viewer now accepts a ?t=<epochMs> deep-link parameter that centers the initial viewport (±1 minute) on a given timestamp — used by the Event Explorer's event table to jump from a matched event straight to its waveform context. Targets outside the session's recording are ignored, and the snap applies once so subsequent panning/zooming is preserved.
  • Event deep links now frame the whole event. Opening an event from the Event Explorer's event table or the Breathing (PB/CSR) episode catalog no longer drops you on a fixed ±1-minute window centered on the event's start — which pushed the rest of a multi-minute periodic-breathing or Cheyne-Stokes episode off the right edge. The Signal Viewer now frames the entire event with comfortable margins, so the episode fills roughly 90% of the view and you can see it end to end at a glance. Very short or point-in-time events are given a sensible minimum window (~30 seconds) so there is always context around them. These links carry the event's full time span; older ?t=<epochMs> links that encode only a single timestamp still open a centered ±1-minute window as before.
  • Signal Viewer lane labels are now a single, clearer label. Each lane shows one label instead of two: the lane name is drawn in the lane's own line color, with the units in muted grey and a source tag (CPAP / WEAR / SLEEP) marking where the signal came from. This replaces the previous pair of labels and makes it immediately obvious which trace a label belongs to.
  • Signal Viewer lane controls are simpler. To collapse or expand a lane, click its name — this replaces the separate collapse chevron button. A hide (✕) button now appears next to the lane's drag handle when you hover the lane (or move keyboard focus to it), so a lane can be removed without opening the Lanes drawer. Reordering is unchanged: drag the handle, or reorder from the keyboard.
  • The Signal Viewer legend bar is pinned and no longer scrolls horizontally. The wide "Signals" toggle row has been removed from the legend bar — it was the cause of horizontal scrolling — and signal visibility is now managed entirely from the per-lane controls and the Lanes drawer. The DEVICE EVENTS and DETECTIONS legends remain, and the legend bar now stays fixed in place as you scroll.
  • Smoother Signal Viewer zoom and panning. Wheel-zoom and click-drag scrubbing through high-frequency waveform data are now noticeably smoother, especially on long sessions. Each interactive frame no longer re-resolves event-marker colours per device event or re-runs full-resolution downsampling over the entire visible window: viewport-independent overlays and per-lane styling are computed once and reused, and each CPAP channel is backed by a precomputed multi-resolution pyramid so the work per frame stays bounded no matter how far you zoom out — keeping scrubbing and zooming fluid even on whole-night views with hundreds of events. The displayed waveform is unchanged.
  • Snappier Signal Viewer crosshair on hover. Moving the pointer across the waveform — hovering or scrubbing to read the time and per-lane value/stage badges — no longer repaints the entire multi-lane waveform stack on every pointer move. The crosshair, its intersection dots, and the readout badges now draw on a dedicated transparent overlay layered over the waveforms, so a pointer move repaints only that lightweight overlay rather than every lane's full-resolution trace. This dramatically reduces the per-hover rendering work on long, whole-night views and keeps the pointer readout responsive, bringing hover in line with the snappier zoom and pan. The crosshair, intersection dots, and time and per-lane value/stage readouts look and behave exactly as before, and pan/zoom are unaffected.
  • Even smoother Signal Viewer pan and zoom. Dragging to scroll and wheel-zooming the waveforms now coalesce to a single repaint per animation frame, so high-rate trackpads and mice no longer queue many redundant redraws within one displayed frame. The per-frame drawing buffers are also reused across frames rather than reallocated on every pointer or wheel event, which removes the garbage-collection hitches that could intermittently stutter a drag. This is a performance change only: the displayed waveform is unchanged.
  • More faithful zoomed-out Signal Viewer waveforms. When you are zoomed out — where many samples fall within a single screen pixel — dense CPAP waveform lanes (flow, pressure, leak, and the like) now render as a per-pixel min/max envelope rather than the previous representative downsampled polyline (LTTB). The envelope is more faithful to the data because it cannot hide a brief extreme between the points a downsampler happens to keep: a true per-pixel min and max always span the full range of samples in that pixel column, so transient extremes — a leak spike, a flow-limitation notch — are now always visible when zoomed out, even when that draws the trace slightly outside the usual breathing band, because that is genuinely what the underlying data shows. Zoomed in, where each sample is at least one pixel wide, the exact previous per-sample line rendering is retained, and the transition between the envelope and the line is seamless. Drawing an envelope is also incidentally faster than resolving a downsampled polyline. This changes only how the trace is drawn when zoomed out — it does not change any clinical or computed values, and the crosshair and hover readouts continue to report exact per-sample values as before.
  • The Event Explorer's clustering, inter-event-interval, and duration-distribution analyses (formerly fixed sections of Event Analysis) are now selectable lenses over the filtered set. Kaplan-Meier survival analysis was retired with this view; the worker primitive remains available for analyses to reuse.

Security

  • Added an app-wide Content-Security-Policy to production builds, injected as a <meta http-equiv> tag at build time (GitHub Pages cannot set HTTP headers). Restricts scripts, styles, workers, connections, and embeds to same-origin; blocks external network calls until opt-in integrations are enabled.

Fixed

  • The Sessions list now remembers your pagination page when you navigate into a session and back. Opening a session's detail and then using the browser's Back button previously dropped you back on page 1 of the Sessions list, regardless of which page you came from — so paging deep into a long history and inspecting a night meant scrolling all the way back each time. The current page is now reflected in the URL (?page=N) and restored on Back/Forward, so returning lands you exactly where you were. The paginated view is also shareable and bookmarkable. Presentation/navigation-only; no data or behaviour changed.
  • ResMed STR.edf summary files are no longer rejected on import, restoring per-day settings and mask-on/mask-off intervals. STR.edf is the SD card's summary file, and it legitimately stores one EDF data record per calendar day (a record duration of 86,400 seconds). The EDF parser enforced a per-record duration ceiling of 60 seconds, so it raised INVALID_RECORD_DURATION and discarded the entire STR.edf — the failing import surfaced 1 error and 1,801 warnings — silently dropping every per-day machine setting (min/max pressure, EPR, ramp, mode, mask, humidifier) and every MaskOn/MaskOff usage interval the file carried. The per-record ceiling is now raised to one calendar day (86,400 s), the correct upper bound for a single EDF data record. A second guard then rejected STR.edf at the derived-recording-duration check, because that cap (previously 366 days) wrongly assumed a single continuous recording — whereas STR.edf accrues one 86,400 s record per calendar day across the device's entire service life, so a long-term user's history spans years (e.g. 406 daily records ≈ 406 days). That soft sanity bound is now raised to 20 years, which still rejects absurd/corrupt headers while accepting any single device's full STR history. The true denial-of-service guard is unchanged: allocations remain bounded by the on-disk file-size check, so a small STR.edf cannot exhaust memory regardless of the duration it declares. Because STR-derived metrics are computed and keyed at import time, re-importing affected data is required for previously-imported nights to recover their lost settings and usage intervals.
  • Fitbit / Google Health sleep data (sessions and stage hypnograms) is now discovered from Global Export Data/. The Google Health import scanner — the sole producer of the discovered-data-type list — only looked for sleep logs under a Sleep/ directory, but real Google Takeout / Fitbit exports place them under Global Export Data/ (the same directory as intraday heart rate). As a result, sleep was silently skipped entirely for these exports: neither the session summaries nor the Sleep Stages hypnogram lane were imported, even though every step downstream of the scanner already handled the data correctly. The scanner now discovers both sleep_session and sleep_stages from the dated sleep-YYYY-MM-DD.json files under Global Export Data/ (verified against a full real export). The hypnogram (Wake / REM / Light / Deep) is imported and shown as a Sleep Stages ribbon lane. A fresh Google Health import is required to pick up sleep data. All client-side; nothing leaves the browser.
  • Multi-segment nights are no longer truncated and time-shifted on import. When a single therapy session was split across consecutive EDF segment files — for example after a brief mask-off mid-night caused the device to start a new segment — the importer kept only the longest single segment and placed it at the session's start time. This truncated the night (the data ended early), shifted the waveform earlier than it actually occurred, and corrupted that night's AHI, usage, pressure, and leak aggregates by computing them over a fraction of the real recording. Segments belonging to the same session are now concatenated into a single, full-night, gap-aware signal — preserving the real time gaps between segments — so the complete session is stored and every nightly metric is computed over the entire night. Because nightly metrics are computed and stored at import time, re-importing affected nights is required to recompute the corrected values.
  • Phantom SpO₂/Pulse lanes no longer appear in the Signal Viewer for machines that never had a pulse-oximeter attached. On these nights the device still writes an SpO₂ and a Pulse channel, but fills them entirely with its probe-off / no-sensor sentinel — and on the affected machine that sentinel is -1, not 0. The viewer's empty-channel test previously treated only 0 (and NaN) as "no data," so an all--1 channel was mistaken for real measurements and drawn as two flat lanes pinned at -1.00. The "has meaningful data" test is now sentinel- and range-aware: a sample counts as real only if it is finite, non-zero, and — when a physiological range is defined for that channel — within it, so all--1 (and all-0) SpO₂/Pulse channels are correctly classified as empty. Their lanes now auto-hide and appear only as a disabled "(no data this night)" entry in the Lanes drawer, exactly like other no-data channels. The clinical floor used for the SpO₂ visibility check is deliberately loose (30%, distinct from the stricter 50% validation range) so a genuinely profound desaturation is never mistaken for a sentinel and hidden. Sentinel values are also excluded from each lane's y-axis min/max, so a lane's display scale is no longer dragged toward -1. All client-side; no re-import is required — the corrected behaviour appears on next view.
  • Empty SpO₂/oximetry cards no longer appear for sessions with no real oximetry data. Some CPAP devices record an SpO₂/Pulse channel that is entirely sentinel values (probe off / no oximeter attached); the Session Detail view previously rendered a blank SpO₂ metric card for these nights because a session's hasOximetry flag was set from channel existence rather than valid-data presence. hasOximetry now reflects at least one valid (non-sentinel) oximetry sample, and the SpO₂ card additionally guards on actual aggregate data so already-imported sessions are covered without re-import. (The Signal Viewer's matching fix for sentinel-only SpO₂/Pulse lanes — including the -1 probe-off sentinel, which earlier escaped detection — is described in the companion entry above.)
  • The status bar is now pinned to the bottom of the viewport. The app-shell footer (session count, date coverage, last import, and storage meter) previously sat below the page content, so on long pages it was only visible after scrolling to the very bottom. It now stays pinned to the bottom of the viewport at all times. Because it remains in normal document flow, page content is never hidden behind it. The fix mirrors the already-sticky header (position: sticky; bottom: 0), keeps its opaque background and stacking above scrolling content, and works on both desktop (with the 240px sidebar) and mobile (where the sidebar collapses). Presentation-only; no data or behaviour changed.
  • Signal Viewer wearable lane y-axis scaling is now computed over the session window. After the midnight-truncation fix loaded each session's neighbour-day wearable data (to keep the drawn line continuous across midnight), a lane's fixed y-axis range was computed over all merged samples — including the neighbour days' off-session-window tails (e.g. an adjacent day's daytime/exercise heart rate of 150–180 bpm). That inflated the heart-rate lane's maximum and vertically compressed the actual nighttime waveform. The range is now window-aware: only samples falling within the session's own time window drive the data-expanded edges, while the full merged series is still drawn (so neighbour data legitimately overlapping the window remains visible). No data was wrong or lost — only the vertical scaling. The corrected scaling appears on next view; no re-import is required.
  • Signal Viewer wearable lanes are no longer truncated at midnight. On nights that cross midnight, the intraday wearable lanes (heart rate, SpO₂, HRV detail, snoring, sleep stages) lost the data on one side of midnight — heart rate, for example, would simply stop or start at 00:00. Intraday samples are stored split by the calendar date of each sample's own timestamp, so a night spanning e.g. 23:00→07:00 lands in two date-keyed records; the viewer was fetching only the session's anchor date, silently dropping the other half. The viewer now loads the anchor date together with its adjacent dates and merges the samples onto the shared time axis, so each lane spans the full night regardless of where midnight falls. No re-import is required — the complete data appears on next view. All client-side; nothing leaves the browser.
  • Signal Viewer waveforms are no longer clipped or squashed by the EDF's declared range. Lanes were scaled to each channel's declared physical range (physicalMin/physicalMax), which is a decode calibration anchor rather than a display bound. When the declared range was too narrow, real data that exceeded it — a leak spike, for example — was drawn flat against the lane edge, losing its shape; when the declared range was too wide, normal therapy was squashed into a thin band, losing fidelity. Lanes now scale to a clinical default that expands to fit the data, so spikes keep their shape and normal therapy fills the lane. See the corresponding entry under Changed for the full behaviour.
  • Unqualified ResMed "Apnea" events are no longer mislabelled as mixed apneas. ResMed devices emit a bare "Apnea" annotation for an apnea they confirmed but could not resolve as obstructive or central (most often under high mask leak, when the forced-oscillation measurement is unreliable). The interpreter previously mapped these to MixedApnea — with a code comment incorrectly citing "AASM guidelines," even though AASM defines a mixed apnea specifically as central onset followed by obstructive effort. These events are now classified as a distinct UnclassifiedApnea type. They still count toward AHI (so total AHI and severity are unchanged), but they no longer inflate the mixed-apnea slice of the event-type breakdown; the per-night aggregate gained an ahiUnclassified index so the per-type breakdown reconciles to AHI, and the Session Detail breakdown and PDF/CSV reports surface the unclassified bucket when present.
  • Corrected a citation in the normality-test code and two effect-size/rank-test docstrings. The Shapiro–Francia p-value transform was attributed to "Applied Statistics" (JRSS Series C); it is Royston (1993) in The Statistician (JRSS Series D). The Mann–Whitney exact-test threshold comment now matches the code (n₁ ≤ 28 and n₂ ≤ 28, not n₁·n₂ ≤ 784), and the Cohen's-d confidence-interval comment now correctly describes the large-sample normal approximation it uses.
  • Signal Viewer breathing-pattern (PB / CSR) confidence chips no longer overlap the lane label. The clickable candidate chips were anchored at the very top of the airflow lane, where they collided with the newly repositioned lane label and pill — making the label unreadable and the chip hard to click. The chip band now sits just below the label band, so both are fully readable and clickable, with the chip's keyboard focus outline unclipped.
  • Breathing-pattern candidate detection (Periodic Breathing / Cheyne-Stokes) now produces genuinely measured, per-episode results instead of a single meaningless overlay. The detector resampled its ventilation envelope incorrectly — ResMed MinuteVent is sampled at 0.5 Hz, but the envelope was zero-filled to a higher rate, producing a degenerate "comb" signal. As a result, every session showed one full-width detection band on the Signal Viewer airflow lane, and the Explore → Breathing (/explore/breathing) episode catalog reported near-identical, meaningless values for every night — confidence stuck around 47%, cycle length pinned to 40.0 s (the search-band floor), and modulation depth pinned to 1.00, all flagged "sub-threshold." The envelope is now resampled correctly (linear interpolation / decimation, never zero-fill), and the autocorrelation cycle-length estimator was hardened so it no longer pins to the search-band floor. Cycle length, modulation depth, and confidence are now measured genuinely per episode, and overlays mark only real candidate spans. These remain statistical candidates, not diagnoses — discuss any concerning pattern with your clinician. (The four-class TECSA trajectory classifier was reviewed and confirmed correct; it is unchanged. Because detection runs on demand in the analysis layer, no re-import is required — the corrected values appear on next view.)
  • Google Health import no longer silently imports zero records. The directory scanner stored bare filenames instead of paths relative to the export root, so the import service could not locate files in their subdirectories. The import service also now resolves the export root directory consistently with the scanner, preventing path mismatches when the user selects a parent directory.
  • Dashboard no longer intermittently fails to load. An IndexedDB connection failure during startup was cached permanently, causing every subsequent data query to fail silently and the dashboard to show the empty-state import wizard instead of therapy data. The singleton now retries on the next attempt. A contributing cause — unnecessary transaction.abort() calls on readonly verification transactions — was also removed.
  • AHI, compliance, and usage hours no longer read 0 for ResMed imports. A regression caused usage time to be computed as zero for every night, which cascaded to AHI = 0 (usage is its denominator) and a 0% compliance rate — even though events, leak, and pressure were detected correctly. Two causes: (1) the machine's recorded mask-on/mask-off intervals from STR.edf were decoded as minutes-from-midnight when ResMed actually records them as minutes-since-noon (a noon-to-noon "session day"), so the intervals landed ~12 hours off and never matched any session; and (2) once STR mask data was present, a session with no matching interval was treated as authoritative zero usage instead of falling back to pressure-based detection. The STR decoding is corrected, and STR intervals are now used only when they actually overlap a session — otherwise the proven pressure-based usage detector is used — so a night with real therapy data can no longer be silently zeroed. Because nightly metrics are computed and stored at import time, re-importing affected data is required to recompute the corrected usage, AHI, and compliance values.
  • ResMed machine settings (min/max pressure, EPR, ramp, mode, mask, humidifier) now display instead of being blank. The STR.edf Date channel was decoded with the wrong epoch — the Excel/Lotus 1900 serial-date origin (1899-12-30) instead of the Unix epoch (1970-01-01) — so each night's settings were keyed to dates in 1955–1956 and never matched any real session. Session.machineSettings was therefore always null, leaving the entire machine-settings UI dead. The decoder now uses the correct Unix epoch, so settings align with their sessions. Because settings are computed and keyed at import time, re-importing affected data is required for previously-imported sessions to show their settings.
  • Help pages (Help home and Help articles) now render their intended card, input, button, and divider borders in both light and dark themes. These borders referenced an undefined --color-border design token and were silently invisible; they now use the defined --color-border-default token.
  • Very short or low-usage nights no longer report a wildly inflated AHI/RDI/ODI, and aggregate AHI is now duration-weighted. A per-hour index is the event count divided by recording hours, so on a tiny recording (e.g. a ~5-minute mask-fit test, or a night whose usage decoded to ~1 second) a single event extrapolated to an absurd rate — one apnea over one second of data became an AHI of 3600 — which then poisoned the trend charts, the dashboard KPIs, the median and trend percentages, and the exported reports. Now, below a 1-hour rate-validity floor, every per-hour index (AHI, RDI, the obstructive/central/mixed/unclassified/hypopnea/RERA sub-indices, and ODI) is reported as "insufficient recording time" (—) rather than a number — the rate is genuinely undefined on that little data, not zero. The raw event counts for such nights are still recorded and shown. Separately, the window-level mean AHI is now a duration-weighted pooled rate (total events ÷ total hours) instead of a plain average of nightly rates, so one short, noisy night can no longer dominate the figure; the median, trend, sparklines, and report statistics now exclude undefined-rate nights (drawn as gaps, never as 0). This rate-validity floor is deliberately distinct from the 4-hour CMS compliance threshold (which is about adherence, not rate stability) — a 2-hour night is non-compliant yet still yields a perfectly valid AHI. Nightly metrics are computed at import time, so re-importing affected data is required for previously-imported short nights to switch from an inflated number to the honest "insufficient data" indicator. (See ADR 0020.)

Fixed (Phase 10: Correctness, Performance & UX pass)

  • Data import no longer stalls at the start. Previously, starting an import could leave the progress indicator stuck at "0 / n imported" indefinitely, because the EDF parser failed to load in production builds. Imports now begin and progress normally.
  • Import no longer fails to store sessions with a "machineId_date uniqueness" error. Multiple sessions on the same calendar day (e.g. a nap plus an overnight, or mask removal and reapplication) are now stored independently instead of colliding. Root cause was a wrongly-unique database index; a v1→v2 schema migration auto-upgrades existing databases losslessly on first launch — no re-import required.
  • Empty or header-only ResMed files that contain no events (for example a CSL Cheyne-Stokes annotation file from a night with none) are now skipped quietly during import instead of being reported as errors. The import summary reports how many such files were skipped.
  • Session writes are now atomic: a failure partway through writing a session no longer leaves orphaned nightly aggregates, events, or signal chunks behind.
  • URL-encoded date ranges no longer shift by one day for users in time zones behind or ahead of UTC. Shared and bookmarked date-range links now resolve to the intended local dates.
  • "Learn More" on the empty dashboard now navigates correctly within the app (previously a broken full-page link).
  • The Signal Viewer no longer carries one session's hidden-channel selection over into another session; channel visibility is again scoped per session.
  • "Delete all data" (in both Settings and the Data Management view) no longer fails with an OPFS not initialized error and could previously delete nothing; the OPFS signal-storage service now self-initializes, so a full wipe completes reliably. In Settings, a deletion failure is now surfaced to the user instead of being silently swallowed.
  • "Delete all data" now also clears residual app-owned localStorage/sessionStorage entries — including the per-session Signal Viewer view preferences stored under signal-viewer-hidden-<sessionId> keys — so no session metadata survives a full wipe (previously these keys were left behind).

Changed (Phase 10 — clinical: some displayed numbers will change)

These corrections improve clinical and statistical accuracy. As a result, several metrics may display different values than in earlier versions. The new values are the correct ones; prior values were affected by the issues described below. This tool is for informational analysis and does not diagnose — discuss any changes that concern you with your clinician.

  • AHI now excludes RERAs (AASM / ICSD-3 correct). Respiratory effort-related arousals (RERAs) were previously summed into the AHI — that quantity is actually the Respiratory Disturbance Index (RDI), not the AHI. Displayed AHI will be lower on nights that had RERAs. A separate RDI value (AHI + RERA index) is now reported. This also resolves an internal contradiction with the app's own glossary, which already (correctly) defined RERAs as part of RDI and not AHI.
  • ODI is now event-based. The Oxygen Desaturation Index is now computed from discrete desaturation events (a fall of ≥3% below a rolling baseline, sustained ≥10 s, counted once per event) per hour of valid oximetry, replacing a per-sample-drop count. ODI values will change and are now clinically valid.
  • Usage time / mask-on detection now uses the machine's recorded intervals. When ResMed's mask-on/mask-off intervals are present in STR.edf, they are used directly; otherwise an improved hysteresis detector (separate on/off thresholds) is used, replacing the previous fixed 2 cmH₂O instantaneous threshold. Because usage time is the denominator for AHI, ODI, leak-duration, and the CMS 4-hour compliance test, usage hours and these dependent metrics may shift slightly and are now more accurate. Subtherapeutic ramp handling is documented in the Usage Hours glossary entry.
  • T90 (% of time with SpO₂ < 90%) is now time-based, integrating the duration spent below 90% over valid-oximetry time, with oximetry-dropout periods excluded from both numerator and denominator. An oximetry coverage % is now reported so SpO₂ statistics can be read in the context of how much valid signal a night actually had.
  • Missing samples are no longer folded in as real zeros. Pressure, leak, and respiratory statistics are now computed only over recorded samples; sensor-gap periods are excluded rather than counted as zero, which previously biased means and percentiles downward.
  • Normality test correctly labeled Shapiro–Francia. The implementation always computed the Shapiro–Francia statistic (the correlation-based variant), not Shapiro–Wilk; the label and the p-value transform are corrected to match.
  • "Median EPAP/IPAP" cards relabeled "Mean EPAP/IPAP" in Pressure Optimization, because they compute the mean across nights of each night's median pressure (a mean of nightly medians), not a median.
  • Granger causality results now flag exploratory and non-stationary cases. Selection-affected p-values (from scanning many metric pairs without multiple-comparison correction) and non-stationary inputs are now flagged, since both can produce spurious apparent "causality."

Added (Phase 10)

  • RDI (Respiratory Disturbance Index) metric: apneas + hypopneas + RERAs per hour (AHI + RERA index), always ≥ AHI. Includes a dedicated glossary entry and metric tooltip; device-derived RERA counts are noted as proxy estimates.
  • SpO₂ coverage % metric: the fraction of analyzed time with a valid pulse-oximetry signal, surfaced as a data-quality denominator for all SpO₂ statistics.
  • Granger Causality tab under Statistical Analysis: tests whether one nightly metric helps predict another (lagged VAR F-test) for a user-chosen metric pair (X→Y), reporting the F-statistic and p-value for that direction only. Granger causality measures predictive precedence, not physical causation, and the result is directional — it does not imply the reverse Y→X relationship.
  • The tab surfaces statistical-honesty flags: an "Exploratory p-value (lag auto-selected)" badge when the lag is AIC-selected (selection-affected, anti-conservative inference), and a non-stationarity caution when an input series shows a significant linear trend. An inference-mode control (Exploratory auto-lag vs. Confirmatory fixed-lag) lets users pin a lag to obtain a clean inferential p-value, alongside an AIC-by-lag chart for inspecting the order-selection landscape.
  • "Empty files skipped" count in the import summary, for transparency when header-only/event-free files are encountered.
  • Glossary entries for RDI, T90, and SpO₂ Coverage; updated AHI, RERA, ODI, SpO₂, Usage Hours, Compliance, and Normal Distribution entries; help-article updates covering the Shapiro–Francia test, the Mean EPAP/IPAP relabel, Granger causality caveats, missing-data handling, and multiple-sessions-per-day import.
  • In-app help for Granger causality: a dedicated "Interpreting Granger Causality" help article (with references) plus glossary entries for Granger causality, F-test, AIC, and stationarity, surfaced from the Granger Causality tab via a contextual help popover and an "how to read this tab" interpretation guide.
  • Full keyboard navigation for the Statistical Analysis tab strip (WAI-ARIA APG tabs pattern, manual activation): arrow keys move focus between tabs with wrap-around, Home/End jump to the first/last tab, and Enter/Space activate the focused tab. Benefits all six tabs (Descriptive, Trends, Distribution, Correlation, Granger Causality, Hypothesis).

Performance (Phase 10)

  • Import parsing now runs in parallel across a worker pool, with signal buffers transferred (not copied) across the worker boundary, eliminating duplicate large-array allocations.
  • Per-day streaming during import caps peak memory on large multi-year imports, and redundant per-channel sorts were removed. Net effect: faster imports and substantially lower memory use.

Added (Phase 9: Analysis Views + Visualization Library)

  • Analysis views: Statistical Analysis (src/views/Analysis/StatisticalAnalysis/), Event Analysis (src/views/Analysis/EventAnalysis/), and Pressure Optimization (src/views/Analysis/PressureOptimization/) with tabbed layouts following WAI-ARIA APG tabs pattern
  • Chart library with interactive charts: ThemedLineChart, ThemedAreaChart, ThemedBarChart, ThemedScatterPlot (Recharts); BoxPlot, ViolinPlot, CorrelationHeatmap, KaplanMeierCurve, QQPlot, STLDecompositionPanel, CalendarHeatmap (D3)
  • ChartContainer (src/components/charts/ChartContainer.tsx) with PNG export, View as Table toggle, loading skeleton, and error states
  • useAnalysis hook for executing analysis with AbortController cancellation and result caching
  • useChartColors hook for theme-aware chart color palette access
  • Chart interaction store (src/stores/useChartInteractionStore.ts) for synchronized zoom/crosshair across linked charts
  • All chart components wrapped in React.memo for render performance
  • 87 new unit tests (1062 total) covering chart components, analysis views, hooks, and interaction store
  • 132 new E2E tests (450 total across 3 browsers) covering analysis view rendering, chart interactions, parameter changes, and PNG export

Added

  • Event marker legend in signal viewer

Changed

  • Signal data now preloaded into memory for instant zoom/pan
  • Crosshair renders via direct canvas calls for zero-lag response
  • Channel/event legend always visible above chart

Fixed

  • Passive wheel listener warning in signal viewer
  • Timeseries disappearing on zoom in signal viewer
  • Loading flicker during pan in signal viewer
  • Crosshair lag in signal viewer
  • Empty unit parentheses for Snore/FlowLimitation channels
  • Event timing offset for multi-file sessions

Added (Phase 8: Analysis Engine — Advanced Algorithms)

  • Shared math utilities module (src/analysis/math/) extracting lnGamma, regularizedIncompleteBeta, erf, normalCDF, studentTCDF, inverseNormalCDF, percentileFromSorted, and other helpers from duplicated implementations
  • Hypothesis testing module (src/analysis/hypothesis/) with Mann-Whitney U test (exact DP for n ≤ 28, normal approximation with tie correction), Wilcoxon signed-rank test, Cohen's d / Hedges' g effect sizes, and paired before/after comparison helper
  • Distribution analysis module (src/analysis/distribution/) with QQ-normal plot (Hazen formula), Shapiro-Wilk test (Royston approximation), Kolmogorov-Smirnov test (Dallal-Wilkinson p-value), and Gaussian KDE (Silverman bandwidth)
  • Event analysis module (src/analysis/events/) with FLG-bridged clustering (3 presets: strict/balanced/lenient), K-means++ clustering (Arthur & Vassilvitskii 2007, deterministic PRNG), single-link agglomerative clustering, event duration distribution by type, and inter-event interval analysis
  • False-negative event detection (src/analysis/events/false-negatives.ts) with heuristic FLG signal analysis and 3 sensitivity presets (conservative/balanced/aggressive)
  • Survival analysis module (src/analysis/survival/) with Kaplan-Meier estimator, Greenwood variance, log-log transformed 95% confidence intervals, and median survival time
  • Pressure analysis module (src/analysis/pressure/) with titration helper (optimal pressure range estimation), pressure-response curves, BiPAP EPAP×IPAP effectiveness analysis, and pressure variability metrics
  • Granger causality analysis (src/analysis/correlation/granger.ts) with VAR model F-test, AIC-based optimal lag selection, and bidirectional causality testing via ml-matrix OLS
  • All analysis output interfaces across descriptive, timeseries, and correlation modules marked with readonly properties for immutability
  • Analysis worker updated with 16 new function exports for all Phase 8 algorithms
  • 197 new unit tests (975 total across 49 test files) covering all Phase 8 modules with scipy/R reference validation
  • 23 new E2E tests (69 across 3 browsers, 318 total) verifying Phase 8 algorithms execute correctly in real browser JavaScript engines

Fixed (Phase 8)

  • Granger causality F-distribution survival function formula corrected (was computing inverted p-values)
  • Session comparison breadcrumb navigation URL pattern now correctly matches URLs with query parameters

Added (Phase 7: Analysis Engine — Core Algorithms)

  • Descriptive statistics module (src/analysis/descriptive/) with Welford's online algorithm for mean/variance/skewness/kurtosis, Type 7 interpolated percentiles, Tukey's fences outlier detection, and Freedman-Diaconis histogram binning
  • Time-series analysis module (src/analysis/timeseries/) with rolling mean/median with confidence intervals, linear trend with t-test significance, LOESS smoothing (tricube kernel), PELT change-point detection, simplified STL decomposition, and ACF/PACF (Durbin-Levinson recursion)
  • Correlation analysis module (src/analysis/correlation/) with Pearson and Spearman correlation coefficients, Fisher's z-transformation confidence intervals, correlation matrix computation, recursive partial correlation, and cross-correlation with configurable lag
  • Analysis pipeline engine (src/services/analysis/AnalysisEngine.ts) with cache-first execution, lazy Comlink worker initialization, AbortSignal support, and metric extraction from NightlyAggregate data
  • Analysis Web Worker (src/services/workers/analysis.worker.ts) exposing all 18 analysis functions via Comlink for off-main-thread execution
  • Barrel re-export module (src/analysis/index.ts) for unified analysis API access
  • 230 new unit tests (778 total) covering descriptive statistics, time-series analysis, correlation analysis, and AnalysisEngine pipeline
  • 24 new E2E tests (72 across 3 browsers, 249 total) covering analysis module loading, in-browser algorithm execution, edge cases, and integration scenarios

Added (initial)

  • Initial project scaffolding and repository structure
  • Project documentation and design specification
  • Agent and skill definitions for AI-assisted development workflow
  • CI/CD pipeline configuration via GitHub Actions
  • Pre-commit hooks for code quality enforcement
  • Complete TypeScript domain type system (src/types/) covering sessions, events, signals, analysis, plugins, errors, settings, and storage
  • Design token system with CSS custom properties for light and dark themes (src/styles/tokens.css)
  • CSS reset and base typography styles (src/styles/reset.css, src/styles/base.css)
  • Theme provider with system preference detection, localStorage persistence, and real-time OS preference tracking
  • 16 design system components built on Radix UI primitives (Button, Card, Input, Badge, Select, Switch, Tabs, Dialog, Tooltip, Accordion, Toast, Skeleton, Table, DropdownMenu, Popover, Slider)
  • Application shell with sidebar navigation layout and responsive design
  • React Router v6 routing with lazy-loaded views for all application sections (Dashboard, Sessions, Analysis, Reports, Data Management, Settings, Help)
  • Zustand stores for application state (useAppStore), persisted settings (useSettingsStore), and data cache (useDataStore)
  • Three-tier error boundary system (Root, Route, Component level) with recovery actions
  • Bidirectional URL state sync hook for deep-linkable date ranges and session selection
  • 113 unit tests across 15 test files
  • E2E tests for navigation, theme switching, and responsive layout
  • IndexedDB 7-store schema (sessions, nightly_aggregates, events, analysis_results, settings, import_history, integration_data) with full CRUD and cursor-based range queries
  • OPFS signal storage service for Float32Array chunk read/write, quota checking, and streaming reads
  • LRU cache service for analysis results with hash-based keys and import-triggered invalidation
  • Schema migration framework with versioned up/down/verify and dependency resolution
  • Comlink worker factory (src/services/workers/createWorker.ts) with typed wrappers, structured error marshalling, and timeout support
  • Priority-based worker pool (src/services/workers/WorkerPool.ts) with round-robin dispatch, idle timeout, crash recovery, and graceful shutdown
  • EDF binary parser (src/parsers/edf/EDFParser.ts) for fixed headers, per-signal headers, interleaved 16-bit data records, and EDF+ TAL annotations
  • ResMed interpreter (src/parsers/resmed/ResMedInterpreter.ts) with channel label normalization, event annotation mapping, machine info extraction, and capability detection
  • Session builder (src/parsers/resmed/SessionBuilder.ts) for multi-file merge, time alignment, session boundary detection, and usage time computation
  • EDF validator (src/parsers/validation/Validator.ts) with header integrity checks, physiological range validation, AASM compliance, and AHI sanity checks
  • Synthetic EDF data generator (src/test/generators/edf-generator.ts) for deterministic test data
  • Import pipeline service (src/services/import/ImportService.ts) with File System Access API support, <input type="file"> fallback, SHA-256 deduplication, and progress tracking
  • EDF parser Web Worker (src/services/workers/edfParser.worker.ts) wrapping EDFParser + ResMedInterpreter + Validator behind Comlink interface
  • AirSense 11 suffixed channel label support (Flow.40ms, MaskPress.2s, Leak.2s, etc.) in ResMedInterpreter
  • Machine info extraction from EDF+ recordingId field (SRN=, MID=, VID= key-value parsing)
  • Generic "Apnea" and CSR Start/End event type mapping in ResMedInterpreter
  • Automatic leak unit conversion (L/s → L/min) for downstream consistency
  • SpO2/Pulse sentinel value filtering (all-zero = no oximeter attached)
  • Maximum file size guard (100 MB) in import pipeline
  • Buffer bounds validation before signal header parsing in EDFParser
  • Synthetic AirSense 11 EDF test fixtures with manifest (tests/fixtures/edf/)
  • 84 new unit tests (424 total) covering fixture parsing, import pipeline, and interpreter edge cases
  • 14 new E2E tests (27 total, 81 across 3 browsers) covering import routes, browser APIs, and fixture handling

Fixed

  • EDF parser now handles numDataRecords = -1 (unknown record count per EDF spec) by computing actual count from file size
  • EDF parser now allows dataRecordDuration = 0 for EDF+ annotation-only files (EVE, CSL)
  • TAL annotation parsing corrected to use \x14 (not \x15) as duration-to-label separator per EDF+ specification
  • SessionBuilder channel merge now prefers channels with more samples (not just higher sample rate)
  • Validator now accepts dataRecordDuration = 0 for annotation-only files
  • Synthetic EDF generator TAL format corrected to match EDF+ specification
  • GitHub Pages deployment now works correctly — configured Vite base path, React Router basename, and 404.html fallback for SPA routing on /cpap-analyzer/ subpath

Added (Phase 6: Session Views + Signal Viewer)

  • Session list view (src/views/Sessions/SessionList.tsx) with filterable search, sortable columns (date, duration, usage, AHI, leak, events), pagination (25/page), and AHI severity badges
  • Session detail view (src/views/Sessions/SessionDetail.tsx) with AHI breakdown (obstructive/central/mixed/hypopnea/RERA), leak metrics (median/P95/max/duration), pressure metrics (mean/median/P95/max with bilevel support), SpO₂ metrics (mean/min/<90%/ODI), event timeline, and event summary table
  • Signal viewer (src/views/Sessions/SignalViewer.tsx) with Canvas 2D multi-channel waveform rendering, zoom (mouse wheel + presets: 1m/5m/30m/1h/All), pan (pointer drag), crosshair with time + value readout, event marker overlays, and OPFS signal streaming
  • Canvas signal rendering engine (src/components/charts/canvas/SignalRenderer.ts) with DPI-aware rendering, multi-channel stacked display, grid lines, dynamic time axis formatting, and requestAnimationFrame coalescing
  • Session comparison view (src/views/Sessions/SessionComparison.tsx) with session pickers, side-by-side metric table with delta columns (absolute + percentage), color-coded improvement direction, and CSS bar chart
  • LTTB (Largest Triangle Three Buckets) downsampling Web Worker (src/services/workers/downsample.worker.ts) with min-max downsampling, Comlink.transfer() for zero-copy results
  • Signal data hooks (src/hooks/useSignalData.ts): useSessionDetail(sessionId), useEventData(sessionId), useSignalData(params) with cached OPFS service and lazy worker creation
  • Granular import progress reporting during parsing, building, and storing stages with per-file/per-session detail and setTimeout yields for UI repainting
  • 80 new unit tests (548 total): LTTB/min-max downsampling correctness, SignalRenderer helper functions and spatial queries, session comparison delta calculations
  • 19 new E2E tests (46 total, 138 across 3 browsers): session list rendering/filtering/sorting, session detail metrics and navigation, signal viewer chrome and controls, session comparison flow with deltas, full navigation journey

Fixed (Phase 6 post-merge)

  • Signal Viewer canvas now renders waveform data correctly — ResizeObserver/renderer setup used a []-dependency useEffect that ran on mount when the canvas was not in the DOM (loading skeleton shown instead); converted to a callback ref pattern so setup happens when the canvas actually mounts
  • Signal Viewer channel colors now match their designated palette — CHANNEL_COLORS keys corrected from PascalCase (Flow, MaskPress, SpO2) to camelCase (flow, maskPressure, spo2) matching the ResMed interpreter's output
  • Signal Viewer zoom no longer truncates viewport at duration boundary — clamping logic corrected
  • Signal Viewer pan no longer desyncs viewport start/end — pointer drag delta applied consistently
  • Signal Viewer vertical scrolling no longer blocked — Ctrl/Cmd modifier required for wheel zoom, plain scroll passes through to overflow container

Added (Phase 6 QA fixes)

  • Multi-channel crosshair values on hover — hovering over the signal canvas shows interpolated physical values for all visible channels with coloured readout badges
  • Loading indicator for signal data — semi-transparent overlay with spinner shown while channel data is being fetched/downsampled
  • Clickable channel legend with persistence — legend buttons toggle channel visibility, hidden state persisted in localStorage per session

Improved

  • Import wizard now shows stage-specific progress labels and percentages during parsing, building, and storing stages (previously showed indeterminate state during CPU-intensive building)