From dfd8dbc5fd90b44bbec6f7ee75669c5182be1988 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 6 Sep 2026 10:02:47 -0700 Subject: [PATCH 001/119] test: audit coverage and fix calibration trigger ordering (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing Vitest report showed 98.21% statement coverage while omitting 113 unimported `src` files and counting test helpers. Python coverage also counted test code. Make both denominators explicit and add 168 behavioral test cases for the highest-risk gaps found during a repository-wide coverage audit. - Add 48 TypeScript cases for automation biometrics freshness/query/conversion rules, temperature-dial commands and drag lifecycle, power-button optimistic/linked failure behavior, and diagnostic chart data/formatting. - Add 120 Python cases for RAW CBOR framing and truncation, calibration trigger files, sensor baseline algorithms, and vital-validation boundaries. - Include unimported application files in Vitest coverage, exclude test helpers, export JSON summaries, and enable Python branch coverage with test modules excluded. - Record the audit, per-area coverage, reproduction commands, and remaining gaps in `docs/testing/coverage-audit-2026-09-06.md`. Validation: 3,828 Vitest tests passed (one pre-existing skip), 353 Python tests passed across all six CI scopes, TypeScript typecheck passed, and changed-file ESLint passed. Biometrics signal reading, RAW framing, and both diagnostic charts now have 100% line/branch coverage; the dial and power button have 100% line coverage. Shared Python calibration coverage rises from 19% to 80% (statements + branches). The corrected overall TypeScript report is 64.44% statements / 53.09% branches / 65.49% lines. This drop reflects the expanded denominator, not removed tests or a runtime regression. The audit documents the substantial remaining UI and Python lifecycle coverage backlog; it does not claim complete application coverage. CodeRabbit follow-up: fixed calibration trigger FIFO ordering. Both reads and deletions now compare numeric timestamp/PID/counter suffixes, so counter 9 precedes 10, including existing unpadded queued files. Strengthened tests fail against the original lexical ordering and pass with the fix; 194 common-module tests pass. Mixed legacy, padded and unpadded filenames are also covered.
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update test/dev-coverage-audit ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/test/dev-coverage-audit/scripts/install \ | sudo bash -s -- --branch test/dev-coverage-audit ``` 🎯 **Pin to this exact build** ([run 34047208411](https://github.com/sleepypod/core/actions/runs/34047208411) Β· `329a19c`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/329a19ca41826b55de709d6771deec83f2234745/scripts/install \ | sudo bash -s -- \ --branch test/dev-coverage-audit \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/34047208411/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/34047208411/artifacts/9993481568) Β· self-destructs in 30 days πŸ’₯
## Summary by CodeRabbit - **Bug Fixes** - Calibration trigger requests are now processed in the correct chronological order, including requests created at the same time and older-format filenames. - **Tests** - Expanded automated coverage for calibration, signal reading, biometrics, temperature controls, power controls, trend charts, and data validation. - Improved coverage reporting to include production code and branch coverage while excluding test artifacts. - **Documentation** - Added a detailed test coverage audit with results, inventories, and follow-up priorities. --- .github/workflows/python-modules.yml | 3 + .gitignore | 2 + docs/testing/coverage-audit-2026-09-06.md | 144 ++++++++++++++ modules/.coveragerc | 5 + modules/common/calibration.py | 13 +- modules/common/test_calibration_algorithms.py | 146 ++++++++++++++ modules/common/test_calibration_triggers.py | 97 ++++++++++ modules/common/test_cbor_raw.py | 86 +++++++++ .../tests/biometricsSignalReader.test.ts | 180 ++++++++++++++++++ .../PowerButton/tests/PowerButton.test.tsx | 137 +++++++++++++ .../tests/TemperatureDial.test.tsx | 113 +++++++++++ .../diagnostics/tests/trendChartData.test.tsx | 105 ++++++++++ vitest.config.mts | 6 +- 13 files changed, 1034 insertions(+), 3 deletions(-) create mode 100644 docs/testing/coverage-audit-2026-09-06.md create mode 100644 modules/.coveragerc create mode 100644 modules/common/test_calibration_algorithms.py create mode 100644 modules/common/test_calibration_triggers.py create mode 100644 modules/common/test_cbor_raw.py create mode 100644 src/automation/tests/biometricsSignalReader.test.ts create mode 100644 src/components/PowerButton/tests/PowerButton.test.tsx create mode 100644 src/components/TemperatureDial/tests/TemperatureDial.test.tsx create mode 100644 src/components/diagnostics/tests/trendChartData.test.tsx diff --git a/.github/workflows/python-modules.yml b/.github/workflows/python-modules.yml index 5c3af8b47..8c1763c4b 100644 --- a/.github/workflows/python-modules.yml +++ b/.github/workflows/python-modules.yml @@ -7,6 +7,7 @@ on: - 'modules/**/*.py' - 'modules/**/pyproject.toml' - 'modules/**/uv.lock' + - 'modules/.coveragerc' - '.github/workflows/python-modules.yml' pull_request: types: [opened, synchronize, reopened] @@ -14,6 +15,7 @@ on: - 'modules/**/*.py' - 'modules/**/pyproject.toml' - 'modules/**/uv.lock' + - 'modules/.coveragerc' - '.github/workflows/python-modules.yml' workflow_dispatch: @@ -110,6 +112,7 @@ jobs: # shellcheck source=/dev/null . .venv/bin/activate pytest ${{ matrix.module.target }} \ + --cov-config=${{ github.workspace }}/modules/.coveragerc \ --cov=${{ matrix.module.cov_source }} \ --cov-report=xml:coverage.xml \ --junit-xml=junit.xml \ diff --git a/.gitignore b/.gitignore index e66ee0895..d6762454e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ node_modules # Testing test-results coverage +.coverage +.coverage.* .stryker-tmp reports/mutation diff --git a/docs/testing/coverage-audit-2026-09-06.md b/docs/testing/coverage-audit-2026-09-06.md new file mode 100644 index 000000000..025861ecc --- /dev/null +++ b/docs/testing/coverage-audit-2026-09-06.md @@ -0,0 +1,144 @@ +# Test coverage audit β€” 2026-09-06 + +Audited `dev` at `2e13b79` in an isolated worktree, using Node 24.16.0, +pnpm 10.34.5, and Python 3.10.19. This is a unit-test audit; passing results do +not establish real-Pod, browser-layout, or end-to-end correctness. + +## Measurement findings + +- The original Vitest run passed **3,780 tests in 158 files**, with one existing + skipped test. It reported **98.21% statements / 94.71% branches / 98.75% lines**. +- That report omitted 113 unimported TypeScript files under `src/` (including + type-only files) and included some test helpers. It did not measure the whole + application. The new explicit include covers `src/`, `app/`, instrumentation, + and the proxy; test files, helpers, and declarations are excluded. +- With the added tests and corrected denominator, coverage is **64.44% + statements / 53.09% branches / 65.49% lines**. The lower aggregate reflects + newly visible untested code; it is not a test-coverage regression. There is + deliberately no arbitrary global threshold imposed on the existing backlog. +- Python CI previously counted test modules and measured only statements. The + shared `.coveragerc` excludes test code and enables branches for all six CI + module jobs. The prototype in piezo-processor remains visible in the report. + +## Tests added + +**48 TypeScript cases** across four suites, preserving all pre-existing tests: + +| Area | Behavioral checks | Final lines / branches | +| --- | --- | --- | +| Automation biometrics reader | Real SQLite ordering and side filters; null vs zero; Fahrenheit conversion; partial zones; exact 5m/15m/30s freshness boundaries; cap scalar/matrix/reference handling; partial and total DB failure | 100% / 100% | +| Temperature dial | F/C keyboard steps in hardware units; 55–110Β°F clamps; pointer preview, deduplication, release/cancel, arc gap, scaled viewport; off-state inactivity; accessible values | 100% / 96.05% | +| Power button | Selected/linked side commands; neutral power-on setpoint; power-off payload; optimistic timeout and server acknowledgement; pending state; partial linked failure recovery | 100% / 91.66% | +| Diagnostic charts | Actual data sorting without mutation; downsampling keeps latest sample; missing values and precision; finite thermal domains and axis/tooltip formatting | 100% / 100% each | + +The reader previously had only 10.2% line / 0% branch coverage. The dial and +power button were absent from the report. Both chart suites previously had +71.42% line coverage, and their rendering tests did not assert data ordering. +Remaining dial branches are defensive null fallbacks behind non-null props and +the mounted SVG ref; the power button's remaining branch is the handler guard +behind the disabled pending button. No private handlers are exposed to force +these branches. + +**120 Python cases** across three new suites: + +- RAW parser: all supported integer and byte-length widths; every truncated + prefix; empty markers; exact cursor position between consecutive records; + real-file payload sizes crossing 4 KiB buffers; malformed framing and 1 MB + payload guard. `common/cbor_raw.py` reaches **100% lines and branches**. + The private import explicitly avoids another suite's `sys.modules` stub. +- Calibration triggers: temporary-file invisibility, non-destructive reads, + oldest-file consumption, invalid JSON/non-object removal, same-millisecond + write uniqueness and FIFO across counter 9β†’10, and failed-rename isolation + using real temporary files. +- Calibration algorithms: minimum usable samples; final quiet-window selection; + 6/8-channel capSense2 and optional reference channels; standard-deviation + floors; signed piezo lists/bytes/bytearrays and trailing bytes; minimum and + signal-scaled thresholds; inclusive HR/HRV/BR bounds; dynamic-bound flags; + missing vitals; calibration-age decay and signal-to-noise scoring. These + assert the existing algorithm contract, not clinical validity. + +`common/calibration.py` improves from **19% to 80% combined statement/branch +coverage**. Its remaining areas include persistence, temperature calibration, +legacy presence helpers, and exceptional filesystem paths. + +## Remaining coverage inventory + +This audit prioritizes untested control commands, sensor framing, calibration, +and live automation inputs. It does not claim every coverage gap is closed. +Many UI components have tested hooks/utilities but no component-level behavior +tests. Reported coverage now makes that distinction explicit. + +| TypeScript area | Lines | Branches | +| --- | ---: | ---: | +| `app` | 91/182 (50.00%) | 56/106 (52.83%) | +| `instrumentation.ts` | 148/168 (88.10%) | 57/86 (66.28%) | +| `proxy.ts` | 0/19 (0.00%) | 0/9 (0.00%) | +| `src/automation` | 610/614 (99.35%) | 561/572 (98.08%) | +| `src/components` | 422/4573 (9.23%) | 418/4919 (8.50%) | +| `src/db` | 197/197 (100.00%) | 59/67 (88.06%) | +| `src/hardware` | 1616/1618 (99.88%) | 1000/1039 (96.25%) | +| `src/homekit` | 539/539 (100.00%) | 224/236 (94.92%) | +| `src/hooks` | 723/727 (99.45%) | 478/497 (96.18%) | +| `src/lib` | 537/537 (100.00%) | 389/398 (97.74%) | +| `src/locales` | 0/0 (100.00%) | 0/0 (100.00%) | +| `src/modules` | 0/0 (100.00%) | 0/0 (100.00%) | +| `src/providers` | 35/101 (34.65%) | 4/38 (10.53%) | +| `src/scheduler` | 620/620 (100.00%) | 233/239 (97.49%) | +| `src/server` | 1671/1673 (99.88%) | 1182/1212 (97.52%) | +| `src/services` | 152/155 (98.06%) | 79/85 (92.94%) | +| `src/streaming` | 984/987 (99.70%) | 732/771 (94.94%) | +| `src/ui` | 0/37 (0.00%) | 0/37 (0.00%) | +| `src/utils` | 12/12 (100.00%) | 6/6 (100.00%) | + +Highest-priority follow-ups are schedule/automation editors (mutation payloads, +validation and failure recovery), device settings (save/revert and side scope), +and VitalsPanel/PiezoWaveform (stream lifecycle and cleanup). Large uncovered +presentation surfaces also include DiagnosticsConsole, VitalsChart, +DualSideChart and SleepStagesCard. Hardware paths already have high statement +coverage, but NATS lifecycle/probe branches and startup instrumentation still +need targeted failure-path work. + +| Python CI scope | Passing tests | Statements | Branches | +| --- | ---: | ---: | ---: | +| `common` | 194 | 700/888 | 242/308 | +| `cover-buttons` | 11 | 43/71 | 15/22 | +| `environment-monitor` | 21 | 71/131 | 15/42 | +| `calibrator` | 12 | 63/185 | 24/74 | +| `piezo-processor` | 84 | 442/909 | 142/322 | +| `sleep-detector` | 31 | 268/475 | 70/162 | + +Python follow-ups: common calibration persistence/temperature correction; +calibrator orchestration; sleep-detector run-loop transitions; environment +writer lifecycle; NATS reconnect/shutdown and RAW rotation. The piezo-processor +scope includes the untested `prototype_v2.py`; its production `main.py` has +76% combined statement/branch coverage. Unit stubs do not establish runtime +compatibility with Pod-specific services. + +## Validation and reproduction + +- Full Vitest run: **3,828 passed, one existing skipped, 162 files passed**. +- Full six-job Python matrix: **353 passed** under Python 3.10. +- TypeScript typecheck and ESLint on all added TS/TSX tests passed. +- CodeRabbit follow-up: the strengthened FIFO assertions failed against the + original lexical queue ordering. Queue reads and deletion now share numeric + timestamp/PID/counter ordering, preserving existing unpadded filenames. + This is the only production runtime change in the audit PR. + +```sh +pnpm install --frozen-lockfile +pnpm test run --coverage --maxWorkers=4 +pnpm tsc +pnpm exec eslint src/automation/tests/biometricsSignalReader.test.ts \ + src/components/TemperatureDial/tests/TemperatureDial.test.tsx \ + src/components/PowerButton/tests/PowerButton.test.tsx \ + src/components/diagnostics/tests/trendChartData.test.tsx + +# In modules/ (Python 3.10 with pytest, pytest-cov and cbor2): +python -m pytest common --cov=common --cov-config=.coveragerc --cov-report=term-missing +# In each module directory, with its test/runtime dependencies installed: +python -m pytest . --cov=. --cov-config=../.coveragerc --cov-report=term-missing +``` + +The new Vitest `coverage/coverage-summary.json` and the existing lcov output +provide machine-readable results; Python CI continues uploading coverage XML +and JUnit for each module independently. diff --git a/modules/.coveragerc b/modules/.coveragerc new file mode 100644 index 000000000..5607e2da0 --- /dev/null +++ b/modules/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = True +omit = + */test_*.py + */conftest.py diff --git a/modules/common/calibration.py b/modules/common/calibration.py index 890230212..6957d7237 100644 --- a/modules/common/calibration.py +++ b/modules/common/calibration.py @@ -800,6 +800,15 @@ def is_present_piezo_calibrated( # ── CalibrationWatcher ── +def _trigger_sort_key(path: Path) -> tuple: + """Order timestamp/PID/counter numerically, including legacy queued files. + + The bare legacy trigger has no numeric suffix and sorts first. Within a + writer's same-millisecond requests, counter 9 must precede counter 10. + """ + return tuple(int(part) for part in path.name.split(".")[2:] if part.isdecimal()) + + class CalibrationWatcher: """Watch for calibration trigger files written by tRPC. @@ -813,7 +822,7 @@ def check_trigger(self) -> Optional[dict]: try: # Check for queued triggers (*.trigger files) trigger_dir = TRIGGER_PATH.parent - triggers = sorted(trigger_dir.glob(".calibrate-trigger*")) + triggers = sorted(trigger_dir.glob(".calibrate-trigger*"), key=_trigger_sort_key) triggers = [t for t in triggers if not t.suffix == ".tmp"] if not triggers: return None @@ -836,7 +845,7 @@ def clear_trigger(self) -> None: """Delete the oldest trigger file after calibration completes.""" try: trigger_dir = TRIGGER_PATH.parent - triggers = sorted(trigger_dir.glob(".calibrate-trigger*")) + triggers = sorted(trigger_dir.glob(".calibrate-trigger*"), key=_trigger_sort_key) triggers = [t for t in triggers if not t.suffix == ".tmp"] if triggers: triggers[0].unlink(missing_ok=True) diff --git a/modules/common/test_calibration_algorithms.py b/modules/common/test_calibration_algorithms.py new file mode 100644 index 000000000..fb74ec402 --- /dev/null +++ b/modules/common/test_calibration_algorithms.py @@ -0,0 +1,146 @@ +"""Deterministic sensor baseline and validation contracts.""" +import struct +from unittest.mock import Mock +import pytest +from common.calibration import CapCalibrator, CapSense2Calibrator, HRValidator, PiezoCalibrator + + +def cap_records(count): + return [{"ts": 1000 + i, "left": {"out": 100, "cen": 200, "in": 300}} for i in range(count)] + + +def cap2_records(count): + return [{"ts": 1000 + i, "right": {"values": [10, 12, 20, 22, 30, 32]}} for i in range(count)] + + +@pytest.mark.parametrize("calibrator", [CapCalibrator, CapSense2Calibrator, PiezoCalibrator]) +def test_empty_input_rejected(calibrator): + with pytest.raises(ValueError, match="No .* records"): + calibrator().calibrate([], "left") + + +@pytest.mark.parametrize("calibrator,records,side", [ + (CapCalibrator, cap_records(59) + [{"right": {"out": 100}}], "left"), + (CapSense2Calibrator, cap2_records(59) + [{}, {"right": {"values": [1, 2]}}], "right"), + (PiezoCalibrator, [{"left1": [-1, 1]}] * 59 + [{}, {"left1": b""}, {"left1": []}, {"left1": "bad"}], "left"), +]) +def test_minimum_counts_only_usable_samples_for_selected_side(calibrator, records, side): + with pytest.raises(ValueError, match="Insufficient .*59"): + calibrator().calibrate(records, side) + + +def test_cap_baseline_scans_final_window_and_floors_std(): + records = cap_records(301) + records[0]["left"] = {"out": 10000, "cen": 10000, "in": 10000} + result = CapCalibrator().calibrate(records, "left") + assert (result.window_start, result.window_end, result.samples_used) == (1001, 1300, 300) + assert result.quality_score == 1 + assert result.params == {"threshold": 6.0, "channels": { + "out": {"mean": 100, "std": 5}, "cen": {"mean": 200, "std": 5}, "in": {"mean": 300, "std": 5}, + }} + + +@pytest.mark.parametrize("count", [60, 300]) +def test_capsense2_accepts_six_channel_firmware_and_short_quiet_windows(count): + result = CapSense2Calibrator().calibrate(cap2_records(count), "right") + assert (result.window_start, result.window_end, result.samples_used) == (1000, 999 + count, count) + assert result.quality_score == 1 + assert result.params == {"format": "capSense2", "threshold": 6.0, "channels": { + "A": {"mean": 11, "std": .05}, "B": {"mean": 21, "std": .05}, "C": {"mean": 31, "std": .05}, + }} + + +def test_capsense2_uses_final_quiet_sensing_window_ignoring_ref_variance(): + records = cap2_records(301) + records[0]["right"]["values"] = [1000] * 6 + for i in range(1, 301): + records[i]["right"]["values"] = [10, 12, 20, 22, 30, 32, i, i + 2] + result = CapSense2Calibrator().calibrate(records, "right") + assert result.window_start == 1001 + assert result.samples_used == 300 + assert result.quality_score == 1 + assert result.params["ref"] == {"mean": 151.5, "std": pytest.approx(86.6021)} + + +def test_capsense2_mixed_reference_frames_keep_timestamp_alignment(): + records = cap2_records(60) + records[-1]["right"]["values"] = [10, 12, 20, 22, 30, 32, 1.1, 1.3] + result = CapSense2Calibrator().calibrate(records, "right") + assert result.params["ref"] == {"mean": 1.2, "std": .001} + assert result.samples_used == 60 + + +@pytest.mark.parametrize("encoding", [list, bytes, bytearray]) +@pytest.mark.parametrize("amplitude,threshold,quality", [(100, 50000, .996), (10000, 120000, .833)]) +def test_piezo_signed_samples_threshold_floor_and_signal_scaled_quality(encoding, amplitude, threshold, quality): + samples = [-amplitude, 0, amplitude] + raw = samples if encoding is list else encoding(struct.pack("<3i", *samples) + b"\xff") + result = PiezoCalibrator().calibrate([{"ts": 1000 + i, "left1": raw} for i in range(60)], "left") + assert result.params == { + "noise_floor_rms": amplitude * 2, "presence_threshold": threshold, "baseline_mean_range": amplitude * 2, + } + assert result.quality_score == quality + assert (result.window_start, result.window_end, result.samples_used) == (1000, 1059, 60) + + +def test_piezo_scans_last_quiet_window(): + records = [{"ts": 1000 + i, "right1": [-100, 100]} for i in range(301)] + records[0]["right1"] = [-100000, 100000] + result = PiezoCalibrator().calibrate(records, "right") + assert (result.window_start, result.window_end, result.samples_used) == (1001, 1300, 300) + assert result.params["noise_floor_rms"] == 200 + + +@pytest.mark.parametrize("field,valid,invalid", [ + ("hr", [30, 100], [29.99, 100.01]), ("hrv", [8, 100], [7.99, 100.01]), ("br", [6, 22], [5.99, 22.01]), +]) +def test_validator_hard_bounds_are_inclusive_and_reject_without_clamping(field, valid, invalid): + for value in valid: + result = HRValidator().validate(**{"hr": None, field: value}) + assert getattr(result, field + "_validated") == value + assert result.flags == [] + for value in invalid: + result = HRValidator().validate(**{"hr": None, field: value}) + assert getattr(result, field + "_validated") is None + assert result.flags == [field + "_out_of_bounds"] + if field == "hr": + assert result.hr_raw == value + + +def test_validator_missing_data_does_not_invent_vitals(): + result = HRValidator().validate(None) + assert (result.hr_validated, result.hrv_validated, result.br_validated, result.hr_raw) == (None,) * 4 + assert result.flags == [] + assert result.quality_score == .32 + + +def test_validator_dynamic_bounds_flag_but_retain_plausible_stage_transition(): + validator = HRValidator() + for _ in range(30): + assert validator.validate(60).flags == [] + assert validator.validate(67.5).flags == [] + result = validator.validate(68) + assert result.hr_validated == 68 + assert result.flags == ["hr_dynamic_bounds"] + assert result.quality_score == .47 + + +@pytest.mark.parametrize("age,quality", [(0, 1), (12, 1), (30, .925), (48, .85), (72, .85)]) +def test_validator_calibration_freshness_decays_between_12_and_48_hours(age, quality): + store = Mock() + store.get_active.return_value = {"parameters": '{"noise_floor_rms":100}'} + store.get_profile_age_hours.return_value = age + result = HRValidator(store, "right").validate(60, 40, 12, signal_rms=1000) + assert result.quality_score == quality + assert result.flags == [] + store.get_active.assert_called_once_with("right", "piezo") + + +@pytest.mark.parametrize("rms,flags,quality", [(200, ["low_signal"], .68), (300, [], .72), (2000, [], 1)]) +def test_validator_signal_to_noise_threshold_and_saturation(rms, flags, quality): + store = Mock() + store.get_active.return_value = {"parameters": {"noise_floor_rms": 100}} + store.get_profile_age_hours.return_value = 0 + result = HRValidator(store).validate(60, 40, 12, signal_rms=rms) + assert result.flags == flags + assert result.quality_score == quality diff --git a/modules/common/test_calibration_triggers.py b/modules/common/test_calibration_triggers.py new file mode 100644 index 000000000..ae9854903 --- /dev/null +++ b/modules/common/test_calibration_triggers.py @@ -0,0 +1,97 @@ +"""Calibration trigger queue contracts using a real temporary directory.""" +import json +from itertools import count +import pytest +from common import calibration + + +@pytest.fixture +def trigger_dir(tmp_path, monkeypatch): + monkeypatch.setattr(calibration, "TRIGGER_PATH", tmp_path / ".calibrate-trigger") + return tmp_path + + +def test_empty_queue_and_clear_are_idempotent(trigger_dir): + watcher = calibration.CalibrationWatcher() + assert watcher.check_trigger() is None + watcher.clear_trigger() + watcher.clear_trigger() + assert list(trigger_dir.iterdir()) == [] + + +def test_reads_oldest_without_consuming_and_ignores_incomplete_tmp_files(trigger_dir): + older = trigger_dir / ".calibrate-trigger.9" + newer = trigger_dir / ".calibrate-trigger.10" + incomplete = trigger_dir / ".calibrate-trigger.000.tmp" + older.write_text(json.dumps({"side": "left", "sensor": "cap"})) + newer.write_text(json.dumps({"side": "right", "sensor": "piezo"})) + incomplete.write_text('{"side":') + watcher = calibration.CalibrationWatcher() + assert watcher.check_trigger() == {"side": "left", "sensor": "cap"} + assert watcher.check_trigger() == {"side": "left", "sensor": "cap"} + assert older.exists() + watcher.clear_trigger() + assert watcher.check_trigger() == {"side": "right", "sensor": "piezo"} + watcher.clear_trigger() + assert watcher.check_trigger() is None + assert incomplete.exists() + + +@pytest.mark.parametrize("invalid", ['{"side":', '[]', 'null', '42', '"left"']) +def test_discards_bad_trigger_then_allows_next_request(trigger_dir, invalid): + bad = trigger_dir / ".calibrate-trigger.9" + good = trigger_dir / ".calibrate-trigger.10" + bad.write_text(invalid) + good.write_text('{"side":"right"}') + watcher = calibration.CalibrationWatcher() + assert watcher.check_trigger() is None + assert not bad.exists() + assert good.exists() + assert watcher.check_trigger() == {"side": "right"} + + +def test_same_millisecond_writes_remain_distinct_and_leave_no_tmp_files(trigger_dir, monkeypatch): + monkeypatch.setattr(calibration.time, "time", lambda: 1_000.125) + monkeypatch.setattr(calibration, "_trigger_seq", count(8)) + payloads = [{"side": "left", "request": n} for n in range(3)] + for payload in payloads: + calibration.write_trigger_atomic(payload) + files = sorted(trigger_dir.iterdir()) + assert len(files) == len(payloads) + assert all(path.suffix != ".tmp" for path in files) + assert sorted(json.loads(path.read_text())["request"] for path in files) == [0, 1, 2] + watcher = calibration.CalibrationWatcher() + received = [] + while (payload := watcher.check_trigger()) is not None: + received.append(payload) + watcher.clear_trigger() + assert received == payloads + assert list(trigger_dir.iterdir()) == [] + + +def test_failed_rename_is_never_visible_as_a_complete_request(trigger_dir, monkeypatch): + def fail_rename(*args): + raise OSError("rename failed") + monkeypatch.setattr(calibration.Path, "rename", fail_rename) + with pytest.raises(OSError, match="rename failed"): + calibration.write_trigger_atomic({"side": "left"}) + assert calibration.CalibrationWatcher().check_trigger() is None + assert all(path.suffix == ".tmp" for path in trigger_dir.iterdir()) + + +def test_mixed_legacy_and_sequenced_filenames_are_read_and_cleared_in_order(trigger_dir): + names = [ + ".calibrate-trigger", + ".calibrate-trigger.9", + ".calibrate-trigger.10.1.000000008", + ".calibrate-trigger.10.1.9", + ".calibrate-trigger.10.1.10", + ] + for index, name in reversed(list(enumerate(names))): + (trigger_dir / name).write_text(json.dumps({"request": index})) + watcher = calibration.CalibrationWatcher() + for index, name in enumerate(names): + assert watcher.check_trigger() == {"request": index} + watcher.clear_trigger() + assert not (trigger_dir / name).exists() + assert watcher.check_trigger() is None diff --git a/modules/common/test_cbor_raw.py b/modules/common/test_cbor_raw.py new file mode 100644 index 000000000..1584a046a --- /dev/null +++ b/modules/common/test_cbor_raw.py @@ -0,0 +1,86 @@ +"""Protocol tests for RAW framing, independent of follower tests' module stubs.""" +import importlib.util +import io +from pathlib import Path +import pytest + +# Follower tests stub common.cbor_raw. Load the actual parser under a private +# name so collection order cannot select the stub instead of production code. +_spec = importlib.util.spec_from_file_location( + "_raw_parser_under_test", Path(__file__).with_name("cbor_raw.py") +) +_parser = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_parser) +read_raw_record = _parser.read_raw_record + +SEQ_ENCODINGS = [ + b"\x00", b"\x17", b"\x18\xff", b"\x19\x12\x34", + b"\x1a\x12\x34\x56\x78", b"\x1b\x01\x02\x03\x04\x05\x06\x07\x08", +] +LENGTH_ENCODINGS = [ + b"\x43", b"\x58\x03", b"\x59\x00\x03", + b"\x5a\x00\x00\x00\x03", b"\x5b\x00\x00\x00\x00\x00\x00\x00\x03", +] + + +def record(seq=b"\x1a\x00\x00\x00\x01", length=b"\x43", payload=b"abc"): + return b"\xa2\x63seq" + seq + b"\x64data" + length + payload + + +@pytest.mark.parametrize("seq", SEQ_ENCODINGS) +@pytest.mark.parametrize("length", LENGTH_ENCODINGS) +def test_accepts_unsigned_sequence_and_byte_string_length_widths(seq, length): + encoded = record(seq, length) + stream = io.BytesIO(encoded + record(payload=b"xyz")) + assert read_raw_record(stream) == b"abc" + assert stream.tell() == len(encoded) + assert read_raw_record(stream) == b"xyz" + assert stream.tell() == len(stream.getvalue()) + with pytest.raises(EOFError): + read_raw_record(stream) + + +@pytest.mark.parametrize("seq", SEQ_ENCODINGS) +@pytest.mark.parametrize("length", LENGTH_ENCODINGS) +def test_every_incomplete_prefix_is_retryable_eof(seq, length): + encoded = record(seq, length) + for end in range(len(encoded)): + with pytest.raises(EOFError): + read_raw_record(io.BytesIO(encoded[:end])) + + +def test_placeholder_consumes_only_its_record(): + placeholder = record(length=b"\x40", payload=b"") + stream = io.BytesIO(placeholder + record()) + assert read_raw_record(stream) is None + assert stream.tell() == len(placeholder) + assert read_raw_record(stream) == b"abc" + + +@pytest.mark.parametrize("size", [23, 24, 255, 256, 4096, 5000, 65536, 1_000_000]) +def test_real_file_cursor_does_not_skip_records_across_buffer_boundaries(tmp_path, size): + payload = bytes(range(256)) * (size // 256) + bytes(range(size % 256)) + encoded = record(length=b"\x5a" + size.to_bytes(4, "big"), payload=payload) + path = tmp_path / "frame.RAW" + path.write_bytes(encoded + record()) + with path.open("rb") as stream: + assert read_raw_record(stream) == payload + assert stream.tell() == len(encoded) + assert read_raw_record(stream) == b"abc" + + +@pytest.mark.parametrize("encoded, message", [ + (b"\xa1", "Expected outer map"), + (b"\xa2\x63bad", "Expected seq key"), + (record(seq=b"\x20"), "seq must be unsigned"), + (record(seq=b"\x1c"), "Unexpected seq encoding"), + (record(seq=b"\x1f"), "Unexpected seq encoding"), + (b"\xa2\x63seq\x00\x64oops", "Expected data key"), + (record(length=b"\x63"), "data must be a byte string"), + (record(length=b"\x5c"), "Unsupported data length encoding"), + (record(length=b"\x5f"), "Unsupported data length encoding"), + (record(length=b"\x5a" + (1_000_001).to_bytes(4, "big"), payload=b""), "Implausibly large"), +]) +def test_rejects_malformed_structure_without_waiting_for_payload(encoded, message): + with pytest.raises(ValueError, match=message): + read_raw_record(io.BytesIO(encoded)) diff --git a/src/automation/tests/biometricsSignalReader.test.ts b/src/automation/tests/biometricsSignalReader.test.ts new file mode 100644 index 000000000..248f50295 --- /dev/null +++ b/src/automation/tests/biometricsSignalReader.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as schema from '@/src/db/biometrics-schema' +import type { LatestCapSenseSnapshot } from '@/src/streaming/piezoStream' +import { BiometricsSignalReader } from '../signals.biometrics' + +const state = vi.hoisted(() => ({ + db: null as ReturnType> | null, + cap: null as LatestCapSenseSnapshot | null, +})) +vi.mock('@/src/db', () => ({ + get biometricsDb() { + return state.db + }, +})) +vi.mock('@/src/streaming/piezoStream', () => ({ getLatestCapSenseSnapshot: () => state.cap })) + +const NOW = new Date('2026-09-06T12:00:00Z') +const ago = (ms: number) => new Date(NOW.getTime() - ms) +let raw: Database.Database +let db: ReturnType> +const reader = new BiometricsSignalReader() + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + raw = new Database(':memory:') + db = drizzle(raw, { schema }) + migrate(db, { migrationsFolder: path.resolve('src/db/biometrics-migrations') }) + state.db = db + state.cap = null +}) +afterEach(() => { + if (raw.open) raw.close() + state.db = null + vi.useRealTimers() + vi.restoreAllMocks() +}) + +function seedEnvironment(timestamp: Date) { + db.insert(schema.bedTemp).values({ + timestamp, ambientTemp: 2000, humidity: 4500, + leftOuterTemp: 1000, leftCenterTemp: 2000, leftInnerTemp: 3000, + rightOuterTemp: 3000, rightCenterTemp: 2000, rightInnerTemp: 1000, + }).run() + db.insert(schema.freezerTemp).values({ timestamp, leftWaterTemp: 0, rightWaterTemp: 4000 }).run() + db.insert(schema.ambientLight).values({ timestamp, lux: 0 }).run() +} + +const environment = { + 'ambient.temperature': 68, 'ambient.humidity': 45, 'ambient.light': 0, + 'left.surfaceTemp': 68, 'left.surfaceTemp.spread': 36, 'left.surfaceTemp.gradient': 36, + 'right.surfaceTemp': 68, 'right.surfaceTemp.spread': 36, 'right.surfaceTemp.gradient': -36, + 'left.waterTemp': 32, 'right.waterTemp': 104, +} + +describe('BiometricsSignalReader', () => { + it('returns no signals before any samples arrive', () => { + expect(reader.read()).toEqual({}) + }) + + it('selects the newest row independently for each side and source', () => { + // Insert older rows last: an unordered query must not accidentally pass. + for (const [side, heartRate, totalMovement] of [['left', 61, 0], ['right', 72, 15]] as const) { + db.insert(schema.vitals).values([ + { side, timestamp: NOW, heartRate, hrv: 40, breathingRate: 12 }, + { side, timestamp: ago(60_000), heartRate: 99, hrv: 99, breathingRate: 99 }, + ]).run() + db.insert(schema.movement).values([ + { side, timestamp: NOW, totalMovement }, + { side, timestamp: ago(60_000), totalMovement: 99 }, + ]).run() + } + expect(reader.read()).toEqual({ + 'left.heartRate': 61, 'left.hrv': 40, 'left.breathingRate': 12, 'left.movement': 0, + 'right.heartRate': 72, 'right.hrv': 40, 'right.breathingRate': 12, 'right.movement': 15, + }) + }) + + it('keeps zero vitals but does not resurrect missing values from older rows', () => { + db.insert(schema.vitals).values([ + { side: 'left', timestamp: NOW, heartRate: 0, hrv: 0, breathingRate: 0 }, + { side: 'right', timestamp: NOW }, + { side: 'right', timestamp: ago(1000), heartRate: 65, hrv: 30, breathingRate: 14 }, + ]).run() + expect(reader.read()).toEqual({ 'left.heartRate': 0, 'left.hrv': 0, 'left.breathingRate': 0 }) + }) + + it.each([0, 1])('expires vitals and movement %i ms past their five-minute boundary', (extra) => { + db.insert(schema.vitals).values({ side: 'left', timestamp: ago(300_000), heartRate: 60 }).run() + db.insert(schema.movement).values({ side: 'right', timestamp: ago(300_000), totalMovement: 7 }).run() + vi.setSystemTime(NOW.getTime() + extra) + expect(reader.read()).toEqual(extra ? {} : { 'left.heartRate': 60, 'right.movement': 7 }) + }) + + it.each([0, 1])('expires all environment sources %i ms past their fifteen-minute boundary', (extra) => { + seedEnvironment(ago(900_000)) + vi.setSystemTime(NOW.getTime() + extra) + expect(reader.read()).toEqual(extra ? {} : environment) + }) + + it('uses the newest environment rows even when they contain missing readings', () => { + seedEnvironment(ago(1000)) + db.insert(schema.bedTemp).values({ timestamp: NOW }).run() + db.insert(schema.freezerTemp).values({ timestamp: NOW }).run() + db.insert(schema.ambientLight).values({ timestamp: NOW }).run() + expect(reader.read()).toEqual({}) + }) + + it('averages only available surface zones and requires both endpoints for a gradient', () => { + db.insert(schema.bedTemp).values({ + timestamp: NOW, leftCenterTemp: 0, rightOuterTemp: 1000, rightCenterTemp: 3000, + }).run() + expect(reader.read()).toEqual({ + 'left.surfaceTemp': 32, 'right.surfaceTemp': 68, 'right.surfaceTemp.spread': 36, + }) + }) + + it('computes a gradient when the center is missing and keeps independent nullable water readings', () => { + db.insert(schema.bedTemp).values({ timestamp: NOW, leftOuterTemp: 3000, leftInnerTemp: 1000 }).run() + db.insert(schema.freezerTemp).values({ timestamp: NOW, rightWaterTemp: 0 }).run() + expect(reader.read()).toEqual({ + 'left.surfaceTemp': 68, 'left.surfaceTemp.spread': 36, 'left.surfaceTemp.gradient': -36, + 'right.waterTemp': 32, + }) + }) + + it('drops stale sources without suppressing fresh sources on either side', () => { + seedEnvironment(ago(901_000)) + db.insert(schema.vitals).values({ side: 'left', timestamp: ago(301_000), heartRate: 60 }).run() + db.insert(schema.vitals).values({ side: 'right', timestamp: NOW, heartRate: 70 }).run() + db.insert(schema.movement).values({ side: 'left', timestamp: NOW, totalMovement: 3 }).run() + expect(reader.read()).toEqual({ 'right.heartRate': 70, 'left.movement': 3 }) + }) + + it('reduces capSense2 body channels without including the two reference channels', () => { + state.cap = { + type: 'capSense2', ts: 0, receivedAtMs: NOW.getTime(), + left: [1, 2, 3, 4, 5, 6, 999, -999], right: [10, 20, 30, 40, 50, 60, 9999, -9999], + } + expect(reader.read()).toEqual({ + 'left.cap.max': 6, 'left.cap.mean': 3.5, 'left.cap.spread': 5, + 'right.cap.max': 60, 'right.cap.mean': 35, 'right.cap.spread': 50, + }) + }) + + it.each([0, 1])('expires scalar capSense %i ms past receipt freshness, ignoring firmware time', (extra) => { + state.cap = { type: 'capSense', ts: 0, receivedAtMs: NOW.getTime() - 30_000, left: 0, right: 12 } + vi.setSystemTime(NOW.getTime() + extra) + expect(reader.read()).toEqual(extra + ? {} + : { + 'left.cap.max': 0, 'left.cap.mean': 0, 'left.cap.spread': 0, + 'right.cap.max': 12, 'right.cap.mean': 12, 'right.cap.spread': 0, + }) + }) + + it('omits empty capacitive arrays independently by side', () => { + state.cap = { type: 'capSense2', ts: 0, receivedAtMs: NOW.getTime(), left: [], right: [4, 6] } + expect(reader.read()).toEqual({ 'right.cap.max': 6, 'right.cap.mean': 5, 'right.cap.spread': 2 }) + }) + + it('warns and returns an empty snapshot if the database is unavailable', () => { + raw.close() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(reader.read()).toEqual({}) + expect(warn).toHaveBeenCalledWith('[automation] BiometricsSignalReader.read failed:', expect.any(Error)) + }) + + it('retains signals already read when a later source fails', () => { + db.insert(schema.vitals).values({ side: 'left', timestamp: NOW, heartRate: 63 }).run() + raw.exec('DROP TABLE movement') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(reader.read()).toEqual({ 'left.heartRate': 63 }) + expect(warn).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/PowerButton/tests/PowerButton.test.tsx b/src/components/PowerButton/tests/PowerButton.test.tsx new file mode 100644 index 000000000..a3c638b36 --- /dev/null +++ b/src/components/PowerButton/tests/PowerButton.test.tsx @@ -0,0 +1,137 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PowerButton } from '../PowerButton' + +const state = vi.hoisted(() => ({ + activeSides: ['left'] as Array<'left' | 'right'>, + primarySide: 'left' as 'left' | 'right', + status: null as { leftSide: { targetLevel: number }, rightSide: { targetLevel: number } } | null, + pending: false, + mutate: vi.fn<(input: { side: string, powered: boolean, temperature?: number }) => Promise>(), + invalidate: vi.fn(), + onSuccess: () => {}, +})) +vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => state })) +vi.mock('@/src/hooks/useDeviceStatus', () => ({ useDeviceStatus: () => ({ status: state.status }) })) +vi.mock('@/src/utils/trpc', () => ({ + trpc: { + useUtils: () => ({ device: { getStatus: { invalidate: state.invalidate } } }), + device: { setPower: { useMutation: (options: { onSuccess: () => void }) => { + state.onSuccess = options.onSuccess + return { isPending: state.pending, mutateAsync: state.mutate } + } } }, + }, +})) + +function status() { + if (!state.status) throw new Error('Status fixture is absent') + return state.status +} + +beforeEach(() => { + vi.useFakeTimers() + state.activeSides = ['left'] + state.primarySide = 'left' + state.status = { leftSide: { targetLevel: 0 }, rightSide: { targetLevel: 0 } } + state.pending = false + state.invalidate.mockReset() + state.mutate.mockReset().mockImplementation(async () => { + state.onSuccess() + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +async function click(name: 'Turn on' | 'Turn off') { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name })) + }) +} + +describe('PowerButton', () => { + it('turns on a selected side at the neutral setpoint and holds optimism across stale frames', async () => { + const { rerender } = render() + await click('Turn on') + expect(state.mutate).toHaveBeenCalledExactlyOnceWith({ side: 'left', powered: true, temperature: 75 }) + expect(state.invalidate).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() + rerender() + expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() + act(() => vi.advanceTimersByTime(5999)) + expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() + }) + + it('follows subsequent server changes once power has been acknowledged', async () => { + const { rerender } = render() + await click('Turn on') + status().leftSide.targetLevel = 1 + rerender() + expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() + status().leftSide.targetLevel = 0 + rerender() + expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() + }) + + it('uses the right primary side and powers it off without sending a temperature', async () => { + state.primarySide = 'right' + state.activeSides = ['right'] + status().rightSide.targetLevel = -10 + render() + await click('Turn off') + expect(state.mutate.mock.calls).toEqual([[{ side: 'right', powered: false }]]) + expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() + }) + + it.each([true, false])('sends linked power=%s to both sides', async (powered) => { + state.activeSides = ['left', 'right'] + status().leftSide.targetLevel = powered ? 0 : 10 + render() + await click(powered ? 'Turn on' : 'Turn off') + expect(state.mutate.mock.calls).toEqual(powered + ? [[{ side: 'left', powered: true, temperature: 75 }], [{ side: 'right', powered: true, temperature: 75 }]] + : [[{ side: 'left', powered: false }], [{ side: 'right', powered: false }]]) + }) + + it('reverts optimism and refetches after one linked-side mutation fails', async () => { + state.activeSides = ['left', 'right'] + state.mutate.mockImplementation(async ({ side }) => { + if (side === 'right') throw new Error('right DAC rejected write') + state.onSuccess() + }) + render() + await click('Turn on') + expect(state.mutate).toHaveBeenCalledTimes(2) + expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() + expect(state.invalidate).toHaveBeenCalledTimes(2) // success + recovery refetch + expect(vi.getTimerCount()).toBe(0) + }) + + it('shows optimism while a mutation is unresolved and disables further clicks while pending', async () => { + let resolve!: () => void + state.mutate.mockImplementation(() => new Promise((done) => { + resolve = done + })) + const { rerender } = render() + await click('Turn on') + expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() + state.pending = true + rerender() + const button = screen.getByRole('button', { name: 'Turn off' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(state.mutate).toHaveBeenCalledOnce() + await act(async () => { + resolve() + }) + }) + + it('starts off before the first status frame arrives', () => { + state.status = null + render() + expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() + }) +}) diff --git a/src/components/TemperatureDial/tests/TemperatureDial.test.tsx b/src/components/TemperatureDial/tests/TemperatureDial.test.tsx new file mode 100644 index 000000000..6c65fe067 --- /dev/null +++ b/src/components/TemperatureDial/tests/TemperatureDial.test.tsx @@ -0,0 +1,113 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TemperatureDial } from '../TemperatureDial' + +// JSDOM has no layout or pointer capture. Keep React event handling real while +// supplying a scaled, offset SVG viewport, as a browser would. +function setup(props: Partial> = {}) { + const onTemperatureChange = vi.fn() + const onTemperatureCommit = vi.fn() + const view = render() + const dial = screen.getByRole('slider') + const [, , width, height] = (dial.getAttribute('viewBox') ?? '').split(' ').map(Number) + vi.spyOn(dial, 'getBoundingClientRect').mockReturnValue({ + left: 40, top: 20, width: width / 2, height: height / 2, + right: 40 + width / 2, bottom: 20 + height / 2, x: 40, y: 20, toJSON: () => ({}), + }) + Object.assign(dial, { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() }) + const at = (x: number, y: number) => ({ clientX: 40 + x / 2, clientY: 20 + y / 2, pointerId: 1 }) + return { ...view, dial, at, onTemperatureChange, onTemperatureCommit } +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('TemperatureDial', () => { + it.each([ + ['ArrowUp', 75, 'F', 76], ['ArrowRight', 75, 'F', 76], + ['ArrowDown', 75, 'F', 74], ['ArrowLeft', 75, 'F', 74], + ['ArrowUp', 110, 'F', 110], ['ArrowDown', 55, 'F', 55], + ['ArrowUp', 68, 'C', 70], ['ArrowDown', 68, 'C', 66], + ['ArrowUp', 110, 'C', 110], ['ArrowDown', 55, 'C', 55], + ] as const)('%s at %iΒ°F displayed in %s commits %iΒ°F', (key, targetTempF, unit, expected) => { + const { dial, onTemperatureChange, onTemperatureCommit } = setup({ targetTempF, unit }) + fireEvent.keyDown(dial, { key }) + expect(onTemperatureChange).toHaveBeenCalledExactlyOnceWith(expected) + expect(onTemperatureCommit).toHaveBeenCalledExactlyOnceWith(expected) + }) + + it('exposes Fahrenheit bounds and a Celsius accessible value', () => { + const { dial } = setup({ targetTempF: 68, unit: 'C' }) + expect(dial.getAttribute('aria-valuemin')).toBe('55') + expect(dial.getAttribute('aria-valuemax')).toBe('110') + expect(dial.getAttribute('aria-valuenow')).toBe('68') + expect(dial.getAttribute('aria-valuetext')).toBe('20Β°C') + expect(dial.getAttribute('tabindex')).toBe('0') + }) + + it('ignores unrelated keys, pointer moves, and releases before a drag', () => { + const { dial, at, onTemperatureChange, onTemperatureCommit } = setup() + fireEvent.keyDown(dial, { key: 'Enter' }) + fireEvent.pointerMove(dial, at(302, 162)) + fireEvent.pointerUp(dial, at(302, 162)) + expect(onTemperatureChange).not.toHaveBeenCalled() + expect(onTemperatureCommit).not.toHaveBeenCalled() + }) + + it('previews drag changes once per temperature and commits only on release', () => { + const { dial, at, onTemperatureChange, onTemperatureCommit } = setup() + fireEvent.pointerDown(dial, at(162, 22)) // top of dial = midpoint, rounded to 83Β°F + expect(onTemperatureChange).toHaveBeenLastCalledWith(83) + expect(onTemperatureCommit).not.toHaveBeenCalled() + fireEvent.pointerMove(dial, at(302, 162)) // right of dial = 101Β°F + fireEvent.pointerMove(dial, at(302, 162)) + expect(onTemperatureChange.mock.calls).toEqual([[83], [101]]) + expect(onTemperatureCommit).not.toHaveBeenCalled() + fireEvent.pointerUp(dial, at(302, 162)) + expect(onTemperatureCommit).toHaveBeenCalledExactlyOnceWith(101) + fireEvent.pointerMove(dial, at(22, 162)) + fireEvent.pointerUp(dial, at(22, 162)) + expect(onTemperatureChange).toHaveBeenCalledTimes(2) + expect(onTemperatureCommit).toHaveBeenCalledTimes(1) + }) + + it('ignores the bottom arc gap and commits the last valid preview on cancellation', () => { + const { dial, at, onTemperatureChange, onTemperatureCommit } = setup() + fireEvent.pointerDown(dial, at(162, 302)) + expect(onTemperatureChange).not.toHaveBeenCalled() + fireEvent.pointerMove(dial, at(22, 162)) + expect(onTemperatureChange).toHaveBeenCalledExactlyOnceWith(64) + fireEvent.pointerMove(dial, at(162, 302)) + fireEvent.pointerCancel(dial, at(162, 302)) + expect(onTemperatureCommit).toHaveBeenCalledExactlyOnceWith(64) + }) + + it('keeps all interactions inactive when power is off', () => { + const { dial, at, onTemperatureChange, onTemperatureCommit } = setup({ isOn: false }) + expect(dial.getAttribute('tabindex')).toBe('-1') + fireEvent.keyDown(dial, { key: 'ArrowUp' }) + fireEvent.pointerDown(dial, at(162, 22)) + fireEvent.pointerMove(dial, at(302, 162)) + fireEvent.pointerUp(dial, at(302, 162)) + expect(onTemperatureChange).not.toHaveBeenCalled() + expect(onTemperatureCommit).not.toHaveBeenCalled() + expect(screen.queryByText('NOW')).toBeNull() + }) + + it.each([[70, 'WARMING'], [80, 'COOLING']] as const)('shows the direction from %iΒ°F', (currentTempF, label) => { + setup({ currentTempF }) + expect(screen.getByText(label)).toBeTruthy() + }) + + it('shows no direction at the target and supports preview-only consumers', () => { + const { dial, at, onTemperatureChange } = setup({ onTemperatureCommit: undefined }) + expect(screen.queryByText('WARMING')).toBeNull() + expect(screen.queryByText('COOLING')).toBeNull() + fireEvent.keyDown(dial, { key: 'ArrowUp' }) + fireEvent.pointerDown(dial, at(162, 22)) + fireEvent.pointerUp(dial, at(162, 22)) + expect(onTemperatureChange.mock.calls).toEqual([[76], [83]]) + }) +}) diff --git a/src/components/diagnostics/tests/trendChartData.test.tsx b/src/components/diagnostics/tests/trendChartData.test.tsx new file mode 100644 index 000000000..6100ac434 --- /dev/null +++ b/src/components/diagnostics/tests/trendChartData.test.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BiometricsTrendChart, type VitalSample } from '../BiometricsTrendChart' +import { ThermalTrendChart } from '../ThermalTrendChart' + +// Capture the public chart boundary: assert the data and formatters we hand to +// Recharts without depending on ResizeObserver/layout or its SVG internals. +const chart = vi.hoisted(() => ({ + data: [] as Array>, + axes: [] as Array<{ domain?: unknown, tickFormatter?: (value: number) => string }>, + tooltip: null as { formatter: (value: number | null, name: string) => string[], labelFormatter: (value: number) => string } | null, +})) +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => children, + LineChart: ({ data, children }: { data: typeof chart.data, children: ReactNode }) => { + chart.data = data + return children + }, + XAxis: (props: (typeof chart.axes)[number]) => { + chart.axes.push(props) + return null + }, + YAxis: (props: (typeof chart.axes)[number]) => { + chart.axes.push(props) + return null + }, + Tooltip: (props: NonNullable) => { + chart.tooltip = props + return null + }, + Line: () => null, + CartesianGrid: () => null, + Legend: () => null, +})) + +beforeEach(() => { + chart.data = [] + chart.axes = [] + chart.tooltip = null +}) +afterEach(cleanup) + +describe('BiometricsTrendChart data', () => { + it('sorts without mutating input, retaining nulls and distinct vital series', () => { + const rows: VitalSample[] = [ + { timestamp: new Date(3000), heartRate: 63, hrv: 41, breathingRate: 12.5 }, + { timestamp: new Date(1000).toISOString(), heartRate: null, hrv: 0, breathingRate: null }, + ] + const original = structuredClone(rows) + render() + expect(chart.data).toEqual([ + { t: 1000, hr: null, hrv: 0, br: null }, + { t: 3000, hr: 63, hrv: 41, br: 12.5 }, + ]) + expect(rows).toEqual(original) + }) + + it('downsamples a long history in chronological order and always retains the latest sample', () => { + const rows = Array.from({ length: 242 }, (_, i) => ({ + timestamp: new Date(i * 60_000), heartRate: i, hrv: null, breathingRate: null, + })).reverse() + render() + expect(chart.data).toHaveLength(122) + expect(chart.data[0]).toEqual({ t: 0, hr: 0, hrv: null, br: null }) + expect(chart.data[1]).toEqual({ t: 120_000, hr: 2, hrv: null, br: null }) + expect(chart.data.at(-1)).toEqual({ t: 241 * 60_000, hr: 241, hrv: null, br: null }) + expect(chart.data.every((point, i) => i === 0 || Number(point.t) > Number(chart.data[i - 1].t))).toBe(true) + expect(rows[0].heartRate).toBe(241) + }) + + it('formats missing values, whole HR/HRV and fractional breathing independently', () => { + render( ({ timestamp: new Date(t), heartRate: 60, hrv: 40, breathingRate: 12 }))} />) + expect(chart.tooltip?.formatter(null, 'HR')).toEqual(['β€”', 'HR']) + expect(chart.tooltip?.formatter(60.6, 'HR')).toEqual(['61', 'HR']) + expect(chart.tooltip?.formatter(40.4, 'HRV')).toEqual(['40', 'HRV']) + expect(chart.tooltip?.formatter(12.56, 'Breathing')).toEqual(['12.6', 'Breathing']) + const time = new Date(1000) + expect(chart.tooltip?.labelFormatter(1000)).toBe(time.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })) + expect(chart.axes[0].tickFormatter?.(1000)).toBe(time.toLocaleDateString([], { weekday: 'short' })) + }) +}) + +describe('ThermalTrendChart data', () => { + it('bounds all available temperatures, excluding missing readings, with two degrees of padding', () => { + const points = [ + { t: 1000, target: 75, bed: 73.4, water: null }, + { t: 2000, target: null, bed: null, water: 68.2 }, + ] + render() + expect(chart.data).toEqual(points) + expect(chart.axes[1].domain).toEqual([66, 77]) + expect(chart.axes[1].tickFormatter?.(68.6)).toBe('69Β°') + expect(chart.axes[0].tickFormatter?.(1000)).toBe(new Date(1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })) + expect(chart.tooltip?.formatter(null, 'Bed')).toEqual(['β€”', 'Bed']) + expect(chart.tooltip?.formatter(68.25, 'Water')).toEqual(['68.3Β°F', 'Water']) + expect(chart.tooltip?.labelFormatter(1000)).toBe(new Date(1000).toLocaleTimeString()) + }) + + it('does not render a chart with an infinite domain for an off side without water samples', () => { + render( ({ t, target: null, bed: null, water: null }))} />) + expect(chart.axes).toEqual([]) + expect(chart.data).toEqual([]) + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index 467af23c6..da8678591 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -39,7 +39,11 @@ export default defineConfig({ }, coverage: { provider: 'v8', - reporter: ['text', 'lcov'], + reporter: ['text', 'lcov', 'json-summary'], + // Count production files even when no test imports them. Otherwise new, + // untested components disappear from the coverage denominator entirely. + include: ['src/**/*.{ts,tsx}', 'app/**/*.{ts,tsx}', 'instrumentation.ts', 'proxy.ts'], + exclude: ['**/tests/**', '**/*.test.{ts,tsx}', '**/*.d.ts'], }, name: 'unit', exclude: ['.claude/worktrees/**', '.codex/**', '.ygg/worktrees/**', 'node_modules/**', '.next/**'], From 7351643b39ee22086f7d047c15e19bcf122e5182 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:58:26 +0000 Subject: [PATCH 002/119] chore(deps): Update @base-ui/react all non-major dependencies (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ℹ️ **Note** > > This PR body was truncated due to platform limits. This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update | Pending | |---|---|---|---|---|---|---| | [@base-ui/react](https://base-ui.com) ([source](https://redirect.github.com/mui/base-ui/tree/HEAD/packages/react)) | [`1.7.0` β†’ `1.8.0`](https://renovatebot.com/diffs/npm/@base-ui%2freact/1.7.0/1.8.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@base-ui%2freact/1.8.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@base-ui%2freact/1.7.0/1.8.0?slim=true) | dependencies | minor | | | [@types/react-dom](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-dom) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom)) | [`19.2.5` β†’ `19.2.7`](https://renovatebot.com/diffs/npm/@types%2freact-dom/19.2.5/19.2.7) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2freact-dom/19.2.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2freact-dom/19.2.5/19.2.7?slim=true) | devDependencies | patch | | | [@xyflow/react](https://reactflow.dev) ([source](https://redirect.github.com/xyflow/xyflow/tree/HEAD/packages/react)) | [`12.11.5` β†’ `12.11.6`](https://renovatebot.com/diffs/npm/@xyflow%2freact/12.11.5/12.11.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@xyflow%2freact/12.11.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@xyflow%2freact/12.11.5/12.11.6?slim=true) | dependencies | patch | | | [eslint-config-next](https://nextjs.org/docs/app/api-reference/config/eslint) ([source](https://redirect.github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next)) | [`16.3.3` β†’ `16.3.4`](https://renovatebot.com/diffs/npm/eslint-config-next/16.3.3/16.3.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-config-next/16.3.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-config-next/16.3.3/16.3.4?slim=true) | devDependencies | patch | | | [globals](https://redirect.github.com/sindresorhus/globals) | [`17.11.0` β†’ `17.12.0`](https://renovatebot.com/diffs/npm/globals/17.11.0/17.12.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/globals/17.12.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/globals/17.11.0/17.12.0?slim=true) | devDependencies | minor | | | [lucide-react](https://lucide.dev) ([source](https://redirect.github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react)) | [`1.35.0` β†’ `1.41.0`](https://renovatebot.com/diffs/npm/lucide-react/1.35.0/1.41.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/lucide-react/1.41.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/lucide-react/1.35.0/1.41.0?slim=true) | dependencies | minor | `1.42.0` | | [next](https://nextjs.org) ([source](https://redirect.github.com/vercel/next.js)) | [`16.3.3` β†’ `16.3.4`](https://renovatebot.com/diffs/npm/next/16.3.3/16.3.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/next/16.3.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/next/16.3.3/16.3.4?slim=true) | dependencies | patch | | | [python](https://redirect.github.com/actions/python-versions) | `3.10` β†’ `3.14` | ![age](https://developer.mend.io/api/mc/badges/age/github-releases/actions%2fpython-versions/3.14.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/github-releases/actions%2fpython-versions/3.10.21/3.14.7?slim=true) | uses-with | minor | | | [shadcn](https://ui.shadcn.com) ([source](https://redirect.github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn)) | [`4.19.0` β†’ `4.21.0`](https://renovatebot.com/diffs/npm/shadcn/4.19.0/4.21.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/shadcn/4.21.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/shadcn/4.19.0/4.21.0?slim=true) | dependencies | minor | | | [tsx](https://tsx.hirok.io) ([source](https://redirect.github.com/privatenumber/tsx)) | [`4.23.12` β†’ `4.23.13`](https://renovatebot.com/diffs/npm/tsx/4.23.12/4.23.13) | ![age](https://developer.mend.io/api/mc/badges/age/npm/tsx/4.23.13?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/tsx/4.23.12/4.23.13?slim=true) | devDependencies | patch | | | [typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint) ([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)) | [`8.68.0` β†’ `8.69.0`](https://renovatebot.com/diffs/npm/typescript-eslint/8.68.0/8.69.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/typescript-eslint/8.69.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/typescript-eslint/8.68.0/8.69.0?slim=true) | devDependencies | minor | | | [zod](https://zod.dev) ([source](https://redirect.github.com/colinhacks/zod)) | [`4.4.3` β†’ `4.5.4`](https://renovatebot.com/diffs/npm/zod/4.4.3/4.5.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/zod/4.5.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zod/4.4.3/4.5.4?slim=true) | dependencies | minor | | --- ### Release Notes
mui/base-ui (@​base-ui/react) ### [`v1.8.0`](https://redirect.github.com/mui/base-ui/blob/HEAD/CHANGELOG.md#v180) [Compare Source](https://redirect.github.com/mui/base-ui/compare/v1.7.0...v1.8.0) *Sep 4, 2026* ##### General changes - Fix label association when a control unregisters ([#​5456](https://redirect.github.com/mui/base-ui/issues/5456)) by [@​atomiks](https://redirect.github.com/atomiks) - Reduce animation completion work ([#​5535](https://redirect.github.com/mui/base-ui/issues/5535)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix prop and ref merging for lazy render elements ([#​5562](https://redirect.github.com/mui/base-ui/issues/5562)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix roving focus when items are added or removed ([#​5447](https://redirect.github.com/mui/base-ui/issues/5447)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix hover and focus interaction issues ([#​5037](https://redirect.github.com/mui/base-ui/issues/5037)) by [@​atomiks](https://redirect.github.com/atomiks) - Improve trigger mount performance ([#​5426](https://redirect.github.com/mui/base-ui/issues/5426)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix disabled anchor tracking on scroll ([#​5478](https://redirect.github.com/mui/base-ui/issues/5478)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix transform origin for start/end alignment ([#​5015](https://redirect.github.com/mui/base-ui/issues/5015)) by [@​atomiks](https://redirect.github.com/atomiks) - Remove duplicate options key from arrow middleware ([#​5606](https://redirect.github.com/mui/base-ui/issues/5606)) by [@​atomiks](https://redirect.github.com/atomiks) - Register passive touch listeners ([#​5572](https://redirect.github.com/mui/base-ui/issues/5572)) by [@​wanxiankai](https://redirect.github.com/wanxiankai) ##### Alert Dialog - Ignore outside clicks from presses that began before open ([#​5378](https://redirect.github.com/mui/base-ui/issues/5378)) by [@​michaldudak](https://redirect.github.com/michaldudak) ##### Autocomplete - Render groups with the rowgroup role in grid mode ([#​5564](https://redirect.github.com/mui/base-ui/issues/5564)) by [@​atomiks](https://redirect.github.com/atomiks) - Allow opening and browsing the popup while `readOnly` ([#​5541](https://redirect.github.com/mui/base-ui/issues/5541)) by [@​vikuscz](https://redirect.github.com/vikuscz) - Move `aria-orientation` to the role owners ([#​5551](https://redirect.github.com/mui/base-ui/issues/5551)) by [@​gonzoblasco](https://redirect.github.com/gonzoblasco) - Hide group labels and scrollbars from the accessibility tree ([#​5598](https://redirect.github.com/mui/base-ui/issues/5598)) by [@​azeng4499](https://redirect.github.com/azeng4499) ##### Avatar - Add `keepMounted` prop to `` ([#​5536](https://redirect.github.com/mui/base-ui/issues/5536)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Checkbox - Fix stale and duplicated control IDs ([#​5457](https://redirect.github.com/mui/base-ui/issues/5457)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix controlled blur validation and stale filled state ([#​5563](https://redirect.github.com/mui/base-ui/issues/5563)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Checkbox Group - Fix stale and duplicated control IDs ([#​5457](https://redirect.github.com/mui/base-ui/issues/5457)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Combobox - Fix applying `data-readonly` to `` ([#​5418](https://redirect.github.com/mui/base-ui/issues/5418)) by [@​sai6855](https://redirect.github.com/sai6855) - Add `createItems` collection API ([#​5326](https://redirect.github.com/mui/base-ui/issues/5326)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix cancellation when preserving the filter after selection ([#​5362](https://redirect.github.com/mui/base-ui/issues/5362)) by [@​atomiks](https://redirect.github.com/atomiks) - Render groups with the rowgroup role in grid mode ([#​5564](https://redirect.github.com/mui/base-ui/issues/5564)) by [@​atomiks](https://redirect.github.com/atomiks) - Allow opening and browsing the popup while `readOnly` ([#​5541](https://redirect.github.com/mui/base-ui/issues/5541)) by [@​vikuscz](https://redirect.github.com/vikuscz) - Move `aria-orientation` to the role owners ([#​5551](https://redirect.github.com/mui/base-ui/issues/5551)) by [@​gonzoblasco](https://redirect.github.com/gonzoblasco) - Fix record item label lookup reading from `Object.prototype` ([#​5518](https://redirect.github.com/mui/base-ui/issues/5518)) by [@​XionWCFM](https://redirect.github.com/XionWCFM) - Anchor multiple selection to the first selected item with a linear lookup ([#​5573](https://redirect.github.com/mui/base-ui/issues/5573), [#​5613](https://redirect.github.com/mui/base-ui/issues/5613)) by [@​atomiks](https://redirect.github.com/atomiks) and [@​michaldudak](https://redirect.github.com/michaldudak) - Hide group labels and scrollbars from the accessibility tree ([#​5598](https://redirect.github.com/mui/base-ui/issues/5598)) by [@​azeng4499](https://redirect.github.com/azeng4499) ##### Dialog - Ignore outside clicks from presses that began before open ([#​5378](https://redirect.github.com/mui/base-ui/issues/5378)) by [@​michaldudak](https://redirect.github.com/michaldudak) ##### Drawer - Ignore swipes without an attributed direction when using snap points ([#​5477](https://redirect.github.com/mui/base-ui/issues/5477)) by [@​atomiks](https://redirect.github.com/atomiks) - Respect canceled snap point dismissal ([#​5571](https://redirect.github.com/mui/base-ui/issues/5571)) by [@​radist2s](https://redirect.github.com/radist2s) - Ignore the page scroller when starting a swipe ([#​5567](https://redirect.github.com/mui/base-ui/issues/5567)) by [@​flaviendelangle](https://redirect.github.com/flaviendelangle) ##### Field - Fix custom validity ownership and validation lifecycle ([#​5449](https://redirect.github.com/mui/base-ui/issues/5449)) by [@​atomiks](https://redirect.github.com/atomiks) - Sync controlled value changes with field state ([#​5460](https://redirect.github.com/mui/base-ui/issues/5460)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix stale and duplicated control IDs ([#​5457](https://redirect.github.com/mui/base-ui/issues/5457)) by [@​atomiks](https://redirect.github.com/atomiks) - Validate once on Enter inside a Form ([#​5459](https://redirect.github.com/mui/base-ui/issues/5459)) by [@​atomiks](https://redirect.github.com/atomiks) - Publish neutral validity while async validation is in flight ([#​5600](https://redirect.github.com/mui/base-ui/issues/5600)) by [@​atomiks](https://redirect.github.com/atomiks) - Fix controlled blur validation and stale filled state ([#​5563](https://redirect.github.com/mui/base-ui/issues/5563)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Form - Fix `clearErrors` dropping updates when multiple fields change at once ([#​5446](https://redirect.github.com/mui/base-ui/issues/5446)) by [@​sai6855](https://redirect.github.com/sai6855) ##### Menu - Play the enter transition for an initially open submenu ([#​4383](https://redirect.github.com/mui/base-ui/issues/4383)) by [@​michaldudak](https://redirect.github.com/michaldudak) - Move `aria-orientation` to the role owners ([#​5551](https://redirect.github.com/mui/base-ui/issues/5551)) by [@​gonzoblasco](https://redirect.github.com/gonzoblasco) - Hide group labels and scrollbars from the accessibility tree ([#​5598](https://redirect.github.com/mui/base-ui/issues/5598)) by [@​azeng4499](https://redirect.github.com/azeng4499) ##### Menubar - Fix menubar accessibility tree to satisfy `aria-required-children` ([#​5058](https://redirect.github.com/mui/base-ui/issues/5058)) by [@​michaldudak](https://redirect.github.com/michaldudak) ##### Navigation Menu - Fix pointer-events lock when sweeping quickly across a trigger ([#​5454](https://redirect.github.com/mui/base-ui/issues/5454)) by [@​edwardhorsey](https://redirect.github.com/edwardhorsey) - Keep focus on trigger when opening ([#​5479](https://redirect.github.com/mui/base-ui/issues/5479)) by [@​atomiks](https://redirect.github.com/atomiks) - Add disabled data attribute to `` ([#​5521](https://redirect.github.com/mui/base-ui/issues/5521)) by [@​sai6855](https://redirect.github.com/sai6855) ##### Number Field - Stop incrementing when disabled during press-and-hold ([#​5435](https://redirect.github.com/mui/base-ui/issues/5435)) by [@​alex-js-ltd](https://redirect.github.com/alex-js-ltd) - Prevent scrubbing on horizontal wheel events ([#​5463](https://redirect.github.com/mui/base-ui/issues/5463)) by [@​sai6855](https://redirect.github.com/sai6855) - Preserve native and consumer-controlled selection when focusing the input ([#​5578](https://redirect.github.com/mui/base-ui/issues/5578), [#​5619](https://redirect.github.com/mui/base-ui/issues/5619)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Popover - Ignore outside clicks from presses that began before open ([#​5378](https://redirect.github.com/mui/base-ui/issues/5378)) by [@​michaldudak](https://redirect.github.com/michaldudak) - Fix detached trigger store migration ([#​5442](https://redirect.github.com/mui/base-ui/issues/5442)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Scroll Area - Hide group labels and scrollbars from the accessibility tree ([#​5598](https://redirect.github.com/mui/base-ui/issues/5598)) by [@​azeng4499](https://redirect.github.com/azeng4499) - Prevent scrollbars from stealing focus ([#​5430](https://redirect.github.com/mui/base-ui/issues/5430)) by [@​marwenilo](https://redirect.github.com/marwenilo) ##### Select - Move `aria-orientation` to the role owners ([#​5551](https://redirect.github.com/mui/base-ui/issues/5551)) by [@​gonzoblasco](https://redirect.github.com/gonzoblasco) - Anchor multiple selection to the first selected item with a linear lookup ([#​5573](https://redirect.github.com/mui/base-ui/issues/5573), [#​5613](https://redirect.github.com/mui/base-ui/issues/5613)) by [@​atomiks](https://redirect.github.com/atomiks) and [@​michaldudak](https://redirect.github.com/michaldudak) - Hide group labels and scrollbars from the accessibility tree ([#​5598](https://redirect.github.com/mui/base-ui/issues/5598)) by [@​azeng4499](https://redirect.github.com/azeng4499) - Fix the root ID being ignored inside a Field ([#​5461](https://redirect.github.com/mui/base-ui/issues/5461)) by [@​atomiks](https://redirect.github.com/atomiks) - Remove redundant size check in `` ([#​5469](https://redirect.github.com/mui/base-ui/issues/5469)) by [@​sai6855](https://redirect.github.com/sai6855) - Allow opening and browsing the popup while `readOnly` ([#​5531](https://redirect.github.com/mui/base-ui/issues/5531)) by [@​vikuscz](https://redirect.github.com/vikuscz) - Fix record item label lookup reading from `Object.prototype` ([#​5518](https://redirect.github.com/mui/base-ui/issues/5518)) by [@​XionWCFM](https://redirect.github.com/XionWCFM) ##### Slider - Prevent update loops from unstable refs ([#​5441](https://redirect.github.com/mui/base-ui/issues/5441)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Switch - Fix controlled blur validation and stale filled state ([#​5563](https://redirect.github.com/mui/base-ui/issues/5563)) by [@​atomiks](https://redirect.github.com/atomiks) ##### Tabs - Prevent update loops from unstable refs ([#​5441](https://redirect.github.com/mui/base-ui/issues/5441)) by [@​atomiks](https://redirect.github.com/atomiks) - Consider 3D transforms when positioning the indicator ([#​4852](https://redirect.github.com/mui/base-ui/issues/4852)) by [@​michaldudak](https://redirect.github.com/michaldudak) ##### Toast - Allow functional toast updates derived from the current toast ([#​5464](https://redirect.github.com/mui/base-ui/issues/5464), [#​5611](https://redirect.github.com/mui/base-ui/issues/5611), [#​5629](https://redirect.github.com/mui/base-ui/issues/5629)) by [@​chuganzy](https://redirect.github.com/chuganzy) and [@​michaldudak](https://redirect.github.com/michaldudak) ##### Tooltip - Respect trigger delay with zero provider delay ([#​5444](https://redirect.github.com/mui/base-ui/issues/5444)) by [@​atomiks](https://redirect.github.com/atomiks) All contributors of this release in alphabetical order: [@​alex-js-ltd](https://redirect.github.com/alex-js-ltd), [@​atomiks](https://redirect.github.com/atomiks), [@​azeng4499](https://redirect.github.com/azeng4499), [@​chuganzy](https://redirect.github.com/chuganzy), [@​edwardhorsey](https://redirect.github.com/edwardhorsey), [@​flaviendelangle](https://redirect.github.com/flaviendelangle), [@​gilsmt](https://redirect.github.com/gilsmt), [@​gonzoblasco](https://redirect.github.com/gonzoblasco), [@​jjenzz](https://redirect.github.com/jjenzz), [@​marwenilo](https://redirect.github.com/marwenilo), [@​michaldudak](https://redirect.github.com/michaldudak), [@​radist2s](https://redirect.github.com/radist2s), [@​sai6855](https://redirect.github.com/sai6855), [@​vikuscz](https://redirect.github.com/vikuscz), [@​wanxiankai](https://redirect.github.com/wanxiankai), [@​XionWCFM](https://redirect.github.com/XionWCFM)
xyflow/xyflow (@​xyflow/react) ### [`v12.11.6`](https://redirect.github.com/xyflow/xyflow/blob/HEAD/packages/react/CHANGELOG.md#12116) [Compare Source](https://redirect.github.com/xyflow/xyflow/compare/@xyflow/react@12.11.5...@xyflow/react@12.11.6) ##### Patch Changes - [#​5994](https://redirect.github.com/xyflow/xyflow/pull/5994) [`2350158`](https://redirect.github.com/xyflow/xyflow/commit/2350158ba91e82823477b0a1160c0319e79d6172) - Reset all properties in provider store when unmounting flow - [#​5997](https://redirect.github.com/xyflow/xyflow/pull/5997) [`0117cc9`](https://redirect.github.com/xyflow/xyflow/commit/0117cc9447b082369f238243d15327a5ac9c3dc3) - Prevent attribution warning when pane is not rendered either - Updated dependencies \[[`0117cc9`](https://redirect.github.com/xyflow/xyflow/commit/0117cc9447b082369f238243d15327a5ac9c3dc3)]: - [@​xyflow/system](https://redirect.github.com/xyflow/system)@0.0.82
vercel/next.js (eslint-config-next) ### [`v16.3.4`](https://redirect.github.com/vercel/next.js/compare/v16.3.3...v16.3.4) [Compare Source](https://redirect.github.com/vercel/next.js/compare/v16.3.3...v16.3.4)
sindresorhus/globals (globals) ### [`v17.12.0`](https://redirect.github.com/sindresorhus/globals/releases/tag/v17.12.0) [Compare Source](https://redirect.github.com/sindresorhus/globals/compare/v17.11.0...v17.12.0) - Update globals (2026-09-01) ([#​353](https://redirect.github.com/sindresorhus/globals/issues/353)) [`50a2119`](https://redirect.github.com/sindresorhus/globals/commit/50a2119) - Add `__webpack_layer__` global ([#​351](https://redirect.github.com/sindresorhus/globals/issues/351)) [`779a11a`](https://redirect.github.com/sindresorhus/globals/commit/779a11a) ***
lucide-icons/lucide (lucide-react) ### [`v1.41.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.41.0): Version 1.41.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.40.0...1.41.0) #### What's Changed - feat(icons): Add new icons `germ` and `germ-off` by [@​rrod497](https://redirect.github.com/rrod497) in [#​4056](https://redirect.github.com/lucide-icons/lucide/pull/4056) - feat(icons): added `door-stairwell` icon & updated `door-*` icons by [@​jguddas](https://redirect.github.com/jguddas) in [#​3554](https://redirect.github.com/lucide-icons/lucide/pull/3554) - feat(icons): added `credit-card-reader` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4616](https://redirect.github.com/lucide-icons/lucide/pull/4616) - feat(icons): added 'engine' icon by [@​benhaube](https://redirect.github.com/benhaube) in [#​4598](https://redirect.github.com/lucide-icons/lucide/pull/4598) - feat(icons): fixed `germ` & `germ-off` by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4789](https://redirect.github.com/lucide-icons/lucide/pull/4789) - chore(deps): bump the vue-deps group with 2 updates by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4771](https://redirect.github.com/lucide-icons/lucide/pull/4771) - feat(icons): added `virus`/`virus-off` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4765](https://redirect.github.com/lucide-icons/lucide/pull/4765) - chore(copilot-reviews): Improve use-cases description. by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4558](https://redirect.github.com/lucide-icons/lucide/pull/4558) - feat(icons): add `can-soda` icon by [@​jaynewey](https://redirect.github.com/jaynewey) in [#​4718](https://redirect.github.com/lucide-icons/lucide/pull/4718) - feat(icons): added `square-alert` Icon by [@​viralcodex](https://redirect.github.com/viralcodex) in [#​3687](https://redirect.github.com/lucide-icons/lucide/pull/3687) - chore(lab): Add label for lab icons by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4793](https://redirect.github.com/lucide-icons/lucide/pull/4793) - feat(icons): changed `lab/bottle-toothbrush-comb` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4756](https://redirect.github.com/lucide-icons/lucide/pull/4756) - ci([@​lucide/lab](https://redirect.github.com/lucide/lab)): Create automatic release flow for `@lucide/lab` by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4792](https://redirect.github.com/lucide-icons/lucide/pull/4792) - fix(icons): removed `trash` icon in favour of `trash-2` by [@​jguddas](https://redirect.github.com/jguddas) in [#​3141](https://redirect.github.com/lucide-icons/lucide/pull/3141) - feat(icons): changed `leaf` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4801](https://redirect.github.com/lucide-icons/lucide/pull/4801) #### New Contributors - [@​jaynewey](https://redirect.github.com/jaynewey) made their first contribution in [#​4718](https://redirect.github.com/lucide-icons/lucide/pull/4718) - [@​viralcodex](https://redirect.github.com/viralcodex) made their first contribution in [#​3687](https://redirect.github.com/lucide-icons/lucide/pull/3687) **Full Changelog**: ### [`v1.40.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.40.0): Version 1.40.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.39.0...1.40.0) #### What's Changed - feat(icons): added `can` icon by [@​l0uisgrange](https://redirect.github.com/l0uisgrange) in [#​4767](https://redirect.github.com/lucide-icons/lucide/pull/4767) - feat(icons): added `bridge` icon by [@​Nykoula](https://redirect.github.com/Nykoula) in [#​3949](https://redirect.github.com/lucide-icons/lucide/pull/3949) - feat(icons): added `shrimp-off` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3613](https://redirect.github.com/lucide-icons/lucide/pull/3613) - feat(icons): added `shopping-cart-plus` & `shopping-cart-minus` icons by [@​Ajay199210](https://redirect.github.com/Ajay199210) in [#​4248](https://redirect.github.com/lucide-icons/lucide/pull/4248) - fix(docs): updated artboard name on illustrator template by [@​EthanHazel](https://redirect.github.com/EthanHazel) in [#​4724](https://redirect.github.com/lucide-icons/lucide/pull/4724) - chore(deps): bump softprops/action-gh-release from 3.0.2 to 3.0.3 in the github-actions group by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4781](https://redirect.github.com/lucide-icons/lucide/pull/4781) - feat(icons): added `lighthouse` icon by [@​Xougui](https://redirect.github.com/Xougui) in [#​4507](https://redirect.github.com/lucide-icons/lucide/pull/4507) #### New Contributors - [@​Nykoula](https://redirect.github.com/Nykoula) made their first contribution in [#​3949](https://redirect.github.com/lucide-icons/lucide/pull/3949) - [@​Ajay199210](https://redirect.github.com/Ajay199210) made their first contribution in [#​4248](https://redirect.github.com/lucide-icons/lucide/pull/4248) - [@​Xougui](https://redirect.github.com/Xougui) made their first contribution in [#​4507](https://redirect.github.com/lucide-icons/lucide/pull/4507) **Full Changelog**: ### [`v1.39.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.39.0): Version 1.39.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.38.0...1.39.0) #### What's Changed - fix(icons): added RWD metadata to monitor, tablet & smartphone icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4775](https://redirect.github.com/lucide-icons/lucide/pull/4775) - feat(icons): add `whistle` icon by [@​timmy471](https://redirect.github.com/timmy471) in [#​3006](https://redirect.github.com/lucide-icons/lucide/pull/3006) - feat(icons): Add `mail-pen` icon by [@​jennieboops](https://redirect.github.com/jennieboops) in [#​4399](https://redirect.github.com/lucide-icons/lucide/pull/4399) **Full Changelog**: ### [`v1.38.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.38.0): Version 1.38.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.37.0...1.38.0) #### What's Changed - feat(icons): Add polygon icon by [@​timmy471](https://redirect.github.com/timmy471) in [#​3007](https://redirect.github.com/lucide-icons/lucide/pull/3007) **Full Changelog**: ### [`v1.37.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.37.0): Version 1.37.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.36.0...1.37.0) #### What's Changed - feat(icons): added 'robot-vacuum' icon by [@​benhaube](https://redirect.github.com/benhaube) in [#​4599](https://redirect.github.com/lucide-icons/lucide/pull/4599) - feat(icons): made `face-angry` even more angry by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4708](https://redirect.github.com/lucide-icons/lucide/pull/4708) - fix(icons): changed `swords` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4707](https://redirect.github.com/lucide-icons/lucide/pull/4707) - fix(icons): changed `shopping-cart` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​2909](https://redirect.github.com/lucide-icons/lucide/pull/2909) - docs(icon-description): added old and empty tags/use case for ghost by [@​TFJ5183](https://redirect.github.com/TFJ5183) in [#​4762](https://redirect.github.com/lucide-icons/lucide/pull/4762) - feat(icons): add `playing-card`, `playing-cards`, and `playing-cards-fan` by [@​Barakudum](https://redirect.github.com/Barakudum) in [#​4258](https://redirect.github.com/lucide-icons/lucide/pull/4258) - fix(icons): changed `beef` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3457](https://redirect.github.com/lucide-icons/lucide/pull/3457) - chore(tags): added music disc related tags to `circle-dot` by [@​jguddas](https://redirect.github.com/jguddas) in [#​3909](https://redirect.github.com/lucide-icons/lucide/pull/3909) - fix(icons): changed `panda` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3187](https://redirect.github.com/lucide-icons/lucide/pull/3187) - fix(icons): rotate `blend` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3105](https://redirect.github.com/lucide-icons/lucide/pull/3105) - fix(icons): rotate `key` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3111](https://redirect.github.com/lucide-icons/lucide/pull/3111) - fix(icons): changed `square-split-vertical` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3939](https://redirect.github.com/lucide-icons/lucide/pull/3939) - fix(icons): changed `asterisk` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3906](https://redirect.github.com/lucide-icons/lucide/pull/3906) - fix(icons): unify check, cross and plus sizes across icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​3875](https://redirect.github.com/lucide-icons/lucide/pull/3875) - feat(icons): added `message-circle-dashed-check` icon by [@​aliyasirnac](https://redirect.github.com/aliyasirnac) in [#​3678](https://redirect.github.com/lucide-icons/lucide/pull/3678) - fix(icons): changed `piano` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4766](https://redirect.github.com/lucide-icons/lucide/pull/4766) - feat(icons): added `credit-card-x` & `credit-card-check` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4763](https://redirect.github.com/lucide-icons/lucide/pull/4763) - feat(icons): added `credit-card-minus` icon by [@​ameniti-mx](https://redirect.github.com/ameniti-mx) in [#​4440](https://redirect.github.com/lucide-icons/lucide/pull/4440) - feat(icons): added `credit-card-plus` icon by [@​ameniti-mx](https://redirect.github.com/ameniti-mx) in [#​4441](https://redirect.github.com/lucide-icons/lucide/pull/4441) #### New Contributors - [@​benhaube](https://redirect.github.com/benhaube) made their first contribution in [#​4599](https://redirect.github.com/lucide-icons/lucide/pull/4599) - [@​TFJ5183](https://redirect.github.com/TFJ5183) made their first contribution in [#​4762](https://redirect.github.com/lucide-icons/lucide/pull/4762) - [@​aliyasirnac](https://redirect.github.com/aliyasirnac) made their first contribution in [#​3678](https://redirect.github.com/lucide-icons/lucide/pull/3678) - [@​ameniti-mx](https://redirect.github.com/ameniti-mx) made their first contribution in [#​4440](https://redirect.github.com/lucide-icons/lucide/pull/4440) **Full Changelog**: ### [`v1.36.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.36.0): Version 1.36.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.35.0...1.36.0) #### What's Changed - feat(icons): added 'robot-vacuum' icon by [@​benhaube](https://redirect.github.com/benhaube) in [#​4599](https://redirect.github.com/lucide-icons/lucide/pull/4599) - feat(icons): made `face-angry` even more angry by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4708](https://redirect.github.com/lucide-icons/lucide/pull/4708) - fix(icons): changed `swords` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4707](https://redirect.github.com/lucide-icons/lucide/pull/4707) - fix(icons): changed `shopping-cart` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​2909](https://redirect.github.com/lucide-icons/lucide/pull/2909) - docs(icon-description): added old and empty tags/use case for ghost by [@​TFJ5183](https://redirect.github.com/TFJ5183) in [#​4762](https://redirect.github.com/lucide-icons/lucide/pull/4762) - feat(icons): add `playing-card`, `playing-cards`, and `playing-cards-fan` by [@​Barakudum](https://redirect.github.com/Barakudum) in [#​4258](https://redirect.github.com/lucide-icons/lucide/pull/4258) - fix(icons): changed `beef` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3457](https://redirect.github.com/lucide-icons/lucide/pull/3457) - chore(tags): added music disc related tags to `circle-dot` by [@​jguddas](https://redirect.github.com/jguddas) in [#​3909](https://redirect.github.com/lucide-icons/lucide/pull/3909) - fix(icons): changed `panda` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3187](https://redirect.github.com/lucide-icons/lucide/pull/3187) - fix(icons): rotate `blend` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3105](https://redirect.github.com/lucide-icons/lucide/pull/3105) - fix(icons): rotate `key` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3111](https://redirect.github.com/lucide-icons/lucide/pull/3111) - fix(icons): changed `square-split-vertical` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3939](https://redirect.github.com/lucide-icons/lucide/pull/3939) - fix(icons): changed `asterisk` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3906](https://redirect.github.com/lucide-icons/lucide/pull/3906) - fix(icons): unify check, cross and plus sizes across icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​3875](https://redirect.github.com/lucide-icons/lucide/pull/3875) - feat(icons): added `message-circle-dashed-check` icon by [@​aliyasirnac](https://redirect.github.com/aliyasirnac) in [#​3678](https://redirect.github.com/lucide-icons/lucide/pull/3678) - fix(icons): changed `piano` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4766](https://redirect.github.com/lucide-icons/lucide/pull/4766) - feat(icons): added `credit-card-x` & `credit-card-check` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4763](https://redirect.github.com/lucide-icons/lucide/pull/4763) - feat(icons): added `credit-card-minus` icon by [@​ameniti-mx](https://redirect.github.com/ameniti-mx) in [#​4440](https://redirect.github.com/lucide-icons/lucide/pull/4440) - feat(icons): added `credit-card-plus` icon by [@​ameniti-mx](https://redirect.github.com/ameniti-mx) in [#​4441](https://redirect.github.com/lucide-icons/lucide/pull/4441) #### New Contributors - [@​benhaube](https://redirect.github.com/benhaube) made their first contribution in [#​4599](https://redirect.github.com/lucide-icons/lucide/pull/4599) - [@​TFJ5183](https://redirect.github.com/TFJ5183) made their first contribution in [#​4762](https://redirect.github.com/lucide-icons/lucide/pull/4762) - [@​aliyasirnac](https://redirect.github.com/aliyasirnac) made their first contribution in [#​3678](https://redirect.github.com/lucide-icons/lucide/pull/3678) - [@​ameniti-mx](https://redirect.github.com/ameniti-mx) made their first contribution in [#​4440](https://redirect.github.com/lucide-icons/lucide/pull/4440) **Full Changelog**: #### What's Changed - feat(icons): added 'robot-vacuum' icon by [@​benhaube](https://redirect.github.com/benhaube) in [#​4599](https://redirect.github.com/lucide-icons/lucide/pull/4599) - feat(icons): made `face-angry` even more angry by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4708](https://redirect.github.com/lucide-icons/lucide/pull/4708) - fix(icons): changed `swords` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4707](https://redirect.github.com/lucide-icons/lucide/pull/4707) - fix(icons): changed `shopping-cart` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​2909](https://redirect.github.com/lucide-icons/lucide/pull/2909) - docs(icon-description): added old and empty tags/use case for ghost by [@​TFJ5183](https://redirect.github.com/TFJ5183) in [#​4762](https://redirect.github.com/lucide-icons/lucide/pull/4762) - feat(icons): add `playing-card`, `playing-cards`, and `playing-cards-fan` by [@​Barakudum](https://redirect.github.com/Barakudum) in [#​4258](https://redirect.github.com/lucide-icons/lucide/pull/4258) - fix(icons): changed `beef` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3457](https://redirect.github.com/lucide-icons/lucide/pull/3457) - chore(tags): added music disc related tags to `circle-dot` by [@​jguddas](https://redirect.github.com/jguddas) in [#​3909](https://redirect.github.com/lucide-icons/lucide/pull/3909) - fix(icons): changed `panda` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3187](https://redirect.github.com/lucide-icons/lucide/pull/3187) - fix(icons): rotate `blend` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3105](https://redirect.github.com/lucide-icons/lucide/pull/3105) - fix(icons): rotate `key` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3111](https://redirect.github.com/lucide-icons/lucide/pull/3111) - fix(icons): changed `square-split-vertical` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3939](https://redirect.github.com/lucide-icons/lucide/pull/3939) - fix(icons): changed `asterisk` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​3906](https://redirect.github.com/lucide-icons/lucide/pull/3906) - fix(icons): unify check, cross and plus sizes across icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​3875](https://redirect.github.com/lucide-icons/lucide/pull/3875) - feat(icons): added `message-circle-dashed-check` icon by [@​aliyasirnac](https://redirect.github.com/aliyasirnac) in [#​3678](https://redirect.github.com/lucide-icons/lucide/pull/3678) - fix(icons): changed `piano` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4766](https://redirect.github.com/lucide-icons/lucide/pull/4766) - feat(icons): added `credit-card-x` & `credit-card-check` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4763](https://redirect.github.com/lucide-icons/lucide/pull/4763) - feat(icons): added `credit-card-minus` icon by [@​ameniti-mx](https://redirect.github.com/ameniti-mx) in [#​4440](https://redirect.github.com/lucide-icons/lucide/pull/4440) - feat(icons): added `credit-card-plus` icon by [@​ameniti-mx](https://redirect.github.com/ameniti-mx) in [#​4441](https://redirect.github.com/lucide-icons/lucide/pull/4441) #### New Contributors - [@​benhaube](https://redirect.github.com/benhaube) made their first contribution in [#​4599](https://redirect.github.com/lucide-icons/lucide/pull/4599) - [@​TFJ5183](https://redirect.github.com/TFJ5183) made their first contribution in [#​4762](https://redirect.github.com/lucide-icons/lucide/pull/4762) - [@​aliyasirnac](https://redirect.github.com/aliyasirnac) made their first contribution in [#​3678](https://redirect.github.com/lucide-icons/lucide/pull/3678) - [@​ameniti-mx](https://redirect.github.com/ameniti-mx) made their first contribution in [#​4440](https://redirect.github.com/lucide-icons/lucide/pull/4440) **Full Changelog**:
vercel/next.js (next) ### [`v16.3.4`](https://redirect.github.com/vercel/next.js/compare/v16.3.3...v16.3.4) [Compare Source](https://redirect.github.com/vercel/next.js/compare/v16.3.3...v16.3.4)
actions/python-versions (python) ### [`v3.14.7`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.7-31064857500): 3.14.7 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.6-27283001424...3.14.7-31064857500) Python 3.14.7 ### [`v3.14.6`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.6-27283001424): 3.14.6 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.5-25647354415...3.14.6-27283001424) Python 3.14.6 ### [`v3.14.5`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.5-25647354415): 3.14.5 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.4-25113653268...3.14.5-25647354415) Python 3.14.5 ### [`v3.14.4`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.4-25113653268): 3.14.4 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.3-21673711214...3.14.4-25113653268) Python 3.14.4 ### [`v3.14.3`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.3-21673711214): 3.14.3 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.2-20014991423...3.14.3-21673711214) Python 3.14.3 ### [`v3.14.2`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.2-20014991423): 3.14.2 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.1-19879739908...3.14.2-20014991423) Python 3.14.2 ### [`v3.14.1`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.1-19879739908): 3.14.1 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.14.0-18313368925...3.14.1-19879739908) Python 3.14.1 ### [`v3.14.0`](https://redirect.github.com/actions/python-versions/releases/tag/3.14.0-18313368925): 3.14.0 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.15-31064747964...3.14.0-18313368925) Python 3.14.0 ### [`v3.13.15`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.15-31064747964): 3.13.15 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.14-27320626148...3.13.15-31064747964) Python 3.13.15 ### [`v3.13.14`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.14-27320626148): 3.13.14 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.13-27225391538...3.13.14-27320626148) Python 3.13.14 ### [`v3.13.13`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.13-27225391538): 3.13.13 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.12-21673645133...3.13.13-27225391538) Python 3.13.13 ### [`v3.13.12`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.12-21673645133): 3.13.12 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.11-20014977833...3.13.12-21673645133) Python 3.13.12 ### [`v3.13.11`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.11-20014977833): 3.13.11 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.10-19879712315...3.13.11-20014977833) Python 3.13.11 ### [`v3.13.10`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.10-19879712315): 3.13.10 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.9-18515951191...3.13.10-19879712315) Python 3.13.10 ### [`v3.13.9`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.9-18515951191): 3.13.9 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.8-18331000654...3.13.9-18515951191) Python 3.13.9 ### [`v3.13.8`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.8-18331000654): 3.13.8 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.7-16980743123...3.13.8-18331000654) Python 3.13.8 ### [`v3.13.7`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.7-16980743123): 3.13.7 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.6-16792117939...3.13.7-16980743123) Python 3.13.7 ### [`v3.13.6`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.6-16792117939): 3.13.6 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.5-15601068749...3.13.6-16792117939) Python 3.13.6 ### [`v3.13.5`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.5-15601068749): 3.13.5 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.4-15433317575...3.13.5-15601068749) Python 3.13.5 ### [`v3.13.4`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.4-15433317575): 3.13.4 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.3-14344076652...3.13.4-15433317575) Python 3.13.4 ### [`v3.13.3`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.3-14344076652): 3.13.3 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.2-13708744326...3.13.3-14344076652) Python 3.13.3 ### [`v3.13.2`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.2-13708744326): 3.13.2 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.1-13437882550...3.13.2-13708744326) Python 3.13.2 ### [`v3.13.1`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.1-13437882550): 3.13.1 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.13.0-13707372259...3.13.1-13437882550) Python 3.13.1 ### [`v3.13.0`](https://redirect.github.com/actions/python-versions/releases/tag/3.13.0-13707372259): 3.13.0 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.14-31661455385...3.13.0-13707372259) Python 3.13.0 ### [`v3.12.14`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.14-31661455385): 3.12.14 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.13-27650778726...3.12.14-31661455385) Python 3.12.14 ### [`v3.12.13`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.13-27650778726): 3.12.13 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.12-18393146713...3.12.13-27650778726) Python 3.12.13 ### [`v3.12.12`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.12-18393146713): 3.12.12 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.11-15433310049...3.12.12-18393146713) Python 3.12.12 ### [`v3.12.11`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.11-15433310049): 3.12.11 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.10-14343898437...3.12.11-15433310049) Python 3.12.11 ### [`v3.12.10`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.10-14343898437): 3.12.10 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.9-13149478207...3.12.10-14343898437) Python 3.12.10 ### [`v3.12.9`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.9-13149478207): 3.12.9 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.8-12154062663...3.12.9-13149478207) Python 3.12.9 ### [`v3.12.8`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.8-12154062663): 3.12.8 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.7-11128208086...3.12.8-12154062663) Python 3.12.8 ### [`v3.12.7`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.7-11128208086): 3.12.7 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.6-10765725458...3.12.7-11128208086) Python 3.12.7 ### [`v3.12.6`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.6-10765725458): 3.12.6 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.5-10375840348...3.12.6-10765725458) Python 3.12.6 ### [`v3.12.5`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.5-10375840348): 3.12.5 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.4-9947065640...3.12.5-10375840348) Python 3.12.5 ### [`v3.12.4`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.4-9947065640): 3.12.4 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.3-11057844995...3.12.4-9947065640) Python 3.12.4 ### [`v3.12.3`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.3-11057844995): 3.12.3 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.2-11057786931...3.12.3-11057844995) Python 3.12.3 ### [`v3.12.2`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.2-11057786931): 3.12.2 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.1-11057762749...3.12.2-11057786931) Python 3.12.2 ### [`v3.12.1`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.1-11057762749): 3.12.1 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.12.0-11057302691...3.12.1-11057762749) Python 3.12.1 ### [`v3.12.0`](https://redirect.github.com/actions/python-versions/releases/tag/3.12.0-11057302691): 3.12.0 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.16-31661435402...3.12.0-11057302691) Python 3.12.0 ### [`v3.11.16`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.16-31661435402): 3.11.16 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.15-27649667267...3.11.16-31661435402) Python 3.11.16 ### [`v3.11.15`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.15-27649667267): 3.11.15 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.14-18393181605...3.11.15-27649667267) Python 3.11.15 ### [`v3.11.14`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.14-18393181605): 3.11.14 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.13-15433298024...3.11.14-18393181605) Python 3.11.14 ### [`v3.11.13`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.13-15433298024): 3.11.13 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.12-14343939122...3.11.13-15433298024) Python 3.11.13 ### [`v3.11.12`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.12-14343939122): 3.11.12 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.11-12160100664...3.11.12-14343939122) Python 3.11.12 ### [`v3.11.11`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.11-12160100664): 3.11.11 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.10-10765870205...3.11.11-12160100664) Python 3.11.11 ### [`v3.11.10`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.10-10765870205): 3.11.10 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.9-9947079978...3.11.10-10765870205) Python 3.11.10 ### [`v3.11.9`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.9-9947079978): 3.11.9 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.8-11113201752...3.11.9-9947079978) Python 3.11.9 ### [`v3.11.8`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.8-11113201752): 3.11.8 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.7-11113197120...3.11.8-11113201752) Python 3.11.8 ### [`v3.11.7`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.7-11113197120): 3.11.7 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.6-11113179737...3.11.7-11113197120) Python 3.11.7 ### [`v3.11.6`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.6-11113179737): 3.11.6 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.5-11113174019...3.11.6-11113179737) Python 3.11.6 ### [`v3.11.5`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.5-11113174019): 3.11.5 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.4-11113170699...3.11.5-11113174019) Python 3.11.5 ### [`v3.11.4`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.4-11113170699): 3.11.4 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.3-11059198104...3.11.4-11113170699) Python 3.11.4 ### [`v3.11.3`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.3-11059198104): 3.11.3 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.2-11059137522...3.11.3-11059198104) Python 3.11.3 ### [`v3.11.2`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.2-11059137522): 3.11.2 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.1-11058799881...3.11.2-11059137522) Python 3.11.2 ### [`v3.11.1`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.1-11058799881): 3.11.1 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.11.0-11058707212...3.11.1-11058799881) Python 3.11.1 ### [`v3.11.0`](https://redirect.github.com/actions/python-versions/releases/tag/3.11.0-11058707212): 3.11.0 [Compare Source](https://redirect.github.com/actions/python-versions/compare/3.10.21-31661269155...3.11.0-11058707212) Python 3.11.0
shadcn-ui/ui (shadcn) ### [`v4.21.0`](https://redirect.github.com/shadcn-ui/ui/blob/HEAD/packages/shadcn/CHANGELOG.md#4210) [Compare Source](https://redirect.github.com/shadcn-ui/ui/compare/shadcn@4.20.1...shadcn@4.21.0) ##### Minor Changes - [#​11758](https://redirect.github.com/shadcn-ui/ui/pull/11758) [`c257f688cf4de7ec10cc1be84cad29cd4631182c`](https://redirect.github.com/shadcn-ui/ui/commit/c257f688cf4de7ec10cc1be84cad29cd4631182c) Thanks [@​shadcn](https://redirect.github.com/shadcn)! - install `cn` and generate `export { cn } from "cn"` for `lib/utils` on init. Registry components now import `cn` from the `cn` package. > βœ‚ **Note** > > PR body was truncated to here.
--- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - "before 6am on monday" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ‘» **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/sleepypod/core).
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update renovate/all-minor-patch ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/renovate/all-minor-patch/scripts/install \ | sudo bash -s -- --branch renovate/all-minor-patch ``` 🎯 **Pin to this exact build** ([run 34140743682](https://github.com/sleepypod/core/actions/runs/34140743682) Β· `aad5dd8`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/aad5dd83196449cb8511851d423c763ac97701b2/scripts/install \ | sudo bash -s -- \ --branch renovate/all-minor-patch \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/34140743682/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/34140743682/artifacts/10025822811) Β· self-destructs in 30 days πŸ’₯
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/python-modules.yml | 2 +- pnpm-lock.yaml | 717 ++++++++++++++------------- 2 files changed, 377 insertions(+), 342 deletions(-) diff --git a/.github/workflows/python-modules.yml b/.github/workflows/python-modules.yml index 8c1763c4b..5e307b39a 100644 --- a/.github/workflows/python-modules.yml +++ b/.github/workflows/python-modules.yml @@ -86,7 +86,7 @@ jobs: with: # Pod runtime is pinned to 3.10 by every module's pyproject # (`requires-python = ">=3.9,<3.11"`). Match it in CI. - python-version: '3.10' + python-version: '3.14' - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691b93dae..dfc618ab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@ajayche/trpc-panel': specifier: ^2.0.4 - version: 2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.2.18)(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.2.18)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) '@base-ui/react': specifier: ^1.2.0 - version: 1.7.0(@date-fns/tz@1.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.8.0(@date-fns/tz@1.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@formatjs/intl-localematcher': specifier: ^0.8.0 version: 0.8.13 @@ -37,7 +37,7 @@ importers: version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/next': specifier: ^11.10.0 - version: 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) '@trpc/react-query': specifier: ^11.10.0 version: 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3) @@ -46,7 +46,7 @@ importers: version: 11.18.0(typescript@6.0.3) '@xyflow/react': specifier: ^12.10.1 - version: 12.11.5(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.11.6(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) better-sqlite3: specifier: ^12.6.2 version: 12.11.1 @@ -76,7 +76,7 @@ importers: version: 1.2.0 lucide-react: specifier: ^1.0.0 - version: 1.35.0(react@19.2.8) + version: 1.41.0(react@19.2.8) mqtt: specifier: ^5.15.1 version: 5.15.2 @@ -85,7 +85,7 @@ importers: version: 1.1.0 next: specifier: ^16.1.6 - version: 16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) node-schedule: specifier: ^2.1.1 version: 2.1.1 @@ -103,7 +103,7 @@ importers: version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.4)(react@19.2.8)(redux@5.0.1) shadcn: specifier: ^4.0.0 - version: 4.19.0(babel-plugin-macros@3.1.0)(typescript@6.0.3) + version: 4.21.0(babel-plugin-macros@3.1.0)(typescript@6.0.3) superjson: specifier: ^2.2.6 version: 2.2.6 @@ -112,7 +112,7 @@ importers: version: 3.6.0 trpc-to-openapi: specifier: ^3.1.0 - version: 3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.4.3))(zod@4.4.3) + version: 3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.5.4))(zod@4.5.4) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -121,17 +121,17 @@ importers: version: 8.21.3 zod: specifier: ^4.3.6 - version: 4.4.3 + version: 4.5.4 zod-openapi: specifier: ^5.4.6 - version: 5.4.6(zod@4.4.3) + version: 5.4.6(zod@4.5.4) devDependencies: '@babel/preset-typescript': specifier: ^7.28.5 version: 7.29.7(@babel/core@7.29.7) '@codecov/nextjs-webpack-plugin': specifier: ^2.0.1 - version: 2.0.1(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(webpack@5.104.1(lightningcss@1.32.0)) + version: 2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(webpack@5.104.1(lightningcss@1.32.0)) '@eslint/css': specifier: ^1.0.0 version: 1.4.0 @@ -152,10 +152,10 @@ importers: version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(webpack@5.104.1(lightningcss@1.32.0)) '@lingui/swc-plugin': specifier: ^5.11.0 - version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) '@lingui/vite-plugin': specifier: ^5.9.2 - version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@semantic-release/commit-analyzer': specifier: ^13.0.1 version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) @@ -182,7 +182,7 @@ importers: version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/next': specifier: ^2.0.5 version: 2.0.6 @@ -212,13 +212,13 @@ importers: version: 19.2.18 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.5(@types/react@19.2.18) + version: 19.2.7(@types/react@19.2.18) '@types/ws': specifier: ^8.18.1 version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.2.0(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 5.2.0(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 4.1.11 version: 4.1.11(vitest@4.1.11) @@ -233,13 +233,13 @@ importers: version: 9.39.5(jiti@2.7.0) eslint-config-next: specifier: ^16.1.6 - version: 16.3.3(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 16.3.4(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) globals: specifier: ^17.0.0 - version: 17.11.0 + version: 17.12.0 jiti: specifier: ^2.6.1 version: 2.7.0 @@ -257,19 +257,19 @@ importers: version: 4.3.3 tsx: specifier: ^4.21.0 - version: 4.23.12 + version: 4.23.13 typescript: specifier: ^6.0.0 version: 6.0.3 typescript-eslint: specifier: ^8.56.1 - version: 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) vitest: specifier: ^4.0.18 - version: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -574,8 +574,8 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.7.0': - resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -591,8 +591,8 @@ packages: date-fns: optional: true - '@base-ui/utils@0.3.2': - resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -1638,8 +1638,8 @@ packages: resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} '@inquirer/checkbox@5.1.4': @@ -1660,8 +1660,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.3.0': - resolution: {integrity: sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==} + '@inquirer/confirm@6.3.1': + resolution: {integrity: sha512-HvnOHal39DTenOVoRpIy8Z+n4YYfNa3Qhi/P8zFqn9/d1crpmQG0DPx34rwOeOFvotgKr9Vov/14OmKR/Iwfjw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1678,8 +1678,8 @@ packages: '@types/node': optional: true - '@inquirer/core@12.0.1': - resolution: {integrity: sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==} + '@inquirer/core@12.0.2': + resolution: {integrity: sha512-9pBhkxE14uUlnHhs8lOt7qVPtS4caRY6CjADl8COejl9Mf7w8i6Uoe3DrljCqYtmYM3Iv2Q6EmPxx/oTYbvTAQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1718,8 +1718,8 @@ packages: resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/figures@2.0.8': - resolution: {integrity: sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==} + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} '@inquirer/input@5.0.12': @@ -1794,8 +1794,8 @@ packages: '@types/node': optional: true - '@inquirer/type@4.1.0': - resolution: {integrity: sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==} + '@inquirer/type@4.1.1': + resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1981,60 +1981,60 @@ packages: resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} engines: {node: '>= 18.0.0'} - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/eslint-plugin-next@16.3.3': - resolution: {integrity: sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw==} + '@next/eslint-plugin-next@16.3.4': + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2717,8 +2717,8 @@ packages: '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - '@types/react-dom@19.2.5': - resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: '@types/react': ^19.2.0 @@ -2758,39 +2758,39 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.68.0': - resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + '@typescript-eslint/eslint-plugin@8.69.0': + resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.68.0 + '@typescript-eslint/parser': ^8.69.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.68.0': - resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + '@typescript-eslint/parser@8.69.0': + resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.68.0': - resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + '@typescript-eslint/project-service@8.69.0': + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.68.0': - resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + '@typescript-eslint/scope-manager@8.69.0': + resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.68.0': - resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.68.0': - resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + '@typescript-eslint/type-utils@8.69.0': + resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2800,25 +2800,25 @@ packages: resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.68.0': - resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + '@typescript-eslint/types@8.69.0': + resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.68.0': - resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + '@typescript-eslint/typescript-estree@8.69.0': + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.68.0': - resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + '@typescript-eslint/utils@8.69.0': + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.68.0': - resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + '@typescript-eslint/visitor-keys@8.69.0': + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.1': @@ -3039,8 +3039,8 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@xyflow/react@12.11.5': - resolution: {integrity: sha512-QqoryGkqEhWBuQN9bZWRKhwr3Uoj9lCGj/tg0NnIWHHEOXV+5c8cYsc7Q9TST63V92lHqcNV9P5cjvJ9ZAmblQ==} + '@xyflow/react@12.11.6': + resolution: {integrity: sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==} peerDependencies: '@types/react': '>=17' '@types/react-dom': '>=17' @@ -3052,8 +3052,8 @@ packages: '@types/react-dom': optional: true - '@xyflow/system@0.0.81': - resolution: {integrity: sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==} + '@xyflow/system@0.0.82': + resolution: {integrity: sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ==} abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -3233,8 +3233,8 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + ast-types@0.16.3: + resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} engines: {node: '>=4'} ast-v8-to-istanbul@1.0.0: @@ -3277,8 +3277,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.20: - resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -3342,8 +3342,8 @@ packages: broker-factory@3.1.15: resolution: {integrity: sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==} - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3523,6 +3523,11 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cn@0.2.6: + resolution: {integrity: sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==} + engines: {node: '>=20'} + hasBin: true + co-body@6.2.0: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} @@ -4068,8 +4073,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.415: - resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4206,8 +4211,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.3.3: - resolution: {integrity: sha512-teqtsR26tnlfXFHfVLTM/4tzEzU8DMu6GS1sddZzhfGzgd2f2ofbgDUcsk6cssSCzX6Tk6fmWifJcdANSdPJrw==} + eslint-config-next@16.3.4: + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4396,8 +4401,8 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.6.2: - resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -4446,8 +4451,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -4455,8 +4460,8 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -4661,8 +4666,8 @@ packages: resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} engines: {node: '>=18'} - globals@17.11.0: - resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} globalthis@1.0.4: @@ -4764,8 +4769,8 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - hono@4.13.5: - resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} hook-std@4.0.0: @@ -4891,6 +4896,10 @@ packages: resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5188,8 +5197,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} @@ -5214,6 +5223,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -5479,8 +5492,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.35.0: - resolution: {integrity: sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg==} + lucide-react@1.41.0: + resolution: {integrity: sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5798,8 +5811,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5841,8 +5854,8 @@ packages: node-persist@0.0.12: resolution: {integrity: sha512-Fbia3FYnURzaql53wLu0t19dmAwQg/tXT6O7YPmdwNwysNKEyFmgoT2BQlPD3XXQnYeiQVNvR5lfvufGwKuxhg==} - node-releases@2.0.53: - resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} node-schedule@2.1.1: @@ -6038,8 +6051,8 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - open@11.0.1: - resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} + open@11.0.2: + resolution: {integrity: sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==} engines: {node: '>=20'} open@8.4.2: @@ -6265,8 +6278,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@7.1.5: - resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss@8.5.21: @@ -6277,16 +6290,16 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - powershell-utils@0.2.0: - resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} prebuild-install@7.1.3: @@ -6315,8 +6328,8 @@ packages: resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==} engines: {node: '>=14.16'} - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} process-nextick-args@2.0.1: @@ -6392,8 +6405,8 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -6556,6 +6569,9 @@ packages: reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -6710,8 +6726,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.19.0: - resolution: {integrity: sha512-EQF6R+CUXTsEP2BpyhxrUEAFesrtFD1POvVOf5jM+wkgtA4kG1EW1+1Wlmi9LqiprSL681JJXBcS0u8WkVVVyQ==} + shadcn@4.21.0: + resolution: {integrity: sha512-UU2mFNusW8C5rvadKdH69vERYZqUlOOlXBcf0MYhYLdTGP6DPti7X4qovCu+RTfCqsAgq/T+YfE0Vnttxh9aiw==} engines: {node: '>=20.18.1'} hasBin: true @@ -6798,6 +6814,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + socks@2.8.10: + resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + socks@2.8.9: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} @@ -7020,8 +7040,8 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - systeminformation@5.33.3: - resolution: {integrity: sha512-CFdkRa2yfWBXtRtinSm7QbFGRntq1nC2V2hPAjfRTGwncZbNw/UWyiKDT8rZQz6MB70/3ndzP0scIR0dJHJjBA==} + systeminformation@5.33.8: + resolution: {integrity: sha512-v4F6OGYGh7wDvV68YmjOmZwGixV9A/GQ7d2b84t0UF4CaOy9jipNWIJDkHqYDYiTPuiojqlwVQd0hfUKOUN7tQ==} engines: {node: '>=10.0.0'} os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true @@ -7161,15 +7181,15 @@ packages: tldts-core@7.0.30: resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts-core@7.4.11: - resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} tldts@7.0.30: resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true - tldts@7.4.11: - resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} hasBin: true to-regex-range@5.0.1: @@ -7243,8 +7263,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} hasBin: true @@ -7324,8 +7344,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.68.0: - resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + typescript-eslint@8.69.0: + resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -7360,16 +7380,16 @@ packages: undici-types@7.25.0: resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} - undici@6.28.0: - resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} - undici@7.29.0: - resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} unicode-emoji-modifier-base@1.0.0: @@ -7431,8 +7451,8 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - update-browserslist-db@1.3.1: - resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7833,8 +7853,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -7873,27 +7893,27 @@ snapshots: '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.7) '@octokit/request': 10.0.7 '@octokit/request-error': 7.1.0 - undici: 6.28.0 + undici: 6.28.1 '@actions/http-client@3.0.2': dependencies: tunnel: 0.0.6 - undici: 6.28.0 + undici: 6.28.1 '@actions/http-client@4.0.1': dependencies: tunnel: 0.0.6 - undici: 6.28.0 + undici: 6.28.1 '@actions/io@3.0.2': {} - '@ajayche/trpc-panel@2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.2.18)(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)': + '@ajayche/trpc-panel@2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.2.18)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4)': dependencies: '@microlink/react-json-view': 1.31.20(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@trpc/server': 11.18.0(typescript@6.0.3) clsx: 2.1.1 fuzzysort: 2.0.4 - nuqs: 2.8.9(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + nuqs: 2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) path: 0.12.7 pretty-bytes: 6.1.1 pretty-ms: 8.0.0 @@ -7901,8 +7921,8 @@ snapshots: string-byte-length: 1.6.0 tailwind-merge: 2.6.1 url: 0.11.4 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) + zod: 4.5.4 + zod-to-json-schema: 3.25.2(zod@4.5.4) transitivePeerDependencies: - '@remix-run/react' - '@tanstack/react-router' @@ -8022,7 +8042,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -8030,7 +8050,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -8300,10 +8320,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.7.0(@date-fns/tz@1.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/react@1.8.0(@date-fns/tz@1.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 - '@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@base-ui/utils': 0.4.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/utils': 0.2.12 react: 19.2.8 @@ -8313,13 +8333,13 @@ snapshots: '@date-fns/tz': 1.4.1 '@types/react': 19.2.18 - '@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/utils@0.4.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@floating-ui/utils': 0.2.12 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - reselect: 5.2.0 + reselect: 5.3.0 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 @@ -8357,11 +8377,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(webpack@5.104.1(lightningcss@1.32.0))': + '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(webpack@5.104.1(lightningcss@1.32.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 '@codecov/webpack-plugin': 2.0.1(webpack@5.104.1(lightningcss@1.32.0)) - next: 16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) unplugin: 1.16.1 webpack: 5.104.1(lightningcss@1.32.0) @@ -8415,8 +8435,8 @@ snapshots: object-treeify: 1.1.33 open: 8.4.2 picomatch: 4.0.7 - systeminformation: 5.33.3 - undici: 7.29.0 + systeminformation: 5.33.8 + undici: 7.29.1 which: 4.0.0 yocto-spinner: 1.2.2 @@ -8889,9 +8909,9 @@ snapshots: safe-buffer: 5.2.1 xml2js: 0.6.2 - '@hono/node-server@2.1.1(hono@4.13.5)': + '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: - hono: 4.13.5 + hono: 4.13.7 '@humanfs/core@0.19.2': dependencies: @@ -9020,7 +9040,7 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/ansi@2.0.7': + '@inquirer/ansi@2.0.8': optional: true '@inquirer/checkbox@5.1.4(@types/node@25.9.5)': @@ -9039,10 +9059,10 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 - '@inquirer/confirm@6.3.0(@types/node@25.9.5)': + '@inquirer/confirm@6.3.1(@types/node@25.9.5)': dependencies: - '@inquirer/core': 12.0.1(@types/node@25.9.5) - '@inquirer/type': 4.1.0(@types/node@25.9.5) + '@inquirer/core': 12.0.2(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 optional: true @@ -9059,11 +9079,11 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 - '@inquirer/core@12.0.1(@types/node@25.9.5)': + '@inquirer/core@12.0.2(@types/node@25.9.5)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.8 - '@inquirer/type': 4.1.0(@types/node@25.9.5) + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@25.9.5) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -9096,7 +9116,7 @@ snapshots: '@inquirer/figures@2.0.5': {} - '@inquirer/figures@2.0.8': + '@inquirer/figures@2.0.9': optional: true '@inquirer/input@5.0.12(@types/node@25.9.5)': @@ -9164,7 +9184,7 @@ snapshots: optionalDependencies: '@types/node': 25.9.5 - '@inquirer/type@4.1.0(@types/node@25.9.5)': + '@inquirer/type@4.1.1(@types/node@25.9.5)': optionalDependencies: '@types/node': 25.9.5 optional: true @@ -9322,17 +9342,17 @@ snapshots: '@lingui/babel-plugin-lingui-macro': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) babel-plugin-macros: 3.1.0 - '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': + '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': dependencies: '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) optionalDependencies: - next: 16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@lingui/cli': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) '@lingui/conf': 5.9.5(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -9354,7 +9374,7 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': dependencies: - '@hono/node-server': 2.1.1(hono@4.13.5) + '@hono/node-server': 2.1.1(hono@4.13.7) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -9363,9 +9383,9 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.6.2(express@5.2.1) - hono: 4.13.5 - jose: 6.2.10 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.7 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -9411,37 +9431,37 @@ snapshots: '@nats-io/nkeys': 2.0.3 '@nats-io/nuid': 3.0.0 - '@next/env@16.3.3': {} + '@next/env@16.3.4': {} - '@next/eslint-plugin-next@16.3.3(eslint@9.39.5(jiti@2.7.0))': + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@noble/hashes@1.8.0': @@ -9457,7 +9477,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + fastq: 1.20.3 '@nolyfill/is-core-module@1.0.39': {} @@ -9722,7 +9742,7 @@ snapshots: p-filter: 4.1.0 semantic-release: 25.0.9(typescript@6.0.3) tinyglobby: 0.2.17 - undici: 7.29.0 + undici: 7.29.1 url-join: 5.0.0 transitivePeerDependencies: - kerberos @@ -9848,7 +9868,7 @@ snapshots: '@stryker-mutator/util': 9.6.1 semver: 7.7.4 tslib: 2.8.1 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': dependencies: @@ -9951,7 +9971,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 @@ -9959,18 +9979,18 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@trpc/server': 11.18.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/next@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': + '@trpc/next@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': dependencies: '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3) - next: 16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) typescript: 6.0.3 @@ -10152,7 +10172,7 @@ snapshots: dependencies: '@types/node': 25.9.5 - '@types/react-dom@19.2.5(@types/react@19.2.18)': + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -10192,14 +10212,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.69.0 eslint: 9.39.5(jiti@2.7.0) ignore: 7.0.8 natural-compare: 1.4.0 @@ -10208,41 +10228,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.68.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) - '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.68.0': + '@typescript-eslint/scope-manager@8.69.0': dependencies: - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 - '@typescript-eslint/tsconfig-utils@8.68.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -10252,14 +10272,14 @@ snapshots: '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/types@8.68.0': {} + '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.68.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.68.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -10269,20 +10289,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.68.0': + '@typescript-eslint/visitor-keys@8.69.0': dependencies: - '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.1': {} @@ -10357,7 +10377,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -10365,7 +10385,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -10381,7 +10401,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -10392,14 +10412,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.6(@types/node@25.9.5)(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10505,20 +10525,20 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.11.5(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@xyflow/react@12.11.6(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@xyflow/system': 0.0.81 + '@xyflow/system': 0.0.82 classcat: 5.0.5 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) transitivePeerDependencies: - immer - '@xyflow/system@0.0.81': + '@xyflow/system@0.0.82': dependencies: '@types/d3-drag': 3.0.7 '@types/d3-interpolate': 3.0.4 @@ -10595,7 +10615,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -10715,7 +10735,7 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.16.1: + ast-types@0.16.3: dependencies: tslib: 2.8.1 @@ -10752,7 +10772,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.20: {} + baseline-browser-mapping@2.11.21: {} before-after-hook@4.0.0: {} @@ -10796,7 +10816,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -10844,13 +10864,13 @@ snapshots: tslib: 2.8.1 worker-factory: 7.0.50 - browserslist@4.28.8: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.20 + baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.415 - node-releases: 2.0.53 - update-browserslist-db: 1.3.1(browserslist@4.28.8) + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) buffer-from@1.1.2: {} @@ -11042,6 +11062,8 @@ snapshots: clsx@2.1.1: {} + cn@0.2.6: {} + co-body@6.2.0: dependencies: '@hapi/bourne': 3.0.0 @@ -11212,7 +11234,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -11454,7 +11476,7 @@ snapshots: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.12 + tsx: 4.23.13 drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1): optionalDependencies: @@ -11475,7 +11497,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.415: {} + electron-to-chromium@1.5.422: {} emoji-regex@10.6.0: {} @@ -11809,18 +11831,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.3(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.3.4(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.3.3(eslint@9.39.5(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -11848,22 +11870,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11874,7 +11896,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11886,7 +11908,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -11917,8 +11939,8 @@ snapshots: '@babel/parser': 7.29.8 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.5.4 + zod-validation-error: 4.0.2(zod@4.5.4) transitivePeerDependencies: - supports-color @@ -12090,7 +12112,7 @@ snapshots: is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.0 + pretty-ms: 9.3.1 signal-exit: 4.1.0 strip-final-newline: 4.0.0 yoctocolors: 2.2.0 @@ -12099,11 +12121,11 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.6.2(express@5.2.1): + express-rate-limit@8.7.0(express@5.2.1): dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.5.0 + ip-address: 10.7.0 transitivePeerDependencies: - supports-color @@ -12129,7 +12151,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 @@ -12181,7 +12203,7 @@ snapshots: fast-uri@3.1.0: {} - fast-uri@3.1.6: {} + fast-uri@3.1.7: {} fast-wrap-ansi@0.2.0: dependencies: @@ -12192,7 +12214,7 @@ snapshots: fast-string-width: 3.0.2 optional: true - fastq@1.20.1: + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -12405,7 +12427,7 @@ snapshots: globals@16.4.0: {} - globals@17.11.0: {} + globals@17.12.0: {} globalthis@1.0.4: dependencies: @@ -12538,7 +12560,7 @@ snapshots: highlight.js@10.7.3: {} - hono@4.13.5: {} + hono@4.13.7: {} hook-std@4.0.0: {} @@ -12650,6 +12672,8 @@ snapshots: ip-address@10.5.0: {} + ip-address@10.7.0: {} + ipaddr.js@1.9.1: {} iron-webcrypto@1.2.1: {} @@ -12915,7 +12939,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.10: {} + jose@6.2.12: {} js-md4@0.3.2: {} @@ -12935,6 +12959,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@1.8.0): dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -13177,7 +13205,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.35.0(react@19.2.8): + lucide-react@1.41.0(react@19.2.8): dependencies: react: 19.2.8 @@ -13554,7 +13582,7 @@ snapshots: msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.3.0(@types/node@25.9.5) + '@inquirer/confirm': 6.3.1(@types/node@25.9.5) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -13587,7 +13615,7 @@ snapshots: mutation-server-protocol@0.4.1: dependencies: - zod: 4.4.3 + zod: 4.5.4 mutation-testing-elements@3.7.3: {} @@ -13623,25 +13651,25 @@ snapshots: nerf-dart@1.0.0: {} - next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.4 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.20 + baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 postcss: 8.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 sharp: 0.35.4(@types/node@25.9.5) transitivePeerDependencies: - '@babel/core' @@ -13678,7 +13706,7 @@ snapshots: mkdirp: 0.5.6 q: 1.1.2 - node-releases@2.0.53: {} + node-releases@2.0.54: {} node-schedule@2.1.1: dependencies: @@ -13724,12 +13752,12 @@ snapshots: transitivePeerDependencies: - supports-color - nuqs@2.8.9(next@16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + nuqs@2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.8 optionalDependencies: - next: 16.3.3(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) object-assign@4.1.1: {} @@ -13801,13 +13829,13 @@ snapshots: dependencies: mimic-function: 5.0.1 - open@11.0.1: + open@11.0.2: dependencies: default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.2.0 + powershell-utils: 0.2.1 wsl-utils: 1.0.0 open@8.4.2: @@ -14026,7 +14054,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@7.1.5: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -14043,7 +14071,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.26: + postcss@8.5.28: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -14051,7 +14079,7 @@ snapshots: powershell-utils@0.1.0: {} - powershell-utils@0.2.0: {} + powershell-utils@0.2.1: {} prebuild-install@7.1.3: dependencies: @@ -14088,7 +14116,7 @@ snapshots: dependencies: parse-ms: 3.0.0 - pretty-ms@9.3.0: + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -14149,7 +14177,7 @@ snapshots: dependencies: side-channel: 1.1.0 - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -14301,7 +14329,7 @@ snapshots: recast@0.23.21: dependencies: - ast-types: 0.16.1 + ast-types: 0.16.3 esprima: 4.0.1 source-map: 0.6.1 tiny-invariant: 1.3.3 @@ -14382,6 +14410,8 @@ snapshots: reselect@5.2.0: {} + reselect@5.3.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -14625,7 +14655,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.19.0(babel-plugin-macros@3.1.0)(typescript@6.0.3): + shadcn@4.21.0(babel-plugin-macros@3.1.0)(typescript@6.0.3): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.8 @@ -14634,7 +14664,8 @@ snapshots: '@dotenvx/dotenvx': 1.75.1 '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.8 + browserslist: 4.28.9 + cn: 0.2.6 commander: 14.0.3 cosmiconfig: 9.0.2(typescript@6.0.3) dedent: 1.7.2(babel-plugin-macros@3.1.0) @@ -14645,18 +14676,17 @@ snapshots: fs-extra: 11.4.0 fuzzysort: 3.1.0 kleur: 4.1.5 - open: 11.0.1 + open: 11.0.2 ora: 8.2.0 - postcss: 8.5.26 - postcss-selector-parser: 7.1.5 + postcss: 8.5.28 + postcss-selector-parser: 7.1.6 prompts: 2.4.2 recast: 0.23.21 - socks: 2.8.9 + socks: 2.8.10 stringify-object: 5.0.0 - tailwind-merge: 3.6.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 - undici: 7.29.0 + undici: 7.29.1 validate-npm-package-name: 7.0.2 zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) @@ -14789,6 +14819,11 @@ snapshots: smart-buffer@4.2.0: {} + socks@2.8.10: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + socks@2.8.9: dependencies: ip-address: 10.5.0 @@ -15031,7 +15066,7 @@ snapshots: symbol-tree@3.2.4: {} - systeminformation@5.33.3: {} + systeminformation@5.33.8: {} tagged-tag@1.0.0: {} @@ -15140,16 +15175,16 @@ snapshots: tldts-core@7.0.30: {} - tldts-core@7.4.11: + tldts-core@7.4.12: optional: true tldts@7.0.30: dependencies: tldts-core: 7.0.30 - tldts@7.4.11: + tldts@7.4.12: dependencies: - tldts-core: 7.4.11 + tldts-core: 7.4.12 optional: true to-regex-range@5.0.1: @@ -15164,7 +15199,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.11 + tldts: 7.4.12 optional: true tr46@6.0.0: @@ -15179,14 +15214,14 @@ snapshots: trough@2.2.0: {} - trpc-to-openapi@3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.4.3))(zod@4.4.3): + trpc-to-openapi@3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.5.4))(zod@4.5.4): dependencies: '@trpc/server': 11.18.0(typescript@6.0.3) co-body: 6.2.0 h3: 1.15.11 openapi3-ts: 4.4.0 - zod: 4.4.3 - zod-openapi: 5.4.6(zod@4.4.3) + zod: 4.5.4 + zod-openapi: 5.4.6(zod@4.5.4) optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.6.1 @@ -15218,7 +15253,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.12: + tsx@4.23.13: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -15318,12 +15353,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -15351,11 +15386,11 @@ snapshots: undici-types@7.25.0: {} - undici@6.28.0: {} + undici@6.28.1: {} undici@7.25.0: {} - undici@7.29.0: {} + undici@7.29.1: {} unicode-emoji-modifier-base@1.0.0: {} @@ -15443,9 +15478,9 @@ snapshots: until-async@3.0.2: optional: true - update-browserslist-db@1.3.1(browserslist@4.28.8): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -15525,22 +15560,22 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0): + vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - postcss: 8.5.26 + postcss: 8.5.28 rollup: 4.63.1 tinyglobby: 0.2.17 optionalDependencies: @@ -15549,13 +15584,13 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.51.2 - tsx: 4.23.12 + tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15572,7 +15607,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 @@ -15613,7 +15648,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.8 + browserslist: 4.28.9 chrome-trace-event: 1.0.4 enhanced-resolve: 5.24.5 es-module-lexer: 2.3.2 @@ -15868,25 +15903,25 @@ snapshots: yoctocolors@2.2.0: {} - zod-openapi@5.4.6(zod@4.4.3): + zod-openapi@5.4.6(zod@4.5.4): dependencies: - zod: 4.4.3 + zod: 4.5.4 zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.2(zod@4.4.3): + zod-to-json-schema@3.25.2(zod@4.5.4): dependencies: - zod: 4.4.3 + zod: 4.5.4 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.5.4): dependencies: - zod: 4.4.3 + zod: 4.5.4 zod@3.25.76: {} - zod@4.4.3: {} + zod@4.5.4: {} zustand@4.5.7(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8): dependencies: From 64c70e64b32d2eb8eabec3205a799d00c21f43a8 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Mon, 7 Sep 2026 15:22:56 -0700 Subject: [PATCH 003/119] feat(streaming): expose LPS diagnostic subscriptions (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LPS frames on Pod 4 currently produce an unknown-type warning and cannot be requested through an explicit sensor subscription. Recognize `lps` on the server and in the typed client hook, preserving the nested byte payload, channel names, temperature placeholders, and sentinel values. This adds diagnostic access only; no hardware controls, biometric ingestion, calibration, or database behavior changes. Document the reported occupancy trial and outstanding questions. Field JSONL data remains pending; regression fixtures are synthetic. Validation: 1,703 related tests, TypeScript, ESLint, and both Drizzle schema checks passed. Branch Release and Build & Package succeeded for 77f255b.
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update feat/trinity-lps ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/feat/trinity-lps/scripts/install \ | sudo bash -s -- --branch feat/trinity-lps ``` 🎯 **Pin to this exact build** ([run 34166313032](https://github.com/sleepypod/core/actions/runs/34166313032) Β· `296ad50`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/296ad507f1b81d85eba484cacdc7ef6eedbadb77/scripts/install \ | sudo bash -s -- \ --branch feat/trinity-lps \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/34166313032/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/34166313032/artifacts/10034280217) Β· self-destructs in 30 days πŸ’₯
## Summary by CodeRabbit - **New Features** - Added support for subscribing to the LPS diagnostic sensor stream. - LPS frames are available through sensor-frame access with their raw channel data, byte buffers, and temperature values preserved. - LPS diagnostic subscriptions retain channel labels and support round-trip delivery without affecting other sensor data. - **Documentation** - Added documentation covering LPS frame fields, observed occupancy sequences, and current diagnostic limitations. --- docs/hardware/lps-diagnostics.md | 65 +++++++++++++++++++++++++ src/hooks/tests/useSensorStream.test.ts | 18 +++++++ src/hooks/useSensorStream.ts | 18 ++++++- src/streaming/piezoStream.ts | 6 ++- src/streaming/tests/piezoStream.test.ts | 35 +++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 docs/hardware/lps-diagnostics.md diff --git a/docs/hardware/lps-diagnostics.md b/docs/hardware/lps-diagnostics.md new file mode 100644 index 000000000..5095f1bb0 --- /dev/null +++ b/docs/hardware/lps-diagnostics.md @@ -0,0 +1,65 @@ +# LPS diagnostic stream + +`lps` is a recognized WebSocket sensor type, available through an explicit +subscription and `useSensorFrame('lps')`. It has no occupancy, calibration, +biometric, or persistence consumer. Recognition removes the generic unknown-type +warning; it does not imply physiological validation. + +```json +{"type":"subscribe","sensors":["lps","piezo-dual"]} +``` + +Both RAW and NATS sources use the shared dispatcher. NATS field validation is +still pending; the LPS reports below are from RAW firmware. + +## Reported schema + +Trinity reports Pod 4 model prefix `20500-0004-G09`, firmware 1.4.2, +Eight Layer 4.0.2, running core `dev` at `dd5c425`. Only the capture README +and summary were available for this implementation, not the JSONL records. +Tests use synthetic payloads matching the reported schema. + +Each frame has `type: "lps"`, `ts` in Unix seconds, `temp` with numeric +`left1`, `left2`, `right1`, `right2`, and `pres` containing `adc: 1`, +`freq: 200`, and four equally named byte channels. Each channel contains +800 bytes: 200 little-endian int32 samples, one second at the reported rate. +The stream runs alongside `piezo-dual`, not in its place. The acronym, +measurement units, and underlying sensor hardware remain unconfirmed. + +LPS preserves its nested payload. On the Node WebSocket stream byte buffers +serialize as `{"type":"Buffer","data":[...bytes]}`. In contrast, `piezo-dual` +is already converted to numeric sample arrays before WebSocket transmission; +its WebSocket payload is not the original firmware byte representation. + +## Reported occupancy sequence + +The 210-second sequence comprises empty (30 s), left occupied (60 s), empty +(30 s), right occupied (60 s), empty (30 s), with both sides off throughout. +"Left" means the sleeper's left while lying face-up. + +- `right1` responds most strongly to physical left occupancy; `left1` responds + most strongly to physical right occupancy. Preserve firmware names until the + paired piezo capture establishes whether this is a shared convention. +- `left2` and `right2` stay around 32767 with zero IQR, independent of load. + This supports inactivity in the trial, not proof of unpopulated hardware. +- All temperature fields remain zero. Units and validity are unknown; do not + present these as measured temperatures. +- Empty phases after occupancy remain elevated. Settling, sensor recovery, or + filtering may contribute; the initial empty phase is a reference, not a + calibrated physical zero. +- `0x7fffffff` occurs at sample indices 20–29 in all four channels of two + frames: eight affected channel buffers out of 840, two frames out of 210. + The synchronized ten-sample burst spans 50 ms at 200 Hz. Treat this as a + candidate missing-data marker in future analysis, preserving time positions. + Diagnostic transport deliberately preserves the original bytes, including + these values, rather than deleting samples or replacing them with zero. + +## Remaining evidence + +Field JSONL data remains pending. When the capture is available, use it with the +phase metadata to verify the sample bytes, compare LPS and piezo side responses +and timing, and plot unload recovery. No further physical experiment is requested. +Complete this analysis before adding +sample interpretation or biometric consumers. Do not globally swap channels, +label channels as disconnected, or assume the format is exclusive to Pod 4 +based on this single-device report. diff --git a/src/hooks/tests/useSensorStream.test.ts b/src/hooks/tests/useSensorStream.test.ts index aa31ba587..315832fbb 100644 --- a/src/hooks/tests/useSensorStream.test.ts +++ b/src/hooks/tests/useSensorStream.test.ts @@ -264,6 +264,24 @@ describe('useSensorStream', () => { }) describe('useSensorFrame', () => { + it('exposes lps diagnostic payloads without normalizing bytes or temperatures', async () => { + const stream = renderHook(() => useSensorStream({ sensors: ['lps'] })) + await waitFor(() => expect(wsMock.sockets.length).toBeGreaterThan(0)) + const ws = wsMock.sockets[0] as FakeWS + act(() => ws.triggerOpen()) + const frame = renderHook(() => useSensorFrame('lps')) + const bytes = { type: 'Buffer', data: [255, 255, 255, 127] } + const payload = { + type: 'lps', ts: 123, + temp: { left1: 0, left2: 0, right1: 0, right2: 0 }, + pres: { adc: 1, freq: 200, left1: bytes, left2: bytes, right1: bytes, right2: bytes }, + } + act(() => ws.triggerMessage(payload)) + await waitFor(() => expect(frame.result.current).toEqual(payload)) + frame.unmount() + stream.unmount() + }) + it('returns the latest frame for a specific sensor only', async () => { const stream = renderHook(() => useSensorStream()) await waitFor(() => expect(wsMock.sockets.length).toBeGreaterThan(0)) diff --git a/src/hooks/useSensorStream.ts b/src/hooks/useSensorStream.ts index e84f42005..c8deb3b43 100644 --- a/src/hooks/useSensorStream.ts +++ b/src/hooks/useSensorStream.ts @@ -11,7 +11,7 @@ import type { SideStatus } from '@/src/hardware/types' export const ALL_SENSOR_TYPES = [ 'piezo-dual', 'capSense', 'capSense2', 'bedTemp', 'bedTemp2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', - 'deviceStatus', 'gesture', + 'deviceStatus', 'gesture', 'lps', ] as const export type SensorType = typeof ALL_SENSOR_TYPES[number] @@ -27,6 +27,21 @@ export interface PiezoDualFrame { right2?: number[] } +/** + * LPS diagnostic frame. Channel bytes remain in their JSON Buffer envelope, + * unlike piezo-dual's decoded sample arrays. Units and physical side mapping + * are unconfirmed; preserve sentinel samples and temperature placeholders. + */ +export interface LpsFrame { + type: 'lps' + ts: number + temp: Record<'left1' | 'left2' | 'right1' | 'right2', number> + pres: { + adc: number + freq: number + } & Record<'left1' | 'left2' | 'right1' | 'right2', { type: 'Buffer', data: number[] }> +} + /** Capacitive presence sensor frame (~2 Hz). */ export interface CapSenseFrame { type: 'capSense' @@ -152,6 +167,7 @@ export interface DeviceStatusFrame { /** Union of all sensor frame types. */ export type SensorFrame = | PiezoDualFrame + | LpsFrame | CapSenseFrame | CapSense2Frame | BedTempFrame diff --git a/src/streaming/piezoStream.ts b/src/streaming/piezoStream.ts index 2228327d6..58b337be9 100644 --- a/src/streaming/piezoStream.ts +++ b/src/streaming/piezoStream.ts @@ -365,11 +365,11 @@ async function findLatestRawAsync(dir: string): Promise { // Sensor types // --------------------------------------------------------------------------- -/** All sensor record types the firmware emits. */ +/** Recognized stream types; subscription support does not imply ingestion. */ const ALL_SENSOR_TYPES = [ 'piezo-dual', 'capSense', 'capSense2', 'bedTemp', 'bedTemp2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', - 'deviceStatus', 'gesture', + 'deviceStatus', 'gesture', 'lps', ] as const /** Valid sensor type string. Used for subscription filtering. */ @@ -833,6 +833,8 @@ function dispatchSensorFrame(frame: Record): void { recordFirstSensorFrame() const frameType = frame.type as string + // lps is recognized for diagnostic subscriptions only. Preserve its nested + // payload and channel labels; no biometric consumer is wired for it. // New / out-of-scope firmware types (blanketReadings, …) pass through to // subscribers but are not ingested β€” log the first sight of each, once. if (!(ALL_SENSOR_TYPES as readonly string[]).includes(frameType) diff --git a/src/streaming/tests/piezoStream.test.ts b/src/streaming/tests/piezoStream.test.ts index a21ab6f34..21ee6c6cf 100644 --- a/src/streaming/tests/piezoStream.test.ts +++ b/src/streaming/tests/piezoStream.test.ts @@ -739,6 +739,41 @@ describe('piezoStream β€” server lifecycle and protocol', () => { await client.close() }) + it('supports lps-only diagnostic subscriptions and preserves channel bytes', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + __test__.warnedUnknownTypes.delete('lps') + const client = await connectClient(startAndPort()) + try { + client.ws.send(JSON.stringify({ type: 'subscribe', sensors: ['lps'] })) + const ack = await client.waitFor(m => m.type === 'subscribed') + expect(ack.sensors).toEqual(['lps']) + // Synthetic schema fixture, not a field capture. Include the reported + // 10-sample sentinel gap and distinct channels to catch swaps/conversion. + const channel = (value: number) => { + const bytes = Buffer.alloc(800) + for (let i = 0; i < 200; i++) bytes.writeInt32LE(i >= 20 && i < 30 ? 0x7fffffff : value, i * 4) + return bytes + } + const frame = { + type: 'lps', ts: 123, + temp: { left1: 0, left2: 0, right1: 0, right2: 0 }, + pres: { adc: 1, freq: 200, left1: channel(453), left2: channel(32767), right1: channel(256), right2: channel(32768) }, + } + const encoded = Buffer.from(new Encoder({ useRecords: false }).encode(frame)) + const before = getLatestCapSenseSnapshot() + __test__.dispatchSensorFrame({ type: 'piezo-dual', ts: 123, freq: 500, left1: [], right1: [] }) + for (const decoded of decodeSensorFrames(encoded)) __test__.dispatchSensorFrame(decoded) + const received = await client.waitFor(m => m.type === 'lps') + expect(received).toEqual(JSON.parse(JSON.stringify(frame))) + expect(getLatestCapSenseSnapshot()).toBe(before) + expect(warn.mock.calls.filter(args => args[1] === 'lps')).toEqual([]) + expect(client.messages.some(m => m.type === 'piezo-dual')).toBe(false) + } + finally { + await client.close() + } + }) + it('subscribe with an empty sensors array β†’ subscribes to all types', async () => { const logSpy = vi.spyOn(console, 'log') const port = startAndPort() From e8c742f6f93dbb7062d41b83d6f1d72441fae4e5 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Tue, 8 Sep 2026 18:23:09 -0700 Subject: [PATCH 004/119] fix(deploy): require prebuilt updates and stage local builds over SSH (#703) A branch without a GitHub release previously made `sp-update` replace the installed app and attempt a full dependency install and Next.js build on the Pod. Updates now require a validated prebuilt bundle and report missing releases, API errors, and failed downloads without falling back to source. Staging and rollback use persistent storage, backups must succeed, and symlinked installation paths are resolved before stale files are removed. From a clone on macOS or Linux, `./scripts/deploy POD_IP [branch]` installs locked dependencies, builds locally with isolated in-memory databases, and uploads a complete archive before invoking `sp-update --archive`. Optional origin branches build in a temporary worktree without changing the user's checkout. The archive retains the standalone runtime and build metadata while excluding environment files, Git metadata, and workstation node_modules. The Pod installs production dependencies for its own architecture. Validation: - Deployed commit `9544003` to Pod 88 through the local build/upload/update path. The running version API reports that exact commit; UI, version API, and OpenAPI return HTTP 200. Firmware connection, sensor frames, and 329 scheduled jobs initialized, with zero service restarts after startup. The Pod's `.env` checksum is unchanged. - Live testing exercised code/database rollback after a failed start and exposed the SSH PATH, build-worker SQLite contention, symlink cleanup, and standalone-runtime issues fixed in this PR. - 24 targeted deployment/firewall tests pass, along with TypeScript, ESLint, both Drizzle schema checks, Bash syntax, and ShellCheck at warning severity. GitHub CI builds and unit tests pass. ESLint has one existing warning in stryker.config.mjs. - CodeRabbit reviewed the initial commit; all five findings were addressed. Its follow-up review of the latest changes was rate-limited for 45 minutes. Limits: the Pod still needs WAN access for production/native dependencies. Rollback restores code/database but does not restore node_modules or module/systemd changes. First-time installations continue to use scripts/install. --- README.md | 12 +- docs/DEPLOYMENT.md | 88 +++++--- scripts/bin/sp-update | 292 +++++++++++++----------- scripts/deploy | 188 ++++++++-------- tests/iptablesHelpers.contract.test.ts | 4 +- tests/updateDeploy.test.ts | 298 +++++++++++++++++++++++++ 6 files changed, 640 insertions(+), 242 deletions(-) create mode 100644 tests/updateDeploy.test.ts diff --git a/README.md b/README.md index beaf8c998..77fd5dbbe 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,21 @@ After install, these are available system-wide: sp-status # systemctl status sleepypod.service sp-restart # restart the service sp-logs # journalctl -u sleepypod.service -f -sp-update # pull latest, rebuild, migrate, restart (with automatic rollback) +sp-update # install latest pre-built release, migrate, restart (with rollback) sp-maintenance # one-shot manual prime / reboot / status sp-uninstall # stop services, remove systemd units, optionally wipe data ``` +Cloned the repo on your computer? Build locally and deploy to an installed Pod: + +```bash +./scripts/deploy POD_IP # current checkout +./scripts/deploy POD_IP fix/my-fix # branch from your clone's origin +``` + +See the [deployment guide](docs/DEPLOYMENT.md) for SSH requirements and fork/PR validation. + + --- ## What it does diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 55c5b02a8..45c4a2179 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,7 +26,7 @@ flowchart TB end subgraph trigger["Trigger Sources"] - mac["Mac CLI
scripts/deploy"] + mac["Local CLI
scripts/deploy"] web["Web UI"] ios["iOS App"] end @@ -35,7 +35,7 @@ flowchart TB repo -->|"push / tag"| build_ci build_ci --> release - mac -->|"1. build locally
2. tar+ssh over LAN"| install + mac -->|"1. build locally
2. archive over SSH"| spupdate web -->|"tRPC"| spupdate ios -->|"tRPC"| spupdate @@ -49,25 +49,51 @@ flowchart TB ## Three Deployment Paths -### Path 1: Mac Deploy (Development) +### Path 1: Local Deploy (macOS / Linux) -Builds locally (fast, full RAM), pushes built artifacts to the pod. No WAN needed on the pod. +From a clone on your computer, build and transfer to an **already-installed** Pod: ```bash -./scripts/deploy # current branch -> 192.168.1.88 -./scripts/deploy 192.168.1.50 # current branch -> different pod -./scripts/deploy 192.168.1.88 feat/alarms # specific branch +./scripts/deploy 192.168.1.50 # current checkout, including local edits +./scripts/deploy 192.168.1.50 fix/my-fix # branch fetched from this clone's origin +SSH_PORT=8822 ./scripts/deploy pod.local ``` -**How it works:** -1. Checks out the requested branch locally (if specified) -2. Runs `pnpm build` on the Mac -3. Cleans stale files on the pod (preserves `node_modules`, `.env`) -4. Tars source + `.next` build, pipes over SSH -5. Runs `scripts/install --local --no-ssh` on the pod -6. Install script: installs prod deps (with prebuilt native modules), restarts service (DB migrations run automatically on startup via `instrumentation.ts`) +Use the Node.js major in `.node-version` and the pnpm version pinned in +`package.json`. The script installs locked dependencies, builds locally, and +checks the bundle before uploading. Build workers use in-memory databases to +avoid modifying local data or contending over SQLite files. An optional branch builds in a temporary +Git worktree; your current branch and edits are preserved. + +It uploads a complete archive before invoking `sp-update --archive` on the Pod. +Git metadata, `.env*`, databases, caches, and local `node_modules` are excluded. +The standalone server and its compiled configuration are retained; native +modules resolve from the production dependencies installed on the Pod. +The updater preserves the Pod's `.env`, backs up the installed code/database, +installs production dependencies for the Pod's architecture, and restarts the +service. Build-time public environment variables can still be embedded in the +Next.js output; build with the intended deployment configuration. + +**Requirements:** root SSH key access (default port 8822), an existing SleepyPod +installation, and WAN access on the Pod for production/native dependencies. +The updater temporarily opens/restores a blocked WAN using the existing +firewall helpers. No GitHub release is required. For first installation, use +`scripts/install` instead. + +### Validating a fork's PR + +Check out the PR in your local clone and run `./scripts/deploy POD_IP`. This +works even when the fork has not published a branch release. Alternatively, +enable Actions in the fork and push the branch so its Branch Release workflow +publishes `sleepypod-core.tar.gz` under `-latest`. +Then run on the Pod: -**Requirements:** SSH access to pod on port 8822 with key auth. +```bash +sudo env SLEEPYPOD_GITHUB_REPO=OWNER/core sp-update fix/my-fix +``` + +A Git push alone does not upload a locally built `.next`; use the deployment +script or let CI build and publish the release. ### Path 2: CI Release (Production) @@ -88,7 +114,8 @@ The pod self-updates by downloading from GitHub. Triggered via the `system.trigg ```bash # From SSH on the pod: sp-update # latest release (pre-built) -sp-update feat/alarms # specific branch (source only, needs build) +sp-update feat/alarms # pre-built feat-alarms-latest release +sp-update --archive /persistent/sleepypod-core.tar.gz # uploaded pre-built archive # From web UI or iOS app (tRPC): # system.triggerUpdate({ branch: "main" }) @@ -96,14 +123,25 @@ sp-update feat/alarms # specific branch (source only, needs build) ``` **How it works:** -1. Opens iptables (temporarily allows WAN) -2. Tries to download latest CI release tarball (includes `.next` β€” no build needed) -3. Falls back to GitHub source tarball if no release or if requesting a non-main branch -4. Installs prod dependencies (prebuild-install fetches linux-arm64 native modules) -5. Closes iptables (re-blocks WAN) -6. Restarts service (DB migrations run automatically on startup via `instrumentation.ts`) - -**If the update fails:** iptables are restored and the service restarts with existing code. Database is restored from backup. +1. Resolves `main`/`latest` to the latest stable release, `dev` to `dev-latest`, and other branches to `-latest`. +2. Downloads and validates the pre-built archive before replacing installed files. +3. Backs up the installed code and database, then installs the new code and production dependencies. +4. Restores the firewall and starts the service (database migrations run at startup). + +There is no source-tarball fallback or on-Pod Next.js build. Missing releases, +API errors, failed downloads, and incomplete builds fail with actionable errors. +For a local archive, bundle validation happens before stopping the service. +When WAN is blocked, the service stops before opening the firewall to avoid +racing its firewall self-heal; preparation failures restart it with existing code. + +`TMPDIR` defaults to `/persistent` for staging and rollback backups. It can be +overridden with an existing writable directory. The updater requires the +archive's pinned pnpm version and reports how to correct a mismatch. + +**If the update fails:** the updater attempts to restore the firewall and, +after replacement has begun, the backed-up code and database. A failed update's +code backup remains at `$TMPDIR/sleepypod-rollback.*` for recovery. Dependency +changes in `node_modules` and module/systemd changes are not rolled back. ## Why Build Off-Device @@ -117,7 +155,7 @@ The `.next` output is platform-independent JavaScript β€” only `better-sqlite3` The pod runs "Eight Layer" (Yocto kirkstone) β€” a minimal embedded Linux with no package manager. Instead of git, we use GitHub's tarball API and release assets. No commit history is needed on a deployment target. ### No rsync on the pod -The deploy script uses `tar | ssh` β€” creates a tar archive locally, pipes over SSH, extracts on the pod. Stale files are cleaned before extraction (mimics `rsync --delete`). +The deploy script creates a tar archive locally and transfers it over SSH. The updater stages and validates it before replacing the installed application. ### prebuild-install for native modules `better-sqlite3` ships with `prebuild-install`, which downloads precompiled binaries for `linux-arm64`. No C compiler needed on the pod. diff --git a/scripts/bin/sp-update b/scripts/bin/sp-update index 7108b1392..bf7e7550b 100644 --- a/scripts/bin/sp-update +++ b/scripts/bin/sp-update @@ -13,7 +13,7 @@ export PATH="/usr/local/bin:$PATH" # sp-update feat/alarms # update to a specific branch # # Handles iptables toggle automatically (opens WAN, downloads, closes WAN). -# No git required β€” uses GitHub's tarball API. +# No git required. Requires a pre-built release or a local deployment archive. INSTALL_DIR="/home/dac/sleepypod-core" # DATA_DIR pointer is written by scripts/install (storage-aware picker). @@ -24,11 +24,51 @@ else DATA_DIR="/persistent/sleepypod-data" fi GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-sleepypod/core}" -BRANCH="${1:-main}" +BRANCH=main +ARCHIVE="" +usage() { + cat <<'EOF' +Usage: sp-update [branch] + sp-update --archive /path/to/sleepypod-core.tar.gz + +Downloads a pre-built GitHub release; never builds Next.js on the Pod. +For a cloned repo on your computer, run: ./scripts/deploy POD_IP [branch] +SLEEPYPOD_GITHUB_REPO selects the release repository (default: sleepypod/core). +TMPDIR selects temporary storage (default: /persistent). +EOF +} +case "${1:-}" in + -h|--help) usage; exit 0 ;; + --archive) + if [ "$#" -ne 2 ] || [ ! -f "$2" ]; then + echo "Error: --archive requires an existing tar.gz file." >&2 + exit 1 + fi + ARCHIVE="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" + ;; + -*) usage >&2; exit 1 ;; + *) + if [ "$#" -gt 1 ]; then usage >&2; exit 1; fi + BRANCH="${1:-main}" + ;; +esac +if [ ! -d "$INSTALL_DIR" ] || ! command -v systemctl >/dev/null; then + echo "Error: sp-update runs on an installed Pod. On your computer use ./scripts/deploy POD_IP [branch]." >&2 + exit 1 +fi +# Existing installations may point at a release via a symlink. find does +# not descend through a command-line symlink by default; resolve it so stale +# .next/standalone files are removed rather than silently booting old code. +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd -P)" +export TMPDIR="${TMPDIR:-/persistent}" echo "=== SleepyPod Update ===" -echo "Branch: $BRANCH" -echo "Repo: $GITHUB_REPO" +if [ -n "$ARCHIVE" ]; then + echo "Archive: $ARCHIVE" +else + echo "Branch: $BRANCH" + echo "Repo: $GITHUB_REPO" +fi echo "" # Source shared iptables helpers (with inline fallback for transitional installs) @@ -128,6 +168,9 @@ reload_iptables_helpers() { # Cleanup β€” re-block WAN, rollback code, and restart service on failure ROLLBACK_READY=false +SERVICE_STOPPED=false +WORK_DIR="" +BACKUP_DIR="" cleanup() { local exit_code=$? if [ "$WAN_WAS_BLOCKED" = true ]; then @@ -147,7 +190,7 @@ cleanup() { ! -name 'node_modules' \ ! -name '.env' \ -exec rm -rf {} + - cp -r "$BACKUP_DIR/." "$INSTALL_DIR/" + cp -a "$BACKUP_DIR/." "$INSTALL_DIR/" # Intentionally NOT restoring sp-* tools to /usr/local/bin/. # They were already swapped in atomically (see install(1) below) # before the failure; restoring the pre-update copies can re-pin a @@ -190,133 +233,147 @@ cleanup() { echo "Database restored from $DB_BACKUP." fi systemctl start sleepypod.service 2>/dev/null || true - elif [ "$exit_code" -ne 0 ]; then + elif [ "$exit_code" -ne 0 ] && [ "$SERVICE_STOPPED" = true ]; then echo "Update failed before rollback point, starting service with existing code..." >&2 systemctl start sleepypod.service 2>/dev/null || true fi + [ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR" + # Keep a failed update's backup for manual recovery. + if [ "$exit_code" -eq 0 ] && [ -n "$BACKUP_DIR" ]; then + rm -rf "$BACKUP_DIR" + fi + return "$exit_code" } trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM # Pre-flight: disk space -DISK_AVAIL=$(df -m "$INSTALL_DIR" | awk 'NR==2{print $4}') -if [ "$DISK_AVAIL" -lt 300 ]; then - echo "Error: Need 300MB free, only ${DISK_AVAIL}MB available" >&2 - exit 1 -fi +for space_dir in "$INSTALL_DIR" "$TMPDIR"; do + DISK_AVAIL=$(df -m "$space_dir" | awk 'NR==2{print $4}') + if [ "$DISK_AVAIL" -lt 300 ]; then + echo "Error: Need 300MB free in $space_dir, only ${DISK_AVAIL}MB available" >&2 + exit 1 + fi +done + +# Local archive validation happens before stopping services. GitHub downloads need +# WAN access, so stop first only when the firewall needs to be opened: the +# service's firewall self-heal otherwise races unblock_wan. +WORK_DIR=$(mktemp -d "$TMPDIR/sleepypod-update.XXXXXX") +open_update_wan() { + if wan_is_blocked; then + if [ "$SERVICE_STOPPED" = false ]; then + systemctl stop sleepypod.service + SERVICE_STOPPED=true + fi + if ! grep -q "# BEGIN sleepypod-telemetry-block" /etc/hosts 2>/dev/null; then + "$INSTALL_DIR/scripts/internet-control" hosts-block 2>/dev/null || true + fi + WAN_WAS_BLOCKED=true + unblock_wan + fi +} -# Stop the service before temporarily flushing the firewall. Its startup and -# health checks repair required LAN rules; leaving it running during the open -# WAN window races those repairs against an empty chain (the observed -# "Index of insertion too big" failure). -systemctl stop sleepypod.service - -# Open WAN if blocked -if wan_is_blocked; then - # Ensure /etc/hosts telemetry block is in place before opening WAN - if ! grep -q "# BEGIN sleepypod-telemetry-block" /etc/hosts 2>/dev/null; then - echo "Installing /etc/hosts telemetry block before opening WAN..." - "$INSTALL_DIR/scripts/internet-control" hosts-block 2>/dev/null || true +if [ -n "$ARCHIVE" ]; then + echo "Using local build: $ARCHIVE" + tar xzf "$ARCHIVE" -C "$WORK_DIR" +else + open_update_wan + if [ "$BRANCH" = main ] || [ "$BRANCH" = latest ]; then + RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/latest" + else + RELEASE_TAG="${BRANCH//\//-}-latest" + RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${RELEASE_TAG}" + fi + echo "Checking for pre-built release: $RELEASE_API" + if ! HTTP_STATUS=$(curl -sS -L --connect-timeout 10 --max-time 60 \ + -o "$WORK_DIR/release.json" -w '%{http_code}' "$RELEASE_API"); then + echo "Error: Could not contact GitHub. Check the Pod's network and retry." >&2 + exit 1 + fi + case "$HTTP_STATUS" in + 200) ;; + 404) + echo "Error: No accessible pre-built release for '$BRANCH' in '$GITHUB_REPO'." >&2 + echo "Enable GitHub Actions in the fork and push the branch to publish its Branch Release." >&2 + echo "Or, from a cloned repo on your computer: ./scripts/deploy POD_IP" >&2 + echo "Installed files have not been replaced; source-only builds are not supported on the Pod." >&2 + exit 1 ;; + *) + echo "Error: GitHub release lookup returned HTTP $HTTP_STATUS (check API access/rate limits)." >&2 + exit 1 ;; + esac + RELEASE_URL=$(node -e ' + const fs = require("fs"); + const release = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const asset = release.assets?.find(a => a.name === "sleepypod-core.tar.gz"); + if (asset && /^https:\/\//.test(asset.browser_download_url)) process.stdout.write(asset.browser_download_url); + ' "$WORK_DIR/release.json") + rm -f "$WORK_DIR/release.json" + if [ -z "$RELEASE_URL" ]; then + echo "Error: Release has no sleepypod-core.tar.gz asset. Build/publish the release, or use ./scripts/deploy POD_IP from your computer." >&2 + exit 1 + fi + echo "Downloading pre-built release..." + if ! curl -fSL --connect-timeout 10 --max-time 600 "$RELEASE_URL" | tar xz -C "$WORK_DIR"; then + echo "Error: Release download/extraction failed. Installed files have not been replaced; retry the update." >&2 + exit 1 fi - WAN_WAS_BLOCKED=true - unblock_wan fi -# Check connectivity -if ! curl -sf --max-time 10 https://github.com > /dev/null 2>&1; then - echo "Error: Cannot reach GitHub. Check network." >&2 +# Validate staged artifacts before touching the installed application. Exclude +# workstation dependencies and environment files even for hand-made archives. +for required in .next/BUILD_ID .next/required-server-files.json .next/standalone/server.js package.json pnpm-lock.yaml; do + if [ ! -s "$WORK_DIR/$required" ]; then + echo "Error: Pre-built archive is missing $required. Build on your computer with ./scripts/deploy POD_IP, or publish a complete CI release." >&2 + exit 1 + fi +done +find "$WORK_DIR" -name node_modules -prune -exec rm -rf {} + +find "$WORK_DIR" -maxdepth 1 -name '.env*' -exec rm -rf {} + + +# Check the target project's package-manager version before replacing files. +# An unpinned global install can silently ignore native-dependency settings. +PNPM_VERSION=$(node -p 'require(process.argv[1]).packageManager' "$WORK_DIR/package.json") +if [[ ! "$PNPM_VERSION" =~ ^pnpm@[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Archive must pin packageManager to pnpm@x.y.z." >&2 + exit 1 +fi +# pnpm may manage its own pinned version, which can require WAN even for a +# local archive. Production dependencies also still need registry access. +open_update_wan +ACTUAL_PNPM=$(cd "$WORK_DIR" && pnpm --version) +if [ "$ACTUAL_PNPM" != "${PNPM_VERSION#pnpm@}" ]; then + echo "Error: Expected $PNPM_VERSION, found pnpm $ACTUAL_PNPM. Install the pinned version (npm install -g $PNPM_VERSION), then retry." >&2 exit 1 fi -# Backup current code for rollback (everything except node_modules) -BACKUP_DIR="/tmp/sleepypod-rollback" -rm -rf "$BACKUP_DIR" -mkdir -p "$BACKUP_DIR" -tar cf - -C "$INSTALL_DIR" --exclude='node_modules' . 2>/dev/null | tar xf - -C "$BACKUP_DIR" || true +if [ "$SERVICE_STOPPED" = false ]; then + systemctl stop sleepypod.service + SERVICE_STOPPED=true +fi + +# Fail closed if the backup cannot be completed. Keep it off the Pod's /tmp +# tmpfs by honoring TMPDIR for both staging and rollback storage. +BACKUP_DIR=$(mktemp -d "$TMPDIR/sleepypod-rollback.XXXXXX") +tar cf - -C "$INSTALL_DIR" --exclude='node_modules' . | tar xf - -C "$BACKUP_DIR" -# Backup database (service is stopped, so WAL/SHM are consistent) -# Timestamped name so two rapid updates don't clobber the previous backup. -# Matches scripts/install's naming; sp-maintenance prunes to 3 most recent. BACKUP_TS=$(date +%s) DB_BACKUP="$DATA_DIR/sleepypod.db.bak.$BACKUP_TS" if [ -f "$DATA_DIR/sleepypod.db" ]; then cp "$DATA_DIR/sleepypod.db" "$DB_BACKUP" - [ -f "$DATA_DIR/sleepypod.db-wal" ] && cp "$DATA_DIR/sleepypod.db-wal" "$DATA_DIR/sleepypod.db-wal.bak.$BACKUP_TS" - [ -f "$DATA_DIR/sleepypod.db-shm" ] && cp "$DATA_DIR/sleepypod.db-shm" "$DATA_DIR/sleepypod.db-shm.bak.$BACKUP_TS" + [ ! -f "$DATA_DIR/sleepypod.db-wal" ] || cp "$DATA_DIR/sleepypod.db-wal" "$DATA_DIR/sleepypod.db-wal.bak.$BACKUP_TS" + [ ! -f "$DATA_DIR/sleepypod.db-shm" ] || cp "$DATA_DIR/sleepypod.db-shm" "$DATA_DIR/sleepypod.db-shm.bak.$BACKUP_TS" echo "Database backed up to $DB_BACKUP." fi -# Download code β€” try CI release first (includes pre-built .next), fall back to source tarball -WORK_DIR=$(mktemp -d) -HAS_BUILD=false - -# Resolve the release endpoint. Mirrors scripts/install so OTA updates use -# the same CI-built tarballs as fresh installs: -# main / latest β†’ releases/latest (semantic-release vX.Y.Z asset) -# dev β†’ releases/tags/dev-latest (built by dev-release.yml) -# β†’ releases/tags/-latest (built by branch-release.yml) -# Slugging matches branch-release.yml: `feat/foo` β†’ `feat-foo-latest`. -if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "latest" ]; then - RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/latest" - echo "Checking for CI release (latest stable)..." -elif [ "$BRANCH" = "dev" ]; then - RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/dev-latest" - echo "Checking for CI release (tag: dev-latest)..." -else - RELEASE_TAG="${BRANCH//\//-}-latest" - RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${RELEASE_TAG}" - echo "Checking for CI release (tag: $RELEASE_TAG)..." -fi -RELEASE_URL=$(curl -sf "$RELEASE_API" \ - | grep -om1 '"browser_download_url": *"[^"]*sleepypod-core\.tar\.gz"' \ - | grep -o 'https://[^"]*' || true) - -if [ -n "$RELEASE_URL" ]; then - echo "Downloading CI release..." - if curl -fSL "$RELEASE_URL" | tar xz -C "$WORK_DIR"; then - HAS_BUILD=true - echo "CI release downloaded (pre-built)." - else - # CI download failed mid-stream (e.g. curl truncation). Partial - # files would otherwise contaminate WORK_DIR β€” the source-tarball - # fallback below extracts into the same dir, and `ls "$WORK_DIR" - # | head -1` would then pick up stale CI-release contents instead - # of the freshly-extracted source subdir. Mirrors scripts/install. - find "$WORK_DIR" -mindepth 1 -delete 2>/dev/null || true - fi -fi - -if [ "$HAS_BUILD" = false ]; then - # A failed CI-release download can leave partial extraction in WORK_DIR; - # drop and recreate so the source-tarball structure check is reliable. - rm -rf "$WORK_DIR" - WORK_DIR=$(mktemp -d) - # Fall back to source tarball (no .next β€” would need build on pod) - # "latest" is not a real branch β€” use main for the source tarball - SOURCE_BRANCH="$BRANCH" - [ "$SOURCE_BRANCH" = "latest" ] && SOURCE_BRANCH="main" - echo "Downloading $SOURCE_BRANCH from GitHub..." - TARBALL_URL="https://github.com/${GITHUB_REPO}/archive/refs/heads/${SOURCE_BRANCH}.tar.gz" - if ! curl -fSL "$TARBALL_URL" | tar xz -C "$WORK_DIR"; then - echo "Error: Failed to download branch '$BRANCH'" >&2 - exit 1 - fi - # Source tarball extracts to a subdirectory (e.g., core-main/) - SUBDIR=$(ls "$WORK_DIR" | head -1) - if [ -z "$SUBDIR" ] || [ ! -d "$WORK_DIR/$SUBDIR" ]; then - echo "Error: Unexpected tarball structure in $WORK_DIR" >&2 - exit 1 - fi - # Move contents up so WORK_DIR is the root - mv "$WORK_DIR/$SUBDIR"/* "$WORK_DIR/$SUBDIR"/.[!.]* "$WORK_DIR/" 2>/dev/null || true - rmdir "$WORK_DIR/$SUBDIR" 2>/dev/null || true -fi - # Clean old files (preserve node_modules, .env) +ROLLBACK_READY=true find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 \ ! -name 'node_modules' \ ! -name '.env' \ -exec rm -rf {} + -ROLLBACK_READY=true # Move new code into place cp -r "$WORK_DIR/." "$INSTALL_DIR/" @@ -333,31 +390,14 @@ if [ -f "$INSTALL_DIR/scripts/lib/relocation-helpers" ]; then ensure_persistent_node_modules "$INSTALL_DIR" fi -# Install production dependencies -pnpm install --frozen-lockfile --prod +# Native dependencies must be installed for the Pod, never copied from a Mac. +CI=true pnpm install --frozen-lockfile --prod -# Build only if no pre-built .next exists -if [ ! -d "$INSTALL_DIR/.next" ]; then - echo "No pre-built .next found, building..." - # CI=true so pnpm doesn't prompt for TTY confirmation when prod-only - # node_modules need to be replaced with the full dev set. - CI=true pnpm install --frozen-lockfile - SP_BRANCH="$BRANCH" pnpm build -else - # Regenerate .git-info with the correct branch for pre-built releases +# Preserve the commit/branch metadata produced by the actual build. +if [ ! -s .git-info ]; then SP_BRANCH="$BRANCH" node scripts/generate-git-info.mjs 2>/dev/null || true fi -# Validate the build actually produced a startable .next bundle. next start -# refuses to boot without BUILD_ID and silently crashloops via systemd -# Restart=on-failure, so without this check sp-update would print "Update -# complete!" while the service is dying every 10 seconds. -if [ ! -f "$INSTALL_DIR/.next/BUILD_ID" ]; then - echo "Error: build did not produce $INSTALL_DIR/.next/BUILD_ID β€” refusing to start service" >&2 - echo "Inspect the build output above; common causes: pnpm build script swallowed an error, OOM/SIGTERM during turbopack, or pre-built tarball missing the file." >&2 - exit 1 -fi - # Sync biometrics modules if [ -d "$INSTALL_DIR/modules" ]; then MODULES_DEST="/opt/sleepypod/modules" diff --git a/scripts/deploy b/scripts/deploy index ee1fad1bf..d3a2b1575 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -1,103 +1,115 @@ #!/bin/bash set -euo pipefail -# deploy β€” build locally, push built artifacts to a Pod -# -# Usage: -# ./scripts/deploy [POD_IP] [branch] -# -# Examples: -# ./scripts/deploy # deploy current branch to 192.168.1.88 -# ./scripts/deploy 192.168.1.50 # current branch -> different pod -# ./scripts/deploy 192.168.1.88 main # pull and deploy main -# ./scripts/deploy 192.168.1.88 feat/alarms # pull and deploy a feature branch -# -# What it does: -# 1. Optionally checks out and pulls the specified branch -# 2. Builds the Next.js app locally (fast, full RAM) with output: 'standalone' -# 3. Syncs standalone build to the pod via tar+ssh -# 4. Runs the install script on the pod (prod deps only, no build) -# -# Requirements: -# - SSH access to the pod on port 8822 with key auth -# - The pod must be on your local network -# - Node.js and pnpm installed locally +# Build a clone on macOS/Linux and deploy to an already-installed Pod. +# An optional branch is fetched from origin into an isolated checkout. +usage() { + cat <<'HELP' +Usage: ./scripts/deploy POD_IP [branch] -SSH_PORT=8822 -SSH_USER=root -REMOTE_DIR="/home/dac/sleepypod-core" +Build the current checkout (including local edits), or a branch from origin, +then transfer a pre-built archive over SSH and update the Pod with rollback. +Requires Node.js, the project's pinned pnpm, and root SSH key access. +The Pod still needs WAN access for production/native dependencies. +SSH_PORT defaults to 8822; TMPDIR on the Pod defaults to /persistent. +HELP +} +case "${1:-}" in -h|--help) usage; exit 0 ;; esac +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then usage >&2; exit 1; fi +POD_IP="$1" +BRANCH="${2:-}" +SSH_PORT="${SSH_PORT:-8822}" +if [[ ! "$POD_IP" =~ ^[a-zA-Z0-9][a-zA-Z0-9.:-]*$ ]] || [[ ! "$SSH_PORT" =~ ^[0-9]+$ ]]; then + echo "Error: Provide a Pod hostname/IP and a numeric SSH_PORT." >&2 + exit 1 +fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -DEFAULT_POD_IP="192.168.1.88" - -POD_IP="${1:-$DEFAULT_POD_IP}" -BRANCH="${2:-}" +SSH=(ssh -p "$SSH_PORT" -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30 "root@$POD_IP") +LOCAL_STAGE=$(mktemp -d "${TMPDIR:-/tmp}/sleepypod-deploy.XXXXXX") +REMOTE_STAGE="" +BUILD_DIR="$PROJECT_DIR" +cleanup() { + local status=$? + if [ -n "$REMOTE_STAGE" ]; then + "${SSH[@]}" "rm -rf '$REMOTE_STAGE'" || true + fi + if [ "$BUILD_DIR" != "$PROJECT_DIR" ]; then + git -C "$PROJECT_DIR" worktree remove --force "$BUILD_DIR" || true + fi + rm -rf "$LOCAL_STAGE" + return "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM echo "=== SleepyPod Deploy ===" -echo "Target: ${SSH_USER}@${POD_IP}:${SSH_PORT}" -echo "Source: $PROJECT_DIR" -echo "" - -# Check SSH connectivity -echo "Checking SSH connectivity..." -if ! ssh -p "$SSH_PORT" -o ConnectTimeout=5 -o BatchMode=yes "$SSH_USER@$POD_IP" "echo ok" >/dev/null 2>&1; then - echo "Error: Cannot SSH to $SSH_USER@$POD_IP:$SSH_PORT" >&2 - echo "Ensure SSH is configured and your key is authorized." >&2 +echo "Target: root@$POD_IP:$SSH_PORT" +# shellcheck disable=SC2016 # Use the Pod binaries in non-login SSH sessions. +"${SSH[@]}" 'export PATH=/usr/local/bin:$PATH; test -d /home/dac/sleepypod-core && command -v node >/dev/null && command -v pnpm >/dev/null' || { + echo "Error: SSH failed or SleepyPod is not installed. Install SleepyPod on the Pod first." >&2 exit 1 +} +if [ -n "$BRANCH" ]; then + # Fetch only the chosen ref; do not switch branches or pull into user edits. + git check-ref-format "refs/heads/$BRANCH" + git -C "$PROJECT_DIR" fetch origin "refs/heads/$BRANCH" + BUILD_DIR="$LOCAL_STAGE/checkout" + git -C "$PROJECT_DIR" worktree add --detach "$BUILD_DIR" FETCH_HEAD fi - -# Optionally switch branch and pull +cd "$BUILD_DIR" +EXPECTED_PNPM=$(node -p 'require("./package.json").packageManager') +ACTUAL_PNPM=$(pnpm --version) +if [ "pnpm@$ACTUAL_PNPM" != "$EXPECTED_PNPM" ]; then + echo "Error: Expected $EXPECTED_PNPM, found pnpm@$ACTUAL_PNPM. Install the pinned pnpm version first." >&2 + exit 1 +fi +pnpm install --frozen-lockfile +# Remove stale output so a failed/incomplete build cannot deploy an old bundle. +rm -rf .next +# Route collection imports the database modules in parallel workers. Give +# each worker an in-memory database rather than locking/altering local data. if [ -n "$BRANCH" ]; then - echo "Checking out branch: $BRANCH" - cd "$PROJECT_DIR" - git fetch origin - git checkout "$BRANCH" - git pull origin "$BRANCH" - echo "" + DATABASE_URL=:memory: BIOMETRICS_DATABASE_URL=:memory: SP_BRANCH="$BRANCH" pnpm build +else + DATABASE_URL=:memory: BIOMETRICS_DATABASE_URL=:memory: pnpm build fi +for required in .next/BUILD_ID .next/required-server-files.json .next/standalone/server.js; do + if [ ! -s "$required" ]; then + echo "Error: Build did not produce $required; Pod files have not been changed." >&2 + exit 1 + fi +done -# Build locally (migrations must be generated and committed beforehand) -echo "Building locally..." -cd "$PROJECT_DIR" -pnpm build -echo "Build complete." -echo "" - -# Sync to pod via tar+ssh -echo "Syncing to pod..." -SSH_CMD="ssh -p $SSH_PORT -o ServerAliveInterval=30 $SSH_USER@$POD_IP" - -# Clean old files on pod (preserve node_modules, .env, persistent data) -$SSH_CMD "mkdir -p '$REMOTE_DIR' && \ - find '$REMOTE_DIR' -mindepth 1 -maxdepth 1 \ - ! -name 'node_modules' \ - ! -name '.env' \ - -exec rm -rf {} +" +# The standalone server changes cwd; keep its version endpoint tied to this +# build even when output-file tracing does not include the root metadata. +if [ -s .git-info ]; then cp -f .git-info .next/standalone/.git-info; fi -# Tar source + build, pipe over SSH, extract on pod -# Includes .next (pre-built) but excludes node_modules (installed on pod for native modules) -COPYFILE_DISABLE=1 tar cf - --no-mac-metadata \ - --exclude='node_modules' \ - --exclude='*.db' \ - --exclude='*.db-shm' \ - --exclude='*.db-wal' \ - --exclude='*.db-journal' \ - --exclude='.DS_Store' \ - --exclude='test-results' \ - --exclude='.serena' \ - --exclude='.claude' \ - --exclude='docs' \ - -C "$PROJECT_DIR" . \ - | $SSH_CMD "tar xf - -C '$REMOTE_DIR' 2>/dev/null" +# Flags shared by BSD tar (macOS) and GNU tar (Linux). Never ship local +# credentials, Git worktrees, or workstation-native modules (including those +# traced into .next/standalone). Keep the standalone server: it embeds the +# compiled config and avoids loading build-only plugins at runtime. +COPYFILE_DISABLE=1 tar czf "$LOCAL_STAGE/sleepypod-core.tar.gz" \ + --exclude='node_modules' --exclude='.next/cache' \ + --exclude='.git' --exclude='.env*' --exclude='*.db' --exclude='*.db-*' \ + --exclude='.DS_Store' --exclude='test-results' --exclude='coverage' \ + --exclude='.serena' --exclude='.claude' --exclude='.codex' --exclude='.ygg' \ + -C "$BUILD_DIR" . -echo "Files synced." -echo "" - -# Run install on pod (local mode, pre-built β€” no build step needed) -echo "Running install on pod..." -ssh -p "$SSH_PORT" -o ServerAliveInterval=30 "$SSH_USER@$POD_IP" \ - "bash $REMOTE_DIR/scripts/install --local --no-ssh" - -echo "" -echo "=== Deploy complete ===" -echo "Web UI: http://$POD_IP:3000/" +# Upload completely before invoking the updater. Use the deploy script's own +# updater, so this also works on Pods/target branches with an older sp-update. +# shellcheck disable=SC2016 # Expanded by the remote shell. +REMOTE_STAGE=$("${SSH[@]}" 'mktemp -d "${TMPDIR:-/persistent}/sleepypod-deploy.XXXXXX"') +# Remote paths are interpolated into SSH shell commands; accept only a simple +# absolute mktemp path and never execute unexpected login-banner output. +if [[ ! "$REMOTE_STAGE" =~ ^/[a-zA-Z0-9/_.-]+/sleepypod-deploy\.[a-zA-Z0-9]+$ ]]; then + echo "Error: Unexpected remote staging path: $REMOTE_STAGE" >&2 + REMOTE_STAGE="" + exit 1 +fi +"${SSH[@]}" "cat > '$REMOTE_STAGE/sleepypod-core.tar.gz'" < "$LOCAL_STAGE/sleepypod-core.tar.gz" +"${SSH[@]}" "cat > '$REMOTE_STAGE/sp-update'" < "$SCRIPT_DIR/bin/sp-update" +echo "Build uploaded. Applying update on the Pod..." +"${SSH[@]}" "bash '$REMOTE_STAGE/sp-update' --archive '$REMOTE_STAGE/sleepypod-core.tar.gz'" +echo "Deploy complete. Web UI: http://$POD_IP:3000/" diff --git a/tests/iptablesHelpers.contract.test.ts b/tests/iptablesHelpers.contract.test.ts index fc4f20d96..8f9641e80 100644 --- a/tests/iptablesHelpers.contract.test.ts +++ b/tests/iptablesHelpers.contract.test.ts @@ -59,8 +59,8 @@ restore_wan it('stops the service before opening WAN and reloads the installed helper before restore', () => { const mainFlow = updateScript.indexOf('# Pre-flight: disk space') - const stopCommand = updateScript.indexOf('\nsystemctl stop sleepypod.service\n', mainFlow) - const unblockCommand = updateScript.indexOf('\n unblock_wan\n', mainFlow) + const stopCommand = updateScript.indexOf('systemctl stop sleepypod.service', mainFlow) + const unblockCommand = updateScript.indexOf(' unblock_wan\n', mainFlow) expect(mainFlow).toBeGreaterThan(-1) expect(stopCommand).toBeGreaterThan(mainFlow) expect(stopCommand).toBeLessThan(unblockCommand) diff --git a/tests/updateDeploy.test.ts b/tests/updateDeploy.test.ts new file mode 100644 index 000000000..836916d3c --- /dev/null +++ b/tests/updateDeploy.test.ts @@ -0,0 +1,298 @@ +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, cpSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' + +const repo = process.cwd() +// Git hooks export GIT_DIR/GIT_INDEX_FILE/etc. Clear them for every fixture +// subprocess so a pre-push test cannot operate on the invoking repository. +const testEnv = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), + NODE_ENV: process.env.NODE_ENV, +} +const scratch: string[] = [] +function temp() { + const dir = mkdtempSync(join(tmpdir(), 'sleepypod-update-test-')) + scratch.push(dir) + return dir +} +function put(path: string, content: string) { + mkdirSync(resolve(path, '..'), { recursive: true }) + writeFileSync(path, content, { mode: 0o755 }) +} +afterEach(() => { + for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +const helper = ` +SAVED_IPTABLES="" +WAN_WAS_BLOCKED=false +wan_is_blocked() { [ "$TEST_WAN_BLOCKED" = 1 ]; } +unblock_wan() { echo unblock >> "$TEST_LOG"; } +restore_wan() { echo restore >> "$TEST_LOG"; } +` +function updater(options: { invalid?: boolean, noAsset?: boolean, archive?: boolean, status?: string, blocked?: boolean, pnpmFail?: boolean, version?: string, downloadFail?: boolean, backupFail?: boolean, symlinkInstall?: boolean, lowTempSpace?: boolean } = {}) { + const dir = temp() + const app = join(dir, 'app') + const bundle = join(dir, 'bundle') + const bin = join(dir, 'bin') + const log = join(dir, 'log') + mkdirSync(join(dir, 'data')) + mkdirSync(join(dir, 'stage')) + put(join(app, 'old.txt'), 'old code') + put(join(app, '.next/standalone/server.js'), 'old standalone server') + const installPath = options.symlinkInstall ? join(dir, 'install-link') : app + if (options.symlinkInstall) symlinkSync(app, installPath) + put(join(app, '.env'), 'POD_SECRET=keep') + put(join(app, 'scripts/lib/iptables-helpers'), helper) + put(join(bundle, 'scripts/lib/iptables-helpers'), helper) + put(join(bundle, 'package.json'), '{"packageManager":"pnpm@10.34.5"}') + put(join(bundle, 'pnpm-lock.yaml'), 'lockfileVersion: 9') + put(join(bundle, '.next/required-server-files.json'), '{}') + put(join(bundle, '.next/standalone/server.js'), 'new standalone server') + if (!options.invalid) put(join(bundle, '.next/BUILD_ID'), 'new-build') + put(join(bundle, '.git-info'), '{"branch":"fix/test","commitHash":"abc1234"}') + put(join(bundle, '.env'), 'LOCAL_SECRET=do-not-copy') + put(join(bundle, 'node_modules/foreign.node'), 'wrong architecture') + const archive = join(dir, 'bundle.tar.gz') + expect(spawnSync('tar', ['czf', archive, '-C', bundle, '.']).status).toBe(0) + let script = readFileSync(join(repo, 'scripts/bin/sp-update'), 'utf8') + const rewrites: [string, string][] = [ + ['export PATH="/usr/local/bin:$PATH"', ''], + ['/home/dac/sleepypod-core', installPath], + ['/persistent/sleepypod-data', join(dir, 'data')], + ['/etc/sleepypod/data-dir', join(dir, 'data-dir')], + ['/etc/systemd/system', join(dir, 'systemd')], + ['/etc/hosts', join(dir, 'hosts')], + ] + for (const [from, to] of rewrites) { + expect(script).toContain(from) + script = script.replaceAll(from, to) + } + put(join(dir, 'hosts'), '# BEGIN sleepypod-telemetry-block') + put(join(dir, 'update'), script) + put(join(bin, 'systemctl'), '#!/bin/bash\necho "systemctl $*" >> "$TEST_LOG"\n') + put(join(bin, 'df'), `#!/bin/bash +available=900 +if [ "$2" = "$TMPDIR" ]; then available=$TEST_TEMP_SPACE; fi +echo 'Filesystem Size Used Available Use% Mounted' +echo "fixture 1000 100 $available 10% /" +`) + put(join(bin, 'sleep'), '#!/bin/bash\nexit 0\n') + put(join(bin, 'pnpm'), `#!/bin/bash +if [ "$1" = --version ]; then echo "$TEST_PNPM_VERSION"; exit; fi +echo "pnpm $*" >> "$TEST_LOG" +exit "$TEST_PNPM_FAIL" +`) + put(join(bin, 'curl'), `#!/bin/bash +echo "curl $*" >> "$TEST_LOG" +if [[ "$*" == *api.github.com* ]]; then + while [ "$#" -gt 0 ] && [ "$1" != -o ]; do shift; done + if [ "$#" -lt 2 ]; then echo "curl stub: missing -o" >&2; exit 2; fi + printf '%s' "$TEST_RELEASE" > "$2" + printf '%s' "$TEST_HTTP_STATUS" +else + if [ "$TEST_DOWNLOAD_FAIL" = 1 ]; then head -c 20 "$TEST_ARCHIVE"; exit 1; fi + cat "$TEST_ARCHIVE" +fi +`) + const tar = spawnSync('which', ['tar'], { encoding: 'utf8' }).stdout.trim() + put(join(bin, 'tar'), `#!/bin/bash +if [ "$TEST_BACKUP_FAIL" = 1 ] && [ "$1" = cf ]; then exit 1; fi +exec '${tar}' "$@" +`) + const result = spawnSync('bash', [join(dir, 'update'), ...(options.archive ? ['--archive', archive] : ['fix/test'])], { + encoding: 'utf8', + env: { ...testEnv, PATH: `${bin}:${process.env.PATH}`, TMPDIR: join(dir, 'stage'), TEST_LOG: log, + TEST_WAN_BLOCKED: options.blocked ? '1' : '0', TEST_PNPM_FAIL: options.pnpmFail ? '1' : '0', + TEST_PNPM_VERSION: options.version ?? '10.34.5', TEST_DOWNLOAD_FAIL: options.downloadFail ? '1' : '0', + TEST_BACKUP_FAIL: options.backupFail ? '1' : '0', TEST_TEMP_SPACE: options.lowTempSpace ? '100' : '900', TEST_HTTP_STATUS: options.status ?? '200', TEST_ARCHIVE: archive, + TEST_RELEASE: JSON.stringify({ assets: options.noAsset ? [] : [{ name: 'sleepypod-core.tar.gz', browser_download_url: 'https://example.test/bundle' }] }) }, + }) + return { result, app, dir, log: existsSync(log) ? readFileSync(log, 'utf8') : '' } +} + +describe('sp-update staged deployment', () => { + it('checks temporary storage before stopping the service or opening WAN', () => { + const { result, app, log } = updater({ archive: true, blocked: true, lowTempSpace: true }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('only 100MB available') + expect(existsSync(join(app, 'old.txt'))).toBe(true) + expect(log).toBe('') + }) + + it('cleans a symlinked installation so stale standalone code cannot shadow the new build', () => { + const { result, app } = updater({ archive: true, symlinkInstall: true }) + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(app, '.next/standalone/server.js'), 'utf8')).toBe('new standalone server') + expect(existsSync(join(app, 'old.txt'))).toBe(false) + expect(readFileSync(join(app, '.next/BUILD_ID'), 'utf8')).toBe('new-build') + }) + + it.each(['404', '403', '500'])('rejects HTTP %s without touching installed files or dependencies', (status) => { + const { result, app, log } = updater({ status, blocked: true }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(status === '404' ? 'No accessible pre-built release' : `HTTP ${status}`) + expect(readFileSync(join(app, 'old.txt'), 'utf8')).toBe('old code') + expect(log).not.toContain('pnpm install') + for (const marker of ['systemctl stop', 'unblock', 'restore', 'systemctl start']) expect(log).toContain(marker) + expect(log.indexOf('systemctl stop')).toBeLessThan(log.indexOf('unblock')) + expect(log.indexOf('restore')).toBeLessThan(log.indexOf('systemctl start')) + expect(log).not.toContain('archive/refs/heads') + }) + it('rejects a release without an asset', () => { + const { result, app } = updater({ noAsset: true }) + expect(result.stderr).toContain('Release has no sleepypod-core.tar.gz asset') + expect(existsSync(join(app, 'old.txt'))).toBe(true) + }) + it('rejects interrupted downloads and keeps the installed app', () => { + const { result, app, log } = updater({ downloadFail: true }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('download/extraction failed') + expect(existsSync(join(app, 'old.txt'))).toBe(true) + expect(log).not.toContain('pnpm install') + }) + it('rejects an incomplete local build before stopping services or opening WAN', () => { + const { result, app, log } = updater({ archive: true, invalid: true, blocked: true }) + expect(result.stderr).toContain('missing .next/BUILD_ID') + expect(existsSync(join(app, 'old.txt'))).toBe(true) + expect(log).toBe('') + }) + it('reports pnpm mismatch before replacing the app', () => { + const { result, app, log } = updater({ archive: true, version: '11.0.0' }) + expect(result.stderr).toContain('Expected pnpm@10.34.5') + expect(existsSync(join(app, 'old.txt'))).toBe(true) + expect(log).not.toContain('pnpm install') + }) + it('does not replace files if the backup fails', () => { + const { result, app, log } = updater({ archive: true, backupFail: true }) + expect(result.status).not.toBe(0) + expect(existsSync(join(app, 'old.txt'))).toBe(true) + expect(log).toContain('systemctl start') + expect(log).not.toContain('pnpm install') + }) + it.each([true, false])('installs only production deps and preserves Pod env/build metadata (archive=%s)', (archive) => { + const { result, app, log } = updater({ archive }) + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + expect(existsSync(join(app, 'old.txt'))).toBe(false) + expect(readFileSync(join(app, '.env'), 'utf8')).toBe('POD_SECRET=keep') + expect(readFileSync(join(app, '.git-info'), 'utf8')).toContain('abc1234') + expect(existsSync(join(app, 'node_modules/foreign.node'))).toBe(false) + expect(log).toContain('pnpm install --frozen-lockfile --prod') + expect(log).not.toContain('pnpm build') + if (archive) expect(log).not.toContain('curl') + }) + it('restores installed code and restarts after a dependency failure', () => { + const { result, app, log } = updater({ archive: true, pnpmFail: true, blocked: true }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('rolling back') + expect(readFileSync(join(app, 'old.txt'), 'utf8')).toBe('old code') + expect(log).toContain('restore') + expect(log).toContain('systemctl start sleepypod.service') + }) +}) + +function deploy(fail: 'build' | 'transfer' | 'apply' | '' = '', branch = false) { + const dir = temp() + const project = join(dir, 'project') + const bin = join(dir, 'bin') + const log = join(dir, 'log') + mkdirSync(join(project, 'scripts'), { recursive: true }) + cpSync(join(repo, 'scripts/deploy'), join(project, 'scripts/deploy')) + put(join(project, 'scripts/bin/sp-update'), '# updater to upload') + put(join(project, 'package.json'), '{"packageManager":"pnpm@10.34.5"}') + put(join(project, '.env'), 'local-secret') + if (!branch) put(join(project, '.git/config'), 'credential') + put(join(project, 'node_modules/native.node'), 'wrong-arch') + put(join(project, 'source.ts'), 'source') + if (branch) { + const git = (args: string[]) => { + const result = spawnSync('git', ['-C', project, ...args], { encoding: 'utf8', env: testEnv }) + expect(result.status, result.stderr).toBe(0) + } + git(['init', '-b', 'main']) + git(['add', '.']) + git(['-c', 'core.hooksPath=/dev/null', '-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-m', 'fixture']) + git(['branch', 'fix/test']) + git(['remote', 'add', 'origin', project]) + put(join(project, 'source.ts'), 'uncommitted local edits') + } + put(join(bin, 'pnpm'), `#!/bin/bash +if [ "$1" = --version ]; then echo 10.34.5; exit; fi +echo "pnpm $*" >> "$TEST_LOG" +if [ "$1" = build ]; then + [ "$DATABASE_URL" = :memory: ] && [ "$BIOMETRICS_DATABASE_URL" = :memory: ] || exit 91 + [ "$TEST_FAIL" != build ] || exit 1 + mkdir -p .next/standalone/node_modules .next/cache + echo build > .next/BUILD_ID + echo '{}' > .next/required-server-files.json + echo standalone > .next/standalone/server.js + echo '{"commitHash":"new-build"}' > .git-info + echo native > .next/standalone/node_modules/native.node + echo cache > .next/cache/item +fi +`) + put(join(bin, 'ssh'), `#!/bin/bash +cmd="\${!#}" +echo "ssh $cmd" >> "$TEST_LOG" +case "$cmd" in + 'export PATH=/usr/local/bin:'*) exit 0 ;; + *mktemp*) mkdir -p "$TEST_REMOTE"; echo "$TEST_REMOTE" ;; + 'cat > '*) + bash -c "$cmd" + if [[ "$cmd" == *tar.gz* ]]; then + cp "$TEST_REMOTE/sleepypod-core.tar.gz" "$TEST_CAPTURE" + [ "$TEST_FAIL" != transfer ] || exit 1 + fi ;; + 'bash '*) [ "$TEST_FAIL" != apply ] ;; + 'rm -rf '*) bash -c "$cmd" ;; + *) exit 90 ;; +esac +`) + const result = spawnSync('bash', [join(project, 'scripts/deploy'), 'pod.local', ...(branch ? ['fix/test'] : [])], { encoding: 'utf8', + env: { ...testEnv, PATH: `${bin}:${process.env.PATH}`, TEST_LOG: log, TEST_FAIL: fail, + TEST_REMOTE: join(dir, 'sleepypod-deploy.abcdef'), TEST_CAPTURE: join(dir, 'captured.tar.gz') }, + }) + return { result, log: existsSync(log) ? readFileSync(log, 'utf8') : '', dir, project } +} + +describe('local deploy', () => { + it('builds an origin branch in isolation and preserves the current checkout and edits', () => { + const { result, project, dir } = deploy('', true) + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(project, 'source.ts'), 'utf8')).toBe('uncommitted local edits') + const git = (args: string[]) => spawnSync('git', ['-C', project, ...args], { encoding: 'utf8', env: testEnv }).stdout + expect(git(['branch', '--show-current']).trim()).toBe('main') + expect(git(['worktree', 'list', '--porcelain']).match(/^worktree /gm)).toHaveLength(1) + const archived = spawnSync('tar', ['xOf', join(dir, 'captured.tar.gz'), './source.ts'], { encoding: 'utf8' }) + expect(archived.stdout).toBe('source') + }) + + it('stages an archive without secrets/native modules before applying with the bundled updater', () => { + const { result, log, dir } = deploy() + expect(result.status, result.stderr).toBe(0) + const archive = spawnSync('tar', ['tzf', join(dir, 'captured.tar.gz')], { encoding: 'utf8' }).stdout + expect(archive).toContain('./.next/BUILD_ID') + expect(archive).toContain('./.next/standalone/server.js') + expect(archive).toContain('./.next/standalone/.git-info') + expect(archive).toContain('./source.ts') + for (const excluded of ['.env', '.git/', 'node_modules', '.next/cache']) expect(archive).not.toContain(excluded) + expect(log).toContain('export PATH=/usr/local/bin:$PATH; test -d /home/dac/sleepypod-core') + expect(log).toContain('--archive') + for (const marker of ['cat >', 'ssh bash']) expect(log).toContain(marker) + expect(log.indexOf('cat >')).toBeLessThan(log.indexOf('ssh bash')) + expect(log).not.toContain('find /home/dac') + }) + it.each(['build', 'transfer'] as const)('does not invoke the updater on %s failure', (fail) => { + const { result, log } = deploy(fail) + expect(result.status).not.toBe(0) + expect(log).not.toContain('ssh bash') + }) + it('propagates update failure without printing success', () => { + const { result } = deploy('apply') + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain('Deploy complete') + }) +}) From 101bbbcda4fcb6b93643f72f15853e73c413469a Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Tue, 8 Sep 2026 21:02:26 -0700 Subject: [PATCH 005/119] fix(deploy): support explicit GitHub forks for branch builds (#705) Deploying `fix/pod3-frozen-heartbeat` from an upstream clone fails because that branch exists in `Kovbo/core`, while the local deploy script fetches only from `origin`. Add `./scripts/deploy --repo Kovbo/core POD_IP fix/pod3-frozen-heartbeat` to fetch the fork branch into the existing temporary worktree without changing remotes, the current branch, or local edits. `SLEEPYPOD_GITHUB_REPO` selects the same repository; `--repo` takes precedence. Explicit repositories require a branch. With no selector, existing origin/current-checkout behavior is preserved. Validation: 30 targeted deployment/firewall tests, including real Git fixtures where the branch exists only in a separate fork, environment selection, flag precedence, and invalid arguments rejected before SSH. Also ran the CLI against the actual `Kovbo/core` fork with SSH/install stubs: it fetched `f46b3892e577d89a7eb32f1e108422c44aa69c5c` and verified the heartbeat staging change before stopping at dependency installation. Bash syntax and ShellCheck pass. No additional Pod deployment is needed for this source-selection change; the archive/update path is unchanged from the live-tested PR #703. --- README.md | 1 + docs/DEPLOYMENT.md | 23 ++++++++++++++---- scripts/deploy | 42 +++++++++++++++++++++++++++------ tests/updateDeploy.test.ts | 48 ++++++++++++++++++++++++++++++++++---- 4 files changed, 99 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 77fd5dbbe..de60367b5 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Cloned the repo on your computer? Build locally and deploy to an installed Pod: ```bash ./scripts/deploy POD_IP # current checkout ./scripts/deploy POD_IP fix/my-fix # branch from your clone's origin +./scripts/deploy --repo Kovbo/core POD_IP fix/pod3-frozen-heartbeat # fork branch ``` See the [deployment guide](docs/DEPLOYMENT.md) for SSH requirements and fork/PR validation. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 45c4a2179..458e72935 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -82,10 +82,25 @@ firewall helpers. No GitHub release is required. For first installation, use ### Validating a fork's PR -Check out the PR in your local clone and run `./scripts/deploy POD_IP`. This -works even when the fork has not published a branch release. Alternatively, -enable Actions in the fork and push the branch so its Branch Release workflow -publishes `sleepypod-core.tar.gz` under `-latest`. +From an up-to-date `sleepypod/core` clone, explicitly select the fork and branch: + +```bash +./scripts/deploy --repo Kovbo/core POD_IP fix/pod3-frozen-heartbeat +# Equivalent repository selection via the updater's existing environment variable: +SLEEPYPOD_GITHUB_REPO=Kovbo/core ./scripts/deploy POD_IP fix/pod3-frozen-heartbeat +``` + +The fork branch is fetched into a temporary worktree; `origin`, the current +branch, and local edits stay unchanged. `--repo` takes precedence over the +environment variable, and either repository selector requires a branch. With +no selector, branch deployments use your clone's `origin` (which can itself be +a fork). With neither a selector nor a branch, the current checkout is built. + +This works even if the fork's branch predates the new CLI: the local clone's +updater is uploaded alongside the build. No fork release is required. +Alternatively, enable Actions in the fork and push the branch so its Branch +Release workflow publishes `sleepypod-core.tar.gz` under +`-latest`. Then run on the Pod: ```bash diff --git a/scripts/deploy b/scripts/deploy index d3a2b1575..69b656a2f 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -2,22 +2,49 @@ set -euo pipefail # Build a clone on macOS/Linux and deploy to an already-installed Pod. -# An optional branch is fetched from origin into an isolated checkout. +# An optional branch is fetched from origin or an explicit fork into an isolated checkout. usage() { cat <<'HELP' -Usage: ./scripts/deploy POD_IP [branch] +Usage: ./scripts/deploy [--repo OWNER/REPO] POD_IP [branch] Build the current checkout (including local edits), or a branch from origin, then transfer a pre-built archive over SSH and update the Pod with rollback. +Use --repo OWNER/REPO to fetch a branch from a GitHub fork without changing +origin. SLEEPYPOD_GITHUB_REPO sets the same default; --repo takes precedence. +An explicit repository requires a branch. Requires Node.js, the project's pinned pnpm, and root SSH key access. The Pod still needs WAN access for production/native dependencies. SSH_PORT defaults to 8822; TMPDIR on the Pod defaults to /persistent. HELP } -case "${1:-}" in -h|--help) usage; exit 0 ;; esac -if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then usage >&2; exit 1; fi -POD_IP="$1" -BRANCH="${2:-}" +GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-}" +POSITIONAL=() +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --repo) + if [ "$#" -lt 2 ] || [ -z "$2" ]; then + echo "Error: --repo requires OWNER/REPO." >&2 + exit 1 + fi + GITHUB_REPO="$2" + shift 2 + ;; + -*) echo "Error: Unknown option $1" >&2; usage >&2; exit 1 ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done +if [ "${#POSITIONAL[@]}" -lt 1 ] || [ "${#POSITIONAL[@]}" -gt 2 ]; then usage >&2; exit 1; fi +POD_IP="${POSITIONAL[0]}" +BRANCH="${POSITIONAL[1]:-}" +FETCH_REPO=origin +if [ -n "$GITHUB_REPO" ]; then + if [[ ! "$GITHUB_REPO" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || [ -z "$BRANCH" ]; then + echo "Error: An explicit repository requires OWNER/REPO and a branch." >&2 + exit 1 + fi + FETCH_REPO="https://github.com/$GITHUB_REPO.git" +fi SSH_PORT="${SSH_PORT:-8822}" if [[ ! "$POD_IP" =~ ^[a-zA-Z0-9][a-zA-Z0-9.:-]*$ ]] || [[ ! "$SSH_PORT" =~ ^[0-9]+$ ]]; then echo "Error: Provide a Pod hostname/IP and a numeric SSH_PORT." >&2 @@ -54,7 +81,8 @@ echo "Target: root@$POD_IP:$SSH_PORT" if [ -n "$BRANCH" ]; then # Fetch only the chosen ref; do not switch branches or pull into user edits. git check-ref-format "refs/heads/$BRANCH" - git -C "$PROJECT_DIR" fetch origin "refs/heads/$BRANCH" + echo "Fetching $BRANCH from $FETCH_REPO..." + git -C "$PROJECT_DIR" fetch "$FETCH_REPO" "refs/heads/$BRANCH" BUILD_DIR="$LOCAL_STAGE/checkout" git -C "$PROJECT_DIR" worktree add --detach "$BUILD_DIR" FETCH_HEAD fi diff --git a/tests/updateDeploy.test.ts b/tests/updateDeploy.test.ts index 836916d3c..50088c0e0 100644 --- a/tests/updateDeploy.test.ts +++ b/tests/updateDeploy.test.ts @@ -194,7 +194,7 @@ describe('sp-update staged deployment', () => { }) }) -function deploy(fail: 'build' | 'transfer' | 'apply' | '' = '', branch = false) { +function deploy(fail: 'build' | 'transfer' | 'apply' | '' = '', branch = false, repository: 'origin' | 'flag' | 'env' | 'override' = 'origin') { const dir = temp() const project = join(dir, 'project') const bin = join(dir, 'bin') @@ -215,7 +215,20 @@ function deploy(fail: 'build' | 'transfer' | 'apply' | '' = '', branch = false) git(['init', '-b', 'main']) git(['add', '.']) git(['-c', 'core.hooksPath=/dev/null', '-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-m', 'fixture']) - git(['branch', 'fix/test']) + if (repository === 'origin') { + git(['branch', 'fix/test']) + } + else { + const fork = join(dir, 'fork') + git(['clone', '--quiet', project, fork]) + git(['-C', fork, 'checkout', '-b', 'fix/test']) + put(join(fork, 'source.ts'), 'fork-only source') + git(['-C', fork, 'add', 'source.ts']) + git(['-C', fork, '-c', 'core.hooksPath=/dev/null', '-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-m', 'fork change']) + // Exercise real Git fetching, redirecting only the fixture's GitHub URL + // to a local fork. The fork-only branch is absent from origin. + git(['config', `url.${fork}.insteadOf`, 'https://github.com/Kovbo/core.git']) + } git(['remote', 'add', 'origin', project]) put(join(project, 'source.ts'), 'uncommitted local edits') } @@ -251,14 +264,41 @@ case "$cmd" in *) exit 90 ;; esac `) - const result = spawnSync('bash', [join(project, 'scripts/deploy'), 'pod.local', ...(branch ? ['fix/test'] : [])], { encoding: 'utf8', - env: { ...testEnv, PATH: `${bin}:${process.env.PATH}`, TEST_LOG: log, TEST_FAIL: fail, + const result = spawnSync('bash', [join(project, 'scripts/deploy'), 'pod.local', ...(branch ? ['fix/test'] : []), ...(['flag', 'override'].includes(repository) ? ['--repo', 'Kovbo/core'] : [])], { encoding: 'utf8', + env: { ...testEnv, SLEEPYPOD_GITHUB_REPO: repository === 'env' ? 'Kovbo/core' : repository === 'override' ? 'unused/wrong-repo' : '', PATH: `${bin}:${process.env.PATH}`, TEST_LOG: log, TEST_FAIL: fail, TEST_REMOTE: join(dir, 'sleepypod-deploy.abcdef'), TEST_CAPTURE: join(dir, 'captured.tar.gz') }, }) return { result, log: existsSync(log) ? readFileSync(log, 'utf8') : '', dir, project } } describe('local deploy', () => { + it.each(['flag', 'env', 'override'] as const)('fetches a fork-only branch using %s without changing origin or local edits', (repository) => { + const { result, project, dir } = deploy('', true, repository) + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(project, 'source.ts'), 'utf8')).toBe('uncommitted local edits') + const git = (args: string[]) => spawnSync('git', ['-C', project, ...args], { encoding: 'utf8', env: testEnv }) + expect(git(['remote', 'get-url', 'origin']).stdout.trim()).toBe(project) + expect(git(['branch', '--show-current']).stdout.trim()).toBe('main') + expect(git(['show-ref', '--verify', 'refs/heads/fix/test']).status).not.toBe(0) + expect(git(['worktree', 'list', '--porcelain']).stdout.match(/^worktree /gm)).toHaveLength(1) + const archived = spawnSync('tar', ['xOf', join(dir, 'captured.tar.gz'), './source.ts'], { encoding: 'utf8' }) + expect(archived.stdout).toBe('fork-only source') + }) + it.each([ + ['--repo'], + ['--repo', 'Kovbo/core', 'pod.local'], + ['--repo', 'https://github.com/Kovbo/core', 'pod.local', 'fix/test'], + ])('rejects incomplete or invalid repository arguments before SSH: %j', (...args) => { + const dir = temp() + put(join(dir, 'ssh'), '#!/bin/bash\necho UNEXPECTED_SSH >&2\nexit 99\n') + const result = spawnSync('bash', [join(repo, 'scripts/deploy'), ...args], { + encoding: 'utf8', env: { ...testEnv, SLEEPYPOD_GITHUB_REPO: '', PATH: `${dir}:${process.env.PATH}` }, + }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('OWNER/REPO') + expect(result.stderr).not.toContain('UNEXPECTED_SSH') + }) + it('builds an origin branch in isolation and preserves the current checkout and edits', () => { const { result, project, dir } = deploy('', true) expect(result.status, result.stderr).toBe(0) From 45a3ddd73a23f9fcf65ee309bf205aa68df72bd5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:34:13 +0000 Subject: [PATCH 006/119] chore(deps): Update @types/node all non-major dependencies (#710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | [`25.9.5` β†’ `25.9.6`](https://renovatebot.com/diffs/npm/@types%2fnode/25.9.5/25.9.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/25.9.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/25.9.5/25.9.6?slim=true) | | [@types/react](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react)) | [`19.2.18` β†’ `19.3.0`](https://renovatebot.com/diffs/npm/@types%2freact/19.2.18/19.3.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2freact/19.3.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2freact/19.2.18/19.3.0?slim=true) | | [@types/react-dom](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-dom) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom)) | [`19.2.7` β†’ `19.3.0`](https://renovatebot.com/diffs/npm/@types%2freact-dom/19.2.7/19.3.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2freact-dom/19.3.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2freact-dom/19.2.7/19.3.0?slim=true) | | [lucide-react](https://lucide.dev) ([source](https://redirect.github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react)) | [`1.41.0` β†’ `1.44.0`](https://renovatebot.com/diffs/npm/lucide-react/1.41.0/1.44.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/lucide-react/1.44.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/lucide-react/1.41.0/1.44.0?slim=true) | | [react](https://react.dev/) ([source](https://redirect.github.com/react/react/tree/HEAD/packages/react)) | [`19.2.8` β†’ `19.3.0`](https://renovatebot.com/diffs/npm/react/19.2.8/19.3.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/react/19.3.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/react/19.2.8/19.3.0?slim=true) | | [react-dom](https://react.dev/) ([source](https://redirect.github.com/react/react/tree/HEAD/packages/react-dom)) | [`19.2.8` β†’ `19.3.0`](https://renovatebot.com/diffs/npm/react-dom/19.2.8/19.3.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/react-dom/19.3.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/react-dom/19.2.8/19.3.0?slim=true) | | [typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint) ([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)) | [`8.69.0` β†’ `8.70.0`](https://renovatebot.com/diffs/npm/typescript-eslint/8.69.0/8.70.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/typescript-eslint/8.70.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/typescript-eslint/8.69.0/8.70.0?slim=true) | | [zod](https://zod.dev) ([source](https://redirect.github.com/colinhacks/zod)) | [`4.5.4` β†’ `4.6.2`](https://renovatebot.com/diffs/npm/zod/4.5.4/4.6.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/zod/4.6.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zod/4.5.4/4.6.2?slim=true) | --- ### Release Notes
lucide-icons/lucide (lucide-react) ### [`v1.44.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.44.0): Version 1.44.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.43.0...1.44.0) #### What's Changed - feat(packages/icons): fixed [@​lucide/icons](https://redirect.github.com/lucide/icons) rollup config by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4824](https://redirect.github.com/lucide-icons/lucide/pull/4824) - fix(icons): changed `door-open` by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4826](https://redirect.github.com/lucide-icons/lucide/pull/4826) - fix(docs): replace missing LucideIcon icon names in guides by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4827](https://redirect.github.com/lucide-icons/lucide/pull/4827) - feat(docs): fixed fuse js search by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4825](https://redirect.github.com/lucide-icons/lucide/pull/4825) - fix(icons): changed `satellite-dish` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4813](https://redirect.github.com/lucide-icons/lucide/pull/4813) - fix(icons): arcified flip icons & renamed them by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4833](https://redirect.github.com/lucide-icons/lucide/pull/4833) - fix(icons): improve legibility of credit card icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4831](https://redirect.github.com/lucide-icons/lucide/pull/4831) - feat(lab): add check-x icon by [@​ishanbagra18](https://redirect.github.com/ishanbagra18) in [#​4737](https://redirect.github.com/lucide-icons/lucide/pull/4737) - fix(icons): correct compromised tags in shield icons by [@​decknamec](https://redirect.github.com/decknamec) in [#​4835](https://redirect.github.com/lucide-icons/lucide/pull/4835) - feat(icons): added `toothbrush` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4755](https://redirect.github.com/lucide-icons/lucide/pull/4755) #### New Contributors - [@​ishanbagra18](https://redirect.github.com/ishanbagra18) made their first contribution in [#​4737](https://redirect.github.com/lucide-icons/lucide/pull/4737) - [@​decknamec](https://redirect.github.com/decknamec) made their first contribution in [#​4835](https://redirect.github.com/lucide-icons/lucide/pull/4835) **Full Changelog**: ### [`v1.43.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.43.0): Version 1.43.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.42.0...1.43.0) #### What's Changed - feat(icons): added `tic-tac-toe` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4772](https://redirect.github.com/lucide-icons/lucide/pull/4772) - fix(icons): changed `id-card` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4820](https://redirect.github.com/lucide-icons/lucide/pull/4820) - fix(icons): changed `id-card-lanyard` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4819](https://redirect.github.com/lucide-icons/lucide/pull/4819) - feat(icons): delegate `carton`/`carton-off` from lab by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4818](https://redirect.github.com/lucide-icons/lucide/pull/4818) **Full Changelog**: ### [`v1.42.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.42.0): Version 1.42.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.41.0...1.42.0) #### What's Changed - feat(docs): added better contribution guide by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4746](https://redirect.github.com/lucide-icons/lucide/pull/4746) - fix(site): fix HomeHeroIconsCard.data.ts by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4805](https://redirect.github.com/lucide-icons/lucide/pull/4805) - fix(icons): deprecated swiss franc icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4799](https://redirect.github.com/lucide-icons/lucide/pull/4799) - feat(icons): add gap-horizontal and gap-vertical by [@​samuelalake](https://redirect.github.com/samuelalake) in [#​4544](https://redirect.github.com/lucide-icons/lucide/pull/4544) - feat(icons): added `trash-off` icon by [@​lx3133584](https://redirect.github.com/lx3133584) in [#​4788](https://redirect.github.com/lucide-icons/lucide/pull/4788) - feat(icons): added `circle-dashed-check` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4796](https://redirect.github.com/lucide-icons/lucide/pull/4796) - feat(icons): added `equal-approximately-not` icon by [@​ryck](https://redirect.github.com/ryck) in [#​4802](https://redirect.github.com/lucide-icons/lucide/pull/4802) - feat(icons): added dome icons by [@​swastik7805](https://redirect.github.com/swastik7805) in [#​4261](https://redirect.github.com/lucide-icons/lucide/pull/4261) - chore(lucide-react): Add Lucide React integration tests by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4804](https://redirect.github.com/lucide-icons/lucide/pull/4804) - feat(packages): extract icon build logic into `@lucide/shared` by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4409](https://redirect.github.com/lucide-icons/lucide/pull/4409) - fix(icons): changed `computer` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4607](https://redirect.github.com/lucide-icons/lucide/pull/4607) - chore(dependencies): Update dependencies by [@​ericfennis](https://redirect.github.com/ericfennis) with [@​Copilot](https://redirect.github.com/Copilot) in [#​4806](https://redirect.github.com/lucide-icons/lucide/pull/4806) - fix(icons): changed `table-2` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4810](https://redirect.github.com/lucide-icons/lucide/pull/4810) - feat(icons): added `user-group` icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4782](https://redirect.github.com/lucide-icons/lucide/pull/4782) #### New Contributors - [@​lx3133584](https://redirect.github.com/lx3133584) made their first contribution in [#​4788](https://redirect.github.com/lucide-icons/lucide/pull/4788) - [@​ryck](https://redirect.github.com/ryck) made their first contribution in [#​4802](https://redirect.github.com/lucide-icons/lucide/pull/4802) **Full Changelog**:
react/react (react) ### [`v19.3.0`](https://redirect.github.com/react/react/compare/v19.2.8...1d34f91dfde6bba84d08b683aaba164c7194dacb) [Compare Source](https://redirect.github.com/react/react/compare/v19.2.8...v19.3.0)
react/react (react-dom) ### [`v19.3.0`](https://redirect.github.com/react/react/compare/v19.2.8...1d34f91dfde6bba84d08b683aaba164c7194dacb) [Compare Source](https://redirect.github.com/react/react/compare/v19.2.8...v19.3.0)
typescript-eslint/typescript-eslint (typescript-eslint) ### [`v8.70.0`](https://redirect.github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8700-2026-09-07) [Compare Source](https://redirect.github.com/typescript-eslint/typescript-eslint/compare/v8.69.0...v8.70.0) ##### 🩹 Fixes - **eslint-plugin:** \[no-deprecated] report deprecated imported values used in object shorthand properties ([#​12780](https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12780)) ##### ❀️ Thank You - Ulrich Stark [@​ulrichstark](https://redirect.github.com/ulrichstark) See [GitHub Releases](https://redirect.github.com/typescript-eslint/typescript-eslint/releases/tag/v8.70.0) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.
colinhacks/zod (zod) ### [`v4.6.2`](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.2) [Compare Source](https://redirect.github.com/colinhacks/zod/compare/v4.6.1...v4.6.2) #### Commits: - [`a00c3f3`](https://redirect.github.com/colinhacks/zod/commit/a00c3f348abb8b56ccb6281ac4c946141e2728d5) docs: use Trigger.dev's brand-kit lockups for the platinum card - [`0c483c5`](https://redirect.github.com/colinhacks/zod/commit/0c483c58849fdb6445aea4f54b1bac6b57ab3d22) docs: Zod 4.6 announcement post ([#​6546](https://redirect.github.com/colinhacks/zod/issues/6546)) - [`9446b5c`](https://redirect.github.com/colinhacks/zod/commit/9446b5cc14c5bf137790f1f66abf602044871223) fix: preserve undefined prefault outputs and object keys ([#​6587](https://redirect.github.com/colinhacks/zod/issues/6587)) - [`e359f73`](https://redirect.github.com/colinhacks/zod/commit/e359f7378fe56d695134701cda1e9055a08892dc) 4.6.2 ### [`v4.6.1`](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.1) [Compare Source](https://redirect.github.com/colinhacks/zod/compare/v4.6.0...v4.6.1) A patch on top of [4.6.0](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.0). - [`b12aa523`](https://redirect.github.com/colinhacks/zod/commit/b12aa523) fix: preserve unique tags with defaulted discriminators ([#​6582](https://redirect.github.com/colinhacks/zod/pull/6582)) β€” closes [#​6577](https://redirect.github.com/colinhacks/zod/issues/6577) - [`dd9c36fa`](https://redirect.github.com/colinhacks/zod/commit/dd9c36fa) fix(v4): defer recursive object index inference ([#​6580](https://redirect.github.com/colinhacks/zod/pull/6580)) - [`3b154992`](https://redirect.github.com/colinhacks/zod/commit/3b154992) feat(lang): add Tajik (`tg`) locale ([#​6581](https://redirect.github.com/colinhacks/zod/pull/6581)) by [@​ismoil77](https://redirect.github.com/ismoil77) - [`2efa8b80`](https://redirect.github.com/colinhacks/zod/commit/2efa8b80) ci: give the npm wait a real budget and drop the back-publish path ([#​6583](https://redirect.github.com/colinhacks/zod/pull/6583)) ### [`v4.6.0`](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.0) [Compare Source](https://redirect.github.com/colinhacks/zod/compare/v4.5.4...v4.6.0) #### Commits: - [`908c9e1`](https://redirect.github.com/colinhacks/zod/commit/908c9e1771befea20962b6eb2ea591983f45d725) fix(docs): retry the GitHub stars fetch and log the real status - [`93186ca`](https://redirect.github.com/colinhacks/zod/commit/93186cab5340fba6ed9b91768628099cd5801495) docs(wiki): drop the zod-compiler benchmark ([#​6505](https://redirect.github.com/colinhacks/zod/issues/6505)) - [`8ce9e8d`](https://redirect.github.com/colinhacks/zod/commit/8ce9e8d56b1f408c445db6f2d7542696c6d20326) feat(mini): publish Zod Mini as the standalone [@​zod/mini](https://redirect.github.com/zod/mini) package ([#​6491](https://redirect.github.com/colinhacks/zod/issues/6491)) - [`2956c4c`](https://redirect.github.com/colinhacks/zod/commit/2956c4c2fc5af83408368907e88a3bd20e8af12e) chore(mini): sync [@​zod/mini](https://redirect.github.com/zod/mini) to 4.5.4 - [`abd41ad`](https://redirect.github.com/colinhacks/zod/commit/abd41adbedbe92ade811809b2cea94cea60103d0) docs(wiki): move plans and comparisons into a gitignored internal/ ([#​6506](https://redirect.github.com/colinhacks/zod/issues/6506)) - [`8106573`](https://redirect.github.com/colinhacks/zod/commit/810657394de9df48744463beb21e103a3ad04eef) chore(docs): build with Turbopack - [`c7ec94d`](https://redirect.github.com/colinhacks/zod/commit/c7ec94d309ee8acb25113e3db738b3e03816a424) ci: check zod and [@​zod/mini](https://redirect.github.com/zod/mini) lockstep on npm after every publish ([#​6507](https://redirect.github.com/colinhacks/zod/issues/6507)) - [`c46eeff`](https://redirect.github.com/colinhacks/zod/commit/c46eeff050f2a6a2ba8db1d14c2c8247bd7623f2) chore: narrow blanket biome-ignore comments ([#​6504](https://redirect.github.com/colinhacks/zod/issues/6504)) - [`a0898b4`](https://redirect.github.com/colinhacks/zod/commit/a0898b4b7ff35b056b9c773b8b3ec629d413ac53) ci: wait hours for npm to serve a publish, not ten minutes ([#​6502](https://redirect.github.com/colinhacks/zod/issues/6502)) - [`1c0bce0`](https://redirect.github.com/colinhacks/zod/commit/1c0bce0c363e5fbd4589669ae89884fd2df77d14) docs: bring the 4.5 charts and worked examples into the docs pages - [`70eb2c0`](https://redirect.github.com/colinhacks/zod/commit/70eb2c0722c98aa0c81bcb68693eda2c668c1f86) docs: drop the traits section and the compilation feature bullet - [`43b9bfc`](https://redirect.github.com/colinhacks/zod/commit/43b9bfc5a59167b42c897eb9c74129fc8b4c31e4) docs: drop the bound-methods section from the Zod package page - [`cd4f9a6`](https://redirect.github.com/colinhacks/zod/commit/cd4f9a679238a5470620f74c2cc568e8865b3f01) perf(v4): report Standard Schema issues without constructing a ZodError ([#​6509](https://redirect.github.com/colinhacks/zod/issues/6509)) - [`f3cb364`](https://redirect.github.com/colinhacks/zod/commit/f3cb3644eb0b3b14b9e8e78b4e04ef221d6ef169) docs: surface the blog on the home page and in the sidebar - [`f3e7c72`](https://redirect.github.com/colinhacks/zod/commit/f3e7c72e8ce66bb1d5113aeb5e1c0c658981d2f7) fix(docs): render the docs 404 page inside the (doc) layout once - [`f412178`](https://redirect.github.com/colinhacks/zod/commit/f412178defd4348872fa581db952f6713677502a) ci: publish [@​zod/mini](https://redirect.github.com/zod/mini) to JSR in lockstep with npm ([#​6510](https://redirect.github.com/colinhacks/zod/issues/6510)) - [`5ff9566`](https://redirect.github.com/colinhacks/zod/commit/5ff9566508e6c95873d2648a5bdcc3a371f1b757) Stop re-exporting the compile internals from zod/v4/core ([#​6511](https://redirect.github.com/colinhacks/zod/issues/6511)) - [`40b4d0b`](https://redirect.github.com/colinhacks/zod/commit/40b4d0b34d09b151505cf726581c908172368e35) fix(ci): read zod's latest version with npm view when picking the backfill dist-tag - [`a6b4939`](https://redirect.github.com/colinhacks/zod/commit/a6b493907066003e28f8b7e967faf4d19f2174f4) Mark the compile internals [@​internal](https://redirect.github.com/internal) instead of hiding them ([#​6518](https://redirect.github.com/colinhacks/zod/issues/6518)) - [`3195ed0`](https://redirect.github.com/colinhacks/zod/commit/3195ed01073d2156fd750effa60c191d2fa048d4) docs: label the memory chart like the compile chart - [`8cd1250`](https://redirect.github.com/colinhacks/zod/commit/8cd1250f0e8e54ee8d269d69b1ee93357b661886) docs: center the memory chart callout labels - [`ff56a55`](https://redirect.github.com/colinhacks/zod/commit/ff56a551b5665aeead40936a8f941d08185109f4) docs: center the memory chart callout labels and pad them off the number - [`fb2fedf`](https://redirect.github.com/colinhacks/zod/commit/fb2fedfd98abd0203626b0d07b114739dca01e8a) docs: tighten the memory chart callout, pad the canvas, say "less memory" - [`93f3ab3`](https://redirect.github.com/colinhacks/zod/commit/93f3ab321211f52f1963b63d5d3503c645c38371) docs: replace the blog navbar's GitHub icon with a star-count pill - [`7ae49d6`](https://redirect.github.com/colinhacks/zod/commit/7ae49d64d5fafee675049cd3dc44e98a41c27e2f) docs: drop the circle around the star pill's GitHub mark and center it on the pill's arc - [`b801439`](https://redirect.github.com/colinhacks/zod/commit/b801439b5fb160d651084ff7d7c27a61e24a7334) bench: add typebox (compiled and dynamic) to the moltar cross-library harness - [`1ec6b7c`](https://redirect.github.com/colinhacks/zod/commit/1ec6b7c5ec58d0e58f6b582c96e7cabfc5cb2f95) docs: add an RSS feed to the blog at /blog/rss.xml - [`08ba069`](https://redirect.github.com/colinhacks/zod/commit/08ba069e29d67b2f7b13b98dc9e70b1d99aeab5d) perf(v4): read Luhn digits with charCodeAt instead of string indexing ([#​6529](https://redirect.github.com/colinhacks/zod/issues/6529)) - [`319f47f`](https://redirect.github.com/colinhacks/zod/commit/319f47f44f40755773a9aa83b6fdaaad1ff01d77) Emit a length-aware base64url pattern in toJSONSchema ([#​6527](https://redirect.github.com/colinhacks/zod/issues/6527)) - [`a2a019a`](https://redirect.github.com/colinhacks/zod/commit/a2a019a57e4444f61bf677ca79c1352823e2b7c8) Accept enum-typed targets in z.toZod ([#​6528](https://redirect.github.com/colinhacks/zod/issues/6528)) - [`74f9a6d`](https://redirect.github.com/colinhacks/zod/commit/74f9a6d3f2818f24e6df5394b8b6e60acb176463) docs: drop the toZod enum block from basics and pin the page's curation rule in a comment - [`f83ab51`](https://redirect.github.com/colinhacks/zod/commit/f83ab51129038a2f868ee11dda906ed78ae2e478) fix(v4): reject component-only strings from z.emoji() ([#​6532](https://redirect.github.com/colinhacks/zod/issues/6532)) - [`84dd3b0`](https://redirect.github.com/colinhacks/zod/commit/84dd3b0fc6cb5a24f773cb6db66bc934338b44ea) perf: build literal and enum pattern regexes lazily ([#​6531](https://redirect.github.com/colinhacks/zod/issues/6531)) - [`0227e53`](https://redirect.github.com/colinhacks/zod/commit/0227e53d03b5652be75422b6d10e0dd9bf340f0b) docs: bump the star pill's GitHub mark to 20px - [`68a609a`](https://redirect.github.com/colinhacks/zod/commit/68a609ac9b9516e6fe84e3558f17ce16b1032bef) Widen literal inputs in property check types ([#​6520](https://redirect.github.com/colinhacks/zod/issues/6520)) - [`2ec972e`](https://redirect.github.com/colinhacks/zod/commit/2ec972ecc5a8af8135ed072d2d5b5363c27bb46f) refactor: collapse toZod's enum leaf normalizer to a dummy union ([#​6533](https://redirect.github.com/colinhacks/zod/issues/6533)) - [`bc1157e`](https://redirect.github.com/colinhacks/zod/commit/bc1157e717c69168ab0b0d0ace27149f6dc3c138) docs: use a Response example for z.properties() - [`07c43e2`](https://redirect.github.com/colinhacks/zod/commit/07c43e2a08f67082bd35c158d528d571a36094e0) Keep the runtime base64 regexes linear so composed parse paths cannot overflow ([#​6534](https://redirect.github.com/colinhacks/zod/issues/6534)) - [`bec73be`](https://redirect.github.com/colinhacks/zod/commit/bec73bea5a8ed19daec15655598fe5501bb797e7) perf(v4): build the safeParse error on first read ([#​6519](https://redirect.github.com/colinhacks/zod/issues/6519)) - [`bf99021`](https://redirect.github.com/colinhacks/zod/commit/bf9902164b5396449540415c2221a24bc9fd0e22) perf: move util.cached's accessor to a prototype ([#​6537](https://redirect.github.com/colinhacks/zod/issues/6537)) - [`69f2a7f`](https://redirect.github.com/colinhacks/zod/commit/69f2a7ff203e546e3a1350fb9152ace03ba0a6e6) Collapse toZod's normalizer and move its docs to the API reference ([#​6539](https://redirect.github.com/colinhacks/zod/issues/6539)) - [`abfb389`](https://redirect.github.com/colinhacks/zod/commit/abfb38977dc28dc9993e3fa6a2ecc2f52a8d0c4c) feat(v4): make z.properties() a schema, and give z.instanceof() a .properties() method ([#​6536](https://redirect.github.com/colinhacks/zod/issues/6536)) - [`51caf01`](https://redirect.github.com/colinhacks/zod/commit/51caf01038d3339d7b9db94b959482c549aba2e3) refactor: collapse cachedInternal back into cached ([#​6540](https://redirect.github.com/colinhacks/zod/issues/6540)) - [`81ded99`](https://redirect.github.com/colinhacks/zod/commit/81ded9918fe39d51b232be72f23ba0d5fb6a837e) perf: answer z.validate from the compiled fast path on invalid input ([#​6538](https://redirect.github.com/colinhacks/zod/issues/6538)) - [`eca9687`](https://redirect.github.com/colinhacks/zod/commit/eca96871536603076bdd00b8779b947144133aa4) fix(v4): enforce the six JSON Schema keywords fromJSONSchema silently dropped ([#​6535](https://redirect.github.com/colinhacks/zod/issues/6535)) - [`68aca3d`](https://redirect.github.com/colinhacks/zod/commit/68aca3dc402c59f5c4e30322e83028389e44b8b5) docs: cover the 4.5 API surface that never made it into the reference - [`18e71c7`](https://redirect.github.com/colinhacks/zod/commit/18e71c71daff6e79ab8f00a48db252ccb340da1f) Rename the JSON Schema `process` helper so bundler polyfills cannot collide ([#​6541](https://redirect.github.com/colinhacks/zod/issues/6541)) - [`90269c6`](https://redirect.github.com/colinhacks/zod/commit/90269c601aae25499ee87e78a07508564825867f) Keep a numeric TS enum's reverse-mapping keys out of `.options` ([#​6542](https://redirect.github.com/colinhacks/zod/issues/6542)) - [`4d73088`](https://redirect.github.com/colinhacks/zod/commit/4d7308828b1a56768741d7cfd6cf476eaead45af) Release the parsed input once a failing safeParse builds its error ([#​6543](https://redirect.github.com/colinhacks/zod/issues/6543)) - [`cafbee4`](https://redirect.github.com/colinhacks/zod/commit/cafbee4772ef481611fef6f5fb9ed9a1b9597069) fix(v4): parse recursive schemas built by a factory ([#​6530](https://redirect.github.com/colinhacks/zod/issues/6530)) - [`62e6624`](https://redirect.github.com/colinhacks/zod/commit/62e6624bc72c4882968ac4ea2c4281b1a99a7ad4) feat(v4): add .validate() and .validateAsync() to Zod Classic ([#​6547](https://redirect.github.com/colinhacks/zod/issues/6547)) - [`07917f4`](https://redirect.github.com/colinhacks/zod/commit/07917f4cb396646dff7a1364f9032a668b0d7eb4) test(v4): pin the lazy safeParse error's stack behavior ([#​6548](https://redirect.github.com/colinhacks/zod/issues/6548)) - [`764ac59`](https://redirect.github.com/colinhacks/zod/commit/764ac59f1afc1a46e7ecc2d5e8e86c57b7d66164) perf(v4): settle z.validate on the first failure in parse order ([#​6544](https://redirect.github.com/colinhacks/zod/issues/6544)) - [`e760471`](https://redirect.github.com/colinhacks/zod/commit/e7604717801fe5cc17c525fb92abe0abeae0ae6e) docs: attribute the compiled failure cost to the fallback, not the double pass - [`5489a53`](https://redirect.github.com/colinhacks/zod/commit/5489a532e65ee3f5ab0959ff8a5da346ced96f2a) test(v4): pin the check-chain case that keeps compiled validate's definite guard ([#​6551](https://redirect.github.com/colinhacks/zod/issues/6551)) - [`7a00236`](https://redirect.github.com/colinhacks/zod/commit/7a00236683c79000dbab0d92f6faf0b7fba39f59) fix(v4): don't let format checks overwrite tighter min/max bounds ([#​6553](https://redirect.github.com/colinhacks/zod/issues/6553)) - [`e4d67f3`](https://redirect.github.com/colinhacks/zod/commit/e4d67f3ea42de031f1f644a1efd7bd0a306dde56) Migrate development and CI to Nub ([#​6562](https://redirect.github.com/colinhacks/zod/issues/6562)) - [`6f04836`](https://redirect.github.com/colinhacks/zod/commit/6f0483671e622d5d01a1e17f69af0825137c7830) fix(v4): derive JSON Schema constraints by folding checks in the converter ([#​6554](https://redirect.github.com/colinhacks/zod/issues/6554)) - [`804e0f5`](https://redirect.github.com/colinhacks/zod/commit/804e0f522747345d6b37581888899be420baa3e9) perf: seal the CommonJS exports so require("zod") stops reading through a getter ([#​6564](https://redirect.github.com/colinhacks/zod/issues/6564)) - [`741981f`](https://redirect.github.com/colinhacks/zod/commit/741981ffdbc8772cffc38cc4eaf84d5a45fa7384) perf(compile): for-in record walk, cheaper issue finalization, and a generative compile differential ([#​6567](https://redirect.github.com/colinhacks/zod/issues/6567)) - [`eb1c108`](https://redirect.github.com/colinhacks/zod/commit/eb1c1089f7f9469078839f6e443830f7e3fdc06b) ci: release only on workflow\_dispatch behind the npm environment ([#​6569](https://redirect.github.com/colinhacks/zod/issues/6569)) - [`277613a`](https://redirect.github.com/colinhacks/zod/commit/277613a61a11f299d9897ddd88490002a995f746) docs: move the release procedure to the maintainer-local notes - [`dcbcf05`](https://redirect.github.com/colinhacks/zod/commit/dcbcf0529ca1e5424541d5d4b58e0a7b79507f51) fix(compile): unwind the doc indent when a child generator throws ([#​6570](https://redirect.github.com/colinhacks/zod/issues/6570)) - [`e54716c`](https://redirect.github.com/colinhacks/zod/commit/e54716cb5676f812cefea686eb07e2df961a0cf8) docs(ecosystem): add [@​apical-ts/craft](https://redirect.github.com/apical-ts/craft) ([#​5946](https://redirect.github.com/colinhacks/zod/issues/5946)) - [`c5b9bcb`](https://redirect.github.com/colinhacks/zod/commit/c5b9bcb39b7953c6a984849c6527863acdae0af7) bench: measure what a runtime island's leaked indent cost the generated source - [`22bed61`](https://redirect.github.com/colinhacks/zod/commit/22bed613281143efa3815fe26ab666c6c56586af) feat(v4): add z.iban() string format with mod-97 checksum ([#​6571](https://redirect.github.com/colinhacks/zod/issues/6571)) - [`36f1796`](https://redirect.github.com/colinhacks/zod/commit/36f17960d1defca5d0896d9424f4e1059fbbf081) fix(v4): stop the memoizer from pinning a finished parse ([#​6572](https://redirect.github.com/colinhacks/zod/issues/6572)) - [`f7fd554`](https://redirect.github.com/colinhacks/zod/commit/f7fd5548063f55a74847b2711630c9b3f8ea00d4) perf(v4): drop the lookaheads from the email regex ([#​6573](https://redirect.github.com/colinhacks/zod/issues/6573)) - [`f9465d4`](https://redirect.github.com/colinhacks/zod/commit/f9465d4e6497f92fe85ef7118ea64d7f86c28676) docs: reconcile the sponsor listings with active sponsorships ([#​6576](https://redirect.github.com/colinhacks/zod/issues/6576)) - [`213ee75`](https://redirect.github.com/colinhacks/zod/commit/213ee75d2ad6e41508587c3fab8246967f006993) feat(compile): add z.withParser for externally generated parsers ([#​6575](https://redirect.github.com/colinhacks/zod/issues/6575)) - [`6de10dc`](https://redirect.github.com/colinhacks/zod/commit/6de10dce1afaba7160d1c7ca810cc40bc4a7a733) docs: reconcile the sponsor listings against every active sponsorship ([#​6579](https://redirect.github.com/colinhacks/zod/issues/6579)) - [`661673a`](https://redirect.github.com/colinhacks/zod/commit/661673ae3ed42b15e8f835ae1bd0223b1db044a4) docs: make the 9thCO logo visible on the light theme - [`1c51cbe`](https://redirect.github.com/colinhacks/zod/commit/1c51cbe0fe23d09f8d520b31487d50a01588fae5) 4.6.0 - [`3b15499`](https://redirect.github.com/colinhacks/zod/commit/3b1549922b56ec17c9c47a521963f820930068e1) feat(lang): add Tajik (`tg`) locale - [`c532d76`](https://redirect.github.com/colinhacks/zod/commit/c532d766de54e13aea5d03698980bf7d976fc544) test(locales): cover Tajik error branches - [`574d480`](https://redirect.github.com/colinhacks/zod/commit/574d480fb443d3fd396efbcb8230f3dfee4a2cd2) fix(locales): clarify Tajik discriminator value message - [`dd9c36f`](https://redirect.github.com/colinhacks/zod/commit/dd9c36fa2ddcee79f71d19f9635586c7b65ead86) fix(v4): defer recursive object index inference ([#​6580](https://redirect.github.com/colinhacks/zod/issues/6580)) - [`b12aa52`](https://redirect.github.com/colinhacks/zod/commit/b12aa523e7e2617c4296cccf9b24d6558ed23e95) fix: preserve unique tags with defaulted discriminators ([#​6582](https://redirect.github.com/colinhacks/zod/issues/6582))
--- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - "before 6am on monday" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. πŸ‘» **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/sleepypod/core).
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update renovate/all-minor-patch ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/renovate/all-minor-patch/scripts/install \ | sudo bash -s -- --branch renovate/all-minor-patch ``` 🎯 **Pin to this exact build** ([run 34806385321](https://github.com/sleepypod/core/actions/runs/34806385321) Β· `c0ebe1d`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/c0ebe1d1079c8d3a9038f79ff04f74f3f4a5b0ad/scripts/install \ | sudo bash -s -- \ --branch renovate/all-minor-patch \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/34806385321/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/34806385321/artifacts/10332059610) Β· self-destructs in 30 days πŸ’₯
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 972 ++++++++++++++++++++++++------------------------- 1 file changed, 486 insertions(+), 486 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfc618ab7..78b84e8f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@ajayche/trpc-panel': specifier: ^2.0.4 - version: 2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.2.18)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + version: 2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.2) '@base-ui/react': specifier: ^1.2.0 - version: 1.8.0(@date-fns/tz@1.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.8.0(@date-fns/tz@1.4.1)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@formatjs/intl-localematcher': specifier: ^0.8.0 version: 0.8.13 @@ -22,31 +22,31 @@ importers: version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) '@lingui/macro': specifier: ^5.9.2 - version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.2.8) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.3.0) '@lingui/react': specifier: ^5.9.2 - version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.2.8) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.3.0) '@nats-io/transport-node': specifier: ^3.4.0 version: 3.4.0 '@tanstack/react-query': specifier: ^5.90.21 - version: 5.102.8(react@19.2.8) + version: 5.102.8(react@19.3.0) '@trpc/client': specifier: ^11.10.0 version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/next': specifier: ^11.10.0 - version: 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) '@trpc/react-query': specifier: ^11.10.0 - version: 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3) + version: 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) '@trpc/server': specifier: ^11.10.0 version: 11.18.0(typescript@6.0.3) '@xyflow/react': specifier: ^12.10.1 - version: 12.11.6(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) better-sqlite3: specifier: ^12.6.2 version: 12.11.1 @@ -76,7 +76,7 @@ importers: version: 1.2.0 lucide-react: specifier: ^1.0.0 - version: 1.41.0(react@19.2.8) + version: 1.44.0(react@19.3.0) mqtt: specifier: ^5.15.1 version: 5.15.2 @@ -85,7 +85,7 @@ importers: version: 1.1.0 next: specifier: ^16.1.6 - version: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) node-schedule: specifier: ^2.1.1 version: 2.1.1 @@ -94,13 +94,13 @@ importers: version: 1.5.4 react: specifier: ^19.2.4 - version: 19.2.8 + version: 19.3.0 react-dom: specifier: ^19.2.4 - version: 19.2.8(react@19.2.8) + version: 19.3.0(react@19.3.0) recharts: specifier: ^3.8.0 - version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.4)(react@19.2.8)(redux@5.0.1) + version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.2.4)(react@19.3.0)(redux@5.0.1) shadcn: specifier: ^4.0.0 version: 4.21.0(babel-plugin-macros@3.1.0)(typescript@6.0.3) @@ -112,7 +112,7 @@ importers: version: 3.6.0 trpc-to-openapi: specifier: ^3.1.0 - version: 3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.5.4))(zod@4.5.4) + version: 3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.6.2))(zod@4.6.2) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -121,17 +121,17 @@ importers: version: 8.21.3 zod: specifier: ^4.3.6 - version: 4.5.4 + version: 4.6.2 zod-openapi: specifier: ^5.4.6 - version: 5.4.6(zod@4.5.4) + version: 5.4.6(zod@4.6.2) devDependencies: '@babel/preset-typescript': specifier: ^7.28.5 version: 7.29.7(@babel/core@7.29.7) '@codecov/nextjs-webpack-plugin': specifier: ^2.0.1 - version: 2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(webpack@5.104.1(lightningcss@1.32.0)) + version: 2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0)) '@eslint/css': specifier: ^1.0.0 version: 1.4.0 @@ -152,10 +152,10 @@ importers: version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(webpack@5.104.1(lightningcss@1.32.0)) '@lingui/swc-plugin': specifier: ^5.11.0 - version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) '@lingui/vite-plugin': specifier: ^5.9.2 - version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@semantic-release/commit-analyzer': specifier: ^13.0.1 version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) @@ -164,13 +164,13 @@ importers: version: 10.0.1(semantic-release@25.0.9(typescript@6.0.3)) '@stryker-mutator/core': specifier: ^9.6.1 - version: 9.6.1(@types/node@25.9.5) + version: 9.6.1(@types/node@25.9.6) '@stryker-mutator/typescript-checker': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.5))(typescript@6.0.3) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(typescript@6.0.3) '@stryker-mutator/vitest-runner': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.5))(vitest@4.1.11) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(vitest@4.1.11) '@stylistic/eslint-plugin': specifier: ^5.9.0 version: 5.10.0(eslint@9.39.5(jiti@2.7.0)) @@ -182,7 +182,7 @@ importers: version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tsconfig/next': specifier: ^2.0.5 version: 2.0.6 @@ -200,7 +200,7 @@ importers: version: 0.6.5 '@types/node': specifier: ^25.3.0 - version: 25.9.5 + version: 25.9.6 '@types/node-schedule': specifier: ^2.1.8 version: 2.1.8 @@ -209,16 +209,16 @@ importers: version: 1.5.6 '@types/react': specifier: ^19.2.14 - version: 19.2.18 + version: 19.3.0 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.7(@types/react@19.2.18) + version: 19.3.0(@types/react@19.3.0) '@types/ws': specifier: ^8.18.1 version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.2.0(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.2.0(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 4.1.11 version: 4.1.11(vitest@4.1.11) @@ -233,7 +233,7 @@ importers: version: 9.39.5(jiti@2.7.0) eslint-config-next: specifier: ^16.1.6 - version: 16.3.4(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -263,13 +263,13 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.56.1 - version: 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) vitest: specifier: ^4.0.18 - version: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -1660,8 +1660,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.3.1': - resolution: {integrity: sha512-HvnOHal39DTenOVoRpIy8Z+n4YYfNa3Qhi/P8zFqn9/d1crpmQG0DPx34rwOeOFvotgKr9Vov/14OmKR/Iwfjw==} + '@inquirer/confirm@6.3.2': + resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1678,8 +1678,8 @@ packages: '@types/node': optional: true - '@inquirer/core@12.0.2': - resolution: {integrity: sha512-9pBhkxE14uUlnHhs8lOt7qVPtS4caRY6CjADl8COejl9Mf7w8i6Uoe3DrljCqYtmYM3Iv2Q6EmPxx/oTYbvTAQ==} + '@inquirer/core@12.0.3': + resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2169,98 +2169,98 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.63.1': - resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + '@rollup/rollup-android-arm-eabi@4.63.2': + resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.63.1': - resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + '@rollup/rollup-android-arm64@4.63.2': + resolution: {integrity: sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.63.1': - resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + '@rollup/rollup-darwin-arm64@4.63.2': + resolution: {integrity: sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.63.1': - resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + '@rollup/rollup-darwin-x64@4.63.2': + resolution: {integrity: sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.63.1': - resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + '@rollup/rollup-freebsd-arm64@4.63.2': + resolution: {integrity: sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.63.1': - resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + '@rollup/rollup-freebsd-x64@4.63.2': + resolution: {integrity: sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.63.1': - resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + '@rollup/rollup-linux-arm-gnueabihf@4.63.2': + resolution: {integrity: sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.63.1': - resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + '@rollup/rollup-linux-arm-musleabihf@4.63.2': + resolution: {integrity: sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.63.1': - resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + '@rollup/rollup-linux-arm64-gnu@4.63.2': + resolution: {integrity: sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.63.1': - resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + '@rollup/rollup-linux-arm64-musl@4.63.2': + resolution: {integrity: sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.63.1': - resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + '@rollup/rollup-linux-loong64-gnu@4.63.2': + resolution: {integrity: sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.63.1': - resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + '@rollup/rollup-linux-loong64-musl@4.63.2': + resolution: {integrity: sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.63.1': - resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + '@rollup/rollup-linux-ppc64-gnu@4.63.2': + resolution: {integrity: sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.63.1': - resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + '@rollup/rollup-linux-ppc64-musl@4.63.2': + resolution: {integrity: sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.63.1': - resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + '@rollup/rollup-linux-riscv64-gnu@4.63.2': + resolution: {integrity: sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.63.1': - resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + '@rollup/rollup-linux-riscv64-musl@4.63.2': + resolution: {integrity: sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.63.1': - resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + '@rollup/rollup-linux-s390x-gnu@4.63.2': + resolution: {integrity: sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==} cpu: [s390x] os: [linux] libc: [glibc] @@ -2271,45 +2271,45 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.63.1': - resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + '@rollup/rollup-linux-x64-gnu@4.63.2': + resolution: {integrity: sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.63.1': - resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + '@rollup/rollup-linux-x64-musl@4.63.2': + resolution: {integrity: sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.63.1': - resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + '@rollup/rollup-openbsd-x64@4.63.2': + resolution: {integrity: sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.63.1': - resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + '@rollup/rollup-openharmony-arm64@4.63.2': + resolution: {integrity: sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.63.1': - resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + '@rollup/rollup-win32-arm64-msvc@4.63.2': + resolution: {integrity: sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.63.1': - resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + '@rollup/rollup-win32-ia32-msvc@4.63.2': + resolution: {integrity: sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.63.1': - resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + '@rollup/rollup-win32-x64-gnu@4.63.2': + resolution: {integrity: sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.63.1': - resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + '@rollup/rollup-win32-x64-msvc@4.63.2': + resolution: {integrity: sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==} cpu: [x64] os: [win32] @@ -2705,8 +2705,8 @@ packages: '@types/node-schedule@2.1.8': resolution: {integrity: sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==} - '@types/node@25.9.5': - resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/node@25.9.6': + resolution: {integrity: sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2717,13 +2717,13 @@ packages: '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - '@types/react-dom@19.2.7': - resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: - '@types/react': ^19.2.0 + '@types/react': ^19.3.0 - '@types/react@19.2.18': - resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} @@ -2758,39 +2758,39 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.69.0': - resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.69.0 + '@typescript-eslint/parser': ^8.70.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.69.0': - resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.69.0': - resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.69.0': - resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.69.0': - resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.69.0': - resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2800,25 +2800,25 @@ packages: resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.69.0': - resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.69.0': - resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.69.0': - resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.69.0': - resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.1': @@ -4099,8 +4099,8 @@ packages: resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.24.5: - resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + enhanced-resolve@5.25.1: + resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -4834,8 +4834,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.8: - resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} + ignore@7.0.9: + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} engines: {node: '>= 4'} immer@11.1.15: @@ -5492,8 +5492,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.41.0: - resolution: {integrity: sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==} + lucide-react@1.44.0: + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6434,10 +6434,10 @@ packages: react-base16-styling@0.10.0: resolution: {integrity: sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==} - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: - react: ^19.2.8 + react: ^19.3.0 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6482,8 +6482,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} read-package-up@11.0.0: @@ -6615,8 +6615,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.63.1: - resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + rollup@4.63.2: + resolution: {integrity: sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6667,11 +6667,11 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + schema-utils@4.5.0: + resolution: {integrity: sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==} engines: {node: '>= 10.13.0'} semantic-release@25.0.9: @@ -7181,15 +7181,15 @@ packages: tldts-core@7.0.30: resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts-core@7.4.12: - resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + tldts-core@7.4.13: + resolution: {integrity: sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==} tldts@7.0.30: resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true - tldts@7.4.12: - resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + tldts@7.4.13: + resolution: {integrity: sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==} hasBin: true to-regex-range@5.0.1: @@ -7344,8 +7344,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.69.0: - resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -7853,8 +7853,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.2: + resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -7907,22 +7907,22 @@ snapshots: '@actions/io@3.0.2': {} - '@ajayche/trpc-panel@2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.2.18)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4)': + '@ajayche/trpc-panel@2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.2)': dependencies: - '@microlink/react-json-view': 1.31.20(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@microlink/react-json-view': 1.31.20(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@trpc/server': 11.18.0(typescript@6.0.3) clsx: 2.1.1 fuzzysort: 2.0.4 - nuqs: 2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + nuqs: 2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) path: 0.12.7 pretty-bytes: 6.1.1 pretty-ms: 8.0.0 - react-markdown: 9.1.0(@types/react@19.2.18)(react@19.2.8) + react-markdown: 9.1.0(@types/react@19.3.0)(react@19.3.0) string-byte-length: 1.6.0 tailwind-merge: 2.6.1 url: 0.11.4 - zod: 4.5.4 - zod-to-json-schema: 3.25.2(zod@4.5.4) + zod: 4.6.2 + zod-to-json-schema: 3.25.2(zod@4.6.2) transitivePeerDependencies: - '@remix-run/react' - '@tanstack/react-router' @@ -8320,29 +8320,29 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.8.0(@date-fns/tz@1.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/react@1.8.0(@date-fns/tz@1.4.1)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.7 - '@base-ui/utils': 0.4.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@base-ui/utils': 0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@floating-ui/utils': 0.2.12 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - use-sync-external-store: 1.6.0(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.6.0(react@19.3.0) optionalDependencies: '@date-fns/tz': 1.4.1 - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@base-ui/utils@0.4.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/utils@0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.7 '@floating-ui/utils': 0.2.12 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) reselect: 5.3.0 - use-sync-external-store: 1.6.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 '@bcoe/v8-coverage@1.0.2': {} @@ -8377,11 +8377,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(webpack@5.104.1(lightningcss@1.32.0))': + '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 '@codecov/webpack-plugin': 2.0.1(webpack@5.104.1(lightningcss@1.32.0)) - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) unplugin: 1.16.1 webpack: 5.104.1(lightningcss@1.32.0) @@ -8875,11 +8875,11 @@ snapshots: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@floating-ui/react-dom@2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@floating-ui/dom': 1.8.0 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) '@floating-ui/utils@0.2.12': {} @@ -9043,150 +9043,150 @@ snapshots: '@inquirer/ansi@2.0.8': optional: true - '@inquirer/checkbox@5.1.4(@types/node@25.9.5)': + '@inquirer/checkbox@5.1.4(@types/node@25.9.6)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/confirm@6.0.12(@types/node@25.9.5)': + '@inquirer/confirm@6.0.12(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/confirm@6.3.1(@types/node@25.9.5)': + '@inquirer/confirm@6.3.2(@types/node@25.9.6)': dependencies: - '@inquirer/core': 12.0.2(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@25.9.6) + '@inquirer/type': 4.1.1(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 optional: true - '@inquirer/core@11.1.9(@types/node@25.9.5)': + '@inquirer/core@11.1.9(@types/node@25.9.6)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/type': 4.0.5(@types/node@25.9.6) cli-width: 4.1.0 fast-wrap-ansi: 0.2.0 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/core@12.0.2(@types/node@25.9.5)': + '@inquirer/core@12.0.3(@types/node@25.9.6)': dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.6) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 optional: true - '@inquirer/editor@5.1.1(@types/node@25.9.5)': + '@inquirer/editor@5.1.1(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/external-editor': 3.0.0(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/external-editor': 3.0.0(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/expand@5.0.13(@types/node@25.9.5)': + '@inquirer/expand@5.0.13(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/external-editor@3.0.0(@types/node@25.9.5)': + '@inquirer/external-editor@3.0.0(@types/node@25.9.6)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@inquirer/figures@2.0.5': {} '@inquirer/figures@2.0.9': optional: true - '@inquirer/input@5.0.12(@types/node@25.9.5)': + '@inquirer/input@5.0.12(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/number@4.0.12(@types/node@25.9.5)': + '@inquirer/number@4.0.12(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/password@5.0.12(@types/node@25.9.5)': + '@inquirer/password@5.0.12(@types/node@25.9.6)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 - - '@inquirer/prompts@8.4.2(@types/node@25.9.5)': - dependencies: - '@inquirer/checkbox': 5.1.4(@types/node@25.9.5) - '@inquirer/confirm': 6.0.12(@types/node@25.9.5) - '@inquirer/editor': 5.1.1(@types/node@25.9.5) - '@inquirer/expand': 5.0.13(@types/node@25.9.5) - '@inquirer/input': 5.0.12(@types/node@25.9.5) - '@inquirer/number': 4.0.12(@types/node@25.9.5) - '@inquirer/password': 5.0.12(@types/node@25.9.5) - '@inquirer/rawlist': 5.2.8(@types/node@25.9.5) - '@inquirer/search': 4.1.8(@types/node@25.9.5) - '@inquirer/select': 5.1.4(@types/node@25.9.5) + '@types/node': 25.9.6 + + '@inquirer/prompts@8.4.2(@types/node@25.9.6)': + dependencies: + '@inquirer/checkbox': 5.1.4(@types/node@25.9.6) + '@inquirer/confirm': 6.0.12(@types/node@25.9.6) + '@inquirer/editor': 5.1.1(@types/node@25.9.6) + '@inquirer/expand': 5.0.13(@types/node@25.9.6) + '@inquirer/input': 5.0.12(@types/node@25.9.6) + '@inquirer/number': 4.0.12(@types/node@25.9.6) + '@inquirer/password': 5.0.12(@types/node@25.9.6) + '@inquirer/rawlist': 5.2.8(@types/node@25.9.6) + '@inquirer/search': 4.1.8(@types/node@25.9.6) + '@inquirer/select': 5.1.4(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/rawlist@5.2.8(@types/node@25.9.5)': + '@inquirer/rawlist@5.2.8(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/search@4.1.8(@types/node@25.9.5)': + '@inquirer/search@4.1.8(@types/node@25.9.6)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/select@5.1.4(@types/node@25.9.5)': + '@inquirer/select@5.1.4(@types/node@25.9.6)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.5) + '@inquirer/core': 11.1.9(@types/node@25.9.6) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.5) + '@inquirer/type': 4.0.5(@types/node@25.9.6) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/type@4.0.5(@types/node@25.9.5)': + '@inquirer/type@4.0.5(@types/node@25.9.6)': optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@inquirer/type@4.1.1(@types/node@25.9.5)': + '@inquirer/type@4.1.1(@types/node@25.9.6)': optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 optional: true '@isaacs/cliui@9.0.0': {} @@ -9200,7 +9200,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9318,10 +9318,10 @@ snapshots: - supports-color - typescript - '@lingui/macro@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.2.8)': + '@lingui/macro@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.3.0)': dependencies: '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) - '@lingui/react': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.2.8) + '@lingui/react': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.3.0) optionalDependencies: '@lingui/babel-plugin-lingui-macro': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) babel-plugin-macros: 3.1.0 @@ -9333,26 +9333,26 @@ snapshots: '@messageformat/parser': 5.1.1 js-sha256: 0.10.1 - '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.2.8)': + '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0)(react@19.3.0)': dependencies: '@babel/runtime': 7.29.2 '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) - react: 19.2.8 + react: 19.3.0 optionalDependencies: '@lingui/babel-plugin-lingui-macro': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) babel-plugin-macros: 3.1.0 - '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': + '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': dependencies: '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) optionalDependencies: - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@lingui/cli': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) '@lingui/conf': 5.9.5(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -9362,13 +9362,13 @@ snapshots: dependencies: moo: 0.5.3 - '@microlink/react-json-view@1.31.20(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@microlink/react-json-view@1.31.20(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 react-base16-styling: 0.10.0 - react-dom: 19.2.8(react@19.2.8) + react-dom: 19.3.0(react@19.3.0) react-lifecycles-compat: 3.0.4 - react-textarea-autosize: 8.5.9(@types/react@19.2.18)(react@19.2.8) + react-textarea-autosize: 8.5.9(@types/react@19.3.0)(react@19.3.0) transitivePeerDependencies: - '@types/react' @@ -9596,7 +9596,7 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)': dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 @@ -9605,87 +9605,87 @@ snapshots: redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.2.0 optionalDependencies: - react: 19.2.8 - react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + react: 19.3.0 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.63.1': + '@rollup/rollup-android-arm-eabi@4.63.2': optional: true - '@rollup/rollup-android-arm64@4.63.1': + '@rollup/rollup-android-arm64@4.63.2': optional: true - '@rollup/rollup-darwin-arm64@4.63.1': + '@rollup/rollup-darwin-arm64@4.63.2': optional: true - '@rollup/rollup-darwin-x64@4.63.1': + '@rollup/rollup-darwin-x64@4.63.2': optional: true - '@rollup/rollup-freebsd-arm64@4.63.1': + '@rollup/rollup-freebsd-arm64@4.63.2': optional: true - '@rollup/rollup-freebsd-x64@4.63.1': + '@rollup/rollup-freebsd-x64@4.63.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + '@rollup/rollup-linux-arm-gnueabihf@4.63.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.63.1': + '@rollup/rollup-linux-arm-musleabihf@4.63.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.63.1': + '@rollup/rollup-linux-arm64-gnu@4.63.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.63.1': + '@rollup/rollup-linux-arm64-musl@4.63.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.63.1': + '@rollup/rollup-linux-loong64-gnu@4.63.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.63.1': + '@rollup/rollup-linux-loong64-musl@4.63.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.63.1': + '@rollup/rollup-linux-ppc64-gnu@4.63.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.63.1': + '@rollup/rollup-linux-ppc64-musl@4.63.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.63.1': + '@rollup/rollup-linux-riscv64-gnu@4.63.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.63.1': + '@rollup/rollup-linux-riscv64-musl@4.63.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.63.1': + '@rollup/rollup-linux-s390x-gnu@4.63.2': optional: true '@rollup/rollup-linux-x64-gnu@4.6.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.63.1': + '@rollup/rollup-linux-x64-gnu@4.63.2': optional: true - '@rollup/rollup-linux-x64-musl@4.63.1': + '@rollup/rollup-linux-x64-musl@4.63.2': optional: true - '@rollup/rollup-openbsd-x64@4.63.1': + '@rollup/rollup-openbsd-x64@4.63.2': optional: true - '@rollup/rollup-openharmony-arm64@4.63.1': + '@rollup/rollup-openharmony-arm64@4.63.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.63.1': + '@rollup/rollup-win32-arm64-msvc@4.63.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.63.1': + '@rollup/rollup-win32-ia32-msvc@4.63.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.63.1': + '@rollup/rollup-win32-x64-gnu@4.63.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.63.1': + '@rollup/rollup-win32-x64-msvc@4.63.2': optional: true '@rtsao/scc@1.1.0': {} @@ -9802,9 +9802,9 @@ snapshots: tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@25.9.5)': + '@stryker-mutator/core@9.6.1(@types/node@25.9.6)': dependencies: - '@inquirer/prompts': 8.4.2(@types/node@25.9.5) + '@inquirer/prompts': 8.4.2(@types/node@25.9.6) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/instrumenter': 9.6.1 '@stryker-mutator/util': 9.6.1 @@ -9851,24 +9851,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.5))(typescript@6.0.3)': + '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(typescript@6.0.3)': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.5) + '@stryker-mutator/core': 9.6.1(@types/node@25.9.6) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 typescript: 6.0.3 '@stryker-mutator/util@9.6.1': {} - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.5))(vitest@4.1.11)': + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(vitest@4.1.11)': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.5) + '@stryker-mutator/core': 9.6.1(@types/node@25.9.6) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 tslib: 2.8.1 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': dependencies: @@ -9955,10 +9955,10 @@ snapshots: '@tanstack/query-core@5.102.8': {} - '@tanstack/react-query@5.102.8(react@19.2.8)': + '@tanstack/react-query@5.102.8(react@19.3.0)': dependencies: '@tanstack/query-core': 5.102.8 - react: 19.2.8 + react: 19.3.0 '@testing-library/dom@10.4.1': dependencies: @@ -9971,39 +9971,39 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@trpc/server': 11.18.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/next@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': + '@trpc/next@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': dependencies: '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3) - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) typescript: 6.0.3 optionalDependencies: - '@tanstack/react-query': 5.102.8(react@19.2.8) - '@trpc/react-query': 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3) + '@tanstack/react-query': 5.102.8(react@19.3.0) + '@trpc/react-query': 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) - '@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3)': + '@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3)': dependencies: - '@tanstack/react-query': 5.102.8(react@19.2.8) + '@tanstack/react-query': 5.102.8(react@19.3.0) '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3) - react: 19.2.8 + react: 19.3.0 typescript: 6.0.3 '@trpc/server@11.18.0(typescript@6.0.3)': @@ -10048,11 +10048,11 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@types/binary-split@1.0.3': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@types/chai@5.2.3': dependencies: @@ -10136,7 +10136,7 @@ snapshots: '@types/jsdom@28.0.3': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@types/tough-cookie': 4.0.5 parse5: 8.0.1 undici-types: 7.25.0 @@ -10157,9 +10157,9 @@ snapshots: '@types/node-schedule@2.1.8': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@types/node@25.9.5': + '@types/node@25.9.6': dependencies: undici-types: 7.24.6 @@ -10170,23 +10170,23 @@ snapshots: '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 - '@types/react-dom@19.2.7(@types/react@19.2.18)': + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@types/react@19.2.18': + '@types/react@19.3.0': dependencies: csstype: 3.2.3 '@types/readable-stream@4.0.24': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 optional: true '@types/statuses@2.0.6': @@ -10204,7 +10204,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@types/yargs-parser@21.0.3': {} @@ -10212,57 +10212,57 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 eslint: 9.39.5(jiti@2.7.0) - ignore: 7.0.8 + ignore: 7.0.9 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.69.0': + '@typescript-eslint/scope-manager@8.70.0': dependencies: - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 - '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -10272,14 +10272,14 @@ snapshots: '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/types@8.70.0': {} - '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/project-service': 8.70.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -10289,20 +10289,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.69.0': + '@typescript-eslint/visitor-keys@8.70.0': dependencies: - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.1': {} @@ -10377,7 +10377,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -10385,7 +10385,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -10401,7 +10401,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -10412,14 +10412,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.6(@types/node@25.9.5)(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + msw: 2.14.6(@types/node@25.9.6)(typescript@6.0.3) + vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10525,16 +10525,16 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.11.6(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@xyflow/react@12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@xyflow/system': 0.0.82 classcat: 5.0.5 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + zustand: 4.5.7(@types/react@19.3.0)(immer@11.1.15)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) transitivePeerDependencies: - immer @@ -11518,7 +11518,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 - enhanced-resolve@5.24.5: + enhanced-resolve@5.25.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -11831,18 +11831,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.4(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -11859,7 +11859,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -11870,22 +11870,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11896,7 +11896,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11908,7 +11908,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -11939,8 +11939,8 @@ snapshots: '@babel/parser': 7.29.8 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.5.4 - zod-validation-error: 4.0.2(zod@4.5.4) + zod: 4.6.2 + zod-validation-error: 4.0.2(zod@4.6.2) transitivePeerDependencies: - supports-color @@ -12626,7 +12626,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.8: {} + ignore@7.0.9: {} immer@11.1.15: {} @@ -12933,7 +12933,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -13205,9 +13205,9 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.41.0(react@19.2.8): + lucide-react@1.44.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 luxon@3.7.2: {} @@ -13580,9 +13580,9 @@ snapshots: ms@2.1.3: {} - msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3): + msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.3.1(@types/node@25.9.5) + '@inquirer/confirm': 6.3.2(@types/node@25.9.6) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -13615,7 +13615,7 @@ snapshots: mutation-server-protocol@0.4.1: dependencies: - zod: 4.5.4 + zod: 4.6.2 mutation-testing-elements@3.7.3: {} @@ -13651,16 +13651,16 @@ snapshots: nerf-dart@1.0.0: {} - next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.4 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 postcss: 8.5.23 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.3.0) optionalDependencies: '@next/swc-darwin-arm64': 16.3.4 '@next/swc-darwin-x64': 16.3.4 @@ -13670,7 +13670,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.4 '@next/swc-win32-arm64-msvc': 16.3.4 '@next/swc-win32-x64-msvc': 16.3.4 - sharp: 0.35.4(@types/node@25.9.5) + sharp: 0.35.4(@types/node@25.9.6) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -13752,12 +13752,12 @@ snapshots: transitivePeerDependencies: - supports-color - nuqs@2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + nuqs@2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.0.0 - react: 19.2.8 + react: 19.3.0 optionalDependencies: - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.5)(babel-plugin-macros@3.1.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) object-assign@4.1.1: {} @@ -14216,10 +14216,10 @@ snapshots: csstype: 3.2.3 lodash-es: 4.18.1 - react-dom@19.2.8(react@19.2.8): + react-dom@19.3.0(react@19.3.0): dependencies: - react: 19.2.8 - scheduler: 0.27.0 + react: 19.3.0 + scheduler: 0.28.0 react-is@16.13.1: {} @@ -14231,16 +14231,16 @@ snapshots: react-lifecycles-compat@3.0.4: {} - react-markdown@9.1.0(@types/react@19.2.18)(react@19.2.8): + react-markdown@9.1.0(@types/react@19.3.0)(react@19.3.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.18 + '@types/react': 19.3.0 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.8 + react: 19.3.0 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -14249,27 +14249,27 @@ snapshots: transitivePeerDependencies: - supports-color - react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 - react: 19.2.8 - use-sync-external-store: 1.6.0(react@19.2.8) + react: 19.3.0 + use-sync-external-store: 1.6.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 redux: 5.0.1 react-refresh@0.18.0: {} - react-textarea-autosize@8.5.9(@types/react@19.2.18)(react@19.2.8): + react-textarea-autosize@8.5.9(@types/react@19.3.0)(react@19.3.0): dependencies: '@babel/runtime': 7.29.7 - react: 19.2.8 - use-composed-ref: 1.4.0(@types/react@19.2.18)(react@19.2.8) - use-latest: 1.3.0(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + use-composed-ref: 1.4.0(@types/react@19.3.0)(react@19.3.0) + use-latest: 1.3.0(@types/react@19.3.0)(react@19.3.0) transitivePeerDependencies: - '@types/react' - react@19.2.8: {} + react@19.3.0: {} read-package-up@11.0.0: dependencies: @@ -14335,21 +14335,21 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.4)(react@19.2.8)(redux@5.0.1): + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.2.4)(react@19.3.0)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0) clsx: 2.1.1 decimal.js-light: 2.5.1 es-toolkit: 1.50.0 eventemitter3: 5.0.4 immer: 11.1.15 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) react-is: 19.2.4 - react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) reselect: 5.2.0 tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.3.0) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -14458,36 +14458,36 @@ snapshots: rfdc@1.4.1: {} - rollup@4.63.1: + rollup@4.63.2: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.1 - '@rollup/rollup-android-arm64': 4.63.1 - '@rollup/rollup-darwin-arm64': 4.63.1 - '@rollup/rollup-darwin-x64': 4.63.1 - '@rollup/rollup-freebsd-arm64': 4.63.1 - '@rollup/rollup-freebsd-x64': 4.63.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 - '@rollup/rollup-linux-arm-musleabihf': 4.63.1 - '@rollup/rollup-linux-arm64-gnu': 4.63.1 - '@rollup/rollup-linux-arm64-musl': 4.63.1 - '@rollup/rollup-linux-loong64-gnu': 4.63.1 - '@rollup/rollup-linux-loong64-musl': 4.63.1 - '@rollup/rollup-linux-ppc64-gnu': 4.63.1 - '@rollup/rollup-linux-ppc64-musl': 4.63.1 - '@rollup/rollup-linux-riscv64-gnu': 4.63.1 - '@rollup/rollup-linux-riscv64-musl': 4.63.1 - '@rollup/rollup-linux-s390x-gnu': 4.63.1 - '@rollup/rollup-linux-x64-gnu': 4.63.1 - '@rollup/rollup-linux-x64-musl': 4.63.1 - '@rollup/rollup-openbsd-x64': 4.63.1 - '@rollup/rollup-openharmony-arm64': 4.63.1 - '@rollup/rollup-win32-arm64-msvc': 4.63.1 - '@rollup/rollup-win32-ia32-msvc': 4.63.1 - '@rollup/rollup-win32-x64-gnu': 4.63.1 - '@rollup/rollup-win32-x64-msvc': 4.63.1 + '@rollup/rollup-android-arm-eabi': 4.63.2 + '@rollup/rollup-android-arm64': 4.63.2 + '@rollup/rollup-darwin-arm64': 4.63.2 + '@rollup/rollup-darwin-x64': 4.63.2 + '@rollup/rollup-freebsd-arm64': 4.63.2 + '@rollup/rollup-freebsd-x64': 4.63.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.2 + '@rollup/rollup-linux-arm-musleabihf': 4.63.2 + '@rollup/rollup-linux-arm64-gnu': 4.63.2 + '@rollup/rollup-linux-arm64-musl': 4.63.2 + '@rollup/rollup-linux-loong64-gnu': 4.63.2 + '@rollup/rollup-linux-loong64-musl': 4.63.2 + '@rollup/rollup-linux-ppc64-gnu': 4.63.2 + '@rollup/rollup-linux-ppc64-musl': 4.63.2 + '@rollup/rollup-linux-riscv64-gnu': 4.63.2 + '@rollup/rollup-linux-riscv64-musl': 4.63.2 + '@rollup/rollup-linux-s390x-gnu': 4.63.2 + '@rollup/rollup-linux-x64-gnu': 4.63.2 + '@rollup/rollup-linux-x64-musl': 4.63.2 + '@rollup/rollup-openbsd-x64': 4.63.2 + '@rollup/rollup-openharmony-arm64': 4.63.2 + '@rollup/rollup-win32-arm64-msvc': 4.63.2 + '@rollup/rollup-win32-ia32-msvc': 4.63.2 + '@rollup/rollup-win32-x64-gnu': 4.63.2 + '@rollup/rollup-win32-x64-msvc': 4.63.2 fsevents: 2.3.3 router@2.2.0: @@ -14549,13 +14549,13 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.27.0: {} + scheduler@0.28.0: {} - schema-utils@4.3.3: + schema-utils@4.5.0: dependencies: '@types/json-schema': 7.0.15 ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats: 3.0.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) semantic-release@25.0.9(typescript@6.0.3): @@ -14696,7 +14696,7 @@ snapshots: - supports-color - typescript - sharp@0.35.4(@types/node@25.9.5): + sharp@0.35.4(@types/node@25.9.6): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -14727,7 +14727,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - '@types/node': 25.9.5 + '@types/node': 25.9.6 optional: true shebang-command@2.0.0: @@ -15027,10 +15027,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.2.8): + styled-jsx@5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.3.0): dependencies: client-only: 0.0.1 - react: 19.2.8 + react: 19.3.0 optionalDependencies: '@babel/core': 7.29.7 babel-plugin-macros: 3.1.0 @@ -15106,7 +15106,7 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 - schema-utils: 4.3.3 + schema-utils: 4.5.0 terser: 5.51.2 webpack: 5.104.1(lightningcss@1.32.0) optionalDependencies: @@ -15175,16 +15175,16 @@ snapshots: tldts-core@7.0.30: {} - tldts-core@7.4.12: + tldts-core@7.4.13: optional: true tldts@7.0.30: dependencies: tldts-core: 7.0.30 - tldts@7.4.12: + tldts@7.4.13: dependencies: - tldts-core: 7.4.12 + tldts-core: 7.4.13 optional: true to-regex-range@5.0.1: @@ -15199,7 +15199,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.12 + tldts: 7.4.13 optional: true tr46@6.0.0: @@ -15214,14 +15214,14 @@ snapshots: trough@2.2.0: {} - trpc-to-openapi@3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.5.4))(zod@4.5.4): + trpc-to-openapi@3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.6.2))(zod@4.6.2): dependencies: '@trpc/server': 11.18.0(typescript@6.0.3) co-body: 6.2.0 h3: 1.15.11 openapi3-ts: 4.4.0 - zod: 4.5.4 - zod-openapi: 5.4.6(zod@4.5.4) + zod: 4.6.2 + zod-openapi: 5.4.6(zod@4.6.2) optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.6.1 @@ -15353,12 +15353,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -15495,28 +15495,28 @@ snapshots: punycode: 1.4.1 qs: 6.15.2 - use-composed-ref@1.4.0(@types/react@19.2.18)(react@19.2.8): + use-composed-ref@1.4.0(@types/react@19.3.0)(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - use-isomorphic-layout-effect@1.2.1(@types/react@19.2.18)(react@19.2.8): + use-isomorphic-layout-effect@1.2.1(@types/react@19.3.0)(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - use-latest@1.3.0(@types/react@19.2.18)(react@19.2.8): + use-latest@1.3.0(@types/react@19.3.0)(react@19.3.0): dependencies: - react: 19.2.8 - use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - use-sync-external-store@1.6.0(react@19.2.8): + use-sync-external-store@1.6.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 util-deprecate@1.0.2: {} @@ -15560,26 +15560,26 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): + vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 postcss: 8.5.28 - rollup: 4.63.1 + rollup: 4.63.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 @@ -15587,10 +15587,10 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.5)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15607,10 +15607,10 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.0(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 25.9.6 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: @@ -15650,7 +15650,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.18.0) browserslist: 4.28.9 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.5 + enhanced-resolve: 5.25.1 es-module-lexer: 2.3.2 eslint-scope: 5.1.1 events: 3.3.0 @@ -15660,7 +15660,7 @@ snapshots: loader-runner: 4.3.2 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 4.3.3 + schema-utils: 4.5.0 tapable: 2.3.3 terser-webpack-plugin: 5.6.1(lightningcss@1.32.0)(webpack@5.104.1(lightningcss@1.32.0)) watchpack: 2.5.2 @@ -15903,32 +15903,32 @@ snapshots: yoctocolors@2.2.0: {} - zod-openapi@5.4.6(zod@4.5.4): + zod-openapi@5.4.6(zod@4.6.2): dependencies: - zod: 4.5.4 + zod: 4.6.2 zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.2(zod@4.5.4): + zod-to-json-schema@3.25.2(zod@4.6.2): dependencies: - zod: 4.5.4 + zod: 4.6.2 - zod-validation-error@4.0.2(zod@4.5.4): + zod-validation-error@4.0.2(zod@4.6.2): dependencies: - zod: 4.5.4 + zod: 4.6.2 zod@3.25.76: {} - zod@4.5.4: {} + zod@4.6.2: {} - zustand@4.5.7(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8): + zustand@4.5.7(@types/react@19.3.0)(immer@11.1.15)(react@19.3.0): dependencies: - use-sync-external-store: 1.6.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 immer: 11.1.15 - react: 19.2.8 + react: 19.3.0 zwitch@2.0.4: {} From 6b661b22cba056d6a273bd0335de75fafa50a35f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:33:32 +0000 Subject: [PATCH 007/119] chore(deps): Update @formatjs/intl-localematcher all non-major dependencies (#711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@formatjs/intl-localematcher](https://redirect.github.com/formatjs/formatjs#readme) | [`^0.8.0` β†’ `^0.9.0`](https://renovatebot.com/diffs/npm/@formatjs%2fintl-localematcher/0.8.13/0.9.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@formatjs%2fintl-localematcher/0.9.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@formatjs%2fintl-localematcher/0.8.13/0.9.0?slim=true) | | [@tanstack/react-query](https://tanstack.com/query) ([source](https://redirect.github.com/TanStack/query/tree/HEAD/packages/react-query)) | [`5.102.8` β†’ `5.103.1`](https://renovatebot.com/diffs/npm/@tanstack%2freact-query/5.102.8/5.103.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@tanstack%2freact-query/5.103.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@tanstack%2freact-query/5.102.8/5.103.1?slim=true) | | [@testing-library/dom](https://redirect.github.com/testing-library/dom-testing-library) | [`10.4.1` β†’ `10.4.2`](https://renovatebot.com/diffs/npm/@testing-library%2fdom/10.4.1/10.4.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@testing-library%2fdom/10.4.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@testing-library%2fdom/10.4.1/10.4.2?slim=true) | | [@trpc/client](https://trpc.io) ([source](https://redirect.github.com/trpc/trpc/tree/HEAD/packages/client)) | [`11.18.0` β†’ `11.19.0`](https://renovatebot.com/diffs/npm/@trpc%2fclient/11.18.0/11.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@trpc%2fclient/11.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@trpc%2fclient/11.18.0/11.19.0?slim=true) | | [@trpc/next](https://trpc.io) ([source](https://redirect.github.com/trpc/trpc/tree/HEAD/packages/next)) | [`11.18.0` β†’ `11.19.0`](https://renovatebot.com/diffs/npm/@trpc%2fnext/11.18.0/11.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@trpc%2fnext/11.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@trpc%2fnext/11.18.0/11.19.0?slim=true) | | [@trpc/react-query](https://trpc.io) ([source](https://redirect.github.com/trpc/trpc/tree/HEAD/packages/react)) | [`11.18.0` β†’ `11.19.0`](https://renovatebot.com/diffs/npm/@trpc%2freact-query/11.18.0/11.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@trpc%2freact-query/11.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@trpc%2freact-query/11.18.0/11.19.0?slim=true) | | [@trpc/server](https://trpc.io) ([source](https://redirect.github.com/trpc/trpc/tree/HEAD/packages/server)) | [`11.18.0` β†’ `11.19.0`](https://renovatebot.com/diffs/npm/@trpc%2fserver/11.18.0/11.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@trpc%2fserver/11.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@trpc%2fserver/11.18.0/11.19.0?slim=true) | | [@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | [`25.9.6` β†’ `25.9.7`](https://renovatebot.com/diffs/npm/@types%2fnode/25.9.6/25.9.7) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/25.9.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/25.9.6/25.9.7?slim=true) | | [eslint-config-next](https://nextjs.org/docs/app/api-reference/config/eslint) ([source](https://redirect.github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next)) | [`16.3.4` β†’ `16.3.5`](https://renovatebot.com/diffs/npm/eslint-config-next/16.3.4/16.3.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-config-next/16.3.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-config-next/16.3.4/16.3.5?slim=true) | | [lucide-react](https://lucide.dev) ([source](https://redirect.github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react)) | [`1.44.0` β†’ `1.47.0`](https://renovatebot.com/diffs/npm/lucide-react/1.44.0/1.47.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/lucide-react/1.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/lucide-react/1.44.0/1.47.0?slim=true) | | [mqtt](https://redirect.github.com/mqttjs/MQTT.js) | [`5.15.2` β†’ `5.16.0`](https://renovatebot.com/diffs/npm/mqtt/5.15.2/5.16.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mqtt/5.16.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mqtt/5.15.2/5.16.0?slim=true) | | [next](https://nextjs.org) ([source](https://redirect.github.com/vercel/next.js)) | [`16.3.4` β†’ `16.3.5`](https://renovatebot.com/diffs/npm/next/16.3.4/16.3.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/next/16.3.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/next/16.3.4/16.3.5?slim=true) | | [tailwind-merge](https://redirect.github.com/dcastil/tailwind-merge) ([source](https://redirect.github.com/dcastil/tailwind-merge/tree/HEAD/packages/tailwind-merge)) | [`3.6.0` β†’ `3.7.0`](https://renovatebot.com/diffs/npm/tailwind-merge/3.6.0/3.7.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/tailwind-merge/3.7.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/tailwind-merge/3.6.0/3.7.0?slim=true) | | [zod](https://zod.dev) ([source](https://redirect.github.com/colinhacks/zod)) | [`4.6.2` β†’ `4.6.5`](https://renovatebot.com/diffs/npm/zod/4.6.2/4.6.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/zod/4.6.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zod/4.6.2/4.6.5?slim=true) | --- ### Release Notes
TanStack/query (@​tanstack/react-query) ### [`v5.103.1`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51031) [Compare Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.103.0...@tanstack/react-query@5.103.1) ##### Patch Changes - Updated dependencies \[[`8330b2f`](https://redirect.github.com/TanStack/query/commit/8330b2f2d431f226d7bb95eb727c637be9d4c6d8), [`3212966`](https://redirect.github.com/TanStack/query/commit/3212966a3681e02d14eb79e9cce0d3265b046877)]: - [@​tanstack/query-core](https://redirect.github.com/tanstack/query-core)@5.103.1 ### [`v5.103.0`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51030) [Compare Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.102.8...@tanstack/react-query@5.103.0) ##### Patch Changes - Updated dependencies \[[`e57f816`](https://redirect.github.com/TanStack/query/commit/e57f8163b18ab3abfe52ad69cdbdda4d31453393), [`fdae2ce`](https://redirect.github.com/TanStack/query/commit/fdae2ce4e5382af7326c7c38ce5d5a12751ada12), [`a1119e5`](https://redirect.github.com/TanStack/query/commit/a1119e5a3ffa52534de7390f17c7183d17658051), [`cbf77bf`](https://redirect.github.com/TanStack/query/commit/cbf77bf8e1a9ce09caef0cdd9a8ed10fef57edf0)]: - [@​tanstack/query-core](https://redirect.github.com/tanstack/query-core)@5.103.0
testing-library/dom-testing-library (@​testing-library/dom) ### [`v10.4.2`](https://redirect.github.com/testing-library/dom-testing-library/releases/tag/v10.4.2) [Compare Source](https://redirect.github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2) ##### Bug Fixes - **deps:** pin [@​types/node](https://redirect.github.com/types/node) to a TypeScript 4-compatible version ([#​1386](https://redirect.github.com/testing-library/dom-testing-library/issues/1386)) ([6049cc0](https://redirect.github.com/testing-library/dom-testing-library/commit/6049cc0bc7cf2201625c476c95fa6299d0e2fa8b))
trpc/trpc (@​trpc/client) ### [`v11.19.0`](https://redirect.github.com/trpc/trpc/releases/tag/v11.19.0) [Compare Source](https://redirect.github.com/trpc/trpc/compare/v11.18.0...v11.19.0) #### What's Changed - ci(example): fix Playwright e2e ffmpeg failures by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) with [@​Copilot](https://redirect.github.com/Copilot) in [#​7412](https://redirect.github.com/trpc/trpc/pull/7412) - fix(upgrade): clear the query cache on every fixture render by [@​noahisdai](https://redirect.github.com/noahisdai) in [#​7448](https://redirect.github.com/trpc/trpc/pull/7448) - fix(client): abort JSONL stream on httpBatchStreamLink unsubscribe by [@​vinamra1102](https://redirect.github.com/vinamra1102) in [#​7390](https://redirect.github.com/trpc/trpc/pull/7390) - fix(deps): update dependency srvx to v0.12.5 by [@​renovate](https://redirect.github.com/renovate)\[bot] in [#​7437](https://redirect.github.com/trpc/trpc/pull/7437) - docs: fix broken star history chart by [@​OctoBored](https://redirect.github.com/OctoBored) in [#​7470](https://redirect.github.com/trpc/trpc/pull/7470) - fix(server): keep middleware factory declarations portable by [@​1988jimi](https://redirect.github.com/1988jimi) in [#​7557](https://redirect.github.com/trpc/trpc/pull/7557) - fix(server): preserve `this` when chaining to a pre-existing dispose method by [@​raine-works](https://redirect.github.com/raine-works) in [#​7457](https://redirect.github.com/trpc/trpc/pull/7457) - chore: approve esbuild by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7570](https://redirect.github.com/trpc/trpc/pull/7570) - fix(server): correct assertIsRequestId boolean logic β€” non-NaN invalid types passed silently by [@​okxint](https://redirect.github.com/okxint) in [#​7477](https://redirect.github.com/trpc/trpc/pull/7477) - fix(docs): fix 19 dead links in llms.txt β€” use permalink, absolute URLs, and H1 title by [@​okxint](https://redirect.github.com/okxint) in [#​7465](https://redirect.github.com/trpc/trpc/pull/7465) - fix(tanstack-react-query): subscriptionOptions(skipToken).enabled incorrectly returns true by [@​okxint](https://redirect.github.com/okxint) in [#​7464](https://redirect.github.com/trpc/trpc/pull/7464) - docs: add observable deprecation note to subscriptions reference projects by [@​algojogacor](https://redirect.github.com/algojogacor) in [#​7375](https://redirect.github.com/trpc/trpc/pull/7375) - fix(client): wire the operation AbortSignal in the WebSocket link by [@​ATKasem](https://redirect.github.com/ATKasem) in [#​7434](https://redirect.github.com/trpc/trpc/pull/7434) - docs: add vscode-toolkit to awesome-trpc by [@​wszgrcy](https://redirect.github.com/wszgrcy) in [#​7363](https://redirect.github.com/trpc/trpc/pull/7363) - docs(www): add Sury to validator library integrations by [@​DZakh](https://redirect.github.com/DZakh) in [#​7466](https://redirect.github.com/trpc/trpc/pull/7466) - docs: add Authier to awesome tRPC projects by [@​capaj](https://redirect.github.com/capaj) in [#​7558](https://redirect.github.com/trpc/trpc/pull/7558) - fix(server): delete stale clientSubscriptions entry on subscription task rejection by [@​Adit-Jain-srm](https://redirect.github.com/Adit-Jain-srm) in [#​7401](https://redirect.github.com/trpc/trpc/pull/7401) - fix(server): match lazy router keys on a path boundary in getProcedureAtPath by [@​contactjawad](https://redirect.github.com/contactjawad) in [#​7474](https://redirect.github.com/trpc/trpc/pull/7474) - fix(client): abort httpLink/httpBatchLink requests on unsubscribe by [@​SnowingFox](https://redirect.github.com/SnowingFox) in [#​7469](https://redirect.github.com/trpc/trpc/pull/7469) - chore(deps): bump vitest from 4.0.18 to 4.1.11 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​7584](https://redirect.github.com/trpc/trpc/pull/7584) - build(deps): upgrade node to 24.21.0 and [@​types/node](https://redirect.github.com/types/node) to v24 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7571](https://redirect.github.com/trpc/trpc/pull/7571) - build(deps): upgrade pnpm to v12 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7572](https://redirect.github.com/trpc/trpc/pull/7572) - build(deps): upgrade tsdown to 0.23.0 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7574](https://redirect.github.com/trpc/trpc/pull/7574) - build(deps): upgrade typescript to v7 for packages/\* by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7575](https://redirect.github.com/trpc/trpc/pull/7575) - build(deps): upgrade eslint to v10 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7576](https://redirect.github.com/trpc/trpc/pull/7576) - build(deps): upgrade prettier to 3.9.6 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7577](https://redirect.github.com/trpc/trpc/pull/7577) - build(deps): upgrade vitest to v5 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7578](https://redirect.github.com/trpc/trpc/pull/7578) - build(deps): upgrade turbo to 2.10.12 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7579](https://redirect.github.com/trpc/trpc/pull/7579) - build(deps): patch security advisories in packages/\* by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7580](https://redirect.github.com/trpc/trpc/pull/7580) - build(deps): upgrade [@​hey-api/openapi-ts](https://redirect.github.com/hey-api/openapi-ts) to 0.99.0 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7586](https://redirect.github.com/trpc/trpc/pull/7586) - build(deps): patch security advisories in www and examples by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7581](https://redirect.github.com/trpc/trpc/pull/7581) - build(deps): upgrade lerna to v10 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7582](https://redirect.github.com/trpc/trpc/pull/7582) - build(deps): upgrade docusaurus to 3.10.2 by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7583](https://redirect.github.com/trpc/trpc/pull/7583) - fix(openapi): drop function-valued properties from generated schemas by [@​Nick-Lucas](https://redirect.github.com/Nick-Lucas) in [#​7588](https://redirect.github.com/trpc/trpc/pull/7588) #### New Contributors - [@​noahisdai](https://redirect.github.com/noahisdai) made their first contribution in [#​7448](https://redirect.github.com/trpc/trpc/pull/7448) - [@​vinamra1102](https://redirect.github.com/vinamra1102) made their first contribution in [#​7390](https://redirect.github.com/trpc/trpc/pull/7390) - [@​OctoBored](https://redirect.github.com/OctoBored) made their first contribution in [#​7470](https://redirect.github.com/trpc/trpc/pull/7470) - [@​1988jimi](https://redirect.github.com/1988jimi) made their first contribution in [#​7557](https://redirect.github.com/trpc/trpc/pull/7557) - [@​raine-works](https://redirect.github.com/raine-works) made their first contribution in [#​7457](https://redirect.github.com/trpc/trpc/pull/7457) - [@​okxint](https://redirect.github.com/okxint) made their first contribution in [#​7477](https://redirect.github.com/trpc/trpc/pull/7477) - [@​algojogacor](https://redirect.github.com/algojogacor) made their first contribution in [#​7375](https://redirect.github.com/trpc/trpc/pull/7375) - [@​ATKasem](https://redirect.github.com/ATKasem) made their first contribution in [#​7434](https://redirect.github.com/trpc/trpc/pull/7434) - [@​DZakh](https://redirect.github.com/DZakh) made their first contribution in [#​7466](https://redirect.github.com/trpc/trpc/pull/7466) - [@​capaj](https://redirect.github.com/capaj) made their first contribution in [#​7558](https://redirect.github.com/trpc/trpc/pull/7558) - [@​Adit-Jain-srm](https://redirect.github.com/Adit-Jain-srm) made their first contribution in [#​7401](https://redirect.github.com/trpc/trpc/pull/7401) - [@​contactjawad](https://redirect.github.com/contactjawad) made their first contribution in [#​7474](https://redirect.github.com/trpc/trpc/pull/7474) - [@​SnowingFox](https://redirect.github.com/SnowingFox) made their first contribution in [#​7469](https://redirect.github.com/trpc/trpc/pull/7469) **Full Changelog**:
vercel/next.js (eslint-config-next) ### [`v16.3.5`](https://redirect.github.com/vercel/next.js/compare/v16.3.4...v16.3.5) [Compare Source](https://redirect.github.com/vercel/next.js/compare/v16.3.4...v16.3.5)
lucide-icons/lucide (lucide-react) ### [`v1.47.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.47.0): Version 1.47.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.46.0...1.47.0) #### What's Changed - feat(icons): add lambda icon by [@​UbaidUllah9962](https://redirect.github.com/UbaidUllah9962) in [#​4017](https://redirect.github.com/lucide-icons/lucide/pull/4017) - feat(icons): delegated `faucet` icon from lab by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4764](https://redirect.github.com/lucide-icons/lucide/pull/4764) - feat(icons): added `door-closed-package` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4814](https://redirect.github.com/lucide-icons/lucide/pull/4814) - feat(icons): added 'nepali-rupee' icon by [@​sarajdhakal](https://redirect.github.com/sarajdhakal) in [#​4608](https://redirect.github.com/lucide-icons/lucide/pull/4608) - feat(icons): added `tube-lotion` icon by [@​AlecRust](https://redirect.github.com/AlecRust) in [#​4029](https://redirect.github.com/lucide-icons/lucide/pull/4029) - feat(icons): Added cupcake icon by [@​briz123](https://redirect.github.com/briz123) in [#​3000](https://redirect.github.com/lucide-icons/lucide/pull/3000) - feat(icons): added square-dashed-x icon by [@​EthanHazel](https://redirect.github.com/EthanHazel) in [#​4535](https://redirect.github.com/lucide-icons/lucide/pull/4535) - feat(icons): add `rotate-cw-clock` icon by [@​gkkconan](https://redirect.github.com/gkkconan) in [#​3979](https://redirect.github.com/lucide-icons/lucide/pull/3979) - fix(icons): remove path from save-off by [@​HPRILLER](https://redirect.github.com/HPRILLER) in [#​4848](https://redirect.github.com/lucide-icons/lucide/pull/4848) - fix(icons): changed `calendar-chevrons-right` by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4865](https://redirect.github.com/lucide-icons/lucide/pull/4865) - fix(icons): changed `broccoli` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4871](https://redirect.github.com/lucide-icons/lucide/pull/4871) - feat(icons): added square-dashed-plus by [@​psjdev](https://redirect.github.com/psjdev) in [#​4849](https://redirect.github.com/lucide-icons/lucide/pull/4849) #### New Contributors - [@​UbaidUllah9962](https://redirect.github.com/UbaidUllah9962) made their first contribution in [#​4017](https://redirect.github.com/lucide-icons/lucide/pull/4017) - [@​sarajdhakal](https://redirect.github.com/sarajdhakal) made their first contribution in [#​4608](https://redirect.github.com/lucide-icons/lucide/pull/4608) - [@​AlecRust](https://redirect.github.com/AlecRust) made their first contribution in [#​4029](https://redirect.github.com/lucide-icons/lucide/pull/4029) - [@​gkkconan](https://redirect.github.com/gkkconan) made their first contribution in [#​3979](https://redirect.github.com/lucide-icons/lucide/pull/3979) - [@​HPRILLER](https://redirect.github.com/HPRILLER) made their first contribution in [#​4848](https://redirect.github.com/lucide-icons/lucide/pull/4848) - [@​psjdev](https://redirect.github.com/psjdev) made their first contribution in [#​4849](https://redirect.github.com/lucide-icons/lucide/pull/4849) **Full Changelog**: ### [`v1.46.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.46.0): Version 1.46.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.45.0...1.46.0) #### What's Changed - feat(icons): Added new icons: `clef-g`, `clef-f` and `clef-c` by [@​rrod497](https://redirect.github.com/rrod497) in [#​4083](https://redirect.github.com/lucide-icons/lucide/pull/4083) - chore(deps-dev): bump astro from 7.3.0 to 7.3.2 in the astro-deps group by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4860](https://redirect.github.com/lucide-icons/lucide/pull/4860) - feat(icons): added `monitor-pc` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4791](https://redirect.github.com/lucide-icons/lucide/pull/4791) **Full Changelog**: ### [`v1.45.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.45.0): Version 1.45.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.44.0...1.45.0) #### What's Changed - feat(icons): added `calendar-chevrons-right` icon by [@​AlexandrePhilibert](https://redirect.github.com/AlexandrePhilibert) in [#​3565](https://redirect.github.com/lucide-icons/lucide/pull/3565) - feat(icons): added `building-complex-plus` icon by [@​tylerkade](https://redirect.github.com/tylerkade) in [#​4758](https://redirect.github.com/lucide-icons/lucide/pull/4758) - feat(icons): add hourglass-cog icon by [@​lazerg](https://redirect.github.com/lazerg) in [#​4635](https://redirect.github.com/lucide-icons/lucide/pull/4635) - feat(icons): added `mouth` & `mouth-off` by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4787](https://redirect.github.com/lucide-icons/lucide/pull/4787) - feat(icons): added `iv-bag` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4821](https://redirect.github.com/lucide-icons/lucide/pull/4821) - fix(icons): changed `lectern` icon by [@​UsamaKhan](https://redirect.github.com/UsamaKhan) in [#​2925](https://redirect.github.com/lucide-icons/lucide/pull/2925) - feat(icons): add `layout-arrow-right` and `layout-arrow-down` by [@​samuelalake](https://redirect.github.com/samuelalake) in [#​4541](https://redirect.github.com/lucide-icons/lucide/pull/4541) - feat(icons): added `park` icon by [@​skajosborn](https://redirect.github.com/skajosborn) in [#​3177](https://redirect.github.com/lucide-icons/lucide/pull/3177) - fix(icons): changed `album`, `book-marked`, `folder-bookmark` icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​3043](https://redirect.github.com/lucide-icons/lucide/pull/3043) - fix(icons): correct misspelled tags by [@​decknamec](https://redirect.github.com/decknamec) in [#​4836](https://redirect.github.com/lucide-icons/lucide/pull/4836) - feat(icons): added `houses` icon by [@​danielbayley](https://redirect.github.com/danielbayley) in [#​3241](https://redirect.github.com/lucide-icons/lucide/pull/3241) - feat(icons): added `notebook-dot` icon by [@​elenakovelskikh](https://redirect.github.com/elenakovelskikh) in [#​3228](https://redirect.github.com/lucide-icons/lucide/pull/3228) - feat(icons): add `messages-circle` icon by [@​Mirazstudio-offical](https://redirect.github.com/Mirazstudio-offical) in [#​4754](https://redirect.github.com/lucide-icons/lucide/pull/4754) - feat(icons): added `plant-pot` icon by [@​vqh2602](https://redirect.github.com/vqh2602) in [#​3122](https://redirect.github.com/lucide-icons/lucide/pull/3122) - fix(icons): changed `cookie` icon by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4815](https://redirect.github.com/lucide-icons/lucide/pull/4815) - fix(icons): remove duplicate use-cases prop from cookie.json by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4838](https://redirect.github.com/lucide-icons/lucide/pull/4838) - fix(site): fix home card icons by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4839](https://redirect.github.com/lucide-icons/lucide/pull/4839) - feat(docs): added "How to use Lucide icons" section to resources by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4829](https://redirect.github.com/lucide-icons/lucide/pull/4829) - fix(packages/vue): fix generated icon declaration types for [@​lucide/vue](https://redirect.github.com/lucide/vue) by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4841](https://redirect.github.com/lucide-icons/lucide/pull/4841) - chore(deps-dev): bump vitest from 4.1.10 to 4.1.11 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4845](https://redirect.github.com/lucide-icons/lucide/pull/4845) - chore(deps-dev): bump vitest from 4.1.10 to 4.1.11 in /integrations/lucide-react/vite by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4844](https://redirect.github.com/lucide-icons/lucide/pull/4844) - ci(ci.yml): Add dispatch post release by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4847](https://redirect.github.com/lucide-icons/lucide/pull/4847) - feat(icons): added `globe-code` icon by [@​AleksejDix](https://redirect.github.com/AleksejDix) in [#​3722](https://redirect.github.com/lucide-icons/lucide/pull/3722) #### New Contributors - [@​tylerkade](https://redirect.github.com/tylerkade) made their first contribution in [#​4758](https://redirect.github.com/lucide-icons/lucide/pull/4758) - [@​alx-xo](https://redirect.github.com/alx-xo) made their first contribution in [#​4115](https://redirect.github.com/lucide-icons/lucide/pull/4115) - [@​skajosborn](https://redirect.github.com/skajosborn) made their first contribution in [#​3177](https://redirect.github.com/lucide-icons/lucide/pull/3177) - [@​elenakovelskikh](https://redirect.github.com/elenakovelskikh) made their first contribution in [#​3228](https://redirect.github.com/lucide-icons/lucide/pull/3228) - [@​Mirazstudio-offical](https://redirect.github.com/Mirazstudio-offical) made their first contribution in [#​4754](https://redirect.github.com/lucide-icons/lucide/pull/4754) - [@​AleksejDix](https://redirect.github.com/AleksejDix) made their first contribution in [#​3722](https://redirect.github.com/lucide-icons/lucide/pull/3722) **Full Changelog**:
mqttjs/MQTT.js (mqtt) ### [`v5.16.0`](https://redirect.github.com/mqttjs/MQTT.js/blob/HEAD/CHANGELOG.md#5160-2026-09-16) [Compare Source](https://redirect.github.com/mqttjs/MQTT.js/compare/v5.15.2...v5.16.0) ##### Bug Fixes - bounds-check the suback granted array against subscriptions sent (GHSA-rj8f-4655-cgg2) ([#​2061](https://redirect.github.com/mqttjs/MQTT.js/issues/2061)) ([46ee23f](https://redirect.github.com/mqttjs/MQTT.js/commit/46ee23f3f1d4a8ef21e5c2b26ea24818f1b2dce3)), closes [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) - enforce MQTT 5 Receive Maximum for inbound QoS 2 messages (GHSA-h8jm-hm87-fqw3) ([#​2065](https://redirect.github.com/mqttjs/MQTT.js/issues/2065)) ([ec5bf3e](https://redirect.github.com/mqttjs/MQTT.js/commit/ec5bf3ea16e960313d670f280b0eac91dac35fe2)), closes [#​2064](https://redirect.github.com/mqttjs/MQTT.js/issues/2064) [#​2063](https://redirect.github.com/mqttjs/MQTT.js/issues/2063) [#​2062](https://redirect.github.com/mqttjs/MQTT.js/issues/2062) [#​2061](https://redirect.github.com/mqttjs/MQTT.js/issues/2061) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) - keep broker CONNACK properties out of the user options object (GHSA-gfxc-3w7m-8ch4) ([#​2062](https://redirect.github.com/mqttjs/MQTT.js/issues/2062)) ([3db16f5](https://redirect.github.com/mqttjs/MQTT.js/commit/3db16f54a12e46e496c6fca352d6bb8324f8634e)), closes [#​2061](https://redirect.github.com/mqttjs/MQTT.js/issues/2061) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) - reject a duplicate CONNACK instead of re-running connection setup (GHSA-8phv-jwjm-93rr) ([#​2064](https://redirect.github.com/mqttjs/MQTT.js/issues/2064)) ([7108ea5](https://redirect.github.com/mqttjs/MQTT.js/commit/7108ea597458e6c538e88f3ac1cf9f7c2001dcc2)), closes [#​2063](https://redirect.github.com/mqttjs/MQTT.js/issues/2063) [#​2062](https://redirect.github.com/mqttjs/MQTT.js/issues/2062) [#​2061](https://redirect.github.com/mqttjs/MQTT.js/issues/2061) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) - reject an unsolicited AUTH instead of crashing the process (GHSA-fwrw-4mhv-wxvm) ([#​2063](https://redirect.github.com/mqttjs/MQTT.js/issues/2063)) ([b511e7b](https://redirect.github.com/mqttjs/MQTT.js/commit/b511e7b7d9a927bc3af4c146b65e493c652a11ec)), closes [#​2062](https://redirect.github.com/mqttjs/MQTT.js/issues/2062) [#​2061](https://redirect.github.com/mqttjs/MQTT.js/issues/2061) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) [#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060) - reject unsolicited MQTT 5 topic alias instead of crashing (GHSA-c8jq-r765-cq7g) ([#​2060](https://redirect.github.com/mqttjs/MQTT.js/issues/2060)) ([7d07757](https://redirect.github.com/mqttjs/MQTT.js/commit/7d07757a1498f52243c64cb8c2dad416cf15449d)) #### [5.15.2](https://redirect.github.com/mqttjs/MQTT.js/compare/v5.15.1...v5.15.2) (2026-07-06) ##### Bug Fixes - **connect/tls:** don't overwrite an explicit servername (SNI) ([#​2051](https://redirect.github.com/mqttjs/MQTT.js/issues/2051)) ([cd1d780](https://redirect.github.com/mqttjs/MQTT.js/commit/cd1d780f5742852e9bcf46dbd9732e0c5e20823a)) - support relative WebSocket URLs ([#​2049](https://redirect.github.com/mqttjs/MQTT.js/issues/2049)) ([c4be563](https://redirect.github.com/mqttjs/MQTT.js/commit/c4be5633594b5b839862440aa78125f22021f8e6)), closes [#​2035](https://redirect.github.com/mqttjs/MQTT.js/issues/2035) #### [5.15.1](https://redirect.github.com/mqttjs/MQTT.js/compare/v5.15.0...v5.15.1) (2026-03-24)
vercel/next.js (next) ### [`v16.3.5`](https://redirect.github.com/vercel/next.js/compare/v16.3.4...v16.3.5) [Compare Source](https://redirect.github.com/vercel/next.js/compare/v16.3.4...v16.3.5)
dcastil/tailwind-merge (tailwind-merge) ### [`v3.7.0`](https://redirect.github.com/dcastil/tailwind-merge/compare/v3.6.0...tailwind-merge@3.7.0) [Compare Source](https://redirect.github.com/dcastil/tailwind-merge/compare/v3.6.0...tailwind-merge@3.7.0)
colinhacks/zod (zod) ### [`v4.6.5`](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.5) [Compare Source](https://redirect.github.com/colinhacks/zod/compare/v4.6.4...v4.6.5) #### Commits: - [`d2b135c`](https://redirect.github.com/colinhacks/zod/commit/d2b135cfb7a3582b9eb515756b9166bcb9521f4a) docs: add the 4.6.x patch highlights to the 4.6 post - [`f1448f7`](https://redirect.github.com/colinhacks/zod/commit/f1448f7cee00df9fe1e9ad84a000aa1828cc8bc1) docs: fold the 4.6.x patch highlights into the 4.6 post's own sections - [`de65a5c`](https://redirect.github.com/colinhacks/zod/commit/de65a5cb39ed22a507fac935788f718fa88d104f) docs: lead the properties section with the check and add a Zod Mini tab ([#​6598](https://redirect.github.com/colinhacks/zod/issues/6598)) - [`56222cd`](https://redirect.github.com/colinhacks/zod/commit/56222cd1532c07bcb91b67df529cab4c0a215330) feat(instanceof): key the .properties() shape off the instance type ([#​6600](https://redirect.github.com/colinhacks/zod/issues/6600)) - [`ca0229a`](https://redirect.github.com/colinhacks/zod/commit/ca0229a404818290e6cdcfefcd7eb2d04bcbb543) Revert "feat: add z.currencyCode() over a vendored ISO 4217 list, refreshed weekly by CI ([#​6595](https://redirect.github.com/colinhacks/zod/issues/6595))" - [`cc4cd4e`](https://redirect.github.com/colinhacks/zod/commit/cc4cd4ee9c52fcaa10964e48cc144541e41a5ed9) Revert "Revert "feat: add z.currencyCode() over a vendored ISO 4217 list, refreshed weekly by CI ([#​6595](https://redirect.github.com/colinhacks/zod/issues/6595))"" - [`0f3f5ee`](https://redirect.github.com/colinhacks/zod/commit/0f3f5ee3ca56c7574bf849e54f79e9a6e02562ee) 4.6.5 - [`59bbc03`](https://redirect.github.com/colinhacks/zod/commit/59bbc03e10c636b9eb3c393dfeb552819774ec21) chore: re-pin the integration peers to the workspace zod after the 4.6.5 bump ### [`v4.6.4`](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.4) [Compare Source](https://redirect.github.com/colinhacks/zod/compare/v4.6.3...v4.6.4) A patch on top of [4.6.3](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.3). - [`d6bc1e30`](https://redirect.github.com/colinhacks/zod/commit/d6bc1e30) feat: add `z.currencyCode()` over a vendored ISO 4217 list, refreshed weekly by CI ([#​6595](https://redirect.github.com/colinhacks/zod/pull/6595)) - [`ad32d751`](https://redirect.github.com/colinhacks/zod/commit/ad32d751) perf: `z.url()` rejects an invalid URL with `URL.canParse()` instead of a throwing constructor, about 50x faster; fewer allocations on the validation path ([#​6588](https://redirect.github.com/colinhacks/zod/pull/6588)) - [`2bb08717`](https://redirect.github.com/colinhacks/zod/commit/2bb08717) chore: re-pin the integration peers to the workspace zod after the 4.6.4 bump - [`f6e1701a`](https://redirect.github.com/colinhacks/zod/commit/f6e1701a) chore(deps): bump next to 15.5.25 and vite to 7.3.6 ([#​6153](https://redirect.github.com/colinhacks/zod/pull/6153)) ### [`v4.6.3`](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.3) [Compare Source](https://redirect.github.com/colinhacks/zod/compare/v4.6.2...v4.6.3) A patch on top of [4.6.2](https://redirect.github.com/colinhacks/zod/releases/tag/v4.6.2). - [`413cce9a`](https://redirect.github.com/colinhacks/zod/commit/413cce9a) fix(v4): make z.properties() a check again ([#​6594](https://redirect.github.com/colinhacks/zod/pull/6594)) β€” removes the standalone `z.properties()` schema from 4.6.0; `z.instanceof().properties()` and `.check(...z.properties())` are unchanged - [`75d63ee1`](https://redirect.github.com/colinhacks/zod/commit/75d63ee1) docs: show only the `.properties()` method form in the 4.6 post - [`46da9572`](https://redirect.github.com/colinhacks/zod/commit/46da9572) docs: match the error-message examples to what the parsers emit
--- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - "before 6am on monday" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ‘» **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/sleepypod/core).
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update renovate/all-minor-patch ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/renovate/all-minor-patch/scripts/install \ | sudo bash -s -- --branch renovate/all-minor-patch ``` 🎯 **Pin to this exact build** ([run 35564801167](https://github.com/sleepypod/core/actions/runs/35564801167) Β· `a56bb57`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/a56bb57c5cd2a52d3826cb4e11497b79f1e8f16f/scripts/install \ | sudo bash -s -- \ --branch renovate/all-minor-patch \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/35564801167/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/35564801167/artifacts/10623636240) Β· self-destructs in 30 days πŸ’₯
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 991 +++++++++++++++++++++++++------------------------ 2 files changed, 505 insertions(+), 488 deletions(-) diff --git a/package.json b/package.json index 02189ea49..f3e399589 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dependencies": { "@ajayche/trpc-panel": "^2.0.4", "@base-ui/react": "^1.2.0", - "@formatjs/intl-localematcher": "^0.8.0", + "@formatjs/intl-localematcher": "^0.9.0", "@lingui/core": "^5.9.2", "@lingui/macro": "^5.9.2", "@lingui/react": "^5.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78b84e8f0..6ceba87d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,13 +10,13 @@ importers: dependencies: '@ajayche/trpc-panel': specifier: ^2.0.4 - version: 2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.2) + version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@base-ui/react': specifier: ^1.2.0 version: 1.8.0(@date-fns/tz@1.4.1)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@formatjs/intl-localematcher': - specifier: ^0.8.0 - version: 0.8.13 + specifier: ^0.9.0 + version: 0.9.0 '@lingui/core': specifier: ^5.9.2 version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) @@ -31,19 +31,19 @@ importers: version: 3.4.0 '@tanstack/react-query': specifier: ^5.90.21 - version: 5.102.8(react@19.3.0) + version: 5.103.1(react@19.3.0) '@trpc/client': specifier: ^11.10.0 - version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) + version: 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/next': specifier: ^11.10.0 - version: 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) + version: 11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) '@trpc/react-query': specifier: ^11.10.0 - version: 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) + version: 11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) '@trpc/server': specifier: ^11.10.0 - version: 11.18.0(typescript@6.0.3) + version: 11.19.0(typescript@6.0.3) '@xyflow/react': specifier: ^12.10.1 version: 12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -76,16 +76,16 @@ importers: version: 1.2.0 lucide-react: specifier: ^1.0.0 - version: 1.44.0(react@19.3.0) + version: 1.47.0(react@19.3.0) mqtt: specifier: ^5.15.1 - version: 5.15.2 + version: 5.16.0 negotiator: specifier: ^1.0.0 version: 1.1.0 next: specifier: ^16.1.6 - version: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) node-schedule: specifier: ^2.1.1 version: 2.1.1 @@ -109,10 +109,10 @@ importers: version: 2.2.6 tailwind-merge: specifier: ^3.5.0 - version: 3.6.0 + version: 3.7.0 trpc-to-openapi: specifier: ^3.1.0 - version: 3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.6.2))(zod@4.6.2) + version: 3.3.0(@trpc/server@11.19.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.6.5))(zod@4.6.5) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -121,17 +121,17 @@ importers: version: 8.21.3 zod: specifier: ^4.3.6 - version: 4.6.2 + version: 4.6.5 zod-openapi: specifier: ^5.4.6 - version: 5.4.6(zod@4.6.2) + version: 5.4.6(zod@4.6.5) devDependencies: '@babel/preset-typescript': specifier: ^7.28.5 version: 7.29.7(@babel/core@7.29.7) '@codecov/nextjs-webpack-plugin': specifier: ^2.0.1 - version: 2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0)) + version: 2.0.1(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0)) '@eslint/css': specifier: ^1.0.0 version: 1.4.0 @@ -152,10 +152,10 @@ importers: version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(webpack@5.104.1(lightningcss@1.32.0)) '@lingui/swc-plugin': specifier: ^5.11.0 - version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) '@lingui/vite-plugin': specifier: ^5.9.2 - version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@semantic-release/commit-analyzer': specifier: ^13.0.1 version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) @@ -164,13 +164,13 @@ importers: version: 10.0.1(semantic-release@25.0.9(typescript@6.0.3)) '@stryker-mutator/core': specifier: ^9.6.1 - version: 9.6.1(@types/node@25.9.6) + version: 9.6.1(@types/node@25.9.7) '@stryker-mutator/typescript-checker': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(typescript@6.0.3) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(typescript@6.0.3) '@stryker-mutator/vitest-runner': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(vitest@4.1.11) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(vitest@4.1.11) '@stylistic/eslint-plugin': specifier: ^5.9.0 version: 5.10.0(eslint@9.39.5(jiti@2.7.0)) @@ -179,10 +179,10 @@ importers: version: 4.3.3 '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.1 + version: 10.4.2 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tsconfig/next': specifier: ^2.0.5 version: 2.0.6 @@ -200,7 +200,7 @@ importers: version: 0.6.5 '@types/node': specifier: ^25.3.0 - version: 25.9.6 + version: 25.9.7 '@types/node-schedule': specifier: ^2.1.8 version: 2.1.8 @@ -218,7 +218,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.2.0(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.2.0(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 4.1.11 version: 4.1.11(vitest@4.1.11) @@ -233,7 +233,7 @@ importers: version: 9.39.5(jiti@2.7.0) eslint-config-next: specifier: ^16.1.6 - version: 16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 16.3.5(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -266,10 +266,10 @@ importers: version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) vitest: specifier: ^4.0.18 - version: 4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -316,14 +316,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -458,6 +450,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-decorators@7.29.0': resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} engines: {node: '>=6.9.0'} @@ -530,10 +527,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -1428,8 +1421,8 @@ packages: '@formatjs/fast-memoize@3.1.7': resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} - '@formatjs/intl-localematcher@0.8.13': - resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==} + '@formatjs/intl-localematcher@0.9.0': + resolution: {integrity: sha512-Z7TD3ssNry1Av1dVDoq/mzpxGyxqWwU0ClWEo+BB3k0JQTeQvUGmeK9WZGYvlqStS0UqkaKXk3dfjsb6NaHAxw==} '@hapi/bourne@3.0.0': resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} @@ -1959,8 +1952,8 @@ packages: os: [linux] libc: [glibc] - '@napi-rs/wasm-runtime@1.2.3': - resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 @@ -1981,60 +1974,60 @@ packages: resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} engines: {node: '>= 18.0.0'} - '@next/env@16.3.4': - resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} + '@next/env@16.3.5': + resolution: {integrity: sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==} - '@next/eslint-plugin-next@16.3.4': - resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} + '@next/eslint-plugin-next@16.3.5': + resolution: {integrity: sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==} - '@next/swc-darwin-arm64@16.3.4': - resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} + '@next/swc-darwin-arm64@16.3.5': + resolution: {integrity: sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.4': - resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} + '@next/swc-darwin-x64@16.3.5': + resolution: {integrity: sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.4': - resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} + '@next/swc-linux-arm64-gnu@16.3.5': + resolution: {integrity: sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.4': - resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} + '@next/swc-linux-arm64-musl@16.3.5': + resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.4': - resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} + '@next/swc-linux-x64-gnu@16.3.5': + resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.4': - resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} + '@next/swc-linux-x64-musl@16.3.5': + resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.4': - resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} + '@next/swc-win32-arm64-msvc@16.3.5': + resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.4': - resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} + '@next/swc-win32-x64-msvc@16.3.5': + resolution: {integrity: sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2169,98 +2162,98 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.63.2': - resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==} + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.63.2': - resolution: {integrity: sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==} + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.63.2': - resolution: {integrity: sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==} + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.63.2': - resolution: {integrity: sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==} + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.63.2': - resolution: {integrity: sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==} + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.63.2': - resolution: {integrity: sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==} + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.63.2': - resolution: {integrity: sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.63.2': - resolution: {integrity: sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==} + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.63.2': - resolution: {integrity: sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==} + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.63.2': - resolution: {integrity: sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==} + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.63.2': - resolution: {integrity: sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==} + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.63.2': - resolution: {integrity: sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==} + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.63.2': - resolution: {integrity: sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==} + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.63.2': - resolution: {integrity: sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==} + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.63.2': - resolution: {integrity: sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==} + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.63.2': - resolution: {integrity: sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==} + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.63.2': - resolution: {integrity: sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==} + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} cpu: [s390x] os: [linux] libc: [glibc] @@ -2271,45 +2264,45 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.63.2': - resolution: {integrity: sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==} + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.63.2': - resolution: {integrity: sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==} + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.63.2': - resolution: {integrity: sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==} + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.63.2': - resolution: {integrity: sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==} + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.63.2': - resolution: {integrity: sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==} + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.63.2': - resolution: {integrity: sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==} + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.63.2': - resolution: {integrity: sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==} + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.63.2': - resolution: {integrity: sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==} + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} cpu: [x64] os: [win32] @@ -2512,16 +2505,16 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tanstack/query-core@5.102.8': - resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} + '@tanstack/query-core@5.103.1': + resolution: {integrity: sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==} - '@tanstack/react-query@5.102.8': - resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} + '@tanstack/react-query@5.103.1': + resolution: {integrity: sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==} peerDependencies: react: ^18 || ^19 - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} '@testing-library/react@16.3.3': @@ -2539,21 +2532,21 @@ packages: '@types/react-dom': optional: true - '@trpc/client@11.18.0': - resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} + '@trpc/client@11.19.0': + resolution: {integrity: sha512-0MKkPq6MejsKB5Opca1Rl22KVIaqo2+A1EiXyZ8EGUsl9TLrPdpGn+Usux20TZKFwnSC2+wI/WilqNJaP4ODfw==} hasBin: true peerDependencies: - '@trpc/server': 11.18.0 + '@trpc/server': 11.19.0 typescript: '>=5.7.2' - '@trpc/next@11.18.0': - resolution: {integrity: sha512-ocwbruAWMGX9hY3HFg86X4jAcoF2v+xx+A2jDn72SbttRRG2hXR+XKPjrLc1dDJC0oi+/2DJEbL14+k1pyY5og==} + '@trpc/next@11.19.0': + resolution: {integrity: sha512-6rSzxEm87zZP3XM4KZakhUTMFrrqs+eVV9zqyX5Xpu03s7VhXfvDYQ/ampnZ5NxL5wrvcl14jV2QYeJ6FSzBJw==} hasBin: true peerDependencies: '@tanstack/react-query': ^5.59.15 - '@trpc/client': 11.18.0 - '@trpc/react-query': 11.18.0 - '@trpc/server': 11.18.0 + '@trpc/client': 11.19.0 + '@trpc/react-query': 11.19.0 + '@trpc/server': 11.19.0 next: '*' react: '>=16.8.0' react-dom: '>=16.8.0' @@ -2564,17 +2557,17 @@ packages: '@trpc/react-query': optional: true - '@trpc/react-query@11.18.0': - resolution: {integrity: sha512-C1+Wwm2pCeUJucI+bnFpxGYjNuvV+ko1BC1T9tUxBVdrhHRCdn9ubxdevdLSAa49XRJRJiZnSuzl3Ys/yvs1vg==} + '@trpc/react-query@11.19.0': + resolution: {integrity: sha512-TrqZBa/6weXX27mf0P2dMCI65duqO3B/wf9BkP1MezUWSUg7SRGJrIv6K8vjgjeHdafFBI1mmcqPEYXHF4AlVQ==} peerDependencies: '@tanstack/react-query': ^5.80.3 - '@trpc/client': 11.18.0 - '@trpc/server': 11.18.0 + '@trpc/client': 11.19.0 + '@trpc/server': 11.19.0 react: '>=18.2.0' typescript: '>=5.7.2' - '@trpc/server@11.18.0': - resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} + '@trpc/server@11.19.0': + resolution: {integrity: sha512-7C1goiDurz82yFKtZUbEzjXd0Rg/jyisaIZQhdvus+1l7E6mwcEYZXjZ1US/HF8kOyz1LERL8UN3bcboJtjcyQ==} hasBin: true peerDependencies: typescript: '>=5.7.2' @@ -2585,8 +2578,8 @@ packages: '@tsconfig/next@2.0.6': resolution: {integrity: sha512-C6eZoNyAMKv2UvzDZqcp8zXPviu6W4Ev1KUbqfmaMSZuq+tmbtTWaqxTif/yaui8WCEcRlQrHC8q9tNzBclHOw==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -2705,8 +2698,11 @@ packages: '@types/node-schedule@2.1.8': resolution: {integrity: sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==} - '@types/node@25.9.6': - resolution: {integrity: sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==} + '@types/node@25.9.7': + resolution: {integrity: sha512-Y+axoNWOFLvDNFxNbDA+kqqV05imHwTu5bZWWO6tQM3Y2NX1jvzUJkYlf4nYnMCnvu4wQBKW6Nr+ok6yHWA9aQ==} + + '@types/node@25.9.8': + resolution: {integrity: sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3202,6 +3198,10 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -3277,8 +3277,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.21: - resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -3347,6 +3347,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4076,6 +4081,9 @@ packages: electron-to-chromium@1.5.422: resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + electron-to-chromium@1.5.433: + resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4211,8 +4219,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.3.4: - resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} + eslint-config-next@16.3.5: + resolution: {integrity: sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4892,12 +4900,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} - engines: {node: '>= 12'} - - ip-address@10.7.0: - resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -4950,8 +4954,8 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -5203,9 +5207,6 @@ packages: js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} - js-sdsl@4.3.0: - resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} - js-sha256@0.10.1: resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==} @@ -5485,15 +5486,15 @@ packages: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} - lru-cache@11.5.2: - resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.44.0: - resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} + lucide-react@1.47.0: + resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5735,8 +5736,8 @@ packages: mqtt-packet@9.0.2: resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==} - mqtt@5.15.2: - resolution: {integrity: sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==} + mqtt@5.16.0: + resolution: {integrity: sha512-G0VsEtuJz5sJUgZJaaADMVfW7SIFPzkIuHR+d6rk+tBeSwdCfd9BaIRycRfd2AbyFDEmLlFM+SDQeti/+IbOnw==} engines: {node: '>=16.0.0'} hasBin: true @@ -5790,6 +5791,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -5811,8 +5817,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@16.3.4: - resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} + next@16.3.5: + resolution: {integrity: sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5858,6 +5864,10 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} + node-schedule@2.1.1: resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} engines: {node: '>=6'} @@ -5961,9 +5971,6 @@ packages: - validate-npm-package-name - which - number-allocator@1.0.14: - resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==} - nuqs@2.8.9: resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} peerDependencies: @@ -6615,8 +6622,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.63.2: - resolution: {integrity: sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==} + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6818,10 +6825,6 @@ packages: resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - socks@2.8.9: - resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sorted-array-functions@1.3.0: resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} @@ -7053,8 +7056,8 @@ packages: tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -7297,12 +7300,12 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-fest@5.8.0: - resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} - type-fest@5.9.0: - resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} type-is@1.6.18: @@ -7457,6 +7460,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -7712,8 +7721,8 @@ packages: worker-timers-worker@9.0.15: resolution: {integrity: sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==} - worker-timers@8.0.33: - resolution: {integrity: sha512-RQVlKkek80v8M6SHvdMKiywaXFmX3XTpYTXTw0r62PdXB06t74XNxcTuG8wsQwnjorAQymNQyyQ0rzv7PykEMQ==} + worker-timers@8.0.34: + resolution: {integrity: sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==} wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} @@ -7853,8 +7862,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.6.2: - resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -7907,13 +7916,13 @@ snapshots: '@actions/io@3.0.2': {} - '@ajayche/trpc-panel@2.1.0(@trpc/server@11.18.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.2)': + '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': dependencies: '@microlink/react-json-view': 1.31.20(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@trpc/server': 11.18.0(typescript@6.0.3) + '@trpc/server': 11.19.0(typescript@6.0.3) clsx: 2.1.1 fuzzysort: 2.0.4 - nuqs: 2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + nuqs: 2.8.9(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) path: 0.12.7 pretty-bytes: 6.1.1 pretty-ms: 8.0.0 @@ -7921,8 +7930,8 @@ snapshots: string-byte-length: 1.6.0 tailwind-merge: 2.6.1 url: 0.11.4 - zod: 4.6.2 - zod-to-json-schema: 3.25.2(zod@4.6.2) + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) transitivePeerDependencies: - '@remix-run/react' - '@tanstack/react-router' @@ -7956,18 +7965,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -7980,7 +7977,7 @@ snapshots: '@babel/core@7.29.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) @@ -8096,7 +8093,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -8160,6 +8157,10 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8251,15 +8252,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.28.4': {} - '@babel/runtime@7.29.2': {} '@babel/runtime@7.29.7': {} '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.8 '@babel/types': 7.29.0 @@ -8271,7 +8270,7 @@ snapshots: '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.29.8 @@ -8377,11 +8376,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0))': + '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 '@codecov/webpack-plugin': 2.0.1(webpack@5.104.1(lightningcss@1.32.0)) - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) unplugin: 1.16.1 webpack: 5.104.1(lightningcss@1.32.0) @@ -8885,7 +8884,7 @@ snapshots: '@formatjs/fast-memoize@3.1.7': {} - '@formatjs/intl-localematcher@0.8.13': + '@formatjs/intl-localematcher@0.9.0': dependencies: '@formatjs/fast-memoize': 3.1.7 @@ -9043,150 +9042,150 @@ snapshots: '@inquirer/ansi@2.0.8': optional: true - '@inquirer/checkbox@5.1.4(@types/node@25.9.6)': + '@inquirer/checkbox@5.1.4(@types/node@25.9.7)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/confirm@6.0.12(@types/node@25.9.6)': + '@inquirer/confirm@6.0.12(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/confirm@6.3.2(@types/node@25.9.6)': + '@inquirer/confirm@6.3.2(@types/node@25.9.7)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.6) - '@inquirer/type': 4.1.1(@types/node@25.9.6) + '@inquirer/core': 12.0.3(@types/node@25.9.7) + '@inquirer/type': 4.1.1(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 optional: true - '@inquirer/core@11.1.9(@types/node@25.9.6)': + '@inquirer/core@11.1.9(@types/node@25.9.7)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.7) cli-width: 4.1.0 fast-wrap-ansi: 0.2.0 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/core@12.0.3(@types/node@25.9.6)': + '@inquirer/core@12.0.3(@types/node@25.9.7)': dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.6) + '@inquirer/type': 4.1.1(@types/node@25.9.7) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 optional: true - '@inquirer/editor@5.1.1(@types/node@25.9.6)': + '@inquirer/editor@5.1.1(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/external-editor': 3.0.0(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/external-editor': 3.0.0(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/expand@5.0.13(@types/node@25.9.6)': + '@inquirer/expand@5.0.13(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/external-editor@3.0.0(@types/node@25.9.6)': + '@inquirer/external-editor@3.0.0(@types/node@25.9.7)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@inquirer/figures@2.0.5': {} '@inquirer/figures@2.0.9': optional: true - '@inquirer/input@5.0.12(@types/node@25.9.6)': + '@inquirer/input@5.0.12(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/number@4.0.12(@types/node@25.9.6)': + '@inquirer/number@4.0.12(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/password@5.0.12(@types/node@25.9.6)': + '@inquirer/password@5.0.12(@types/node@25.9.7)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 - - '@inquirer/prompts@8.4.2(@types/node@25.9.6)': - dependencies: - '@inquirer/checkbox': 5.1.4(@types/node@25.9.6) - '@inquirer/confirm': 6.0.12(@types/node@25.9.6) - '@inquirer/editor': 5.1.1(@types/node@25.9.6) - '@inquirer/expand': 5.0.13(@types/node@25.9.6) - '@inquirer/input': 5.0.12(@types/node@25.9.6) - '@inquirer/number': 4.0.12(@types/node@25.9.6) - '@inquirer/password': 5.0.12(@types/node@25.9.6) - '@inquirer/rawlist': 5.2.8(@types/node@25.9.6) - '@inquirer/search': 4.1.8(@types/node@25.9.6) - '@inquirer/select': 5.1.4(@types/node@25.9.6) + '@types/node': 25.9.7 + + '@inquirer/prompts@8.4.2(@types/node@25.9.7)': + dependencies: + '@inquirer/checkbox': 5.1.4(@types/node@25.9.7) + '@inquirer/confirm': 6.0.12(@types/node@25.9.7) + '@inquirer/editor': 5.1.1(@types/node@25.9.7) + '@inquirer/expand': 5.0.13(@types/node@25.9.7) + '@inquirer/input': 5.0.12(@types/node@25.9.7) + '@inquirer/number': 4.0.12(@types/node@25.9.7) + '@inquirer/password': 5.0.12(@types/node@25.9.7) + '@inquirer/rawlist': 5.2.8(@types/node@25.9.7) + '@inquirer/search': 4.1.8(@types/node@25.9.7) + '@inquirer/select': 5.1.4(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/rawlist@5.2.8(@types/node@25.9.6)': + '@inquirer/rawlist@5.2.8(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/search@4.1.8(@types/node@25.9.6)': + '@inquirer/search@4.1.8(@types/node@25.9.7)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/select@5.1.4(@types/node@25.9.6)': + '@inquirer/select@5.1.4(@types/node@25.9.7)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.6) + '@inquirer/core': 11.1.9(@types/node@25.9.7) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.6) + '@inquirer/type': 4.0.5(@types/node@25.9.7) optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/type@4.0.5(@types/node@25.9.6)': + '@inquirer/type@4.0.5(@types/node@25.9.7)': optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@inquirer/type@4.1.1(@types/node@25.9.6)': + '@inquirer/type@4.1.1(@types/node@25.9.7)': optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 optional: true '@isaacs/cliui@9.0.0': {} @@ -9200,7 +9199,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9342,17 +9341,17 @@ snapshots: '@lingui/babel-plugin-lingui-macro': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) babel-plugin-macros: 3.1.0 - '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': + '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': dependencies: '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) optionalDependencies: - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@lingui/cli': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) '@lingui/conf': 5.9.5(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -9407,11 +9406,11 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 + '@tybys/wasm-util': 0.10.4 optional: true '@nats-io/nats-core@3.4.0': @@ -9431,37 +9430,37 @@ snapshots: '@nats-io/nkeys': 2.0.3 '@nats-io/nuid': 3.0.0 - '@next/env@16.3.4': {} + '@next/env@16.3.5': {} - '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': + '@next/eslint-plugin-next@16.3.5(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.4': + '@next/swc-darwin-arm64@16.3.5': optional: true - '@next/swc-darwin-x64@16.3.4': + '@next/swc-darwin-x64@16.3.5': optional: true - '@next/swc-linux-arm64-gnu@16.3.4': + '@next/swc-linux-arm64-gnu@16.3.5': optional: true - '@next/swc-linux-arm64-musl@16.3.4': + '@next/swc-linux-arm64-musl@16.3.5': optional: true - '@next/swc-linux-x64-gnu@16.3.4': + '@next/swc-linux-x64-gnu@16.3.5': optional: true - '@next/swc-linux-x64-musl@16.3.4': + '@next/swc-linux-x64-musl@16.3.5': optional: true - '@next/swc-win32-arm64-msvc@16.3.4': + '@next/swc-win32-arm64-msvc@16.3.5': optional: true - '@next/swc-win32-x64-msvc@16.3.4': + '@next/swc-win32-x64-msvc@16.3.5': optional: true '@noble/hashes@1.8.0': @@ -9610,82 +9609,82 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.63.2': + '@rollup/rollup-android-arm-eabi@4.63.4': optional: true - '@rollup/rollup-android-arm64@4.63.2': + '@rollup/rollup-android-arm64@4.63.4': optional: true - '@rollup/rollup-darwin-arm64@4.63.2': + '@rollup/rollup-darwin-arm64@4.63.4': optional: true - '@rollup/rollup-darwin-x64@4.63.2': + '@rollup/rollup-darwin-x64@4.63.4': optional: true - '@rollup/rollup-freebsd-arm64@4.63.2': + '@rollup/rollup-freebsd-arm64@4.63.4': optional: true - '@rollup/rollup-freebsd-x64@4.63.2': + '@rollup/rollup-freebsd-x64@4.63.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.63.2': + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.63.2': + '@rollup/rollup-linux-arm-musleabihf@4.63.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.63.2': + '@rollup/rollup-linux-arm64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.63.2': + '@rollup/rollup-linux-arm64-musl@4.63.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.63.2': + '@rollup/rollup-linux-loong64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.63.2': + '@rollup/rollup-linux-loong64-musl@4.63.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.63.2': + '@rollup/rollup-linux-ppc64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.63.2': + '@rollup/rollup-linux-ppc64-musl@4.63.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.63.2': + '@rollup/rollup-linux-riscv64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.63.2': + '@rollup/rollup-linux-riscv64-musl@4.63.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.63.2': + '@rollup/rollup-linux-s390x-gnu@4.63.4': optional: true '@rollup/rollup-linux-x64-gnu@4.6.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.63.2': + '@rollup/rollup-linux-x64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-x64-musl@4.63.2': + '@rollup/rollup-linux-x64-musl@4.63.4': optional: true - '@rollup/rollup-openbsd-x64@4.63.2': + '@rollup/rollup-openbsd-x64@4.63.4': optional: true - '@rollup/rollup-openharmony-arm64@4.63.2': + '@rollup/rollup-openharmony-arm64@4.63.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.63.2': + '@rollup/rollup-win32-arm64-msvc@4.63.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.63.2': + '@rollup/rollup-win32-ia32-msvc@4.63.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.63.2': + '@rollup/rollup-win32-x64-gnu@4.63.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.63.2': + '@rollup/rollup-win32-x64-msvc@4.63.4': optional: true '@rtsao/scc@1.1.0': {} @@ -9802,9 +9801,9 @@ snapshots: tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@25.9.6)': + '@stryker-mutator/core@9.6.1(@types/node@25.9.7)': dependencies: - '@inquirer/prompts': 8.4.2(@types/node@25.9.6) + '@inquirer/prompts': 8.4.2(@types/node@25.9.7) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/instrumenter': 9.6.1 '@stryker-mutator/util': 9.6.1 @@ -9851,24 +9850,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(typescript@6.0.3)': + '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(typescript@6.0.3)': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.6) + '@stryker-mutator/core': 9.6.1(@types/node@25.9.7) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 typescript: 6.0.3 '@stryker-mutator/util@9.6.1': {} - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.6))(vitest@4.1.11)': + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(vitest@4.1.11)': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.6) + '@stryker-mutator/core': 9.6.1(@types/node@25.9.7) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 tslib: 2.8.1 - vitest: 4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': dependencies: @@ -9953,17 +9952,17 @@ snapshots: postcss: 8.5.21 tailwindcss: 4.3.3 - '@tanstack/query-core@5.102.8': {} + '@tanstack/query-core@5.103.1': {} - '@tanstack/react-query@5.102.8(react@19.3.0)': + '@tanstack/react-query@5.103.1(react@19.3.0)': dependencies: - '@tanstack/query-core': 5.102.8 + '@tanstack/query-core': 5.103.1 react: 19.3.0 - '@testing-library/dom@10.4.1': + '@testing-library/dom@10.4.2': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.4 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -9971,42 +9970,42 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 + '@testing-library/dom': 10.4.2 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) optionalDependencies: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3)': + '@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3)': dependencies: - '@trpc/server': 11.18.0(typescript@6.0.3) + '@trpc/server': 11.19.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/next@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': + '@trpc/next@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': dependencies: - '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) - '@trpc/server': 11.18.0(typescript@6.0.3) - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) + '@trpc/server': 11.19.0(typescript@6.0.3) + next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) typescript: 6.0.3 optionalDependencies: - '@tanstack/react-query': 5.102.8(react@19.3.0) - '@trpc/react-query': 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) + '@tanstack/react-query': 5.103.1(react@19.3.0) + '@trpc/react-query': 11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) - '@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3)': + '@trpc/react-query@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3)': dependencies: - '@tanstack/react-query': 5.102.8(react@19.3.0) - '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) - '@trpc/server': 11.18.0(typescript@6.0.3) + '@tanstack/react-query': 5.103.1(react@19.3.0) + '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) + '@trpc/server': 11.19.0(typescript@6.0.3) react: 19.3.0 typescript: 6.0.3 - '@trpc/server@11.18.0(typescript@6.0.3)': + '@trpc/server@11.19.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -10018,7 +10017,7 @@ snapshots: '@tsconfig/next@2.0.6': {} - '@tybys/wasm-util@0.10.3': + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 optional: true @@ -10048,11 +10047,11 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/binary-split@1.0.3': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/chai@5.2.3': dependencies: @@ -10136,7 +10135,7 @@ snapshots: '@types/jsdom@28.0.3': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/tough-cookie': 4.0.5 parse5: 8.0.1 undici-types: 7.25.0 @@ -10157,9 +10156,13 @@ snapshots: '@types/node-schedule@2.1.8': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 - '@types/node@25.9.6': + '@types/node@25.9.7': + dependencies: + undici-types: 7.24.6 + + '@types/node@25.9.8': dependencies: undici-types: 7.24.6 @@ -10170,7 +10173,7 @@ snapshots: '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: @@ -10182,11 +10185,11 @@ snapshots: '@types/readable-stream@4.0.24': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.8 optional: true '@types/statuses@2.0.6': @@ -10204,7 +10207,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@types/yargs-parser@21.0.3': {} @@ -10365,7 +10368,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -10377,7 +10380,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -10385,7 +10388,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -10401,7 +10404,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -10412,14 +10415,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.6(@types/node@25.9.6)(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + msw: 2.14.6(@types/node@25.9.7)(typescript@6.0.3) + vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10680,6 +10683,17 @@ snapshots: is-string: 1.1.1 math-intrinsics: 1.1.0 + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -10772,7 +10786,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.21: {} + baseline-browser-mapping@2.11.25: {} before-after-hook@4.0.0: {} @@ -10866,12 +10880,20 @@ snapshots: browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.21 + baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 electron-to-chromium: 1.5.422 node-releases: 2.0.54 update-browserslist-db: 1.3.2(browserslist@4.28.9) + browserslist@4.29.0: + dependencies: + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.433 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) + buffer-from@1.1.2: {} buffer@5.7.1: @@ -11499,6 +11521,8 @@ snapshots: electron-to-chromium@1.5.422: {} + electron-to-chromium@1.5.433: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -11831,13 +11855,13 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.3.5(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.3.5(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) @@ -11854,12 +11878,12 @@ snapshots: eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.2 + is-core-module: 2.17.0 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -11870,25 +11894,25 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 @@ -11896,9 +11920,9 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 - is-core-module: 2.16.2 + is-core-module: 2.17.0 is-glob: 4.0.3 minimatch: 3.1.5 object.fromentries: 2.0.8 @@ -11917,7 +11941,7 @@ snapshots: eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): dependencies: aria-query: 5.3.2 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 axe-core: 4.13.0 @@ -11936,11 +11960,11 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.6.2 - zod-validation-error: 4.0.2(zod@4.6.2) + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -12125,7 +12149,7 @@ snapshots: dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.7.0 + ip-address: 10.7.2 transitivePeerDependencies: - supports-color @@ -12570,7 +12594,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: @@ -12670,9 +12694,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.5.0: {} - - ip-address@10.7.0: {} + ip-address@10.7.2: {} ipaddr.js@1.9.1: {} @@ -12726,7 +12748,7 @@ snapshots: dependencies: hasown: 2.0.2 - is-core-module@2.16.2: + is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -12933,7 +12955,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.8 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -12943,8 +12965,6 @@ snapshots: js-md4@0.3.2: {} - js-sdsl@4.3.0: {} - js-sha256@0.10.1: {} js-tokens@10.0.0: {} @@ -13199,13 +13219,13 @@ snapshots: lru-cache@11.3.6: {} - lru-cache@11.5.2: {} + lru-cache@11.5.3: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@1.44.0(react@19.3.0): + lucide-react@1.47.0(react@19.3.0): dependencies: react: 19.3.0 @@ -13555,7 +13575,7 @@ snapshots: transitivePeerDependencies: - supports-color - mqtt@5.15.2: + mqtt@5.16.0: dependencies: '@types/readable-stream': 4.0.24 '@types/ws': 8.18.1 @@ -13566,12 +13586,11 @@ snapshots: lru-cache: 10.4.3 minimist: 1.2.8 mqtt-packet: 9.0.2 - number-allocator: 1.0.14 readable-stream: 4.7.0 rfdc: 1.4.1 - socks: 2.8.9 + socks: 2.8.10 split2: 4.2.0 - worker-timers: 8.0.33 + worker-timers: 8.0.34 ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -13580,9 +13599,9 @@ snapshots: ms@2.1.3: {} - msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3): + msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.3.2(@types/node@25.9.6) + '@inquirer/confirm': 6.3.2(@types/node@25.9.7) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -13597,7 +13616,7 @@ snapshots: statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.2 - type-fest: 5.9.0 + type-fest: 5.10.0 until-async: 3.0.2 yargs: 17.7.3 optionalDependencies: @@ -13615,7 +13634,7 @@ snapshots: mutation-server-protocol@0.4.1: dependencies: - zod: 4.6.2 + zod: 4.6.5 mutation-testing-elements@3.7.3: {} @@ -13637,6 +13656,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.19: {} + napi-build-utils@2.0.0: {} napi-postinstall@0.3.4: {} @@ -13651,26 +13672,26 @@ snapshots: nerf-dart@1.0.0: {} - next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.4 + '@next/env': 16.3.5 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.21 + baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 postcss: 8.5.23 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.4 - '@next/swc-darwin-x64': 16.3.4 - '@next/swc-linux-arm64-gnu': 16.3.4 - '@next/swc-linux-arm64-musl': 16.3.4 - '@next/swc-linux-x64-gnu': 16.3.4 - '@next/swc-linux-x64-musl': 16.3.4 - '@next/swc-win32-arm64-msvc': 16.3.4 - '@next/swc-win32-x64-msvc': 16.3.4 - sharp: 0.35.4(@types/node@25.9.6) + '@next/swc-darwin-arm64': 16.3.5 + '@next/swc-darwin-x64': 16.3.5 + '@next/swc-linux-arm64-gnu': 16.3.5 + '@next/swc-linux-arm64-musl': 16.3.5 + '@next/swc-linux-x64-gnu': 16.3.5 + '@next/swc-linux-x64-musl': 16.3.5 + '@next/swc-win32-arm64-msvc': 16.3.5 + '@next/swc-win32-x64-msvc': 16.3.5 + sharp: 0.35.4(@types/node@25.9.7) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -13708,6 +13729,8 @@ snapshots: node-releases@2.0.54: {} + node-releases@2.0.56: {} + node-schedule@2.1.1: dependencies: cron-parser: 4.9.0 @@ -13745,19 +13768,12 @@ snapshots: npm@11.19.0: {} - number-allocator@1.0.14: - dependencies: - debug: 4.4.3 - js-sdsl: 4.3.0 - transitivePeerDependencies: - - supports-color - - nuqs@2.8.9(next@16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): + nuqs@2.8.9(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.0.0 react: 19.3.0 optionalDependencies: - next: 16.3.4(@babel/core@7.29.7)(@types/node@25.9.6)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) object-assign@4.1.1: {} @@ -14006,7 +14022,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 minipass: 7.1.3 path-to-regexp@6.3.0: @@ -14067,7 +14083,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -14421,7 +14437,7 @@ snapshots: resolve@1.22.12: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.2 + is-core-module: 2.17.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 optional: true @@ -14435,7 +14451,7 @@ snapshots: resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.2 + is-core-module: 2.17.0 node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 @@ -14458,36 +14474,36 @@ snapshots: rfdc@1.4.1: {} - rollup@4.63.2: + rollup@4.63.4: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.2 - '@rollup/rollup-android-arm64': 4.63.2 - '@rollup/rollup-darwin-arm64': 4.63.2 - '@rollup/rollup-darwin-x64': 4.63.2 - '@rollup/rollup-freebsd-arm64': 4.63.2 - '@rollup/rollup-freebsd-x64': 4.63.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.2 - '@rollup/rollup-linux-arm-musleabihf': 4.63.2 - '@rollup/rollup-linux-arm64-gnu': 4.63.2 - '@rollup/rollup-linux-arm64-musl': 4.63.2 - '@rollup/rollup-linux-loong64-gnu': 4.63.2 - '@rollup/rollup-linux-loong64-musl': 4.63.2 - '@rollup/rollup-linux-ppc64-gnu': 4.63.2 - '@rollup/rollup-linux-ppc64-musl': 4.63.2 - '@rollup/rollup-linux-riscv64-gnu': 4.63.2 - '@rollup/rollup-linux-riscv64-musl': 4.63.2 - '@rollup/rollup-linux-s390x-gnu': 4.63.2 - '@rollup/rollup-linux-x64-gnu': 4.63.2 - '@rollup/rollup-linux-x64-musl': 4.63.2 - '@rollup/rollup-openbsd-x64': 4.63.2 - '@rollup/rollup-openharmony-arm64': 4.63.2 - '@rollup/rollup-win32-arm64-msvc': 4.63.2 - '@rollup/rollup-win32-ia32-msvc': 4.63.2 - '@rollup/rollup-win32-x64-gnu': 4.63.2 - '@rollup/rollup-win32-x64-msvc': 4.63.2 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 fsevents: 2.3.3 router@2.2.0: @@ -14696,7 +14712,7 @@ snapshots: - supports-color - typescript - sharp@0.35.4(@types/node@25.9.6): + sharp@0.35.4(@types/node@25.9.7): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -14727,7 +14743,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - '@types/node': 25.9.6 + '@types/node': 25.9.7 optional: true shebang-command@2.0.0: @@ -14821,12 +14837,7 @@ snapshots: socks@2.8.10: dependencies: - ip-address: 10.7.0 - smart-buffer: 4.2.0 - - socks@2.8.9: - dependencies: - ip-address: 10.5.0 + ip-address: 10.7.2 smart-buffer: 4.2.0 sorted-array-functions@1.3.0: {} @@ -15072,7 +15083,7 @@ snapshots: tailwind-merge@2.6.1: {} - tailwind-merge@3.6.0: {} + tailwind-merge@3.7.0: {} tailwindcss@4.3.3: {} @@ -15214,14 +15225,14 @@ snapshots: trough@2.2.0: {} - trpc-to-openapi@3.3.0(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.6.2))(zod@4.6.2): + trpc-to-openapi@3.3.0(@trpc/server@11.19.0(typescript@6.0.3))(zod-openapi@5.4.6(zod@4.6.5))(zod@4.6.5): dependencies: - '@trpc/server': 11.18.0(typescript@6.0.3) + '@trpc/server': 11.19.0(typescript@6.0.3) co-body: 6.2.0 h3: 1.15.11 openapi3-ts: 4.4.0 - zod: 4.6.2 - zod-openapi: 5.4.6(zod@4.6.2) + zod: 4.6.5 + zod-openapi: 5.4.6(zod@4.6.5) optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.6.1 @@ -15279,14 +15290,14 @@ snapshots: type-fest@4.41.0: {} - type-fest@5.8.0: + type-fest@5.10.0: dependencies: tagged-tag: 1.0.0 + optional: true - type-fest@5.9.0: + type-fest@5.8.0: dependencies: tagged-tag: 1.0.0 - optional: true type-is@1.6.18: dependencies: @@ -15484,6 +15495,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.3.3(browserslist@4.29.0): + dependencies: + browserslist: 4.29.0 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -15560,26 +15577,26 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): + vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 postcss: 8.5.28 - rollup: 4.63.2 + rollup: 4.63.4 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 @@ -15587,10 +15604,10 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.6)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15607,10 +15624,10 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.0(@types/node@25.9.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.6 + '@types/node': 25.9.7 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: @@ -15648,7 +15665,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.9 + browserslist: 4.29.0 chrome-trace-event: 1.0.4 enhanced-resolve: 5.25.1 es-module-lexer: 2.3.2 @@ -15779,7 +15796,7 @@ snapshots: tslib: 2.8.1 worker-factory: 7.0.50 - worker-timers@8.0.33: + worker-timers@8.0.34: dependencies: '@babel/runtime': 7.29.7 tslib: 2.8.1 @@ -15903,25 +15920,25 @@ snapshots: yoctocolors@2.2.0: {} - zod-openapi@5.4.6(zod@4.6.2): + zod-openapi@5.4.6(zod@4.6.5): dependencies: - zod: 4.6.2 + zod: 4.6.5 zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.2(zod@4.6.2): + zod-to-json-schema@3.25.2(zod@4.6.5): dependencies: - zod: 4.6.2 + zod: 4.6.5 - zod-validation-error@4.0.2(zod@4.6.2): + zod-validation-error@4.0.2(zod@4.6.5): dependencies: - zod: 4.6.2 + zod: 4.6.5 zod@3.25.76: {} - zod@4.6.2: {} + zod@4.6.5: {} zustand@4.5.7(@types/react@19.3.0)(immer@11.1.15)(react@19.3.0): dependencies: From d07adbbb114f3779c00b43ce42c992ba0145c12b Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Fri, 25 Sep 2026 11:43:33 -0700 Subject: [PATCH 008/119] fix(homekit): preserve pairings on shutdown and remove redundant power switches (#718) Normal HomeKit shutdown called hap-nodejs `Accessory.destroy()`, which deletes pairing keys and accessory identifiers despite their persistent storage location. Use `unpublish()` for ordinary shutdown. Explicit unpair/reset still clears HAP storage and rotates identity. If advertisement cleanup fails after HAP closes its server, report the bridge as stopped and allow a fresh listener to start while retired advertiser cleanup remains best-effort. Remove the separate left/right bed power switches. Each existing thermostat already supports Off/Auto and target temperature; its UUID and remaining accessory IDs stay unchanged. Automations that specifically referenced a removed power switch will need to use the corresponding thermostat. Validation: - CI: 3,859 tests passed, 1 skipped; lint, TypeScript, and both builds passed. Local pre-push checks also validated both Drizzle schemas. - Codecov confirms all modified, coverable lines are tested. Bridge and storage have 100% line coverage; bridge branch coverage is 98.52%. - Real hap-nodejs filesystem regression tests prove shutdown preserves keys and identifiers while explicit reset removes them. Reintroducing `destroy()` makes the regression fail. - Real HAP publish/stop/fresh-publish tests open loopback TCP listeners and preserve signing keys, controller pairings, and all AIDs/IIDs. Tests cover both server-destruction failure and advertiser rejection after the listener closes, including recovery while retired cleanup still rejects. Only mDNS and physical sensor builders are stubbed. - Deployed production commit `0b53800` to pod `192.168.1.88`; the final commit adds tests only. Upgrade and HomeKit disable/enable preserved all HAP files byte-for-byte, with two paired controllers and a reconnected Home controller. Initial validation also confirmed thermostats retain AIDs 2/6, all 128 retained identifier-cache entries are unchanged, and the running bridge exposes 10 accessories with no bed power switches. - Addressed CodeRabbit's partial-teardown finding and published-lifecycle test suggestion. CodeRabbit acknowledged the fix and marked its thread resolved; the full follow-up review was rate-limited. Deployment note: the old process still contains the destructive shutdown code. For the first upgrade, back up HomeKit storage before stopping the old process and restore it before starting the fixed build. This was done on .88; subsequent upgrades and restart/toggle verification required no restoration. The test deployment used the pod's existing ARM native/runtime dependencies to supplement the standalone bundle. --- src/homekit/accessories/powerSwitch.ts | 53 ----- src/homekit/accessories/sideController.ts | 2 +- src/homekit/bridge.ts | 80 +++++--- src/homekit/index.ts | 2 +- src/homekit/storage.ts | 5 +- src/homekit/tests/bridge.test.ts | 98 +++++---- src/homekit/tests/index.test.ts | 26 +++ src/homekit/tests/persistence.test.ts | 135 ++++++++++++ src/homekit/tests/powerSwitch.test.ts | 111 ---------- .../tests/publishedPersistence.test.ts | 193 ++++++++++++++++++ src/homekit/tests/storage.test.ts | 11 +- 11 files changed, 463 insertions(+), 253 deletions(-) delete mode 100644 src/homekit/accessories/powerSwitch.ts create mode 100644 src/homekit/tests/persistence.test.ts delete mode 100644 src/homekit/tests/powerSwitch.test.ts create mode 100644 src/homekit/tests/publishedPersistence.test.ts diff --git a/src/homekit/accessories/powerSwitch.ts b/src/homekit/accessories/powerSwitch.ts deleted file mode 100644 index e6566f4c5..000000000 --- a/src/homekit/accessories/powerSwitch.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * HomeKit Switch that mirrors and controls a Pod side's power state. - * - * Sits alongside the Thermostat for the same side. The Thermostat's mode - * already toggles power, but a dedicated Switch gives users a tappable tile - * (and an automation/scene primitive) that doesn't require entering the - * thermostat card. Both surfaces resolve through sideController so the - * writes are serialized and share the in-process target cache. - */ - -import { Service, Characteristic } from 'hap-nodejs' -import type { DacMonitor } from '@/src/hardware/dacMonitor' -import type { DeviceStatus, Side } from '@/src/hardware/types' -import { - isEffectivelyPowered, - isPoweredFromStatus, - reconcileIntendedPower, - setSidePowerOff, - setSidePowerOn, -} from './sideController' - -export interface PowerSwitchAccessory { - service: Service - stop: () => void -} - -export function buildPowerSwitch(side: Side, monitor: DacMonitor): PowerSwitchAccessory { - const service = new Service.Switch(`Bed ${side} power`, `power-${side}`) - - service.getCharacteristic(Characteristic.On) - .onGet(() => isEffectivelyPowered(monitor, side)) - .onSet(async (value) => { - if (value) await setSidePowerOn(monitor, side) - else await setSidePowerOff(monitor, side) - }) - - const onStatus = (status: DeviceStatus): void => { - // Drop the intent latch once firmware confirms it, so later external - // power changes (scheduler, auto-off) are reflected instead of shadowed. - // Lives here rather than in the thermostat: both accessories share the - // sideController latch and are always built together in bridge.ts. - reconcileIntendedPower(status, side) - service.updateCharacteristic(Characteristic.On, isPoweredFromStatus(status, side)) - } - monitor.on('status:updated', onStatus) - - return { - service, - stop: () => { - monitor.off('status:updated', onStatus) - }, - } -} diff --git a/src/homekit/accessories/sideController.ts b/src/homekit/accessories/sideController.ts index 6b9242630..4fb558e06 100644 --- a/src/homekit/accessories/sideController.ts +++ b/src/homekit/accessories/sideController.ts @@ -1,7 +1,7 @@ /** * Per-side hardware-write coordinator for HomeKit accessories. * - * The Thermostat and PowerSwitch surfaces both write to the same pod side. + * Thermostat power and temperature characteristics write to the same pod side. * iOS can dispatch parallel onSet callbacks (batched writes from scenes, * automations, or near-simultaneous user gestures), so any non-serialized * caller can race another and stomp the user's setpoint: diff --git a/src/homekit/bridge.ts b/src/homekit/bridge.ts index be841836d..0e1ac6b92 100644 --- a/src/homekit/bridge.ts +++ b/src/homekit/bridge.ts @@ -2,7 +2,7 @@ * HAP Bridge orchestrator. * * Builds a single Bridge accessory (one HomeKit device) that owns - * Thermostat x2, PowerSwitch x2, OccupancySensor x2, prime Switch, snooze Switch x2. + * Thermostat x2, OccupancySensor x2, prime Switch, snooze Switch x2. * * State updates are driven by the existing DacMonitor event bus. * Pairing data is persisted under /persistent/sleepypod-data/homekit/. @@ -13,6 +13,7 @@ */ import { existsSync } from 'node:fs' +import { EventEmitter } from 'node:events' import { Accessory, Bridge, @@ -33,7 +34,6 @@ import type { DacMonitor } from '@/src/hardware/dacMonitor' import { buildAmbientSensor } from './accessories/ambientSensor' import { buildPumpHealthSensor } from './accessories/pumpHealthSensor' import { buildOccupancySensor } from './accessories/occupancySensor' -import { buildPowerSwitch } from './accessories/powerSwitch' import { buildPrimeSwitch } from './accessories/primeSwitch' import { buildSnoozeSwitch } from './accessories/snoozeSwitch' import { buildThermostatService } from './accessories/thermostat' @@ -60,6 +60,7 @@ const KEYS = { identity: '__sp_homekit_identity__', setupURI: '__sp_homekit_setupURI__', transitioning: '__sp_homekit_transitioning__', + retiredAdvertisers: '__sp_homekit_retiredAdvertisers__', } as const const getBridge = (): Bridge | null => (G[KEYS.bridge] as Bridge | null) ?? null @@ -103,8 +104,28 @@ export interface BridgeStatus { pairedControllers: string[] } +// Failed mDNS cleanup must not strand an already-closed HAP listener. Retain +// only the advertiser for best-effort cleanup on later lifecycle transitions. +type AdvertiserHandle = NonNullable +function retiredAdvertisers(): Set { + return (G[KEYS.retiredAdvertisers] ??= new Set()) as Set +} + +async function retryAdvertiserCleanup(): Promise { + for (const advertiser of retiredAdvertisers()) { + try { + await advertiser.destroy() + retiredAdvertisers().delete(advertiser) + } + catch (e) { + console.warn('[homekit] retired advertiser cleanup failed:', e instanceof Error ? e.message : e) + } + } +} + export async function startBridge(monitor: DacMonitor): Promise { if (getBridge()) return + await retryAdvertiserCleanup() initHapStorage() let identity = loadOrCreateIdentity() @@ -178,12 +199,6 @@ export async function startBridge(monitor: DacMonitor): Promise { snoozeAcc.addService(snooze.service) accessory.addBridgedAccessory(snoozeAcc) localStoppers.push(snooze.stop) - - const power = buildPowerSwitch(side, monitor) - const powerAcc = wrapAccessory(`Bed ${side} power`, `power-${side}`, identity.username) - powerAcc.addService(power.service) - accessory.addBridgedAccessory(powerAcc) - localStoppers.push(power.stop) } const prime = buildPrimeSwitch() @@ -283,6 +298,7 @@ export async function startBridge(monitor: DacMonitor): Promise { } export async function stopBridge(): Promise { + await retryAdvertiserCleanup() for (const stop of getStoppers()) { try { stop() @@ -295,28 +311,33 @@ export async function stopBridge(): Promise { const b = getBridge() if (b) { - // Split unpublish/destroy: if unpublish throws but destroy still runs to - // completion the bridge is safely torn down. Clearing the singleton when - // destroy did NOT complete masks a still-live bridge and causes - // port-conflict / restart confusion on the next enable(). + // destroy() is a factory reset in hap-nodejs: it deletes AccessoryInfo, + // IdentifierCache, and controller storage. Ordinary shutdown must only + // unpublish so paired controllers and accessory IDs survive restarts. try { await b.unpublish() } catch (e) { - console.warn('[homekit] unpublish failed:', e instanceof Error ? e.message : e) - } - let destroyed = false - try { - await b.destroy() - destroyed = true - } - catch (e) { - console.warn('[homekit] destroy failed:', e instanceof Error ? e.message : e) - } - if (destroyed) { - setBridge(null) - setSetupURI(null) + // hap-nodejs 1.2.0 clears _server before awaiting advertiser.destroy(). + // If only mDNS failed, the HAP server is already stopped: release the + // singleton so enable can publish again, while preserving pairing data. + // A failure before server destruction keeps the live bridge in place. + if (!b._server) { + const advertiser = b._advertiser + if (advertiser) { + // A retired advertiser must not rename/save the old accessory after + // a later reset. All HAP advertisers inherit from EventEmitter. + if (advertiser instanceof EventEmitter) advertiser.removeAllListeners('updated-name') + retiredAdvertisers().add(advertiser) + b._advertiser = undefined + } + setBridge(null) + setSetupURI(null) + } + throw e } + setBridge(null) + setSetupURI(null) } else { setSetupURI(null) @@ -349,15 +370,8 @@ export async function unpairAll(): Promise { // Homebridge rotates the MAC for the same reason (homebridge-config-ui-x // resetHomebridgeAccessory). const oldUsername = getIdentity()?.username ?? loadOrCreateIdentity().username + // A failed unpublish throws before any pairing data or identity changes. await stopBridge() - // stopBridge intentionally keeps the singleton live when destroy() fails - // (port-safety on next enable). Rotating identity in that state would - // desync getStatus (new MAC) from the live HAP server (still old MAC). - // Abort instead β€” operator can retry once the underlying destroy issue - // clears. - if (getBridge() !== null) { - throw new Error('homekit unpair aborted: bridge teardown incomplete (destroy() failed)') - } clearPairings(oldUsername) const id = regenerateIdentity() setIdentity(id) diff --git a/src/homekit/index.ts b/src/homekit/index.ts index add4c9089..f27ccbeef 100644 --- a/src/homekit/index.ts +++ b/src/homekit/index.ts @@ -57,7 +57,7 @@ export async function startHomeKitIfEnabled(): Promise { export function enable(): Promise { return serialize(() => withTransition(async () => { - if (isStarted()) return + if (isStarted() && getStatus().running) return const monitor = await getDacMonitor() await startBridge(monitor) setStarted(true) diff --git a/src/homekit/storage.ts b/src/homekit/storage.ts index 7ca3c242d..0476bb559 100644 --- a/src/homekit/storage.ts +++ b/src/homekit/storage.ts @@ -315,12 +315,13 @@ export function readPairedControllers(username: string): string[] { * with the same accessory identity but no controller pairings. * * File naming matches hap-nodejs: AccessoryInfo..json and - * IdentifierCache..json with colons stripped, uppercase. + * IdentifierCache..json / ControllerStorage..json, + * with colons stripped, uppercase. */ export function clearPairings(username: string): void { const dir = getStorageDir() const key = username.replace(/:/g, '').toUpperCase() - for (const name of [`AccessoryInfo.${key}.json`, `IdentifierCache.${key}.json`]) { + for (const name of [`AccessoryInfo.${key}.json`, `IdentifierCache.${key}.json`, `ControllerStorage.${key}.json`]) { rmSync(join(dir, name), { force: true }) } } diff --git a/src/homekit/tests/bridge.test.ts b/src/homekit/tests/bridge.test.ts index e681ad605..8aafd70a5 100644 --- a/src/homekit/tests/bridge.test.ts +++ b/src/homekit/tests/bridge.test.ts @@ -30,6 +30,7 @@ const m = vi.hoisted(() => { } const fakeAccessory = (name: string) => ({ name, + _server: {}, addService: vi.fn(), getService: vi.fn().mockReturnValue(infoService), addBridgedAccessory: vi.fn(), @@ -53,13 +54,11 @@ const m = vi.hoisted(() => { thermostatStop: vi.fn(), occupancyStop: vi.fn(), snoozeStop: vi.fn(), - powerStop: vi.fn(), primeStop: vi.fn(), ambientStop: vi.fn(), buildThermostatService: vi.fn(), buildOccupancySensor: vi.fn(), buildSnoozeSwitch: vi.fn(), - buildPowerSwitch: vi.fn(), buildPrimeSwitch: vi.fn(), buildAmbientSensor: vi.fn(), pumpStop: vi.fn(), @@ -92,9 +91,6 @@ vi.mock('../accessories/occupancySensor', () => ({ vi.mock('../accessories/snoozeSwitch', () => ({ buildSnoozeSwitch: m.buildSnoozeSwitch, })) -vi.mock('../accessories/powerSwitch', () => ({ - buildPowerSwitch: m.buildPowerSwitch, -})) vi.mock('../accessories/primeSwitch', () => ({ buildPrimeSwitch: m.buildPrimeSwitch, })) @@ -136,7 +132,6 @@ describe('homekit bridge', () => { m.thermostatStop.mockClear() m.occupancyStop.mockClear() m.snoozeStop.mockClear() - m.powerStop.mockClear() m.primeStop.mockClear() m.ambientStop.mockClear() m.pumpStop.mockClear() @@ -144,7 +139,6 @@ describe('homekit bridge', () => { m.buildThermostatService.mockImplementation(() => ({ service: fakeService, stop: m.thermostatStop })) m.buildOccupancySensor.mockImplementation(() => ({ service: fakeService, stop: m.occupancyStop })) m.buildSnoozeSwitch.mockImplementation(() => ({ service: fakeService, stop: m.snoozeStop })) - m.buildPowerSwitch.mockImplementation(() => ({ service: fakeService, stop: m.powerStop })) m.buildPrimeSwitch.mockImplementation(() => ({ service: fakeService, stop: m.primeStop })) m.buildAmbientSensor.mockImplementation(() => ({ service: fakeService, stop: m.ambientStop })) m.buildPumpHealthSensor.mockImplementation(() => ({ service: fakeService, stop: m.pumpStop })) @@ -174,18 +168,16 @@ describe('homekit bridge', () => { } }) - it('startBridge wires Thermostat + Occupancy + Snooze + Power per side, plus Prime and Ambient', { timeout: 30_000 }, async () => { + it('startBridge wires Thermostat + Occupancy + Snooze per side, plus Prime and Ambient', { timeout: 30_000 }, async () => { const { startBridge } = await import('../bridge') await startBridge(fakeMonitor) - // 2 sides Γ— 4 per-side accessories + 1 prime + 1 ambient + 2 pump = 12 bridged accessories - expect(m.bridgeInstance?.addBridgedAccessory).toHaveBeenCalledTimes(12) + // 2 sides Γ— 3 per-side accessories + 1 prime + 1 ambient + 2 pump = 10 bridged accessories + expect(m.bridgeInstance?.addBridgedAccessory).toHaveBeenCalledTimes(10) // Builders called with the expected sides. expect(m.buildThermostatService).toHaveBeenCalledWith('left', fakeMonitor) expect(m.buildThermostatService).toHaveBeenCalledWith('right', fakeMonitor) - expect(m.buildPowerSwitch).toHaveBeenCalledWith('left', fakeMonitor) - expect(m.buildPowerSwitch).toHaveBeenCalledWith('right', fakeMonitor) expect(m.buildOccupancySensor).toHaveBeenCalledWith('left') expect(m.buildOccupancySensor).toHaveBeenCalledWith('right') expect(m.buildSnoozeSwitch).toHaveBeenCalledWith('left') @@ -288,11 +280,9 @@ describe('homekit bridge', () => { ['Bed left', 'bed-left'], ['Bed left occupancy', 'occupancy-left'], ['Snooze left', 'snooze-left'], - ['Bed left power', 'power-left'], ['Bed right', 'bed-right'], ['Bed right occupancy', 'occupancy-right'], ['Snooze right', 'snooze-right'], - ['Bed right power', 'power-right'], ['Prime pod', 'prime'], ['Pod ambient', 'ambient'], ['Pod pump left', 'pump-left'], @@ -317,6 +307,7 @@ describe('homekit bridge', () => { m.BridgeCtor.mockImplementationOnce(function BridgeFail(this: unknown, name: string) { const inst = { name, + _server: {}, addService: vi.fn(), getService: vi.fn().mockReturnValue({ setCharacteristic: vi.fn().mockReturnThis() }), addBridgedAccessory: vi.fn(), @@ -333,7 +324,6 @@ describe('homekit bridge', () => { expect(m.thermostatStop).toHaveBeenCalledTimes(2) expect(m.occupancyStop).toHaveBeenCalledTimes(2) expect(m.snoozeStop).toHaveBeenCalledTimes(2) - expect(m.powerStop).toHaveBeenCalledTimes(2) expect(m.primeStop).toHaveBeenCalledTimes(1) expect(m.ambientStop).toHaveBeenCalledTimes(1) }) @@ -345,19 +335,18 @@ describe('homekit bridge', () => { await stopBridge() expect(m.thermostatStop).toHaveBeenCalledTimes(2) - expect(m.powerStop).toHaveBeenCalledTimes(2) expect(m.primeStop).toHaveBeenCalledTimes(1) expect(m.ambientStop).toHaveBeenCalledTimes(1) expect(getStatus().running).toBe(false) }) - it('stopBridge keeps the singleton live when destroy() throws', async () => { + it('stopBridge keeps the singleton live when unpublish() throws', async () => { const { startBridge, stopBridge, getStatus } = await import('../bridge') await startBridge(fakeMonitor) if (m.bridgeInstance) { - m.bridgeInstance.destroy = vi.fn().mockRejectedValue(new Error('destroy failed')) + m.bridgeInstance.unpublish = vi.fn().mockRejectedValueOnce(new Error('unpublish failed')) } - await stopBridge() + await expect(stopBridge()).rejects.toThrow('unpublish failed') expect(getStatus().running).toBe(true) }) @@ -373,14 +362,13 @@ describe('homekit bridge', () => { expect(m.clearPairings).toHaveBeenCalledWith('AA:BB:CC:DD:EE:FF') expect(m.regenerateIdentity).toHaveBeenCalledTimes(1) expect(getStatus().username).toBe('NN:NN:NN:NN:NN:NN') - // The load-bearing order is destroy() β†’ clearPairings. hap-nodejs writes - // to AccessoryInfo during shutdown; deleting the file mid-teardown - // would race that write. clearPairings/regenerateIdentity order is + // Finish unpublishing before deleting state so the old HAP server + // cannot accept pairing requests after its persisted state is cleared. clearPairings/regenerateIdentity order is // file-safe (oldUsername is captured upfront), so we don't pin it. if (!m.bridgeInstance) throw new Error('bridge instance missing') - const destroyCall = m.bridgeInstance.destroy.mock.invocationCallOrder[0] + const unpublishCall = m.bridgeInstance.unpublish.mock.invocationCallOrder[0] const clearCall = m.clearPairings.mock.invocationCallOrder[0] - expect(destroyCall).toBeLessThan(clearCall) + expect(unpublishCall).toBeLessThan(clearCall) }) it('unpairAll without a prior startBridge falls back to loadOrCreateIdentity for the username', async () => { @@ -402,12 +390,12 @@ describe('homekit bridge', () => { const { startBridge, unpairAll, getStatus } = await import('../bridge') await startBridge(fakeMonitor) if (m.bridgeInstance) { - m.bridgeInstance.destroy = vi.fn().mockRejectedValue(new Error('destroy failed')) + m.bridgeInstance.unpublish = vi.fn().mockRejectedValueOnce(new Error('unpublish failed')) } // Identity must stay on the old MAC β€” rotating while a live bridge // still answers on the old MAC would desync getStatus() from the // running HAP server. - await expect(unpairAll()).rejects.toThrow(/bridge teardown incomplete/) + await expect(unpairAll()).rejects.toThrow('unpublish failed') expect(m.clearPairings).not.toHaveBeenCalled() expect(m.regenerateIdentity).not.toHaveBeenCalled() expect(getStatus().username).toBe('AA:BB:CC:DD:EE:FF') @@ -431,21 +419,42 @@ describe('homekit bridge', () => { warnSpy.mockRestore() }) - it('stopBridge warns when unpublish() throws but destroy() still completes', async () => { + it('continues teardown when an accessory stopper throws a non-Error value', async () => { const { startBridge, stopBridge, getStatus } = await import('../bridge') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + m.thermostatStop.mockImplementationOnce(() => { + throw 'stopper failure' + }) await startBridge(fakeMonitor) - if (m.bridgeInstance) { - m.bridgeInstance.unpublish = vi.fn().mockRejectedValue(new Error('unpub boom')) - } - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await stopBridge() - expect(warnSpy).toHaveBeenCalledWith( - '[homekit] unpublish failed:', - expect.stringMatching(/unpub boom/), - ) + + expect(warn).toHaveBeenCalledWith('[homekit] stopper failed:', 'stopper failure') + expect(m.ambientStop).toHaveBeenCalledOnce() expect(getStatus().running).toBe(false) - warnSpy.mockRestore() + }) + + it('retries cleanup for an advertiser without EventEmitter helpers', async () => { + const { startBridge, stopBridge, getStatus } = await import('../bridge') + await startBridge(fakeMonitor) + if (!m.bridgeInstance) throw new Error('bridge instance missing') + const advertiser = { destroy: vi.fn().mockResolvedValue(undefined) } + Object.assign(m.bridgeInstance, { _server: undefined, _advertiser: advertiser }) + m.bridgeInstance.unpublish.mockRejectedValueOnce(new Error('cleanup interrupted')) + + await expect(stopBridge()).rejects.toThrow('cleanup interrupted') + expect(getStatus().running).toBe(false) + await startBridge(fakeMonitor) + expect(advertiser.destroy).toHaveBeenCalledOnce() + expect(getStatus().running).toBe(true) + }) + + it('stopBridge never destroys persisted accessory data', async () => { + const { startBridge, stopBridge } = await import('../bridge') + await startBridge(fakeMonitor) + await stopBridge() + expect(m.bridgeInstance?.unpublish).toHaveBeenCalledOnce() + expect(m.bridgeInstance?.destroy).not.toHaveBeenCalled() }) it('stopBridge clears setupURI even when no bridge was ever published', async () => { @@ -664,11 +673,11 @@ describe('homekit bridge', () => { const { startBridge, regenerate, getStatus } = await import('../bridge') await startBridge(fakeMonitor) if (m.bridgeInstance) { - m.bridgeInstance.destroy = vi.fn().mockRejectedValue(new Error('destroy failed')) + m.bridgeInstance.unpublish = vi.fn().mockRejectedValueOnce(new Error('unpublish failed')) } // Same invariant as unpairAll: rotating identity while a live bridge // still answers on the old MAC desyncs getStatus from the HAP server. - await expect(regenerate()).rejects.toThrow(/bridge teardown incomplete/) + await expect(regenerate()).rejects.toThrow('unpublish failed') expect(m.clearPairings).not.toHaveBeenCalled() expect(m.regenerateIdentity).not.toHaveBeenCalled() expect(getStatus().username).toBe('AA:BB:CC:DD:EE:FF') @@ -849,19 +858,6 @@ describe('homekit bridge', () => { expect(m.readPairedControllers).not.toHaveBeenCalled() }) - it('logs an exact destroy warning and keeps bridge state on teardown failure', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { startBridge, stopBridge, getStatus } = await import('../bridge') - await startBridge(fakeMonitor) - if (!m.bridgeInstance) throw new Error('bridge instance missing') - m.bridgeInstance.destroy = vi.fn().mockRejectedValue(new Error('destroy boom')) - - await stopBridge() - - expect(warn).toHaveBeenCalledWith('[homekit] destroy failed:', 'destroy boom') - expect(getStatus().running).toBe(true) - }) - it('honors HOMEKIT_ADVERTISER=avahi', async () => { const previous = process.env.HOMEKIT_ADVERTISER process.env.HOMEKIT_ADVERTISER = 'avahi' diff --git a/src/homekit/tests/index.test.ts b/src/homekit/tests/index.test.ts index b022dbed8..e56a9afce 100644 --- a/src/homekit/tests/index.test.ts +++ b/src/homekit/tests/index.test.ts @@ -150,6 +150,32 @@ describe('homekit lifecycle', () => { expect(m.stopBridge).toHaveBeenCalledTimes(1) }) + it('keeps a failed shutdown retryable without starting another bridge', async () => { + const mod = await import('../index') + await mod.enable() + m.stopBridge.mockRejectedValueOnce(new Error('unpublish failed')) + await expect(mod.disable()).rejects.toThrow('unpublish failed') + await mod.enable() + expect(m.startBridge).toHaveBeenCalledTimes(1) + await mod.disable() + expect(m.stopBridge).toHaveBeenCalledTimes(2) + }) + + it('re-enables after partial teardown closes the server but rejects mDNS cleanup', async () => { + const mod = await import('../index') + await mod.enable() + m.stopBridge.mockImplementationOnce(async () => { + m.status.running = false + throw new Error('advertiser unavailable') + }) + + await expect(mod.disable()).rejects.toThrow('advertiser unavailable') + expect(mod.status().running).toBe(false) + await mod.enable() + expect(m.startBridge).toHaveBeenCalledTimes(2) + expect(mod.status().running).toBe(true) + }) + it('shutdownHomeKit is a thin wrapper for disable()', async () => { const mod = await import('../index') await mod.enable() diff --git a/src/homekit/tests/persistence.test.ts b/src/homekit/tests/persistence.test.ts new file mode 100644 index 000000000..9dde498be --- /dev/null +++ b/src/homekit/tests/persistence.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment node +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Bridge, HAPStorage, uuid } from 'hap-nodejs' +import { AccessoryInfo } from 'hap-nodejs/dist/lib/model/AccessoryInfo' +import { IdentifierCache } from 'hap-nodejs/dist/lib/model/IdentifierCache' + +// No hardware, databases, or network listeners are needed to exercise teardown. +// Keep HAP, its filesystem storage, and our bridge lifecycle completely real. +vi.mock('../accessories/thermostat', () => ({})) +vi.mock('../accessories/occupancySensor', () => ({})) +vi.mock('../accessories/snoozeSwitch', () => ({})) +vi.mock('../accessories/primeSwitch', () => ({})) +vi.mock('../accessories/ambientSensor', () => ({})) +vi.mock('../accessories/pumpHealthSensor', () => ({})) + +import { getStatus, stopBridge, unpairAll } from '../bridge' +import { loadOrCreateIdentity, readPairedControllers } from '../storage' + +const g = globalThis as Record +const dir = mkdtempSync(join(tmpdir(), 'sleepypod-hap-persistence-')) +const controller = 'synthetic-controller' +let mountedBridge: Bridge + +function mountBridge(info: AccessoryInfo, cache: IdentifierCache): void { + const bridge = new Bridge('Persistence test', uuid.generate(`sleepypod:${info.username}`)) + // Same real HAP models that publish() attaches, without opening LAN ports. + Object.assign(bridge, { _accessoryInfo: info, _identifierCache: cache }) + mountedBridge = bridge + g.__sp_homekit_bridge__ = bridge + g.__sp_homekit_stoppers__ = [] +} + +function snapshot(): Record { + return Object.fromEntries(readdirSync(dir).map(name => [name, readFileSync(join(dir, name), 'utf8')])) +} + +beforeAll(() => { + HAPStorage.setCustomStoragePath(dir) + g.__sp_homekit_cachedDir__ = dir +}) + +beforeEach(() => { + for (const name of readdirSync(dir)) rmSync(join(dir, name)) + const identity = { ...loadOrCreateIdentity(), wasPaired: true } + writeFileSync(join(dir, 'identity.json'), JSON.stringify(identity)) + g.__sp_homekit_identity__ = identity + const info = AccessoryInfo.create(identity.username) + info.addPairedClient(controller, Buffer.alloc(32, 1), 1) + info.save() + const cache = new IdentifierCache(identity.username) + cache.getAID('bed-left') + cache.getIID('bed-left', 'thermostat', 'left', 'target-temperature') + cache.save() + writeFileSync(join(dir, `ControllerStorage.${identity.username.replace(/:/g, '')}.json`), '{}') + mountBridge(info, cache) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await stopBridge() +}) + +afterAll(() => { + for (const key of Object.keys(g)) { + if (key.startsWith('__sp_homekit_')) Reflect.deleteProperty(g, key) + } + rmSync(dir, { recursive: true, force: true }) +}) + +describe('HomeKit persistence with real hap-nodejs', () => { + it('preserves pairing keys and accessory IDs across repeated stop/reload cycles', async () => { + const before = snapshot() + const username = getStatus().username + assert(username) + for (let cycle = 0; cycle < 2; cycle++) { + await stopBridge() + expect(getStatus().running).toBe(false) + // Disk assertions catch destroy() deleting data even if HAP caches it. + expect(snapshot()).toEqual(before) + expect(readPairedControllers(username)).toEqual([controller]) + const info = AccessoryInfo.load(username) + const cache = IdentifierCache.load(username) + assert(info) + assert(cache) + expect(info.isPaired(controller)).toBe(true) + expect(cache.getAID('bed-left')).toBe(2) + mountBridge(info, cache) + } + await stopBridge() + }) + + it('preserves every file on failed shutdown and permits a non-destructive retry', async () => { + const before = snapshot() + vi.spyOn(mountedBridge, 'unpublish').mockRejectedValueOnce(new Error('advertiser unavailable')) + + await expect(stopBridge()).rejects.toThrow('advertiser unavailable') + expect(getStatus().running).toBe(false) + expect(snapshot()).toEqual(before) + expect(getStatus().pairedControllers).toEqual([controller]) + + await stopBridge() + expect(getStatus().running).toBe(false) + expect(snapshot()).toEqual(before) + }) + + it('does not erase pairings or rotate identity when reset teardown fails', async () => { + const before = snapshot() + const username = getStatus().username + vi.spyOn(mountedBridge, 'unpublish').mockRejectedValueOnce(new Error('advertiser unavailable')) + + await expect(unpairAll()).rejects.toThrow('advertiser unavailable') + expect(getStatus().running).toBe(false) + expect(getStatus().username).toBe(username) + expect(snapshot()).toEqual(before) + + await unpairAll() + expect(getStatus().running).toBe(false) + expect(getStatus().username).not.toBe(username) + expect(readdirSync(dir)).toEqual(['identity.json']) + }) + + it('still clears old HAP state and rotates identity on explicit unpair', async () => { + const username = getStatus().username + assert(username) + await unpairAll() + expect(getStatus().running).toBe(false) + expect(getStatus().username).not.toBe(username) + expect(readPairedControllers(username)).toEqual([]) + expect(readdirSync(dir)).toEqual(['identity.json']) + }) +}) diff --git a/src/homekit/tests/powerSwitch.test.ts b/src/homekit/tests/powerSwitch.test.ts deleted file mode 100644 index 8df853b1b..000000000 --- a/src/homekit/tests/powerSwitch.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Characteristic } from 'hap-nodejs' - -const setPower = vi.fn().mockResolvedValue(undefined) -const registerManualOverride = vi.fn() - -vi.mock('@/src/hardware/dacMonitor.instance', () => ({ - getSharedHardwareClient: () => ({ setPower }), -})) -vi.mock('@/src/automation', () => ({ - getAutomationEngineIfRunning: () => ({ registerManualOverride }), -})) -vi.mock('@/src/hardware/pumpStallGuard', () => ({ - shouldBlock: () => false, -})) - -import { buildPowerSwitch } from '../accessories/powerSwitch' -import { __resetSideController } from '../accessories/sideController' -import type { DacMonitor } from '@/src/hardware/dacMonitor' -import type { DeviceStatus } from '@/src/hardware/types' - -const status: DeviceStatus = { - leftSide: { currentTemperature: 75, targetTemperature: 70, currentLevel: -20, targetLevel: -45, heatingDuration: 0 }, - rightSide: { currentTemperature: 80, targetTemperature: 80, currentLevel: 0, targetLevel: 0, heatingDuration: 0 }, - waterLevel: 'ok', - isPriming: false, - podVersion: 'I00' as never, - sensorLabel: 'pod4', -} - -const fakeMonitor: Pick = { - on: vi.fn().mockReturnThis() as never, - off: vi.fn().mockReturnThis() as never, - getLastStatus: () => status, -} - -describe('powerSwitch accessory', () => { - beforeEach(() => { - __resetSideController() - setPower.mockClear() - registerManualOverride.mockClear() - }) - - it.each(['left', 'right'] as const)('uses stable metadata for the %s side', (side) => { - const { service, stop } = buildPowerSwitch(side, fakeMonitor as DacMonitor) - expect(service.displayName).toBe(`Bed ${side} power`) - expect(service.subtype).toBe(`power-${side}`) - stop() - }) - - it('On.onGet reflects targetLevel !== 0', async () => { - const { service: leftSvc } = buildPowerSwitch('left', fakeMonitor as DacMonitor) - expect(await leftSvc.getCharacteristic(Characteristic.On).handleGetRequest()).toBe(true) - - const { service: rightSvc } = buildPowerSwitch('right', fakeMonitor as DacMonitor) - expect(await rightSvc.getCharacteristic(Characteristic.On).handleGetRequest()).toBe(false) - }) - - it('On.onSet routes to setPower and preserves the last target on power-on', async () => { - const { service } = buildPowerSwitch('left', fakeMonitor as DacMonitor) - // handleSetRequest properly awaits onSet's full chain; setValue's await - // resolves on `this` and skips microtasks scheduled inside the chain. - await service.getCharacteristic(Characteristic.On).handleSetRequest(true) - // Left fixture targetTemperature is 70Β°F β€” must pass through so the - // hardware client doesn't fall back to its hardcoded 75Β°F default. - expect(setPower).toHaveBeenCalledWith('left', true, 70) - - setPower.mockClear() - await service.getCharacteristic(Characteristic.On).handleSetRequest(false) - expect(setPower).toHaveBeenCalledWith('left', false) - }) - - it('returns false when monitor has no status yet', async () => { - const blank: Pick = { - on: vi.fn().mockReturnThis() as never, - getLastStatus: () => null, - } - const { service } = buildPowerSwitch('left', blank as DacMonitor) - expect(await service.getCharacteristic(Characteristic.On).handleGetRequest()).toBe(false) - }) - - it('subscribed status:updated handler pushes On updates', () => { - let captured: ((s: DeviceStatus) => void) | null = null - const onSpy = vi.fn((evt: string, fn: (s: DeviceStatus) => void) => { - if (evt === 'status:updated') captured = fn - return monitor as unknown as DacMonitor - }) - const monitor: Pick = { - on: onSpy as never, - getLastStatus: () => status, - } - const { service } = buildPowerSwitch('left', monitor as DacMonitor) - expect(typeof captured).toBe('function') - const updateSpy = vi.spyOn(service, 'updateCharacteristic') - if (captured) (captured as (s: DeviceStatus) => void)(status) - expect(updateSpy).toHaveBeenCalledWith(Characteristic.On, true) - }) - - it('stop() unsubscribes the status listener', () => { - const off = vi.fn() - const onSpy = vi.fn().mockReturnThis() - const monitor = { - on: onSpy as never, - off: off as never, - getLastStatus: () => status, - } - const { stop } = buildPowerSwitch('left', monitor as unknown as DacMonitor) - stop() - expect(off).toHaveBeenCalledWith('status:updated', expect.any(Function)) - }) -}) diff --git a/src/homekit/tests/publishedPersistence.test.ts b/src/homekit/tests/publishedPersistence.test.ts new file mode 100644 index 000000000..d73c16544 --- /dev/null +++ b/src/homekit/tests/publishedPersistence.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment node +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import assert from 'node:assert/strict' +import { EventEmitter, once } from 'node:events' +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Bridge, HAPStorage, Service } from 'hap-nodejs' +import { AccessoryInfo } from 'hap-nodejs/dist/lib/model/AccessoryInfo' +import type { DacMonitor } from '@/src/hardware/dacMonitor' +import { getStatus, startBridge, stopBridge, unpairAll } from '../bridge' +import { loadOrCreateIdentity } from '../storage' + +// Replace physical sensors only; publish, HAP's TCP server, models, IDs, and +// persistence remain real. No mDNS traffic escapes the test process. +const accessory = (service: Service) => ({ service, stop: () => {} }) +vi.mock('../accessories/thermostat', () => ({ + buildThermostatService: (side: string) => accessory(new Service.Thermostat(`Bed ${side}`, side)), +})) +vi.mock('../accessories/occupancySensor', () => ({ + buildOccupancySensor: (side: string) => accessory(new Service.OccupancySensor(`Occupancy ${side}`, side)), +})) +vi.mock('../accessories/snoozeSwitch', () => ({ + buildSnoozeSwitch: (side: string) => accessory(new Service.Switch(`Snooze ${side}`, side)), +})) +vi.mock('../accessories/primeSwitch', () => ({ + buildPrimeSwitch: () => accessory(new Service.Switch('Prime')), +})) +vi.mock('../accessories/ambientSensor', () => ({ + buildAmbientSensor: () => accessory(new Service.TemperatureSensor('Ambient')), +})) +vi.mock('../accessories/pumpHealthSensor', () => ({ + buildPumpHealthSensor: (side: string) => accessory(new Service.LeakSensor(`Pump ${side}`, side)), +})) + +const hapRequire = createRequire(createRequire(import.meta.url).resolve('hap-nodejs')) +const ciao = hapRequire('@homebridge/ciao').default as { getResponder: (...args: unknown[]) => unknown } +const g = globalThis as Record +const dir = mkdtempSync(join(tmpdir(), 'sleepypod-published-hap-')) +const responders: Array<{ shutdown: ReturnType }> = [] +const ports = new WeakMap() +const monitor = {} as DacMonitor +const controller = 'synthetic-controller' +const realPublish = Bridge.prototype.publish + +function bridge(): Bridge { + const b = g.__sp_homekit_bridge__ + assert(b instanceof Bridge) + return b +} + +function snapshot(): Record { + return Object.fromEntries(readdirSync(dir).map(name => [name, readFileSync(join(dir, name), 'utf8')])) +} + +function ids(b: Bridge) { + return b.bridgedAccessories.map(a => ({ + uuid: a.UUID, + aid: a.aid, + services: a.services.map(s => ({ + uuid: s.UUID, + iid: s.iid, + characteristics: s.characteristics.map(c => ({ uuid: c.UUID, iid: c.iid })), + })), + })) +} + +async function responds(b: Bridge): Promise { + const response = await fetch(`http://127.0.0.1:${ports.get(b)}/accessories`, { + headers: { Connection: 'close' }, + }) + await response.arrayBuffer() + return response.status +} + +beforeAll(() => { + HAPStorage.setCustomStoragePath(dir) + g.__sp_homekit_cachedDir__ = dir + g.__sp_homekit_hapInit__ = true +}) + +beforeEach(() => { + for (const name of readdirSync(dir)) rmSync(join(dir, name)) + responders.length = 0 + const identity = { ...loadOrCreateIdentity(), wasPaired: true } + writeFileSync(join(dir, 'identity.json'), JSON.stringify(identity)) + const info = AccessoryInfo.create(identity.username) + info.addPairedClient(controller, Buffer.alloc(32, 1), 1) + info.save() + + vi.spyOn(ciao, 'getResponder').mockImplementation(() => { + const responder = { + createService: () => Object.assign(new EventEmitter(), { + updatePort: vi.fn(), + advertise: vi.fn().mockResolvedValue(undefined), + updateTxt: vi.fn(), + }), + shutdown: vi.fn().mockResolvedValue(undefined), + } + responders.push(responder) + return responder + }) + vi.spyOn(Bridge.prototype, 'publish').mockImplementation(async function (this: Bridge, options, insecure) { + const listening = once(this, 'listening') + await realPublish.call(this, { ...options, port: 0, bind: '127.0.0.1', advertiser: 'ciao' as never }, insecure) + const [port] = await listening + ports.set(this, port as number) + }) +}) + +afterEach(async () => { + for (const responder of responders) responder.shutdown.mockResolvedValue(undefined) + await stopBridge() + vi.restoreAllMocks() +}) + +afterAll(() => { + for (const key of Object.keys(g)) { + if (key.startsWith('__sp_homekit_')) Reflect.deleteProperty(g, key) + } + rmSync(dir, { recursive: true, force: true }) +}) + +describe('published HomeKit bridge persistence', () => { + it('restores pairing keys and all accessory IDs through stop and fresh publish', async () => { + await startBridge(monitor) + const first = bridge() + const before = snapshot() + const firstIds = ids(first) + const key = first._accessoryInfo?.signSk.toString('hex') + expect(await responds(first)).toBe(470) // Real HAP authentication-required response. + + await stopBridge() + expect(snapshot()).toEqual(before) + await startBridge(monitor) + const fresh = bridge() + expect(fresh).not.toBe(first) + expect(fresh._accessoryInfo?.signSk.toString('hex')).toBe(key) + expect(fresh._accessoryInfo?.isPaired(controller)).toBe(true) + expect(ids(fresh)).toEqual(firstIds) + expect(snapshot()).toEqual(before) + expect(await responds(fresh)).toBe(470) + }) + + it('keeps a server-destruction failure live and retryable without deleting data', async () => { + await startBridge(monitor) + const first = bridge() + const before = snapshot() + assert(first._server) + vi.spyOn(first._server, 'destroy').mockImplementationOnce(() => { + throw new Error('server teardown failed') + }) + + await expect(stopBridge()).rejects.toThrow('server teardown failed') + expect(getStatus().running).toBe(true) + expect(await responds(first)).toBe(470) + expect(snapshot()).toEqual(before) + + await stopBridge() + expect(getStatus().running).toBe(false) + expect(snapshot()).toEqual(before) + await expect(responds(first)).rejects.toThrow() + }) + + it.each([new Error('mDNS unavailable'), 'mDNS unavailable'])( + 'reopens HAP while retired advertiser cleanup rejects with %s', async (failure) => { + await startBridge(monitor) + const first = bridge() + const before = snapshot() + const firstIds = ids(first) + const advertiser = first._advertiser + assert(advertiser instanceof EventEmitter) + responders[0].shutdown.mockRejectedValue(failure) + + await expect(unpairAll()).rejects.toEqual(failure) + expect(first._server).toBeUndefined() + expect(getStatus().running).toBe(false) + expect(getStatus().setupURI).toBeNull() + expect(snapshot()).toEqual(before) + await expect(responds(first)).rejects.toThrow() + expect(advertiser.listenerCount('updated-name')).toBe(0) + + await startBridge(monitor) + expect(responders[0].shutdown).toHaveBeenCalledTimes(2) + expect(getStatus().running).toBe(true) + expect(getStatus().pairedControllers).toEqual([controller]) + expect(ids(bridge())).toEqual(firstIds) + expect(snapshot()).toEqual(before) + expect(await responds(bridge())).toBe(470) + }, + ) +}) diff --git a/src/homekit/tests/storage.test.ts b/src/homekit/tests/storage.test.ts index 0309d0e16..e8f1cc5ac 100644 --- a/src/homekit/tests/storage.test.ts +++ b/src/homekit/tests/storage.test.ts @@ -58,12 +58,14 @@ describe('homekit storage', () => { } }) - it('clearPairings removes AccessoryInfo + IdentifierCache for the username', () => { + it('clearPairings removes pairing, identifier, and controller storage for the username', () => { const dir = getStorageDir() const username = 'AA:BB:CC:11:22:33' const key = 'AABBCC112233' const accessory = join(dir, `AccessoryInfo.${key}.json`) const identifiers = join(dir, `IdentifierCache.${key}.json`) + const controllers = join(dir, `ControllerStorage.${key}.json`) + writeFileSync(controllers, '{}') writeFileSync(accessory, '{}') writeFileSync(identifiers, '{}') @@ -71,6 +73,7 @@ describe('homekit storage', () => { expect(existsSync(accessory)).toBe(false) expect(existsSync(identifiers)).toBe(false) + expect(existsSync(controllers)).toBe(false) }) it('clearPairings is a no-op when files are absent', () => { @@ -84,6 +87,12 @@ describe('homekit storage', () => { expect(existsSync(join(dir, 'identity.json'))).toBe(true) }) + it('treats an AccessoryInfo file without pairedClients as unpaired', () => { + const file = join(getStorageDir(), 'AccessoryInfo.AABBCCDDEEFF.json') + writeFileSync(file, JSON.stringify({ displayName: 'sleepypod' })) + expect(readPairedControllers('AA:BB:CC:DD:EE:FF')).toEqual([]) + }) + it('readPairedControllers returns [] when AccessoryInfo missing', () => { expect(readPairedControllers('AA:BB:CC:11:22:33')).toEqual([]) }) From 3ca513b94c2b5223b3de06e59708f1d639a63818 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Fri, 25 Sep 2026 16:38:31 -0400 Subject: [PATCH 009/119] fix(install): make SSH setup work on a stock Pod 4 (#717) --- scripts/README.md | 10 +- scripts/install | 71 +++++++---- scripts/lib/ssh-helpers | 116 ++++++++++++++++- tests/installSshPrompt.contract.test.ts | 95 ++++++++++++++ tests/ssh-helpers.test.ts | 160 ++++++++++++++++++++++++ 5 files changed, 424 insertions(+), 28 deletions(-) create mode 100644 tests/installSshPrompt.contract.test.ts diff --git a/scripts/README.md b/scripts/README.md index 6a47272c2..4f33275bd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -226,11 +226,13 @@ systemctl disable sleepypod ## SSH Access -During installation, you'll be prompted to configure SSH on port 8822 with keys-only authentication. +During installation, you'll be prompted to configure SSH on port 8822 with keys-only authentication β€” including when the installer is piped in with `curl … | sudo bash`. Runs with no terminal at all (e.g. `ssh pod 'bash install'`) skip the prompt; pass `--ssh-key "ssh-ed25519 AAAA… you@host"` to configure SSH without one. If you need to configure SSH later: 1. Edit `/etc/ssh/sshd_config` -2. Set `Port 8822` and `PermitRootLogin prohibit-password` +2. Set `Port 8822` and `PermitRootLogin prohibit-password`. If the config has + an `AllowUsers` line (stock Pod 4 ships `AllowUsers rewt`), add `root` to + it β€” otherwise sshd refuses root before it ever looks at a key 3. Add your public key to the file sshd reads for root β€” check with `sshd -T -C user=root,host=localhost,addr=127.0.0.1 | grep -i authorizedkeysfile` (the `-C` matters: a plain `sshd -T` reports the global value and misses @@ -239,7 +241,9 @@ If you need to configure SSH later: `/home/root/.ssh/authorized_keys`, **not** `/root/.ssh/authorized_keys` 4. Confirm key auth works (`ssh -p 8822 root@`) *before* setting `PasswordAuthentication no` β€” there is no other way back in -5. Restart: `systemctl restart sshd` +5. Restart: `systemctl restart sshd`. Pods whose sshd runs from + `sshd.socket` (per-connection, no `sshd.service`) need no restart β€” each + new connection reads the config fresh Connect with: ```bash diff --git a/scripts/install b/scripts/install index 9dce48d86..52b0eddcd 100755 --- a/scripts/install +++ b/scripts/install @@ -24,6 +24,8 @@ echo "" # Mode flags # --local Skip download (code already on disk, e.g. via scp/deploy) # --no-ssh Skip interactive SSH setup prompt +# --ssh-key KEY Configure SSH with this public key, no prompts. For runs +# with no terminal at all (CI, `ssh pod 'bash install'`). # --branch X Install a specific branch (default: main). For branches # outside main/dev/latest, the latest successful build # artifact is fetched via nightly.link. @@ -37,6 +39,7 @@ echo "" # rootfs vs /persistent by total partition size. INSTALL_LOCAL=false SKIP_SSH=false +SSH_KEY_ARG="" INSTALL_BRANCH="" ARTIFACT_URL_OVERRIDE="" PURGE_FREE_SLEEP=false @@ -50,6 +53,7 @@ while [ $# -gt 0 ]; do case "$1" in --local) INSTALL_LOCAL=true ;; --no-ssh) SKIP_SSH=true ;; + --ssh-key) shift; SSH_KEY_ARG="${1:-}" ;; --branch) shift; INSTALL_BRANCH="${1:-}" ;; --artifact-url) shift; ARTIFACT_URL_OVERRIDE="${1:-}" ;; --purge-free-sleep) PURGE_FREE_SLEEP=true ;; @@ -1766,20 +1770,29 @@ fi fix_db_permissions # Optional SSH configuration +# +# The documented `curl … | sudo bash` install hands bash the script on stdin, +# so stdin is never a terminal β€” the prompts read the controlling terminal +# instead. Only a run with no terminal at all skips them, and --ssh-key covers +# that case without prompting. +SETUP_SSH="" +SSH_KEY="$SSH_KEY_ARG" if [ "$SKIP_SSH" = true ]; then echo "Skipping SSH setup (--no-ssh)" -elif [ ! -t 0 ]; then - echo "Skipping SSH setup (non-interactive)" -else +elif [ -n "$SSH_KEY" ]; then + SETUP_SSH=y +elif { : /dev/null; then echo "" echo "========================================" echo " Optional SSH Configuration" echo "========================================" echo "" - read -rp "Configure SSH on port 8822? [y/N] " SETUP_SSH + read -rp "Configure SSH on port 8822? [y/N] " SETUP_SSH \" to configure it)" fi -if [ "${SKIP_SSH:-}" != true ] && [ -t 0 ] && [[ "${SETUP_SSH:-}" =~ ^[Yy]$ ]]; then +if [[ "$SETUP_SSH" =~ ^[Yy]$ ]]; then echo "Configuring SSH..." if [ ! -f "$INSTALL_DIR/scripts/lib/ssh-helpers" ]; then @@ -1810,9 +1823,11 @@ if [ "${SKIP_SSH:-}" != true ] && [ -t 0 ] && [[ "${SETUP_SSH:-}" =~ ^[Yy]$ ]]; # went to /root/.ssh and sshd (root home = /home/root) never read it. migrate_stranded_authorized_keys root - echo "" - echo "Enter your SSH public key:" - read -r SSH_KEY + if [ -z "$SSH_KEY" ]; then + echo "" + echo "Enter your SSH public key:" + read -r SSH_KEY &2 + echo "Re-run the installer with a valid public key to harden sshd." >&2 + elif ensure_sshd_allows_user root; then + SSH_HARDEN=true + else + echo "sshd's user access rules would refuse root β€” leaving password authentication as-is." >&2 + fi + + if [ "$SSH_HARDEN" = true ]; then set_sshd_directive PermitRootLogin prohibit-password set_sshd_directive PasswordAuthentication no set_sshd_directive PermitEmptyPasswords no - else - echo "No SSH key installed β€” leaving password authentication as-is." >&2 - echo "Re-run the installer with a valid public key to harden sshd." >&2 fi # Validate SSH config before restarting @@ -1866,7 +1889,7 @@ if [ "${SKIP_SSH:-}" != true ] && [ -t 0 ] && [[ "${SETUP_SSH:-}" =~ ^[Yy]$ ]]; # re-enabled password auth for exactly the account being hardened. Port # can't appear in a Match block, so it stays global. SSH_EXPECTED="port=8822" - if [ "$SSH_KEY_INSTALLED" = true ]; then + if [ "$SSH_HARDEN" = true ]; then SSH_EXPECTED="$SSH_EXPECTED root:permitrootlogin=prohibit-password" SSH_EXPECTED="$SSH_EXPECTED root:passwordauthentication=no root:permitemptypasswords=no" fi @@ -1886,16 +1909,20 @@ if [ "${SKIP_SSH:-}" != true ] && [ -t 0 ] && [[ "${SETUP_SSH:-}" =~ ^[Yy]$ ]]; fi done - if [ "$SSH_KEY_INSTALLED" = true ]; then + if [ "$SSH_HARDEN" = true ] && ! sshd_allows_user root; then + echo "Error: sshd's user access rules refuse root after the edit." >&2 + echo "Restoring backup and leaving sshd untouched..." >&2 + cp /etc/ssh/sshd_config.pre-install /etc/ssh/sshd_config + exit 1 + fi + + if [ "$SSH_HARDEN" = true ]; then SSH_RESULT="SSH configured on port 8822 (keys only)" else SSH_RESULT="SSH configured on port 8822 (existing auth methods unchanged)" fi - # Restart SSH (try both service names for compatibility) - if systemctl restart sshd 2>/dev/null; then - echo "$SSH_RESULT" - elif systemctl restart ssh 2>/dev/null; then + if restart_sshd; then echo "$SSH_RESULT" else echo "Error: Failed to restart SSH service" >&2 diff --git a/scripts/lib/ssh-helpers b/scripts/lib/ssh-helpers index 0f071ef7b..ba2ea93ba 100644 --- a/scripts/lib/ssh-helpers +++ b/scripts/lib/ssh-helpers @@ -328,7 +328,7 @@ sshd_effective_value() { value="$(sshd -T -C "user=$user,host=localhost,addr=127.0.0.1" -f "$sshd_config" 2>/dev/null | awk -v want="$lowered" 'tolower($1) == want { print $2; exit }' || true)" if [ -n "$value" ]; then - printf '%s\n' "$value" + _sshd_canonical_value "$lowered" "$value" else # Root-context evaluation is unavailable β€” an older sshd that rejects the # partial connection spec, or a config sshd won't dump. Do NOT degrade to @@ -343,7 +343,117 @@ sshd_effective_value() { return 0 fi - sshd -T -f "$sshd_config" 2>/dev/null | - awk -v want="$lowered" 'tolower($1) == want { print $2; exit }' \ + value="$(sshd -T -f "$sshd_config" 2>/dev/null | + awk -v want="$lowered" 'tolower($1) == want { print $2; exit }' || true)" + [ -z "$value" ] || _sshd_canonical_value "$lowered" "$value" +} + +# `prohibit-password` is the newer spelling of PermitRootLogin's +# `without-password`. Older `sshd -T` prints the old one β€” the pod's OpenSSH +# 8.9 does, 10.x doesn't. Without this, comparing the effective value against +# the directive we wrote fails on every pod and the installer rolls back. +_sshd_canonical_value() { + case "$1:$2" in + permitrootlogin:without-password) printf 'prohibit-password\n' ;; + *) printf '%s\n' "$2" ;; + esac +} + +# Print each entry of the list directive $1 (allowusers, denygroups, …) from +# an `sshd -T` dump in $2, one per line. +_sshd_list_entries() { + printf '%s\n' "$2" | + awk -v want="$1" 'tolower($1) == want { for (i = 2; i <= NF; i++) print $i }' \ || true } + +# AllowUsers/DenyUsers entries are USER or USER@HOST, with * and ? wildcards. +# Only the user half is matched: a host-restricted root entry is the admin's +# decision about where root may log in from, not the installer's to widen. +_sshd_user_pattern_matches() { + local user_part="${1%%@*}" + # shellcheck disable=SC2254 # the pattern is meant to glob + case "$2" in $user_part) return 0 ;; esac + return 1 +} + +# Whether sshd's user access lists let $1 log in at all. They're consulted +# before any key: a stock Pod 4 ships `AllowUsers rewt`, which refuses root no +# matter what authorized_keys holds β€” and once password auth is off, rewt +# can't log in either. +# +# Exit codes: 0 admitted; 1 refused only because AllowUsers doesn't name $1 +# (ensure_sshd_allows_user can fix that); 2 anything else β€” a DenyUsers +# match, group-based rules, or an sshd that can't evaluate $1's context. +# Callers must not harden on anything but 0. +sshd_allows_user() { + local user="$1" sshd_config="${2:-/etc/ssh/sshd_config}" + local dump pattern restricted=false + + dump="$(sshd -T -C "user=$user,host=localhost,addr=127.0.0.1" -f "$sshd_config" 2>/dev/null || true)" + if [ -z "$dump" ]; then + echo "Error: sshd cannot evaluate its access lists for '$user' β€” refusing to verify." >&2 + return 2 + fi + + while IFS= read -r pattern; do + if _sshd_user_pattern_matches "$pattern" "$user"; then + echo "Error: sshd's DenyUsers entry '$pattern' refuses $user." >&2 + return 2 + fi + done < <(_sshd_list_entries denyusers "$dump") + + # Group rules need group membership resolved the way sshd does it. Rather + # than approximate that, stop and let a human look. + if [ -n "$(_sshd_list_entries allowgroups "$dump")$(_sshd_list_entries denygroups "$dump")" ]; then + echo "Error: sshd restricts logins by group (AllowGroups/DenyGroups) β€” check that $user is admitted by hand." >&2 + return 2 + fi + + while IFS= read -r pattern; do + restricted=true + _sshd_user_pattern_matches "$pattern" "$user" && return 0 + done < <(_sshd_list_entries allowusers "$dump") + + [ "$restricted" = false ] || return 1 + return 0 +} + +# Make sshd's access lists admit $1, adding them to AllowUsers when that list +# is the only obstacle. Existing entries are kept, so rewt stays allowed. +# Anything sshd_allows_user can't fix is reported as failure and the config +# is left alone, so the caller declines to harden. +ensure_sshd_allows_user() { + local user="$1" sshd_config="${2:-/etc/ssh/sshd_config}" status=0 entries + sshd_allows_user "$user" "$sshd_config" || status=$? + case "$status" in + 0) return 0 ;; + 1) ;; + *) return 1 ;; + esac + + # Read the global list without -C, so entries a Match block grants only to + # some connections aren't promoted to everyone. set_sshd_directive collapses + # the global AllowUsers lines into one, so every entry is carried into it. + entries="$(_sshd_list_entries allowusers "$(sshd -T -f "$sshd_config" 2>/dev/null || true)")" + if [ -z "$entries" ]; then + echo "Error: AllowUsers refuses $user but isn't set globally β€” not editing it." >&2 + return 1 + fi + entries="$(printf '%s' "$entries" | tr '\n' ' ')" + + echo "Adding $user to sshd's AllowUsers (was: $entries)" + set_sshd_directive AllowUsers "$entries $user" "$sshd_config" || return 1 + sshd_allows_user "$user" "$sshd_config" || return 1 +} + +# Apply an edited sshd_config. A standalone daemon is restarted. The pod's +# Yocto image has no sshd.service or ssh.service: sshd.socket (Accept=yes) +# starts a fresh sshd per connection, which reads the config as it starts, so +# there is nothing to restart. +restart_sshd() { + systemctl restart sshd 2>/dev/null && return 0 + systemctl restart ssh 2>/dev/null && return 0 + systemctl is-active --quiet sshd.socket 2>/dev/null && + [ "$(systemctl show -p Accept sshd.socket 2>/dev/null)" = "Accept=yes" ] +} diff --git a/tests/installSshPrompt.contract.test.ts b/tests/installSshPrompt.contract.test.ts new file mode 100644 index 000000000..d6a07fa40 --- /dev/null +++ b/tests/installSshPrompt.contract.test.ts @@ -0,0 +1,95 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +// Runs the installer's real SSH-prompt block, with /dev/tty swapped for a +// file so each case controls whether a terminal exists and what it answers. +const installScript = readFileSync(resolve(process.cwd(), 'scripts/install'), 'utf8') + +function slice(from: string, to: string): string { + const start = installScript.indexOf(from) + const end = installScript.indexOf(to, start) + if (start < 0 || end < 0) throw new Error(`installer block not found: ${from}`) + return installScript.slice(start, end) +} + +const PROMPT_BLOCK = slice('# Optional SSH configuration', 'if [[ "$SETUP_SSH" =~ ^[Yy]$ ]]; then') +const ARG_LOOP = slice('while [ $# -gt 0 ]; do', 'export NODE_MODULES_DIR') + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'install-ssh-prompt-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function prompt(opts: { skip?: boolean, keyArg?: string, tty?: string | null }): string { + let tty = join(dir, 'no-terminal') + if (opts.tty != null) { + tty = join(dir, 'tty') + writeFileSync(tty, opts.tty) + } + const script = ` + set -euo pipefail + SKIP_SSH=${opts.skip ? 'true' : 'false'} + SSH_KEY_ARG=${JSON.stringify(opts.keyArg ?? '')} + ${PROMPT_BLOCK.replaceAll('/dev/tty', tty)} + echo "SETUP=$SETUP_SSH KEY=$SSH_KEY" + ` + return execFileSync('bash', ['-c', script], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +describe('installer SSH prompt', () => { + test('prompts on the terminal even when stdin is the piped script', () => { + // `curl … | sudo bash` β€” stdin is the pipe, but there is a terminal. + const output = prompt({ tty: 'y\n' }) + + expect(output).toContain('Optional SSH Configuration') + expect(output).toMatch(/SETUP=y KEY=$/) + }) + + test('skips with a pointer to --ssh-key when there is no terminal at all', () => { + const output = prompt({ tty: null }) + + expect(output).toContain('re-run with --ssh-key') + expect(output).toMatch(/SETUP= KEY=$/) + }) + + test('configures SSH from --ssh-key without prompting', () => { + const output = prompt({ keyArg: 'ssh-ed25519 AAAAKEY me@mac', tty: null }) + + expect(output).not.toContain('Optional SSH Configuration') + expect(output).toMatch(/SETUP=y KEY=ssh-ed25519 AAAAKEY me@mac$/) + }) + + test('--no-ssh wins over --ssh-key', () => { + const output = prompt({ skip: true, keyArg: 'ssh-ed25519 AAAAKEY me@mac', tty: 'y\n' }) + + expect(output).toContain('Skipping SSH setup (--no-ssh)') + expect(output).toMatch(/^SETUP= /m) + }) + + test('treats an empty answer as no', () => { + expect(prompt({ tty: '' })).toMatch(/SETUP= KEY=$/) + }) +}) + +describe('installer --ssh-key flag', () => { + test('takes the whole key, spaces included, as one argument', () => { + const script = ` + set -euo pipefail + SKIP_SSH=false; SSH_KEY_ARG=""; INSTALL_BRANCH="" + set -- --ssh-key "ssh-ed25519 AAAAKEY me@mac" --branch dev + ${ARG_LOOP} + echo "KEY=$SSH_KEY_ARG BRANCH=$INSTALL_BRANCH" + ` + const output = execFileSync('bash', ['-c', script], { encoding: 'utf8' }).trim() + + expect(output).toBe('KEY=ssh-ed25519 AAAAKEY me@mac BRANCH=dev') + }) +}) diff --git a/tests/ssh-helpers.test.ts b/tests/ssh-helpers.test.ts index 39666fa12..f972f62b8 100644 --- a/tests/ssh-helpers.test.ts +++ b/tests/ssh-helpers.test.ts @@ -522,6 +522,28 @@ describe('sshd_effective_value', () => { })).toBe('yes') }) + test('reports the pod\'s without-password spelling as prohibit-password', () => { + // OpenSSH 8.9 on the pod prints the legacy name for the value the + // installer writes. Comparing the raw string failed every install and + // rolled the hardening back. + const config = writeConfig('PermitRootLogin prohibit-password\n') + + expect(runBash(`sshd_effective_value PermitRootLogin ${JSON.stringify(config)} root`, { + sshdT: 'permitrootlogin without-password', + })).toBe('prohibit-password') + expect(runBash(`sshd_effective_value PermitRootLogin ${JSON.stringify(config)}`, { + sshdT: 'permitrootlogin without-password', + })).toBe('prohibit-password') + }) + + test('leaves other values alone', () => { + const config = writeConfig('PermitRootLogin no\n') + + expect(runBash(`sshd_effective_value PermitRootLogin ${JSON.stringify(config)} root`, { + sshdT: 'permitrootlogin no', + })).toBe('no') + }) + test('fails closed, not to the global reading, when sshd rejects -C', () => { // A global `sshd -T` can't see a `Match User root` block re-enabling the // password. Reporting its value would let the installer claim a hardened @@ -535,3 +557,141 @@ describe('sshd_effective_value', () => { })).toBe('[]') }) }) + +describe('sshd_allows_user', () => { + function status(opts: RunOptions): string { + const config = writeConfig('Port 8822\n') + return runBash( + `s=0; sshd_allows_user root ${JSON.stringify(config)} 2>/dev/null || s=$?; echo "$s"`, + opts, + ) + } + + test('admits root when no access list is set', () => { + expect(status({ sshdT: 'port 8822\npermitrootlogin without-password' })).toBe('0') + }) + + test('reports a stock Pod 4 AllowUsers rewt as fixable', () => { + expect(status({ sshdT: 'port 8822\nallowusers rewt' })).toBe('1') + }) + + test('admits root when AllowUsers names it, by name or wildcard', () => { + expect(status({ sshdT: 'allowusers rewt\nallowusers root' })).toBe('0') + expect(status({ sshdT: 'allowusers r*' })).toBe('0') + }) + + test('respects a host-restricted root entry instead of widening it', () => { + expect(status({ sshdT: 'allowusers root@192.168.*' })).toBe('0') + }) + + test('refuses outright when DenyUsers matches root', () => { + expect(status({ sshdT: 'denyusers root' })).toBe('2') + }) + + test('ignores a DenyUsers entry for someone else', () => { + expect(status({ sshdT: 'denyusers guest' })).toBe('0') + }) + + test('refuses to guess about group-based rules', () => { + expect(status({ sshdT: 'allowgroups wheel' })).toBe('2') + expect(status({ sshdT: 'denygroups nogroup' })).toBe('2') + }) + + test('fails closed when sshd cannot evaluate root\'s context', () => { + // An empty dump would otherwise read as "no restrictions". + expect(status({ sshdT: 'port 8822', sshdTC: false })).toBe('2') + }) +}) + +describe('ensure_sshd_allows_user', () => { + // An sshd that dumps AllowUsers/DenyUsers from the config file it's + // pointed at, so the recheck after the edit sees the new file. + const FILE_BACKED_SSHD = `sshd() { + local f="" + while [ $# -gt 0 ]; do [ "$1" = -f ] && f="$2"; shift; done + echo "port 8822" + awk 'tolower($1) == "allowusers" || tolower($1) == "denyusers" { $1 = tolower($1); print }' "$f" + }` + + function ensure(contents: string): { output: string, config: string } { + const path = writeConfig(contents) + const output = runBash( + `${FILE_BACKED_SSHD}; ensure_sshd_allows_user root ${JSON.stringify(path)} 2>/dev/null && echo OK || echo REFUSED`, + ) + return { output, config: readFileSync(path, 'utf8') } + } + + test('adds root to a stock AllowUsers rewt and keeps rewt', () => { + const { output, config } = ensure('Port 8822\nAllowUsers rewt\nPasswordAuthentication yes\n') + + expect(output).toContain('OK') + expect(config).toBe('Port 8822\nAllowUsers rewt root\nPasswordAuthentication yes\n') + }) + + test('carries every global AllowUsers line into the one it writes', () => { + const { output, config } = ensure('AllowUsers rewt\nAllowUsers dac\n') + + expect(output).toContain('OK') + expect(config).toBe('AllowUsers rewt dac root\n') + }) + + test('leaves the config alone when root is already admitted', () => { + const { output, config } = ensure('Port 8822\n') + + expect(output).toBe('OK') + expect(config).toBe('Port 8822\n') + }) + + test('does not override a DenyUsers root', () => { + const { output, config } = ensure('AllowUsers rewt\nDenyUsers root\n') + + expect(output).toBe('REFUSED') + expect(config).toBe('AllowUsers rewt\nDenyUsers root\n') + }) + + test('refuses when the restriction only shows up in root\'s context', () => { + // A Match block's AllowUsers isn't in the global list, so there's nothing + // safe to extend β€” hoisting it would widen access for every connection. + const path = writeConfig('Match Address 10.0.0.0/8\n AllowUsers rewt\n') + + const output = runBash( + `ensure_sshd_allows_user root ${JSON.stringify(path)} 2>/dev/null && echo OK || echo REFUSED`, + { sshdT: 'port 8822', sshdTC: 'port 8822\nallowusers rewt' }, + ) + + expect(output).toBe('REFUSED') + expect(readFileSync(path, 'utf8')).toBe('Match Address 10.0.0.0/8\n AllowUsers rewt\n') + }) +}) + +describe('restart_sshd', () => { + function restart(units: { service?: boolean, socket?: boolean, accept?: string }): string { + const stub = `systemctl() { + case "$*" in + "restart sshd"|"restart ssh") ${units.service ? 'return 0' : 'return 1'} ;; + "is-active --quiet sshd.socket") ${units.socket ? 'return 0' : 'return 3'} ;; + "show -p Accept sshd.socket") echo "Accept=${units.accept ?? 'no'}" ;; + *) return 1 ;; + esac + }` + return runBash(`${stub}; restart_sshd && echo OK || echo FAILED`) + } + + test('restarts a standalone sshd', () => { + expect(restart({ service: true })).toBe('OK') + }) + + test('needs no restart for the pod\'s per-connection sshd.socket', () => { + // The pod has no sshd.service or ssh.service; treating the failed restart + // as fatal rolled back every hardening. + expect(restart({ socket: true, accept: 'yes' })).toBe('OK') + }) + + test('fails when a socket hands off to a service that is missing', () => { + expect(restart({ socket: true, accept: 'no' })).toBe('FAILED') + }) + + test('fails when there is no sshd unit at all', () => { + expect(restart({})).toBe('FAILED') + }) +}) From f2d1ec1ebef09c9c9bc02f385e1c0a32794bb23b Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Fri, 25 Sep 2026 16:46:38 -0400 Subject: [PATCH 010/119] UI: single-sleeper view in away mode, ambient labels, alarm editor overflow, smoother temp updates (#716) --- app/[lang]/data/page.tsx | 49 +++++------ src/components/Environment/BedTempChart.tsx | 2 +- .../Environment/EnvironmentPanel.tsx | 6 +- .../MovementChart/MovementChart.tsx | 4 +- src/components/Schedule/TimeInput.tsx | 11 ++- .../Schedule/tests/TimeInput.test.tsx | 11 +++ src/components/Sensors/BedTempMatrix.tsx | 2 +- src/components/Sensors/EnvironmentCard.tsx | 8 +- src/components/Sensors/FreezerHealthCard.tsx | 2 +- src/components/Sensors/SensorsScreen.tsx | 2 +- src/components/TempScreen/LatestSleepChip.tsx | 4 +- src/components/VitalsPanel/VitalsPanel.tsx | 24 ++--- src/components/biometrics/RawDataButton.tsx | 4 +- src/components/biometrics/VitalsGrid.tsx | 4 +- .../diagnostics/DiagnosticsConsole.tsx | 4 +- src/hardware/dacMonitor.instance.ts | 7 +- .../tests/dacMonitor.instance.test.ts | 21 +++++ src/hooks/tests/useBiometricsSide.test.ts | 38 ++++++++ src/hooks/useBiometricsSide.ts | 43 +++++++++ src/lib/singleSleeper.ts | 23 +++++ src/lib/tests/singleSleeper.test.ts | 16 ++++ src/providers/SideProvider.tsx | 54 ++++++++++++ src/providers/tests/SideProvider.test.tsx | 86 ++++++++++++++++++ src/streaming/broadcastMutationStatus.ts | 3 + src/streaming/mutationOverlay.ts | 88 +++++++++++++++++++ src/streaming/tests/mutationOverlay.test.ts | 62 +++++++++++++ 26 files changed, 516 insertions(+), 62 deletions(-) create mode 100644 src/hooks/tests/useBiometricsSide.test.ts create mode 100644 src/hooks/useBiometricsSide.ts create mode 100644 src/lib/singleSleeper.ts create mode 100644 src/lib/tests/singleSleeper.test.ts create mode 100644 src/providers/tests/SideProvider.test.tsx create mode 100644 src/streaming/mutationOverlay.ts create mode 100644 src/streaming/tests/mutationOverlay.test.ts diff --git a/app/[lang]/data/page.tsx b/app/[lang]/data/page.tsx index 5f692c011..1657c5ebd 100644 --- a/app/[lang]/data/page.tsx +++ b/app/[lang]/data/page.tsx @@ -2,7 +2,7 @@ import { useRef, useCallback } from 'react' import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react' -import { useSide } from '@/src/providers/SideProvider' +import { useBiometricsSide } from '@/src/hooks/useBiometricsSide' import { useWeekNavigator } from '@/src/hooks/useWeekNavigator' import { trpc } from '@/src/utils/trpc' @@ -31,16 +31,11 @@ const SECTIONS = [ * Sleep Sessions β†’ Movement β†’ Raw Data */ export default function DataPage() { - const { selectedSide, activeSides, primarySide, selectSide } = useSide() + // Biometrics show one side when a single sleeper is set (one side away). + const { side: primarySide, sides: activeSides, showBothSides, toggleSide } = useBiometricsSide() const week = useWeekNavigator() const scrollContainerRef = useRef(null) - const showBothSides = selectedSide === 'both' - - const handleSideToggle = useCallback(() => { - selectSide(primarySide === 'left' ? 'right' : 'left') - }, [primarySide, selectSide]) - // Fetch sleep records for each active side const leftSleepQuery = trpc.biometrics.getSleepRecords.useQuery( { @@ -113,25 +108,27 @@ export default function DataPage() { - {/* Side toggle β€” tap to swap L ↔ R */} - + + {primarySide === 'left' ? 'L' : 'R'} + + + {primarySide === 'left' ? 'Left' : 'Right'} + + + )} {/* Section Navigation Pills */} diff --git a/src/components/Environment/BedTempChart.tsx b/src/components/Environment/BedTempChart.tsx index 7af982db2..5187cd45f 100644 --- a/src/components/Environment/BedTempChart.tsx +++ b/src/components/Environment/BedTempChart.tsx @@ -151,7 +151,7 @@ export function BedTempChart({ data, unit, showAmbient = false, highlightSide }: } - label="Ambient" + label="Bed Ambient" value={currentAmbient} subValue={ambientTrend === 'warming' ? '↑ warming' : ambientTrend === 'cooling' ? '↓ cooling' : undefined} colorClass="text-[#d4a84a]" @@ -190,7 +190,7 @@ export function EnvironmentPanel({ unit = 'F', dualSide = false }: EnvironmentPa /> } - label="Ambient" + label="Bed Ambient" value={currentAmbient} subValue={ambientTrend === 'warming' ? '↑ warming' : ambientTrend === 'cooling' ? '↓ cooling' : undefined} colorClass="text-[#d4a84a]" @@ -251,7 +251,7 @@ export function EnvironmentPanel({ unit = 'F', dualSide = false }: EnvironmentPa : ( )} - + diff --git a/src/components/MovementChart/MovementChart.tsx b/src/components/MovementChart/MovementChart.tsx index 0cd87f8fe..46c19551e 100644 --- a/src/components/MovementChart/MovementChart.tsx +++ b/src/components/MovementChart/MovementChart.tsx @@ -1,7 +1,7 @@ 'use client' import { trpc } from '@/src/utils/trpc' -import { useSide } from '@/src/hooks/useSide' +import { useBiometricsSide } from '@/src/hooks/useBiometricsSide' import { useWeekNavigator } from '@/src/hooks/useWeekNavigator' import { Card, CardContent, CardHeader, CardTitle } from '@/src/ui/card' import { WeekNavigator } from '@/src/components/WeekNavigator/WeekNavigator' @@ -215,7 +215,7 @@ interface MovementChartProps { * server-side bucketed sums so a week view fits in one query. */ export function MovementChart({ dualSide = false, hideNav = false }: MovementChartProps) { - const { side } = useSide() + const { side } = useBiometricsSide() const otherSide: 'left' | 'right' = side === 'left' ? 'right' : 'left' const { weekStart, diff --git a/src/components/Schedule/TimeInput.tsx b/src/components/Schedule/TimeInput.tsx index 06f159374..fe052740a 100644 --- a/src/components/Schedule/TimeInput.tsx +++ b/src/components/Schedule/TimeInput.tsx @@ -20,6 +20,13 @@ interface TimeInputProps { /** * Touch-friendly time input with HH:MM format. * Uses native time input for mobile pickers. + * + * The native picker indicator is stretched over the whole field (invisible) + * so a tap anywhere opens the picker. Browsers give that indicator its own + * margin/padding, which pushed it ~14px past the field and made the + * surrounding scroll container (the alarm editor) scroll sideways on + * mobile β€” so it's zeroed, the wrapper clips, and appearance-none keeps iOS + * Safari from sizing the input by its native width. */ export function TimeInput({ label, value, onChange, disabled = false, icon, accentClass }: TimeInputProps) { return ( @@ -28,13 +35,13 @@ export function TimeInput({ label, value, onChange, disabled = false, icon, acce {icon && {icon}} {label} -
+
onChange(e.target.value)} disabled={disabled} - className="h-11 w-full min-w-0 rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 pr-9 text-sm font-medium text-white outline-none transition-colors focus:border-sky-500 disabled:cursor-not-allowed disabled:opacity-40 [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer" + className="block h-11 w-full min-w-0 appearance-none rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 pr-9 text-sm font-medium text-white outline-none transition-colors focus:border-sky-500 disabled:cursor-not-allowed disabled:opacity-40 [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:m-0 [&::-webkit-calendar-picker-indicator]:p-0 [&::-webkit-calendar-picker-indicator]:cursor-pointer" />
diff --git a/src/components/Schedule/tests/TimeInput.test.tsx b/src/components/Schedule/tests/TimeInput.test.tsx index a36edc1c4..573e86af1 100644 --- a/src/components/Schedule/tests/TimeInput.test.tsx +++ b/src/components/Schedule/tests/TimeInput.test.tsx @@ -16,6 +16,17 @@ function getTimeInput(container: HTMLElement): HTMLInputElement { } describe('TimeInput component', () => { + it('keeps the invisible picker overlay inside the field (no sideways scroll on mobile)', () => { + const { container } = render( {}} />) + const input = getTimeInput(container) + // The overlay is stretched over the field; its default margin/padding + // used to push it past the field and scroll the alarm editor sideways. + expect(input.className).toContain('[&::-webkit-calendar-picker-indicator]:m-0') + expect(input.className).toContain('[&::-webkit-calendar-picker-indicator]:p-0') + expect(input.className).toContain('appearance-none') + expect(input.parentElement?.className).toContain('overflow-hidden') + }) + it('renders the label and current value', () => { const { container, getByText } = render( {}} />, diff --git a/src/components/Sensors/BedTempMatrix.tsx b/src/components/Sensors/BedTempMatrix.tsx index 013baa17c..f696b0032 100644 --- a/src/components/Sensors/BedTempMatrix.tsx +++ b/src/components/Sensors/BedTempMatrix.tsx @@ -209,7 +209,7 @@ export function BedTempMatrix() { {/* Environment row */}
- Ambient + Bed Ambient {data.ambientTemp} diff --git a/src/components/Sensors/EnvironmentCard.tsx b/src/components/Sensors/EnvironmentCard.tsx index eb0e5b8e9..477d201c4 100644 --- a/src/components/Sensors/EnvironmentCard.tsx +++ b/src/components/Sensors/EnvironmentCard.tsx @@ -100,7 +100,7 @@ export function EnvironmentCard() { } value={leftAmbient} - label="Ambient L" + label="Bed Ambient L" /> } @@ -110,7 +110,7 @@ export function EnvironmentCard() { } value={rightAmbient ?? leftAmbient} - label="Ambient R" + label="Bed Ambient R" />
@@ -122,7 +122,7 @@ export function EnvironmentCard() {
} - label="Ambient" + label="Hub Ambient" value={freezerTempData.ambient} /> diff --git a/src/components/Sensors/SensorsScreen.tsx b/src/components/Sensors/SensorsScreen.tsx index e1121dc46..3c61abb56 100644 --- a/src/components/Sensors/SensorsScreen.tsx +++ b/src/components/Sensors/SensorsScreen.tsx @@ -203,7 +203,7 @@ export function SensorsScreen() { value={summary.avgRightCenterTemp != null ? `${Math.round(summary.avgRightCenterTemp)}Β°` : '--'} /> ('night') // Track the user's explicit pick by session ID so the selection survives @@ -418,15 +418,17 @@ export function VitalsPanel({ dualSide = false, hideNav = false, hideSummary = f
- + {toggleSide && ( + + )}
)} diff --git a/src/components/biometrics/RawDataButton.tsx b/src/components/biometrics/RawDataButton.tsx index 398a9c568..8ae0cc21c 100644 --- a/src/components/biometrics/RawDataButton.tsx +++ b/src/components/biometrics/RawDataButton.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useMemo } from 'react' import { Download, X, FileText, HardDrive, Trash2, Database, Package } from 'lucide-react' import { trpc } from '@/src/utils/trpc' -import { useSide } from '@/src/hooks/useSide' +import { useBiometricsSide } from '@/src/hooks/useBiometricsSide' import { useWeekNavigator } from '@/src/hooks/useWeekNavigator' function formatCSVDate(date: Date): string { @@ -82,7 +82,7 @@ export function RawDataButton() { const [isOpen, setIsOpen] = useState(false) const [showFiles, setShowFiles] = useState(false) const [deletingFile, setDeletingFile] = useState(null) - const { side } = useSide() + const { side } = useBiometricsSide() const { weekStart, weekEnd } = useWeekNavigator() const utils = trpc.useUtils() diff --git a/src/components/biometrics/VitalsGrid.tsx b/src/components/biometrics/VitalsGrid.tsx index d0bd1ea3b..29733729a 100644 --- a/src/components/biometrics/VitalsGrid.tsx +++ b/src/components/biometrics/VitalsGrid.tsx @@ -2,7 +2,7 @@ import { Activity, Heart, Wind } from 'lucide-react' import { trpc } from '@/src/utils/trpc' -import { useSide } from '@/src/hooks/useSide' +import { useBiometricsSide } from '@/src/hooks/useBiometricsSide' import { useWeekNavigator } from '@/src/hooks/useWeekNavigator' /** @@ -10,7 +10,7 @@ import { useWeekNavigator } from '@/src/hooks/useWeekNavigator' * Self-contained: fetches summary from tRPC for the shared week/side context. */ export function VitalsGrid() { - const { side } = useSide() + const { side } = useBiometricsSide() const { weekStart, weekEnd } = useWeekNavigator() const summaryQuery = trpc.biometrics.getVitalsSummary.useQuery({ diff --git a/src/components/diagnostics/DiagnosticsConsole.tsx b/src/components/diagnostics/DiagnosticsConsole.tsx index f549f301b..1cd497708 100644 --- a/src/components/diagnostics/DiagnosticsConsole.tsx +++ b/src/components/diagnostics/DiagnosticsConsole.tsx @@ -168,7 +168,7 @@ function OverviewPanel({ onJump }: { onJump: (s: SectionId) => void }) { {/* armed = green; the inverted flag painted the armed guard amber */} - +
@@ -241,7 +241,7 @@ function ThermalPanel() {
- +
diff --git a/src/hardware/dacMonitor.instance.ts b/src/hardware/dacMonitor.instance.ts index dfbd02fe4..17082740c 100644 --- a/src/hardware/dacMonitor.instance.ts +++ b/src/hardware/dacMonitor.instance.ts @@ -26,6 +26,7 @@ import { trackPrimingState, resetPrimingState, getPrimeCompletedAt } from './pri import { getAllPumpStallNotices } from './pumpStallNotification' import { cancelSnooze, getSnoozeStatus } from './snoozeManager' import { clearSharedHardwareClient, getSharedHardwareClient } from './sharedClient' +import { applyMutationOverlay } from '../streaming/mutationOverlay' const DAC_SOCK_PATH = process.env.DAC_SOCK_PATH || '/persistent/deviceinfo/dac.sock' @@ -103,8 +104,10 @@ export const getDacMonitor = async (): Promise => { broadcastFrame({ type: 'deviceStatus', ts: Date.now(), - leftSide: { ...status.leftSide, isAlarmVibrating: alarmState.left }, - rightSide: { ...status.rightSide, isAlarmVibrating: alarmState.right }, + // A poll can still report the pre-command target right after a + // mutation; keep the mutation's target until the firmware agrees. + leftSide: { ...applyMutationOverlay('left', { ...status.leftSide }), isAlarmVibrating: alarmState.left }, + rightSide: { ...applyMutationOverlay('right', { ...status.rightSide }), isAlarmVibrating: alarmState.right }, waterLevel: status.waterLevel, isPriming: status.isPriming, ...(primeCompletedAt && { primeCompletedNotification: { timestamp: primeCompletedAt } }), diff --git a/src/hardware/tests/dacMonitor.instance.test.ts b/src/hardware/tests/dacMonitor.instance.test.ts index 7a5c66c9d..a652e0172 100644 --- a/src/hardware/tests/dacMonitor.instance.test.ts +++ b/src/hardware/tests/dacMonitor.instance.test.ts @@ -584,6 +584,27 @@ describe('hardware/dacMonitor.instance', () => { vi.restoreAllMocks() }) + it('holds a just-set target over a poll that still reports the old one', async () => { + const { recordMutationOverlay, _resetMutationOverlays } = await import('@/src/streaming/mutationOverlay') + _resetMutationOverlays() + const mod = await freshModule() + await mod.getDacMonitor() + await flushMicrotasks() + broadcastFrameMock.mockClear() + const status: DeviceStatus = parseDeviceStatusMock('raw') + const newTarget = (status.leftSide.targetTemperature ?? 80) === 70 ? 71 : 70 + recordMutationOverlay('left', { targetTemperature: newTarget, targetLevel: -45 }) + + monitorInstances[0].emit('status:updated', status) + await flushMicrotasks() + + const frame = broadcastFrameMock.mock.calls.at(-1)?.[0] as { leftSide: Record, rightSide: Record } + expect(frame.leftSide.targetTemperature).toBe(newTarget) + expect(frame.leftSide.targetLevel).toBe(-45) + expect(frame.rightSide.targetTemperature).toBe(status.rightSide.targetTemperature) + _resetMutationOverlays() + }) + it('status:updated includes primeCompletedNotification when getPrimeCompletedAt returns a value', async () => { const mod = await freshModule() await mod.getDacMonitor() diff --git a/src/hooks/tests/useBiometricsSide.test.ts b/src/hooks/tests/useBiometricsSide.test.ts new file mode 100644 index 000000000..a93b91e8c --- /dev/null +++ b/src/hooks/tests/useBiometricsSide.test.ts @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useBiometricsSide } from '../useBiometricsSide' + +const state = vi.hoisted(() => ({ + selectedSide: 'both' as 'left' | 'right' | 'both', + activeSides: ['left', 'right'] as Array<'left' | 'right'>, + primarySide: 'left' as 'left' | 'right', + singleSleeperSide: null as 'left' | 'right' | null, + selectSide: vi.fn(), +})) +vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => state })) + +beforeEach(() => { + state.selectedSide = 'both' + state.activeSides = ['left', 'right'] + state.primarySide = 'left' + state.singleSleeperSide = null + state.selectSide.mockReset() +}) + +describe('useBiometricsSide', () => { + it('follows the shared selection normally', () => { + const { result } = renderHook(() => useBiometricsSide()) + expect(result.current).toMatchObject({ side: 'left', sides: ['left', 'right'], showBothSides: true }) + result.current.toggleSide?.() + expect(state.selectSide).toHaveBeenCalledWith('right') + }) + + it('shows only the single sleeper, even while controls are on both', () => { + state.singleSleeperSide = 'right' + const { result } = renderHook(() => useBiometricsSide()) + expect(result.current).toMatchObject({ + side: 'right', sides: ['right'], showBothSides: false, singleSleeperSide: 'right', + }) + expect(result.current.toggleSide).toBeUndefined() + }) +}) diff --git a/src/hooks/useBiometricsSide.ts b/src/hooks/useBiometricsSide.ts new file mode 100644 index 000000000..4c1ef36c1 --- /dev/null +++ b/src/hooks/useBiometricsSide.ts @@ -0,0 +1,43 @@ +'use client' + +import { useCallback } from 'react' +import { useSide, type Side } from '@/src/providers/SideProvider' + +/** + * Side selection for biometrics views (sleep, vitals, movement). + * + * With exactly one side in away mode the biometrics modules merge both + * sides' presence, movement and vitals into the single sleeper's side, so + * these views show only that side β€” no left/right split or side toggle. + * Otherwise they follow the shared SideProvider selection. + */ +export function useBiometricsSide(): { + side: Side + sides: Side[] + showBothSides: boolean + singleSleeperSide: Side | null + /** Swap sides; undefined when the view has a single sleeper. */ + toggleSide: (() => void) | undefined +} { + const { selectedSide, activeSides, primarySide, selectSide, singleSleeperSide } = useSide() + const toggle = useCallback(() => { + selectSide(primarySide === 'left' ? 'right' : 'left') + }, [selectSide, primarySide]) + + if (singleSleeperSide) { + return { + side: singleSleeperSide, + sides: [singleSleeperSide], + showBothSides: false, + singleSleeperSide, + toggleSide: undefined, + } + } + return { + side: primarySide, + sides: activeSides, + showBothSides: selectedSide === 'both', + singleSleeperSide: null, + toggleSide: toggle, + } +} diff --git a/src/lib/singleSleeper.ts b/src/lib/singleSleeper.ts new file mode 100644 index 000000000..f6ca43376 --- /dev/null +++ b/src/lib/singleSleeper.ts @@ -0,0 +1,23 @@ +import type { Side } from '@/src/hardware/types' + +interface SideAway { + awayMode?: boolean | null +} + +/** + * The single sleeper's side when exactly one side is in away mode, else null. + * + * One side away means one person sleeps in the bed, on the other side. The + * biometrics modules merge the away side's presence, movement and vitals into + * that side (modules/common/side_mode.py β€” keep the rule identical), so the UI + * shows one sleeper instead of a left/right split. Both sides away, or + * neither, is ordinary per-side operation. + */ +export function singleSleeperSideFor( + sides: { left?: SideAway | null, right?: SideAway | null } | null | undefined, +): Side | null { + const leftAway = Boolean(sides?.left?.awayMode) + const rightAway = Boolean(sides?.right?.awayMode) + if (leftAway === rightAway) return null + return leftAway ? 'right' : 'left' +} diff --git a/src/lib/tests/singleSleeper.test.ts b/src/lib/tests/singleSleeper.test.ts new file mode 100644 index 000000000..c53bd24ef --- /dev/null +++ b/src/lib/tests/singleSleeper.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { singleSleeperSideFor } from '../singleSleeper' + +describe('singleSleeperSideFor', () => { + it.each([ + [{ left: { awayMode: false }, right: { awayMode: true } }, 'left'], + [{ left: { awayMode: true }, right: { awayMode: false } }, 'right'], + [{ left: { awayMode: false }, right: { awayMode: false } }, null], + [{ left: { awayMode: true }, right: { awayMode: true } }, null], + [{ right: { awayMode: true } }, 'left'], + [{ left: null, right: { awayMode: null } }, null], + [undefined, null], + ])('%j β†’ %s', (sides, expected) => { + expect(singleSleeperSideFor(sides)).toBe(expected) + }) +}) diff --git a/src/providers/SideProvider.tsx b/src/providers/SideProvider.tsx index 2da8aa28d..fa0014a41 100644 --- a/src/providers/SideProvider.tsx +++ b/src/providers/SideProvider.tsx @@ -1,6 +1,8 @@ 'use client' import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { singleSleeperSideFor } from '@/src/lib/singleSleeper' +import { trpc } from '@/src/utils/trpc' export type Side = 'left' | 'right' export type SideSelection = 'left' | 'right' | 'both' @@ -18,6 +20,12 @@ interface SideContextValue { activeSides: Side[] /** The primary side used for display when both are selected */ primarySide: Side + /** + * The single sleeper's side when exactly one side is in away mode, else + * null. Biometrics views show only this side (useBiometricsSide). Control + * screens are unaffected beyond defaulting to 'both' when the mode starts. + */ + singleSleeperSide: Side | null } const SideContext = createContext(null) @@ -26,6 +34,10 @@ const STORAGE_KEY_SIDE = 'sleepypod-selected-side' const STORAGE_KEY_LINKED = 'sleepypod-is-linked' const COOKIE_KEY_SIDE = 'sleepypod-side' const COOKIE_MAX_AGE = 365 * 24 * 60 * 60 // 1 year in seconds +// Single-sleeper default: which home side it was applied for, and the +// selection it replaced (restored when away mode is turned off). +const STORAGE_KEY_SINGLE_SLEEPER = 'sleepypod-single-sleeper-side' +const STORAGE_KEY_PRE_SINGLE = 'sleepypod-pre-single-sleeper-selection' /** Read a cookie value by name */ function getCookie(name: string): string | null { @@ -44,6 +56,9 @@ export const SideProvider = ({ children }: { children: React.ReactNode }) => { const [selectedSide, setSelectedSide] = useState('left') const [isLinked, setIsLinked] = useState(false) const [hydrated, setHydrated] = useState(false) + const { data: settings } = trpc.settings.getAll.useQuery({}, { staleTime: 30_000 }) + const singleSleeperSide = singleSleeperSideFor(settings?.sides) + const settingsLoaded = settings?.sides != null // Hydrate from localStorage (primary) or cookie (fallback) on mount useEffect(() => { @@ -91,6 +106,44 @@ export const SideProvider = ({ children }: { children: React.ReactNode }) => { setCookie(COOKIE_KEY_SIDE, selectedSide) }, [selectedSide, isLinked, hydrated]) + // When one side goes into away mode, default the control screens to 'both' + // (linked) once β€” the sleeper may drift across the bed β€” while leaving the + // user free to change it. The prior selection comes back when away mode is + // turned off. Keyed in storage so a reload doesn't re-apply the default + // over a choice the user made while in the mode. + useEffect(() => { + if (!hydrated || !settingsLoaded) return + try { + const appliedFor = localStorage.getItem(STORAGE_KEY_SINGLE_SLEEPER) + /* eslint-disable react-hooks/set-state-in-effect */ + if (singleSleeperSide && appliedFor !== singleSleeperSide) { + if (appliedFor === null) { + localStorage.setItem(STORAGE_KEY_PRE_SINGLE, JSON.stringify({ side: selectedSide, linked: isLinked })) + } + localStorage.setItem(STORAGE_KEY_SINGLE_SLEEPER, singleSleeperSide) + setSelectedSide('both') + setIsLinked(true) + } + else if (!singleSleeperSide && appliedFor !== null) { + const prior = JSON.parse(localStorage.getItem(STORAGE_KEY_PRE_SINGLE) ?? 'null') as + { side?: SideSelection, linked?: boolean } | null + localStorage.removeItem(STORAGE_KEY_SINGLE_SLEEPER) + localStorage.removeItem(STORAGE_KEY_PRE_SINGLE) + if (prior?.side && ['left', 'right', 'both'].includes(prior.side)) { + setSelectedSide(prior.side) + setIsLinked(Boolean(prior.linked)) + } + } + /* eslint-enable react-hooks/set-state-in-effect */ + } + catch { + // localStorage unavailable or corrupt β€” leave the selection alone + } + // selectedSide/isLinked are read only to snapshot the prior selection at + // the transition; re-running on their changes would fight the user. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated, settingsLoaded, singleSleeperSide]) + const selectSide = useCallback((side: SideSelection) => { setSelectedSide(side) // If selecting a specific side while linked, unlink @@ -130,6 +183,7 @@ export const SideProvider = ({ children }: { children: React.ReactNode }) => { toggleLink, activeSides, primarySide, + singleSleeperSide, }} > {/* Suppress side-dependent UI flash during hydration */} diff --git a/src/providers/tests/SideProvider.test.tsx b/src/providers/tests/SideProvider.test.tsx new file mode 100644 index 000000000..df8bd51c9 --- /dev/null +++ b/src/providers/tests/SideProvider.test.tsx @@ -0,0 +1,86 @@ +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SideProvider, useSide } from '../SideProvider' + +const state = vi.hoisted(() => ({ + sides: undefined as undefined | { left: { awayMode: boolean }, right: { awayMode: boolean } }, +})) +vi.mock('@/src/utils/trpc', () => ({ + trpc: { settings: { getAll: { useQuery: () => ({ data: state.sides ? { sides: state.sides } : undefined }) } } }, +})) + +type Ctx = ReturnType +let ctx: Ctx +const capture = (value: Ctx) => { + ctx = value +} +function Probe({ onRender }: { onRender: (value: Ctx) => void }) { + onRender(useSide()) + return null +} +const tree = () => +const away = (left: boolean, right: boolean) => ({ left: { awayMode: left }, right: { awayMode: right } }) + +beforeEach(() => { + localStorage.clear() + document.cookie = 'sleepypod-side=; path=/; max-age=0' // cookie is the fallback store + state.sides = away(false, false) +}) +afterEach(cleanup) + +describe('SideProvider single-sleeper mode', () => { + it('reports no single sleeper and keeps the selection when neither side is away', () => { + localStorage.setItem('sleepypod-selected-side', 'right') + render(tree()) + expect(ctx.singleSleeperSide).toBeNull() + expect(ctx.selectedSide).toBe('right') + }) + + it('defaults control screens to both (linked) when one side goes away', () => { + localStorage.setItem('sleepypod-selected-side', 'right') + state.sides = away(false, true) + render(tree()) + expect(ctx.singleSleeperSide).toBe('left') + expect(ctx.selectedSide).toBe('both') + expect(ctx.isLinked).toBe(true) + }) + + it('still lets the user pick a side, and a reload keeps that choice', () => { + state.sides = away(true, false) + const first = render(tree()) + act(() => ctx.selectSide('left')) + expect(ctx.selectedSide).toBe('left') + first.unmount() + render(tree()) + expect(ctx.selectedSide).toBe('left') + expect(ctx.singleSleeperSide).toBe('right') + }) + + it('restores the previous selection when away mode is turned off', () => { + localStorage.setItem('sleepypod-selected-side', 'right') + state.sides = away(false, true) + const { rerender } = render(tree()) + expect(ctx.selectedSide).toBe('both') + state.sides = away(false, false) + rerender(tree()) + expect(ctx.selectedSide).toBe('right') + expect(ctx.isLinked).toBe(false) + expect(localStorage.getItem('sleepypod-single-sleeper-side')).toBeNull() + }) + + it('waits for settings before deciding (no flip-flop on load)', () => { + localStorage.setItem('sleepypod-selected-side', 'right') + localStorage.setItem('sleepypod-single-sleeper-side', 'left') + state.sides = undefined + render(tree()) + expect(ctx.selectedSide).toBe('right') + expect(localStorage.getItem('sleepypod-single-sleeper-side')).toBe('left') + }) + + it('is per-side when both sides are away', () => { + state.sides = away(true, true) + render(tree()) + expect(ctx.singleSleeperSide).toBeNull() + expect(ctx.selectedSide).toBe('left') + }) +}) diff --git a/src/streaming/broadcastMutationStatus.ts b/src/streaming/broadcastMutationStatus.ts index 586ea7cbb..f3fcc8efc 100644 --- a/src/streaming/broadcastMutationStatus.ts +++ b/src/streaming/broadcastMutationStatus.ts @@ -17,12 +17,15 @@ import { getPrimeCompletedAt } from '@/src/hardware/primeNotification' import { getAllPumpStallNotices } from '@/src/hardware/pumpStallNotification' import { getAlarmState } from '@/src/hardware/deviceStateSync' import { getSnoozeStatus } from '@/src/hardware/snoozeManager' +import { recordMutationOverlay } from './mutationOverlay' export function broadcastMutationStatus( side?: 'left' | 'right', sideOverlay?: Record, ): void { try { + // Hold the new target over polls that still report the old one. + if (side) recordMutationOverlay(side, sideOverlay) const monitor = getDacMonitorIfRunning() const lastStatus = monitor?.getLastStatus() if (!lastStatus) return diff --git a/src/streaming/mutationOverlay.ts b/src/streaming/mutationOverlay.ts new file mode 100644 index 000000000..6a33098fe --- /dev/null +++ b/src/streaming/mutationOverlay.ts @@ -0,0 +1,88 @@ +/** + * Recent-mutation overlay for polled deviceStatus frames. + * + * A mutation (set temperature, power, …) broadcasts an immediate frame with + * the new target (broadcastMutationStatus). A DAC status poll that was + * already in flight β€” or that runs before the firmware applies the command β€” + * can still report the OLD target, and its frame used to go out after the + * overlay: clients showed new β†’ old β†’ new. Polls just after a command + * describing the old state is known (deviceStateSync's freshness window); + * this applies the same idea to the live stream. + * + * Each side's last target overlay is re-applied to polled frames until a + * poll reports those values (the firmware has caught up) or + * OVERLAY_HOLD_MS passes. Only firmware-reported target fields are held; + * DB-backed flags (alarm state) always come fresh from their source. + * + * State lives on globalThis: writers (routers, scheduler) and the DAC + * monitor's broadcast can load separate module instances under Turbopack. + */ + +import type { Side } from '@/src/hardware/types' + +/** Upper bound on holding an overlay the firmware never confirms. */ +export const OVERLAY_HOLD_MS = 10_000 + +/** Side fields both set by mutations and reported by the firmware poll. */ +const HELD_FIELDS = ['targetTemperature', 'targetLevel'] as const + +interface Held { + fields: Record + at: number +} + +const G = globalThis as Record +const KEY = '__sp_mutation_overlays__' + +function store(): Partial> { + let s = G[KEY] as Partial> | undefined + if (!s) { + s = {} + G[KEY] = s + } + return s +} + +/** Remember a mutation's target fields for `side`. Overlays without target + * fields (e.g. alarm state) leave any held target overlay in place. */ +export function recordMutationOverlay( + side: Side, + overlay: Record | undefined, + now = Date.now(), +): void { + if (!overlay) return + const fields: Record = {} + for (const key of HELD_FIELDS) { + if (key in overlay) fields[key] = overlay[key] + } + if (Object.keys(fields).length > 0) store()[side] = { fields, at: now } +} + +/** + * Apply `side`'s held overlay to a polled side status. Drops the overlay + * once the poll reports every held value, or after OVERLAY_HOLD_MS. + */ +export function applyMutationOverlay>( + side: Side, + polled: T, + now = Date.now(), +): T { + const s = store() + const held = s[side] + if (!held) return polled + if (now - held.at >= OVERLAY_HOLD_MS) { + s[side] = undefined + return polled + } + const confirmed = Object.entries(held.fields).every(([k, v]) => Object.is(polled[k], v)) + if (confirmed) { + s[side] = undefined + return polled + } + return { ...polled, ...held.fields } +} + +/** @internal β€” for tests only */ +export function _resetMutationOverlays(): void { + G[KEY] = undefined +} diff --git a/src/streaming/tests/mutationOverlay.test.ts b/src/streaming/tests/mutationOverlay.test.ts new file mode 100644 index 000000000..019abc207 --- /dev/null +++ b/src/streaming/tests/mutationOverlay.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + OVERLAY_HOLD_MS, + _resetMutationOverlays, + applyMutationOverlay, + recordMutationOverlay, +} from '../mutationOverlay' + +const T0 = 1_000_000 +const polled = (targetTemperature: number | null, targetLevel: number) => ({ + currentTemperature: 80, targetTemperature, currentLevel: 0, targetLevel, heatingDuration: 3600, +}) + +beforeEach(() => _resetMutationOverlays()) + +describe('mutation overlay on polled deviceStatus', () => { + it('holds the new target over a stale poll (no new β†’ old β†’ new flicker)', () => { + recordMutationOverlay('left', { targetTemperature: 78, targetLevel: -16 }, T0) + const out = applyMutationOverlay('left', polled(82, 0), T0 + 1500) + expect(out.targetTemperature).toBe(78) + expect(out.targetLevel).toBe(-16) + expect(out.currentTemperature).toBe(80) // untouched fields pass through + }) + + it('drops the overlay once the firmware reports the new target', () => { + recordMutationOverlay('left', { targetTemperature: 78, targetLevel: -16 }, T0) + expect(applyMutationOverlay('left', polled(78, -16), T0 + 2000).targetTemperature).toBe(78) + // A later external change (e.g. schedule) must show through. + expect(applyMutationOverlay('left', polled(85, 9), T0 + 4000).targetTemperature).toBe(85) + }) + + it('gives up after the hold window so a lost write cannot pin the target', () => { + recordMutationOverlay('left', { targetTemperature: 78, targetLevel: -16 }, T0) + expect(applyMutationOverlay('left', polled(82, 0), T0 + OVERLAY_HOLD_MS - 1).targetTemperature).toBe(78) + expect(applyMutationOverlay('left', polled(82, 0), T0 + OVERLAY_HOLD_MS).targetTemperature).toBe(82) + expect(applyMutationOverlay('left', polled(82, 0), T0 + 1).targetTemperature).toBe(82) + }) + + it('ignores overlays without target fields (alarm state stays DB-backed)', () => { + recordMutationOverlay('left', { targetTemperature: 78, targetLevel: -16 }, T0) + recordMutationOverlay('left', { isAlarmVibrating: true }, T0 + 100) + const out = applyMutationOverlay('left', polled(82, 0), T0 + 200) + expect(out.targetTemperature).toBe(78) + expect(out).not.toHaveProperty('isAlarmVibrating') + recordMutationOverlay('left', undefined, T0 + 300) + expect(applyMutationOverlay('left', polled(82, 0), T0 + 400).targetTemperature).toBe(78) + }) + + it('a newer overlay replaces the held one; power-off holds only the level', () => { + recordMutationOverlay('left', { targetTemperature: 78, targetLevel: -16 }, T0) + recordMutationOverlay('left', { targetLevel: 0 }, T0 + 500) + const out = applyMutationOverlay('left', polled(78, -16), T0 + 1000) + expect(out.targetLevel).toBe(0) + expect(out.targetTemperature).toBe(78) + }) + + it('keeps sides independent', () => { + recordMutationOverlay('right', { targetTemperature: 70, targetLevel: -45 }, T0) + expect(applyMutationOverlay('left', polled(82, 0), T0 + 100).targetTemperature).toBe(82) + expect(applyMutationOverlay('right', polled(82, 0), T0 + 100).targetTemperature).toBe(70) + }) +}) From 93998a70e15b1f8986fcbe98671632cd5484d327 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Sat, 26 Sep 2026 18:10:12 -0400 Subject: [PATCH 011/119] fix(biometrics): pin RAW frames so the cold archive actually fills (#719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `sleepypod-biometrics-archiver` logs `archived=0 removed=0 failed=0` on every run and `/persistent/biometrics-archive/` never fills, so raw frames from past nights cannot be replayed offline (e.g. against the piezo-processor beat detector). ADR-0018's cold archive has effectively never worked on current firmware. ## Cause (firmware-side, not this repo) Observed on a Pod 4 on current firmware, read-only: - The logger rotates `*.RAW` every **~15 min** (~10 MB per frame, β‰ˆ1 GB/day). - At rotation the finished frame is **already unlinked** β€” within a second of its successor appearing. The firmware's descriptors have both moved to the new frame and tmpfs usage drops straight back to the size of the fresh file, so the inode is fully released: not even a deleted-but-open grace period. - Journal order per rotation: `raw_logger.h startNewLogFile|[logger] writing to .RAW` β†’ `[Upload] Ending Batch` β†’ `[seq] save - checkpoint: ` β†’ `[cloud] [Upload] File complete/File to upload` β†’ `[Upload] Starting Batch`. - Nothing in this repo removes `*.RAW` on a schedule: the archiver only after a successful gzip, the pruner only inside `biometrics-archive/`, and `POST /raw/files/delete` only on request. The firmware's own `defibrillator.sh` sweeps `*.RAW` under `/persistent` when free space runs low β€” an emergency path, and it spares `*.RAW.gz`. So the archiver's `find -mmin "+$KEEP_RECENT_MIN"` selection can never match: the deletion window is ~0, and `KEEP_RECENT_MIN` also happened to equal the rotation period. ## Fix Pin each frame with a second hard link while the firmware still holds it. - **New `sleepypod-biometrics-linker` + timer (every minute)** hard-links every live `*.RAW` into `/persistent/biometrics/.pending/` on the same tmpfs. While both names exist they are one inode β€” no copy, no extra memory, and everything the firmware appends after the link is still in the pinned frame. When the firmware unlinks its name, ours keeps the frames alive. - **Archiver gains a second pass**: a pinned frame is gzipped to eMMC once the live name is gone, then the link is dropped. eMMC still sees one batched sequential compressed write per frame on the existing 15-min cadence, so ADR-0018's wear goal is unchanged β€” the archive simply starts filling, bounded as designed by the pruner's 80% disk cap. - **Live writes protected**: the linker caps the pending set at `PENDING_MAX_PCT` (default 50%) of the tmpfs and drops oldest-first, so a stuck archiver (eMMC full, gzip failing) can never starve the firmware. - **Invisible to every other reader**: `.pending/` is dot-prefixed and one level down, so the sidecars (`raw_follower`), `piezoStream`, the calibrator, `/raw/files`, the export route and `sp-status` all walk past it. - **NATS transition stays safe**: `remove_biometrics_archiver_for_nats` now refuses to unmount the tmpfs while an unarchived pin remains, exactly as it already did for live frames. Wired into `install`/`sp-update` (via the archiver helpers), `sp-uninstall`, `sp-status` (timer + pinned count) and `sp-bundle-logs`. ## Tests `scripts/tests/biometricsArchiver.test.ts` (+8 cases, 20 total): the pin shares the inode and survives an unlink; appends after the link are preserved; SEQNO.RAW and the tmpfs-prep symlinks are skipped; repeat runs are quiet no-ops; a non-tmpfs path is skipped; the cap drops oldest-first and never touches live frames; an unreadable `df` skips enforcement instead of dropping everything; the archiver archives a released pin (gunzip round-trip), leaves a still-live one alone, drops a pin already in the archive, and clears pins on the `KEEP_RECENT_MIN=-1` transition path; the NATS cleanup refuses to unmount with a pin outstanding. Quality gates: `pnpm tsc`, `pnpm lint`, `pnpm test` (3919 passed) all green. ## Docs ADR-0018 gains a "Rotation deletes the finished frame" section (measurements, the linker decision, and why a shorter cadence, a `.path` unit, an inotify daemon, journal tailing or copying were rejected), plus README diagram/prose and a `docs/DEBUGGING.md` symptom flow for "RAW archive is empty / `archived=0`". ## Deployment note This has not yet run on a pod β€” it is a new timer, so it takes effect after `sp-update`. First check afterwards: `sp-status` shows `sleepypod-biometrics-linker.timer` active with `.RAW pinned (.pending)` = 1, and the archiver starts logging `archived=1` about once per rotation. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Biometrics frames released during firmware rotation are retained for the next archive run. * System status now shows the number of frames awaiting archiving. * Uninstallation now attempts to archive remaining frames before cleanup and preserves unarchived data and recovery tools if cleanup fails. * **Bug Fixes** * Frames are less likely to be lost before the next archive run. * **Documentation** * Updated architecture and troubleshooting guidance with frame-retention details, archive limits, and steps for investigating archiving issues. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Jonathan Ng --- README.md | 6 +- docs/DEBUGGING.md | 25 ++ docs/adr/0018-tmpfs-raw-frames.md | 91 +++++- .../sleepypod-biometrics-archiver | 46 ++- .../sleepypod-biometrics-linker | 103 +++++++ .../sleepypod-biometrics-linker.service | 13 + .../sleepypod-biometrics-linker.timer | 15 + scripts/bin/sp-bundle-logs | 1 + scripts/bin/sp-status | 4 +- scripts/bin/sp-uninstall | 32 +- scripts/lib/biometrics-archiver-helpers | 63 ++-- scripts/tests/biometricsArchiver.test.ts | 275 +++++++++++++++++- 12 files changed, 607 insertions(+), 67 deletions(-) create mode 100755 modules/biometrics-archiver/sleepypod-biometrics-linker create mode 100644 modules/biometrics-archiver/sleepypod-biometrics-linker.service create mode 100644 modules/biometrics-archiver/sleepypod-biometrics-linker.timer diff --git a/README.md b/README.md index de60367b5..f2b9ed7af 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,9 @@ graph LR subgraph RAWFS ["RAW frames β€” ADR 0018"] TMPFS["/persistent/biometrics/*.RAW
tmpfs Β· 500 MB Β· hot"] ARCH["/persistent/biometrics-archive/
eMMC Β· gzip cold"] - TMPFS -. "archiver timer 15m" .-> ARCH + PEND["/persistent/biometrics/.pending/
tmpfs Β· hard links Β· pinned"] + TMPFS -. "linker timer 1m: hard-link" .-> PEND + PEND -. "archiver timer 15m: gzip" .-> ARCH end subgraph TRANSPORT ["Hardware Transport"] @@ -321,7 +323,7 @@ graph LR ### Biometrics data flow -The Pod hardware daemon (frankenfirmware) writes raw sensor data continuously as CBOR-encoded binary records into `/persistent/biometrics/*.RAW`, which is a **500 MB tmpfs** to keep ~1 GB/day of writes off the eMMC. An archiver timer gzips files older than 15 minutes into `/persistent/biometrics-archive/` on eMMC (cold storage with a pruner cap of 80% disk usage). See **[ADR 0018](docs/adr/0018-tmpfs-raw-frames.md)** for the firmware-integration rationale, including how `SEQNO.RAW` and state directories are symlinked through so the firmware sees its persistent state unchanged. +The Pod hardware daemon (frankenfirmware) writes raw sensor data continuously as CBOR-encoded binary records into `/persistent/biometrics/*.RAW`, which is a **500 MB tmpfs** to keep ~1 GB/day of writes off the eMMC. The firmware rotates and then *deletes* each finished frame within a second, so a linker timer hard-links every live frame into `.pending/` on the same tmpfs (same inode, no copy) to outlive that unlink; an archiver timer then gzips the released frames into `/persistent/biometrics-archive/` on eMMC (cold storage with a pruner cap of 80% disk usage). See **[ADR 0018](docs/adr/0018-tmpfs-raw-frames.md)** for the firmware-integration rationale, including how `SEQNO.RAW` and state directories are symlinked through so the firmware sees its persistent state unchanged. Independent Python sidecar processes tail the hot tmpfs files, extract signals, and write results to `biometrics.db` (durable on eMMC). The core app never touches raw data β€” it reads clean rows via tRPC. diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 9c362e29e..3699e70ef 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -23,6 +23,7 @@ SSH setup and hardening: `scripts/README.md`, `docs/DEPLOYMENT.md`. | --- | --- | --- | | App database | `/persistent/sleepypod-data/` | `docs/DEPLOYMENT.md` | | RAW frames (hot, tmpfs) | `/persistent/biometrics/*.RAW` | `docs/adr/0018-tmpfs-raw-frames.md` | +| RAW frames pinned for archiving | `/persistent/biometrics/.pending/*.RAW` (hard links) | `docs/adr/0018-tmpfs-raw-frames.md` | | RAW archive (cold, eMMC) | `/persistent/biometrics-archive/*.RAW.gz` | `docs/adr/0012-biometrics-module-system.md` | | DAC socket | `/persistent/deviceinfo/dac.sock` (Pod 5) Β· `/deviceinfo/dac.sock` (Pod 3/4) | `docs/DEPLOYMENT.md` | @@ -42,6 +43,30 @@ reads red/amber while the bed is occupied, the ingest pipeline has stalled. Deep detail: `docs/adr/0012-biometrics-module-system.md`, `docs/adr/0018-tmpfs-raw-frames.md`. +## RAW archive is empty / `archived=0` on every run + +Past nights can only be replayed (e.g. against the piezo-processor beat +detector) if frames reached `/persistent/biometrics-archive/`. The firmware +rotates `*.RAW` every ~15 min and unlinks the finished frame within a second, so +the archive depends on the **linker** pinning each frame with a hard link first +(ADR-0018). + +1. `sp-status` β€” `biometrics-archiver units` must show + `sleepypod-biometrics-linker.timer` active, and `.RAW pinned (.pending)` + should be 1 while the firmware is writing. +2. `journalctl -u sleepypod-biometrics-linker -n 50 --no-pager` β€” the linker is + quiet on a no-op run; `linked=` lines appear once per rotation. `dropped=` + means pinned frames were discarded because the archiver is not draining. +3. `journalctl -u sleepypod-biometrics-archiver -n 50 --no-pager` β€” expect + `archived=1` roughly once per rotation. `failed=` means gzip could not write + to eMMC (check `df -P /persistent`); a stuck `pending=` with `archived=0` + means frames are pinned but not being released. +4. `ls -la /persistent/biometrics /persistent/biometrics/.pending` β€” one live + frame with a moving mtime, plus its pin. + +Deep detail: `docs/adr/0018-tmpfs-raw-frames.md` ("Rotation deletes the finished +frame"). + ## Pump stalled but the side reads as "powered" The Thermal page now plots per-side **target / bed / water** trends. A stalled diff --git a/docs/adr/0018-tmpfs-raw-frames.md b/docs/adr/0018-tmpfs-raw-frames.md index f981d47c7..1aeb22feb 100644 --- a/docs/adr/0018-tmpfs-raw-frames.md +++ b/docs/adr/0018-tmpfs-raw-frames.md @@ -16,11 +16,16 @@ Mount a **500 MB tmpfs at `/persistent/biometrics`**, redirect frankenfirmware's ``` frank β†’ /persistent/biometrics/ (tmpfs, hot live RAW) - ↓ archiver every 15 min: gzip oldest, atomic rename + ↓ linker every 1 min: hard-link the live frame (same tmpfs, same inode) + /persistent/biometrics/.pending/.RAW + ↓ archiver every 15 min: gzip released frames, atomic rename /persistent/biometrics-archive/.RAW.gz ↓ pruner every 15 min: drop oldest until df < 80% ``` +The linker exists because the firmware deletes a finished frame before any +periodic archiver can see it β€” see "Rotation deletes the finished frame" below. + ### Firmware integration without a binary patch Frank is `cd /persistent && exec frankenfirmware`. The binary writes RAW with relative paths and updates `SEQNO.RAW` (sequence counter) in place. Three things made the migration safe without recompiling firmware: @@ -43,11 +48,82 @@ tmpfs loses contents on reboot. The acceptable loss window is bounded by the arc Loss of 30 min of upstream waveform is a worthwhile trade for years of eMMC wear avoidance plus an indefinitely growable cold archive (capped by the pruner at 80% disk). +## Rotation deletes the finished frame (the linker) + +The archiver as first designed selected `find "$TMPFS_DIR" -name '*.RAW' -mmin +"+$KEEP_RECENT_MIN"` β€” everything except the frame the firmware might still be +writing. On current firmware that set is *always* empty: every run logged +`archiver: archived=0 removed=0 failed=0` and the cold archive stayed at +whatever the one-time install migration put there. + +Measured on a Pod 4 (read-only observation of a live rotation): + +- The logger rotates every **~15 min**, ~10 MB per frame (β‰ˆ1 GB/day). +- At rotation the previous name is **already gone** from the directory β€” within + a second of the successor appearing. The firmware's file descriptors have both + moved to the new frame and tmpfs usage drops straight back to the size of the + fresh file, so the inode is fully released: there is no deleted-but-open + grace period to exploit either. +- Journal order per rotation: `raw_logger.h startNewLogFile|[logger] writing to + .RAW` β†’ `[Upload] Ending Batch` β†’ `[seq] save - checkpoint: ` + β†’ `[cloud] [Upload] File complete/File to upload -> .RAW` β†’ `[Upload] + Starting Batch`. +- Nothing in this repo removes `*.RAW` on a schedule: the archiver only after a + successful gzip, the pruner only inside `biometrics-archive/`, and + `POST /raw/files/delete` only when asked. The firmware also ships + `/opt/eight/bin/defibrillator.sh`, whose `cleanup_raw_if_out_of_space` sweeps + `find /persistent/ -name '*.RAW'` when free space runs low β€” an emergency path, + not the per-rotation delete, and it spares the `*.RAW.gz` archive. + +So the deleter is frankenfirmware itself, at rotation, and the deletion window +is effectively zero. No cadence and no mtime/newest-file threshold can win that +race β€” the old design also happened to pair a 15-minute `KEEP_RECENT_MIN` with a +15-minute rotation, which would have made it marginal even against a firmware +that left the file behind. + +**Decision: pin frames with a second hard link while the firmware still holds +them.** `sleepypod-biometrics-linker.timer` runs every minute and hard-links each +live `*.RAW` into `/persistent/biometrics/.pending/`. While both names exist they +are one inode β€” no extra tmpfs, no copy, and everything the firmware appends +after the link still lands in the pinned frame. When the firmware unlinks its +own name, ours keeps the inode alive; the archiver's second pass gzips a pinned +frame to eMMC once the live name is gone, then drops the link. + +Properties that matter: + +- **eMMC wear unchanged in kind.** Linking is tmpfs-only. The gzip pass keeps its + 15-minute batch cadence, so eMMC still sees one sequential compressed write + per frame instead of a continuous append stream. What does change is that the + archive now actually grows (β‰ˆgzip of ~1 GB/day), and the pruner's 80% cap turns + that into a disk-bounded window β€” days to a couple of weeks of replayable raw + frames depending on free space, which is the outcome this ADR wanted. +- **Live writes are protected.** If the archiver cannot drain (eMMC full, gzip + failing), pinned links would otherwise accumulate in the 500 MB tmpfs and + starve the firmware. The linker caps the pending set at `PENDING_MAX_PCT` + (default 50%) of the tmpfs and drops oldest-first, mirroring the pruner: + recent nights are the ones worth replaying. +- **Invisible to every other reader.** `.pending/` is a dot-prefixed + subdirectory; the sidecars (`modules/common/raw_follower.py`), `piezoStream`, + the calibrator, `/raw/files`, the export route, `sp-status` and the NATS + transition helper all scan one directory level for names ending in `.RAW`, so + none of them see pinned links β€” and the firmware's logger never looks there. +- **Frames pinned but not yet gzipped are still volatile.** They live in tmpfs + (lost on reboot) and are named `*.RAW` under `/persistent`, so the firmware's + low-space sweep can drop them too. The gzipped archive is the durable copy. +- **The NATS transition still cannot lose data.** `remove_biometrics_archiver_for_nats` + archives with `KEEP_RECENT_MIN=-1` and now refuses to unmount the tmpfs while + any unarchived pinned link remains, exactly as it already did for live frames. + ## Alternatives considered - **Overlayfs** with tmpfs upper layer over `/persistent`: would route ALL writes (DBs, settings, etc.) through tmpfs upper, breaking durability of everything else. Per-file routing isn't supported by overlayfs. - **LD_PRELOAD shim**: intercept `open()` for `*.RAW` and redirect. Hacky, fragile under firmware updates. - **inotify-watch + post-write move**: doesn't reduce eMMC writes (writes happen first, then move). +- **Shorter archiver cadence / "archive everything but the newest file"** instead of pinning: still loses a sub-second race, and would have to gzip a frame the firmware may still be appending to. +- **systemd `.path` unit on `PathExistsGlob=/persistent/biometrics/*.RAW`**: path units fire on a falseβ†’true transition of the condition. The glob is *always* true (there is always a live frame), so a new frame arriving while the previous one exists never triggers it. +- **inotify watcher daemon** (`IN_CREATE` on the tmpfs): the right shape, but the pods ship no `inotifywait`, and it means a long-running daemon where a one-minute timer over a 15-minute rotation already pins every frame minutes early. +- **Tailing the journal for `startNewLogFile`**: parses firmware log text, and the rotation line is logged *after* the previous frame is already unlinked β€” too late. +- **Copying rather than hard-linking**: needs 2Γ— tmpfs for the frame plus a full read every rotation, and a copy that starts after the unlink still gets nothing. - **Archiver-only, no tmpfs**: hits the cold-archive and disk-cap goals but doesn't reduce eMMC wear. Considered as a fallback if the firmware patch had been unsafe; not needed once `SEQNO.RAW` write-through was verified. ## Rollback @@ -72,4 +148,17 @@ The frank.sh-shim assumption holds on a shrinking slice of the fleet. As of 2026 - Follow-up epic for new firmware: sleepypod-core-54 (NATS JetStream consumer) - GH issue: #493 (full design doc) - Live validation: Pod 5 fw ca35aafa on 2026-05-04 β€” see PR #499 +- Rotation-delete diagnosis (`archived=0` forever) + pinning linker: observed on Pod 4 firmware, 2026-09 - Firmware-variant gating fix: PR #594 + +### Cleanup and cap validation + +`PENDING_MAX_PCT` accepts decimal integers from 1 through 99 (default 50). +Zero, empty, negative, out-of-range, and nonnumeric values are rejected before +any pins are created, so an invalid setting cannot silently disable the cap. + +Uninstall shares the NATS transition's archive-and-check barrier: stop timers +and active jobs, restore the firmware's original working directory and restart +it, then archive all live and pending frames before unmounting. If restarting, +archiving, or unmounting fails, the command aborts with recovery tools retained. +The cold archive is preserved even without `--keep-data`. diff --git a/modules/biometrics-archiver/sleepypod-biometrics-archiver b/modules/biometrics-archiver/sleepypod-biometrics-archiver index 7b1f61894..fcba0b94c 100755 --- a/modules/biometrics-archiver/sleepypod-biometrics-archiver +++ b/modules/biometrics-archiver/sleepypod-biometrics-archiver @@ -1,7 +1,17 @@ #!/bin/bash # Archive RAW files from the /persistent/biometrics tmpfs into a gzipped -# cold archive on eMMC. Runs every 15 min via timer. Files newer than -# KEEP_RECENT_MIN are left alone (firmware may still be writing them). +# cold archive on eMMC. Runs every 15 min via timer. +# +# Two sources, in order: +# 1. Live frames older than KEEP_RECENT_MIN β€” only reachable on firmware +# variants that leave a finished file behind (the firmware this module +# normally runs against unlinks it within a second of rotating, which is +# what the linker exists for). +# 2. Frames pinned by sleepypod-biometrics-linker under $PENDING_DIR. A +# pinned frame is archived once the firmware has dropped its own name for +# the inode; while both names exist the firmware may still be appending, +# so the link is left in place (it shares the live frame's blocks and +# costs no extra tmpfs). # # Output: /persistent/biometrics-archive/.RAW.gz, atomic via tmp # file + rename. @@ -9,6 +19,7 @@ set -euo pipefail TMPFS_DIR="${TMPFS_DIR:-/persistent/biometrics}" ARCHIVE_DIR="${ARCHIVE_DIR:-/persistent/biometrics-archive}" +PENDING_DIR="${PENDING_DIR:-$TMPFS_DIR/.pending}" KEEP_RECENT_MIN="${KEEP_RECENT_MIN:-15}" GZIP_LEVEL="${GZIP_LEVEL:-6}" @@ -23,14 +34,18 @@ archived=0 removed=0 failed=0 -while IFS= read -r -d '' src; do +# gzip one source path into the archive and drop the source. For a pinned link +# the source is only that extra name β€” the firmware's own name, if it still +# holds one, is untouched. +archive_one() { + local src=$1 base dst tmp base=$(basename "$src") dst="$ARCHIVE_DIR/$base.gz" if [ -f "$dst" ]; then rm -f "$src" removed=$((removed + 1)) - continue + return 0 fi tmp="$dst.tmp.$$" @@ -43,9 +58,30 @@ while IFS= read -r -d '' src; do failed=$((failed + 1)) echo "archiver: failed to gzip $base" >&2 fi +} + +# Pass 1: live frames the firmware has stopped touching. +while IFS= read -r -d '' src; do + archive_one "$src" done < <(find "$TMPFS_DIR" -maxdepth 1 -type f -name '*.RAW' -mmin "+$KEEP_RECENT_MIN" -print0) -echo "archiver: archived=$archived removed=$removed failed=$failed" +# Pass 2: pinned frames the firmware has released. Runs after pass 1 so a link +# whose live twin was just archived is cleaned up in the same run. +if [ -d "$PENDING_DIR" ]; then + while IFS= read -r -d '' src; do + base=$(basename "$src") + if [ -e "$TMPFS_DIR/$base" ] && [ ! -f "$ARCHIVE_DIR/$base.gz" ]; then + continue + fi + archive_one "$src" + done < <(find "$PENDING_DIR" -maxdepth 1 -type f -name '*.RAW' -print0) +fi + +shopt -s nullglob +pending=( "$PENDING_DIR"/*.RAW ) +shopt -u nullglob + +echo "archiver: archived=$archived removed=$removed failed=$failed pending=${#pending[@]}" if [ "$failed" -gt 0 ]; then exit 1 fi diff --git a/modules/biometrics-archiver/sleepypod-biometrics-linker b/modules/biometrics-archiver/sleepypod-biometrics-linker new file mode 100755 index 000000000..3dbed86e9 --- /dev/null +++ b/modules/biometrics-archiver/sleepypod-biometrics-linker @@ -0,0 +1,103 @@ +#!/bin/bash +# Pin live RAW frames with a second hard link so they survive the firmware's +# unlink, then let the archiver gzip them to eMMC at its own cadence. +# +# Why this exists: frankenfirmware rotates RAW every ~15 min and unlinks the +# finished file within a second of opening its successor β€” verified on a Pod 4: +# at rotation the previous name is already gone from the directory, the +# firmware's fd for it is gone, and tmpfs usage drops straight back to the size +# of the fresh file. So a finished file is never on disk long enough for a +# periodic archiver to see it, whatever cadence or mtime threshold it uses +# (`archiver: archived=0` on every run, forever). +# +# A second hard link into a subdirectory of the same tmpfs fixes that: while +# the firmware is still appending, both names share one inode and the link +# costs no extra memory; once the firmware unlinks its name, our link keeps the +# inode (and the frames) alive for the archiver. The firmware never sees the +# extra name β€” it lives in a dot-prefixed subdir that every `*.RAW` scan in +# this repo (and the firmware's own logger) walks straight past. +# +# Runs every minute: link + stat on tmpfs only, no eMMC writes. +set -euo pipefail + +TMPFS_DIR="${TMPFS_DIR:-/persistent/biometrics}" +PENDING_DIR="${PENDING_DIR:-$TMPFS_DIR/.pending}" +# Backstop for a stuck archiver (eMMC full, gzip failing): pinned frames must +# never crowd out the firmware's live writes. Oldest links go first β€” the newest +# frames are the ones worth replaying. +PENDING_MAX_PCT="${PENDING_MAX_PCT-50}" +# Reserve some tmpfs capacity for firmware writes. Reject zero, leading zeros, +# and arithmetic expressions before creating any pins or evaluating arithmetic. +if [[ ! "$PENDING_MAX_PCT" =~ ^[1-9][0-9]?$ ]]; then + echo "linker: PENDING_MAX_PCT must be an integer from 1 to 99" >&2 + exit 1 +fi + +if ! mountpoint -q "$TMPFS_DIR"; then + echo "linker: $TMPFS_DIR is not a tmpfs mount β€” skipping" >&2 + exit 0 +fi + +mkdir -p "$PENDING_DIR" + +linked=0 +dropped=0 +failed=0 + +# Drop oldest pinned frames until the pending set fits under its share of the +# tmpfs. Removing a link never touches the firmware's own name for that inode. +enforce_pending_cap() { + local total_kb max_kb used_kb oldest entries + # `df -Pk` (POSIX, 1024-byte units on GNU *and* busybox) matches `du -ks`. + # Every `|| true` keeps a failed probe from aborting the run under pipefail: + # an unreadable capacity means "skip enforcement", never "drop everything". + total_kb=$(df -Pk "$TMPFS_DIR" 2>/dev/null | awk 'NR==2 {print $2}' || true) + max_kb=$(( ${total_kb:-0} * PENDING_MAX_PCT / 100 )) + [ "$max_kb" -gt 0 ] || return 0 + + while :; do + used_kb=$(du -ks "$PENDING_DIR" 2>/dev/null | awk '{print $1}' || true) + [ "${used_kb:-0}" -gt "$max_kb" ] || return 0 + shopt -s nullglob + entries=( "$PENDING_DIR"/*.RAW ) + shopt -u nullglob + [ "${#entries[@]}" -gt 0 ] || return 0 + # `awk NR==1` rather than `head -1`: head closes the pipe early and, under + # pipefail, SIGPIPE would abort the script (see the pruner). + oldest=$(ls -1tr "${entries[@]}" | awk 'NR==1' || true) + [ -n "$oldest" ] || return 0 + rm -f "$oldest" + dropped=$((dropped + 1)) + echo "linker: dropped unarchived $(basename "$oldest") β€” pending over ${PENDING_MAX_PCT}% of tmpfs" >&2 + done +} + +while IFS= read -r -d '' src; do + base=$(basename "$src") + # SEQNO.RAW is firmware metadata, not sensor data (and once tmpfs-prep has + # staged it, a symlink onto eMMC that `-type f` already skips). + [ "$base" = "SEQNO.RAW" ] && continue + dst="$PENDING_DIR/$base" + [ -e "$dst" ] && continue + if ln "$src" "$dst" 2>/dev/null; then + linked=$((linked + 1)) + else + failed=$((failed + 1)) + echo "linker: failed to link $base" >&2 + fi +done < <(find "$TMPFS_DIR" -maxdepth 1 -type f -name '*.RAW' -print0) + +enforce_pending_cap + +shopt -s nullglob +pending=( "$PENDING_DIR"/*.RAW ) +shopt -u nullglob + +# Quiet on the common no-op run (the live frame is already pinned) so a +# once-a-minute timer stays readable in the journal. +if [ "$linked" -gt 0 ] || [ "$dropped" -gt 0 ] || [ "$failed" -gt 0 ]; then + echo "linker: linked=$linked dropped=$dropped failed=$failed pending=${#pending[@]}" +fi +if [ "$failed" -gt 0 ]; then + exit 1 +fi diff --git a/modules/biometrics-archiver/sleepypod-biometrics-linker.service b/modules/biometrics-archiver/sleepypod-biometrics-linker.service new file mode 100644 index 000000000..a94868144 --- /dev/null +++ b/modules/biometrics-archiver/sleepypod-biometrics-linker.service @@ -0,0 +1,13 @@ +[Unit] +Description=Sleepypod biometrics RAW linker (pin live frames before the firmware unlinks them) +Documentation=https://github.com/sleepypod/core/tree/main/modules/biometrics-archiver +Requires=persistent-biometrics.mount +After=persistent-biometrics.mount + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/sleepypod-biometrics-linker +# tmpfs-only work (one link per rotation): cheap enough to run every minute, +# and deliberately not IOSchedulingClass=idle β€” it must not queue behind the +# archiver's eMMC writes, or a frame can be unlinked before it is pinned. +Nice=10 diff --git a/modules/biometrics-archiver/sleepypod-biometrics-linker.timer b/modules/biometrics-archiver/sleepypod-biometrics-linker.timer new file mode 100644 index 000000000..79add6256 --- /dev/null +++ b/modules/biometrics-archiver/sleepypod-biometrics-linker.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Run sleepypod biometrics RAW linker every minute + +[Timer] +# The firmware rotates every ~15 min, so a 1-minute cadence pins each frame +# minutes before it can be unlinked β€” and a hard link made mid-write still +# preserves the whole file, first byte included. Only a rotation faster than +# the cadence could lose a frame. +OnBootSec=30s +OnUnitActiveSec=1min +AccuracySec=5s +Unit=sleepypod-biometrics-linker.service + +[Install] +WantedBy=timers.target diff --git a/scripts/bin/sp-bundle-logs b/scripts/bin/sp-bundle-logs index bfd9572a4..ba5972ad5 100755 --- a/scripts/bin/sp-bundle-logs +++ b/scripts/bin/sp-bundle-logs @@ -67,6 +67,7 @@ SERVICES=( sleepypod-environment-monitor.service sleepypod-cover-buttons.service sleepypod-calibrator.service + sleepypod-biometrics-linker.service sleepypod-biometrics-archiver.service sleepypod-biometrics-pruner.service sleepypod-tmpfs-prep.service diff --git a/scripts/bin/sp-status b/scripts/bin/sp-status index 149341d63..d09e640ac 100755 --- a/scripts/bin/sp-status +++ b/scripts/bin/sp-status @@ -150,6 +150,7 @@ count_archive_gz() { } printf ".RAW @ /persistent: %s\n" "$(count_raw_excluding_seqno /persistent)" printf ".RAW @ /persistent/biometrics: %s\n" "$(count_raw_excluding_seqno /persistent/biometrics)" +printf ".RAW pinned (.pending): %s\n" "$(count_raw_excluding_seqno /persistent/biometrics/.pending)" printf ".RAW.gz in archive: %s\n" "$(count_archive_gz /persistent/biometrics-archive)" # ----- sleepypod modules --------------------------------------------------- @@ -163,7 +164,8 @@ done # ----- biometrics-archiver units ------------------------------------------- probe_section "biometrics-archiver units" for u in persistent-biometrics.mount sleepypod-tmpfs-prep.service \ - sleepypod-biometrics-archiver.timer sleepypod-biometrics-pruner.timer; do + sleepypod-biometrics-linker.timer sleepypod-biometrics-archiver.timer \ + sleepypod-biometrics-pruner.timer; do printf "%-40s %s\n" "$u" "$(active_or "$u")" done diff --git a/scripts/bin/sp-uninstall b/scripts/bin/sp-uninstall index 5edb136c0..049d9c6ff 100755 --- a/scripts/bin/sp-uninstall +++ b/scripts/bin/sp-uninstall @@ -48,6 +48,9 @@ if [ "$FORCE" != true ]; then fi fi +# Load the shared archive-and-check barrier before deleting any recovery tools. +. "$INSTALL_DIR/scripts/lib/biometrics-archiver-helpers" + echo "=== Uninstalling SleepyPod ===" # Stop and disable services @@ -71,32 +74,13 @@ done # Drop-ins installed by sp-update (e.g. iptables-lockfile.conf for older pods). rm -rf /etc/systemd/system/sleepypod.service.d -# Restore frank.sh from the most recent pre-tmpfs backup BEFORE the mount -# is torn down β€” otherwise frank's cwd disappears mid-restart. -if ls /opt/eight/bin/frank.sh.bak-pre-tmpfs-* >/dev/null 2>&1; then - newest_backup=$(ls -1t /opt/eight/bin/frank.sh.bak-pre-tmpfs-* | head -1) - echo "Restoring frank.sh from $newest_backup" - cp -p "$newest_backup" /opt/eight/bin/frank.sh - systemctl restart frank.service 2>/dev/null || true +# Restore firmware's cwd, stop timers AND active jobs, archive every live/pinned +# frame, and refuse to unmount or remove recovery tools if anything remains. +if ! remove_biometrics_archiver; then + echo "Error: uninstall stopped to preserve unarchived RAW data and recovery tools" >&2 + exit 1 fi -# Tear down biometrics-archiver: timers, prep service, mount unit. -for u in sleepypod-biometrics-archiver.timer sleepypod-biometrics-pruner.timer \ - sleepypod-tmpfs-prep.service persistent-biometrics.mount; do - systemctl stop "$u" 2>/dev/null || true - systemctl disable "$u" 2>/dev/null || true -done -rm -f /etc/systemd/system/sleepypod-biometrics-archiver.service \ - /etc/systemd/system/sleepypod-biometrics-archiver.timer \ - /etc/systemd/system/sleepypod-biometrics-pruner.service \ - /etc/systemd/system/sleepypod-biometrics-pruner.timer \ - /etc/systemd/system/sleepypod-tmpfs-prep.service \ - /etc/systemd/system/persistent-biometrics.mount -rm -rf /etc/systemd/system/frank.service.d -rm -f /usr/local/bin/sleepypod-tmpfs-prep \ - /usr/local/bin/sleepypod-biometrics-archiver \ - /usr/local/bin/sleepypod-biometrics-pruner - systemctl daemon-reload # Remove CLI tools diff --git a/scripts/lib/biometrics-archiver-helpers b/scripts/lib/biometrics-archiver-helpers index 49a545f20..32e6c5df0 100644 --- a/scripts/lib/biometrics-archiver-helpers +++ b/scripts/lib/biometrics-archiver-helpers @@ -2,22 +2,33 @@ # Biometrics-archiver install β€” tmpfs RAW writes + gzip cold archive. # # Frankenfirmware writes ~1 GB/day of *.RAW frames. We move them to a -# 500 MB tmpfs at /persistent/biometrics so wear stays off eMMC; an -# archiver gzips files older than 15 min into /persistent/biometrics-archive/, -# a pruner keeps /persistent < 80% full. SEQNO.RAW + persistent state -# subdirs are symlinked from tmpfs back to /persistent so the firmware -# sees its state without code changes. See modules/biometrics-archiver/README. +# 500 MB tmpfs at /persistent/biometrics so wear stays off eMMC; a linker +# hard-links each live frame into .pending/ every minute so it outlives the +# firmware's unlink at rotation, an archiver gzips released frames into +# /persistent/biometrics-archive/, a pruner keeps /persistent < 80% full. +# SEQNO.RAW + persistent state subdirs are symlinked from tmpfs back to +# /persistent so the firmware sees its state without code changes. See +# modules/biometrics-archiver/README. # # Sourced by: # - scripts/install (fresh install path) # - scripts/bin/sp-update (in-place upgrade path β€” issue #576) +# - scripts/bin/sp-uninstall (drain volatile frames before teardown) # # Idempotent: subsequent runs detect existing mount + patched frank.sh # and no-op. Safe to call on every install/upgrade. # # Requires INSTALL_DIR to be set by the caller. +# Keep the NATS entry point for install/update callers. remove_biometrics_archiver_for_nats() { + remove_biometrics_archiver nats +} + +# Restore firmware writes to persistent storage, drain RAW frames, then unmount. +# Uninstall uses the same barriers without installing NATS consumer drop-ins. +remove_biometrics_archiver() { + local mode="${1:-uninstall}" # A pod can be upgraded from RAW-writing firmware after this module was # already installed. Merely skipping setup would leave frank.service's # WorkingDirectory override, the tmpfs mount, and archive timers enabled. @@ -33,7 +44,8 @@ remove_biometrics_archiver_for_nats() { local restart_frank=false local u - for u in sleepypod-biometrics-archiver.timer sleepypod-biometrics-pruner.timer \ + for u in sleepypod-biometrics-linker.timer sleepypod-biometrics-archiver.timer \ + sleepypod-biometrics-pruner.timer sleepypod-biometrics-linker.service \ sleepypod-biometrics-archiver.service sleepypod-biometrics-pruner.service \ sleepypod-tmpfs-prep.service; do systemctl stop "$u" 2>/dev/null || true @@ -70,14 +82,14 @@ remove_biometrics_archiver_for_nats() { fi rmdir "$systemd_dir/frank.service.d" 2>/dev/null || true if ! systemctl daemon-reload 2>/dev/null; then - echo "Error: failed to reload systemd before NATS firmware cleanup" >&2 + echo "Error: failed to reload systemd before biometrics cleanup" >&2 return 1 fi # Restart after removing the override so frank releases any cwd held inside # the tmpfs. Always do this for a mounted transition: if an earlier attempt # removed the override but its restart failed, the next attempt must retry. - # New firmware resumes publishing to NATS immediately. + # Firmware resumes on its normal persistent-storage or NATS path. if [ "$restart_frank" = true ] || mountpoint -q "$tmpfs_dir"; then if ! systemctl restart frank.service 2>/dev/null; then echo "Error: failed to restart frank.service; preserving RAW tmpfs and recovery tools" >&2 @@ -87,7 +99,7 @@ remove_biometrics_archiver_for_nats() { # Preserve every remaining RAW frame before unmounting the volatile tmpfs. # KEEP_RECENT_MIN=-1 includes files younger than the normal 15-minute guard; - # frank has already restarted on the NATS path and no longer writes them. + # frank has already restarted outside the tmpfs and no longer writes them. local tmpfs_was_mounted=false if mountpoint -q "$tmpfs_dir"; then tmpfs_was_mounted=true @@ -102,7 +114,9 @@ remove_biometrics_archiver_for_nats() { local raw_file local remaining_raw shopt -s nullglob - remaining_raw=("$tmpfs_dir"/*.RAW) + # Pinned links under .pending/ hold the only copy of an already-unlinked + # frame, so they are checked exactly like a live frame. + remaining_raw=("$tmpfs_dir"/*.RAW "$tmpfs_dir"/.pending/*.RAW) shopt -u nullglob for raw_file in "${remaining_raw[@]}"; do [ -f "$raw_file" ] || continue @@ -116,13 +130,15 @@ remove_biometrics_archiver_for_nats() { # Keep RAW fallback on persistent storage after the legacy mount goes away. # NATS consumers still select NATS first; cover-buttons can safely remain idle # when this firmware supplies no RAW button records. - for u in sleepypod sleepypod-piezo-processor sleepypod-sleep-detector \ - sleepypod-environment-monitor sleepypod-calibrator sleepypod-cover-buttons; do - mkdir -p "$systemd_dir/$u.service.d" - printf '[Service]\nEnvironment="RAW_DATA_DIR=/persistent"\n' \ - > "$systemd_dir/$u.service.d/zz-nats-raw-fallback.conf" - done - systemctl daemon-reload + if [ "$mode" = nats ]; then + for u in sleepypod sleepypod-piezo-processor sleepypod-sleep-detector \ + sleepypod-environment-monitor sleepypod-calibrator sleepypod-cover-buttons; do + mkdir -p "$systemd_dir/$u.service.d" + printf '[Service]\nEnvironment="RAW_DATA_DIR=/persistent"\n' \ + > "$systemd_dir/$u.service.d/zz-nats-raw-fallback.conf" + done + systemctl daemon-reload + fi if ! systemctl stop persistent-biometrics.mount 2>/dev/null \ && [ "$tmpfs_was_mounted" = true ]; then @@ -135,17 +151,20 @@ remove_biometrics_archiver_for_nats() { fi systemctl disable persistent-biometrics.mount 2>/dev/null || true - rm -f "$systemd_dir/sleepypod-biometrics-archiver.service" \ + rm -f "$systemd_dir/sleepypod-biometrics-linker.service" \ + "$systemd_dir/sleepypod-biometrics-linker.timer" \ + "$systemd_dir/sleepypod-biometrics-archiver.service" \ "$systemd_dir/sleepypod-biometrics-archiver.timer" \ "$systemd_dir/sleepypod-biometrics-pruner.service" \ "$systemd_dir/sleepypod-biometrics-pruner.timer" \ "$systemd_dir/sleepypod-tmpfs-prep.service" \ "$systemd_dir/persistent-biometrics.mount" \ "$local_bin_dir/sleepypod-tmpfs-prep" \ + "$local_bin_dir/sleepypod-biometrics-linker" \ "$local_bin_dir/sleepypod-biometrics-archiver" \ "$local_bin_dir/sleepypod-biometrics-pruner" systemctl daemon-reload 2>/dev/null || true - echo "biometrics-archiver: removed legacy RAW tmpfs units for NATS firmware (cold archive preserved)" + echo "biometrics-archiver: removed legacy RAW tmpfs units (cold archive preserved)" } install_biometrics_archiver() { @@ -192,13 +211,16 @@ install_biometrics_archiver() { # Scripts local s - for s in sleepypod-tmpfs-prep sleepypod-biometrics-archiver sleepypod-biometrics-pruner; do + for s in sleepypod-tmpfs-prep sleepypod-biometrics-linker \ + sleepypod-biometrics-archiver sleepypod-biometrics-pruner; do install -m 0755 "$archiver_src/$s" "/usr/local/bin/$s" done # Systemd units install -m 0644 "$archiver_src/persistent-biometrics.mount" /etc/systemd/system/ install -m 0644 "$archiver_src/sleepypod-tmpfs-prep.service" /etc/systemd/system/ + install -m 0644 "$archiver_src/sleepypod-biometrics-linker.service" /etc/systemd/system/ + install -m 0644 "$archiver_src/sleepypod-biometrics-linker.timer" /etc/systemd/system/ install -m 0644 "$archiver_src/sleepypod-biometrics-archiver.service" /etc/systemd/system/ install -m 0644 "$archiver_src/sleepypod-biometrics-archiver.timer" /etc/systemd/system/ install -m 0644 "$archiver_src/sleepypod-biometrics-pruner.service" /etc/systemd/system/ @@ -253,6 +275,7 @@ install_biometrics_archiver() { done fi + systemctl enable --now sleepypod-biometrics-linker.timer systemctl enable --now sleepypod-biometrics-archiver.timer systemctl enable --now sleepypod-biometrics-pruner.timer diff --git a/scripts/tests/biometricsArchiver.test.ts b/scripts/tests/biometricsArchiver.test.ts index d99101843..5f2403ae7 100644 --- a/scripts/tests/biometricsArchiver.test.ts +++ b/scripts/tests/biometricsArchiver.test.ts @@ -2,22 +2,30 @@ import { spawnSync } from 'node:child_process' import { + appendFileSync, existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, + statSync, + symlinkSync, + utimesSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { gunzipSync, gzipSync } from 'node:zlib' import { afterEach, beforeEach, describe, expect, it } from 'vitest' const helperPath = resolve('scripts/lib/biometrics-archiver-helpers') const archiverScript = resolve('modules/biometrics-archiver/sleepypod-biometrics-archiver') +const linkerScript = resolve('modules/biometrics-archiver/sleepypod-biometrics-linker') let root: string let tmpfsDir: string +let pendingDir: string let archiveDir: string let systemdDir: string let localBinDir: string @@ -28,22 +36,58 @@ let mountUnit: string let callsFile: string let unmountedFile: string +/** Write a runnable shell stub in the isolated test directory. */ function writeExecutable(path: string, lines: string[]): void { writeFileSync(path, lines.join(String.fromCharCode(10)), { mode: 0o755 }) } -function writeRaw(name = '00001.RAW'): string { +/** Create a live firmware frame with controllable contents. */ +function writeRaw(name = '00001.RAW', body = 'sensor frame'): string { const path = join(tmpfsDir, name) - writeFileSync(path, 'sensor frame') + writeFileSync(path, body) return path } +/** Read recorded systemctl calls in execution order. */ function calls(): string { return existsSync(callsFile) ? readFileSync(callsFile, 'utf8') : '' } +/** Env shared by direct script runs: stub PATH plus the tmpfs/archive paths. */ +function scriptEnv(extraEnv: Partial): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: [stubBinDir, process.env.PATH ?? ''].join(':'), + UNMOUNTED_FILE: unmountedFile, + TMPFS_DIR: tmpfsDir, + ARCHIVE_DIR: archiveDir, + ...extraEnv, + } +} + +/** Run the real linker with stubbed mount detection. */ +function runLinker(extraEnv: Partial = {}) { + return spawnSync('/bin/bash', [linkerScript], { encoding: 'utf8', env: scriptEnv(extraEnv) }) +} + +/** Run the real archiver against isolated RAW and archive directories. */ +function runArchiver(extraEnv: Partial = {}) { + // KEEP_RECENT_MIN defaults high so the live-frame pass stays out of the way + // of tests about pinned links; the firmware-left-behind path passes its own. + return spawnSync('/bin/bash', [archiverScript], { + encoding: 'utf8', + env: scriptEnv({ KEEP_RECENT_MIN: '15', ...extraEnv }), + }) +} + +/** List retained pins deterministically. */ +function pendingEntries(): string[] { + return existsSync(pendingDir) ? readdirSync(pendingDir).sort() : [] +} + +/** Run either cleanup entry point against isolated paths and systemctl stubs. */ function runHelper(extraEnv: Partial = {}) { - return spawnSync('/bin/bash', ['-c', '. "$HELPER_PATH"; remove_biometrics_archiver_for_nats'], { + return spawnSync('/bin/bash', ['-c', '. "$HELPER_PATH"; ${CLEANUP_FUNCTION:-remove_biometrics_archiver_for_nats}'], { encoding: 'utf8', env: { ...process.env, @@ -66,6 +110,7 @@ function runHelper(extraEnv: Partial = {}) { beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'sleepypod-archiver-')) tmpfsDir = join(root, 'biometrics') + pendingDir = join(tmpfsDir, '.pending') archiveDir = join(root, 'biometrics-archive') systemdDir = join(root, 'systemd') localBinDir = join(root, 'local-bin') @@ -107,7 +152,177 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) +describe('sleepypod-biometrics-linker', () => { + it.each(['', '0', '-1', '100', '101', '08', 'abc', '1+1', '50.5', '999999999999999999999'])('rejects invalid cap %j before creating pins', (cap) => { + const live = writeRaw() + const result = runLinker({ PENDING_MAX_PCT: cap }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('PENDING_MAX_PCT must be an integer from 1 to 99') + expect(existsSync(pendingDir)).toBe(false) + expect(readFileSync(live, 'utf8')).toBe('sensor frame') + }) + + it.each(['1', '50', '99'])('accepts cap %s', (cap) => { + writeRaw() + expect(runLinker({ PENDING_MAX_PCT: cap }).status).toBe(0) + }) + + it('pins a live frame so it survives the firmware unlinking it at rotation', () => { + const live = writeRaw('0016B64E.RAW') + + const result = runLinker() + + expect(result.status).toBe(0) + expect(result.stdout).toContain('linked=1') + const pinned = join(pendingDir, '0016B64E.RAW') + // Same inode: no extra tmpfs while the firmware is still appending. + expect(statSync(pinned).ino).toBe(statSync(live).ino) + + rmSync(live) // what the firmware does within a second of rotating + expect(readFileSync(pinned, 'utf8')).toBe('sensor frame') + }) + + it('keeps the frames the firmware appends after the link exists', () => { + // The pin is a second name for one inode, not a snapshot: everything the + // firmware writes between the link and the rotation has to be in there. + const live = writeRaw('0016B64E.RAW', 'first half ') + + runLinker() + appendFileSync(live, 'second half') + rmSync(live) + + expect(readFileSync(join(pendingDir, '0016B64E.RAW'), 'utf8')).toBe('first half second half') + }) + + it('skips SEQNO.RAW and the tmpfs-prep symlinks', () => { + writeRaw('SEQNO.RAW') + const emmcFile = join(root, 'state.RAW') + writeFileSync(emmcFile, 'firmware state') + symlinkSync(emmcFile, join(tmpfsDir, 'staged.RAW')) + + const result = runLinker() + + expect(result.status).toBe(0) + expect(pendingEntries()).toEqual([]) + }) + + it('stays quiet and pins nothing on a repeat run', () => { + writeRaw() + runLinker() + + const result = runLinker() + + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('') + expect(pendingEntries()).toEqual(['00001.RAW']) + }) + + it('skips without touching anything when the tmpfs is not mounted', () => { + writeFileSync(unmountedFile, '') + writeRaw() + + const result = runLinker() + + expect(result.status).toBe(0) + expect(result.stderr).toContain('not a tmpfs mount') + expect(existsSync(pendingDir)).toBe(false) + }) + + it('drops the oldest pinned frame when pending outgrows its tmpfs share', () => { + // A stuck archiver must not let pinned frames crowd out live writes. + const body = 'x'.repeat(256 * 1024) + const older = writeRaw('00001.RAW', body) + const newer = writeRaw('00002.RAW', body) + utimesSync(older, new Date(1000), new Date(1000)) + utimesSync(newer, new Date(2000), new Date(2000)) + // Stub df so the cap lands between one and two pinned frames. + writeExecutable(join(stubBinDir, 'df'), [ + '#!/usr/bin/env bash', + 'echo "Filesystem 1024-blocks Used Available Capacity Mounted-on"', + 'echo "tmpfs 800 0 800 0% /persistent/biometrics"', + ]) + + const result = runLinker({ PENDING_MAX_PCT: '50' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('dropped=1') + expect(pendingEntries()).toEqual(['00002.RAW']) + // Live frames belong to the firmware β€” the linker never removes those. + expect(existsSync(older)).toBe(true) + expect(existsSync(newer)).toBe(true) + }) + + it('keeps every pinned frame when df reports no usable capacity', () => { + writeRaw() + writeExecutable(join(stubBinDir, 'df'), ['#!/usr/bin/env bash', 'exit 1']) + + const result = runLinker() + + expect(result.status).toBe(0) + expect(pendingEntries()).toEqual(['00001.RAW']) + }) +}) + describe('sleepypod-biometrics-archiver', () => { + it('archives a pinned frame once the firmware has released the live file', () => { + const live = writeRaw('0016B64E.RAW', 'waveform bytes') + runLinker() + rmSync(live) + + const result = runArchiver() + + expect(result.status).toBe(0) + expect(result.stdout).toContain('archived=1') + expect(result.stdout).toContain('pending=0') + const gz = join(archiveDir, '0016B64E.RAW.gz') + expect(gunzipSync(readFileSync(gz)).toString()).toBe('waveform bytes') + expect(pendingEntries()).toEqual([]) + }) + + it('leaves a pinned frame alone while the firmware still holds the live file', () => { + writeRaw() + runLinker() + + const result = runArchiver() + + expect(result.status).toBe(0) + expect(result.stdout).toContain('archived=0') + expect(result.stdout).toContain('pending=1') + expect(existsSync(join(archiveDir, '00001.RAW.gz'))).toBe(false) + expect(pendingEntries()).toEqual(['00001.RAW']) + }) + + it('drops a pinned frame that is already in the archive', () => { + const live = writeRaw() + runLinker() + rmSync(live) + mkdirSync(archiveDir, { recursive: true }) + const gz = join(archiveDir, '00001.RAW.gz') + writeFileSync(gz, gzipSync(Buffer.from('archived earlier'))) + + const result = runArchiver() + + expect(result.status).toBe(0) + expect(result.stdout).toContain('removed=1') + expect(pendingEntries()).toEqual([]) + expect(gunzipSync(readFileSync(gz)).toString()).toBe('archived earlier') + }) + + it('archives a live frame the firmware left behind and clears its pin', () => { + writeRaw() + runLinker() + + const result = runArchiver({ KEEP_RECENT_MIN: '-1' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('archived=1') + expect(result.stdout).toContain('removed=1') + expect(result.stdout).toContain('pending=0') + expect(existsSync(join(tmpfsDir, '00001.RAW'))).toBe(false) + expect(pendingEntries()).toEqual([]) + }) + it('returns nonzero and preserves the source when gzip fails', () => { const raw = writeRaw() writeExecutable(join(stubBinDir, 'gzip'), ['#!/usr/bin/env bash', 'exit 1']) @@ -129,12 +344,17 @@ describe('sleepypod-biometrics-archiver', () => { }) }) -describe('remove_biometrics_archiver_for_nats', () => { +describe.each(['remove_biometrics_archiver_for_nats', 'remove_biometrics_archiver'])('%s', (cleanupFunction) => { + /** Exercise the same preservation guarantees for NATS migration and uninstall. */ + function runCleanup(extraEnv: Partial = {}) { + return runHelper({ CLEANUP_FUNCTION: cleanupFunction, ...extraEnv }) + } + it('does not unmount or remove recovery tools when the archiver fails', () => { const raw = writeRaw() writeExecutable(archiverBin, ['#!/usr/bin/env bash', 'exit 1']) - const result = runHelper() + const result = runCleanup() expect(result.status).toBe(1) expect(calls()).not.toContain('stop persistent-biometrics.mount') @@ -147,7 +367,7 @@ describe('remove_biometrics_archiver_for_nats', () => { const raw = writeRaw() writeExecutable(archiverBin, ['#!/usr/bin/env bash', 'exit 0']) - const result = runHelper() + const result = runCleanup() expect(result.status).toBe(1) expect(result.stderr).toContain('unarchived RAW frame remains') @@ -155,11 +375,26 @@ describe('remove_biometrics_archiver_for_nats', () => { expect(existsSync(raw)).toBe(true) }) + it('rejects a successful archiver exit when a pinned frame remains', () => { + // A pinned link can be the only surviving copy of an unlinked frame, so it + // blocks the unmount exactly like a live frame would. + mkdirSync(pendingDir, { recursive: true }) + const pinned = join(pendingDir, '00001.RAW') + writeFileSync(pinned, 'sensor frame') + + const result = runCleanup() + + expect(result.status).toBe(1) + expect(result.stderr).toContain('unarchived RAW frame remains') + expect(calls()).not.toContain('stop persistent-biometrics.mount') + expect(existsSync(pinned)).toBe(true) + }) + it('rejects missing archival tooling when a RAW frame remains', () => { const raw = writeRaw() rmSync(archiverBin) - const result = runHelper() + const result = runCleanup() expect(result.status).toBe(1) expect(calls()).not.toContain('stop persistent-biometrics.mount') @@ -170,14 +405,19 @@ describe('remove_biometrics_archiver_for_nats', () => { const seqno = writeRaw('SEQNO.RAW') rmSync(archiverBin) - const result = runHelper() + const result = runCleanup() expect(result.status).toBe(0) expect(calls()).toContain('stop persistent-biometrics.mount') expect(existsSync(seqno)).toBe(true) for (const unit of ['sleepypod', 'sleepypod-cover-buttons', 'sleepypod-sleep-detector', 'sleepypod-piezo-processor', 'sleepypod-calibrator', 'sleepypod-environment-monitor']) { - expect(readFileSync(join(systemdDir, `${unit}.service.d/zz-nats-raw-fallback.conf`), 'utf8')) - .toContain('Environment="RAW_DATA_DIR=/persistent"') + const fallback = join(systemdDir, `${unit}.service.d/zz-nats-raw-fallback.conf`) + if (cleanupFunction === 'remove_biometrics_archiver_for_nats') { + expect(readFileSync(fallback, 'utf8')).toContain('Environment="RAW_DATA_DIR=/persistent"') + } + else { + expect(existsSync(fallback)).toBe(false) + } } expect(calls().indexOf('daemon-reload')).toBeLessThan(calls().indexOf('stop persistent-biometrics.mount')) expect(existsSync(mountUnit)).toBe(false) @@ -186,8 +426,11 @@ describe('remove_biometrics_archiver_for_nats', () => { it('archives all frames before unmounting and removing recovery tools', () => { const raw = writeRaw() + runLinker() + rmSync(raw) // the pending link is now the only copy + const live = writeRaw('00002.RAW', 'final live frame') - const result = runHelper({ + const result = runCleanup({ BIOMETRICS_ARCHIVER_BIN: archiverScript, TMPFS_DIR: tmpfsDir, ARCHIVE_DIR: archiveDir, @@ -196,13 +439,17 @@ describe('remove_biometrics_archiver_for_nats', () => { expect(result.status).toBe(0) expect(calls()).toContain('stop persistent-biometrics.mount') expect(existsSync(raw)).toBe(false) - expect(existsSync(join(archiveDir, '00001.RAW.gz'))).toBe(true) + expect(gunzipSync(readFileSync(join(archiveDir, '00001.RAW.gz'))).toString()).toBe('sensor frame') + expect(pendingEntries()).toEqual([]) + expect(existsSync(live)).toBe(false) + expect(gunzipSync(readFileSync(join(archiveDir, '00002.RAW.gz'))).toString()).toBe('final live frame') + expect(calls().indexOf('stop sleepypod-biometrics-linker.service')).toBeLessThan(calls().indexOf('restart frank.service')) expect(existsSync(mountUnit)).toBe(false) expect(existsSync(recoveryTool)).toBe(false) }) it('preserves the mount and recovery tools when frank cannot restart', () => { - const result = runHelper({ FAIL_RESTART: '1' }) + const result = runCleanup({ FAIL_RESTART: '1' }) expect(result.status).toBe(1) expect(calls()).toContain('restart frank.service') @@ -212,7 +459,7 @@ describe('remove_biometrics_archiver_for_nats', () => { }) it('preserves recovery tools when the mount remains active after stop', () => { - const result = runHelper({ STOP_LEAVES_MOUNTED: '1' }) + const result = runCleanup({ STOP_LEAVES_MOUNTED: '1' }) expect(result.status).toBe(1) expect(calls()).toContain('stop persistent-biometrics.mount') From 839deea627433d8c30a829f2fb014229901550c8 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Thu, 24 Sep 2026 17:37:59 -0400 Subject: [PATCH 012/119] fix(sleep-detector): raw-unit, rise-only presence for Pod 3/4 capSense The Pod 3/4 presence check summed |val - mean| / std with std floored at 5 and threshold 6. A quiet calibration window puts std at the floor, so ~30 raw units of thermal drift in either direction read as occupied: sessions stayed open for many hours after the sleeper got up, and a baseline captured while someone was in bed made the empty bed (far below it) read occupied until the next recalibration. Presence is now the summed signed rise over the per-channel baseline in raw units (default 300), mirroring the existing capSense2 fix. On Pod 4 hardware empty-bed drift stays around +100 summed while an occupant adds at least ~+450. Legacy profiles store a z-score threshold (6.0) and no format key, so they use the raw-unit default instead of being read as raw units. Co-Authored-By: Claude Opus 5.5 --- docs/sleep-detector.md | 6 +- modules/common/calibration.py | 63 +++++++++++++---- modules/common/test_calibration.py | 69 +++++++++++++++++++ modules/common/test_calibration_algorithms.py | 2 +- modules/sleep-detector/main.py | 5 +- 5 files changed, 125 insertions(+), 20 deletions(-) diff --git a/docs/sleep-detector.md b/docs/sleep-detector.md index c49b574af..8d505a9ea 100644 --- a/docs/sleep-detector.md +++ b/docs/sleep-detector.md @@ -13,7 +13,7 @@ flowchart TD TypeFilter -->|"frzHealth/frzTherm"| PumpState["Update PumpGateCapSense\n(track pump RPM per side)"] TypeFilter -->|"capSense/capSense2"| Extract["Extract channel values\n(sentinel filter, ref compensation)"] TypeFilter -->|Other| Skip[Skip] - Extract --> Presence["Presence detection\n(calibrated z-score or fallback)"] + Extract --> Presence["Presence detection\n(raw-unit rise over baseline or fallback)"] Extract --> PumpGate{"Pump gate active?\n(RPM > 0 OR guard OR ref anomaly)"} PumpGate -->|Yes| ZeroDelta["delta = 0\n(suppress artifact)"] PumpGate -->|No| Delta["Movement delta\n(|current - previous| per channel)"] @@ -127,7 +127,9 @@ The median filter output is clamped to [0, 1000] before writing to the database. ## Presence Detection -Presence uses calibrated z-score thresholds from `calibration_profiles` when available, falling back to a fixed sum threshold (`PRESENCE_THRESHOLD = 1500` for capSense, `60.0` for capSense2). Calibration profiles are reloaded every 60 seconds. +Presence is the summed **signed** raw-unit deviation of the sensing channels from the calibrated per-channel means, compared against the profile threshold in raw units (`CAPSENSE_PRESENCE_THRESHOLD = 300` for capSense, the profile's `threshold` for capSense2). Only a rise counts: a body adds capacitance, so a reading below baseline is an empty bed. Legacy capSense profiles without `format: "capSense"` stored a z-score threshold (6.0) and use the raw-unit default instead. Without any profile the detector falls back to a fixed sum threshold (`PRESENCE_THRESHOLD = 1500` for capSense, `60.0` for capSense2). Calibration profiles are reloaded every 60 seconds. + +The former z-score check (sum of `|val - mean| / std`, std floored at 5) flagged ~30 raw units of thermal drift in either direction as occupied, holding sessions open until the 16 h cap or the next recalibration. ## Sleep Sessions diff --git a/modules/common/calibration.py b/modules/common/calibration.py index 6957d7237..a5d769749 100644 --- a/modules/common/calibration.py +++ b/modules/common/calibration.py @@ -252,7 +252,7 @@ def calibrate(self, records: list, side: str) -> CalibrationResult: # Compute baselines from best window window_end = min(best_start + window_samples, len(timestamps)) - baseline = {"channels": {}, "threshold": 6.0} + baseline = {"channels": {}, "threshold": CAPSENSE_PRESENCE_THRESHOLD, "format": "capSense"} for ch in self.CHANNELS: segment = channels[ch][best_start:window_end] @@ -701,11 +701,55 @@ def validate(self, hr: Optional[float], hrv: Optional[float] = None, # ── Presence detection helpers ── +# Presence threshold for Pod 3/4 capSense, in RAW units summed over the three +# channels. On Pod 4 hardware, empty-bed drift and bedding shifts stay within +# a few tens of units per channel (~+100 summed), while an occupant raises the +# level by at least ~+150/channel (+450 summed), typically ~+600/channel. +CAPSENSE_PRESENCE_THRESHOLD = 300.0 +CAPSENSE_CHANNELS = ("out", "cen", "in") + + +def capsense_threshold(baselines: dict) -> float: + """Raw-unit presence threshold for a capSense profile. + + Profiles written before the raw-unit check carry `threshold: 6.0` as a + z-score and have no `format` key. Reading that as raw units would make + presence 50x more sensitive, so legacy profiles use the default instead. + """ + if baselines.get("format") == "capSense": + return float(baselines.get("threshold", CAPSENSE_PRESENCE_THRESHOLD)) + return CAPSENSE_PRESENCE_THRESHOLD + + +def capsense_deviation(record: dict, side: str, baselines: dict) -> Optional[float]: + """Summed SIGNED raw-unit deviation of a capSense frame from the baseline + channel means, or None when the frame has no data for `side`.""" + data = record.get(side, {}) + if not data: + return None + channels = baselines.get("channels", {}) + return sum( + int(data.get(ch, 0)) - float(channels.get(ch, {}).get("mean", 0)) + for ch in CAPSENSE_CHANNELS + ) + + def is_present_capsense_calibrated( record: dict, side: str, baselines: Optional[dict], fallback_threshold: int = 1500, ) -> bool: - """Z-score based presence detection using calibrated baselines. + """Presence detection for Pod 3/4 capSense (named int channels). + + Occupied only when the summed signed deviation from the calibrated + channel means RISES above the raw-unit threshold. A body only ever adds + capacitance, so a reading below baseline is an empty bed. + + History: this used to be a z-score check (sum of |val-mean|/std, std + floored at 5, threshold 6). A quiet calibration window puts std at the + floor, so ~30 raw units of thermal drift β€” in either direction β€” read as + occupied: sessions stuck open until the MAX_SESSION_S cap or the next + recalibration. Same failure and fix as + is_present_capsense2_calibrated. Falls back to simple sum threshold if no calibration available. """ @@ -714,21 +758,10 @@ def is_present_capsense_calibrated( return False if baselines is None: - total = int(data.get("out", 0)) + int(data.get("cen", 0)) + int(data.get("in", 0)) + total = sum(int(data.get(ch, 0)) for ch in CAPSENSE_CHANNELS) return total > fallback_threshold - z_sum = 0.0 - channels = baselines.get("channels", {}) - for ch in ("out", "cen", "in"): - val = int(data.get(ch, 0)) - ch_cal = channels.get(ch, {}) - std = ch_cal.get("std", 1) - mean = ch_cal.get("mean", 0) - if std > 0: - z_sum += abs((val - mean) / std) - - threshold = baselines.get("threshold", 6.0) - return z_sum > threshold + return capsense_deviation(record, side, baselines) > capsense_threshold(baselines) # Nominal capSense2 reference-channel value used when a profile predates the diff --git a/modules/common/test_calibration.py b/modules/common/test_calibration.py index e2641fa08..a3613334d 100644 --- a/modules/common/test_calibration.py +++ b/modules/common/test_calibration.py @@ -13,6 +13,9 @@ """ from common.calibration import ( CAPSENSE2_REF_NOMINAL, + CAPSENSE_PRESENCE_THRESHOLD, + capsense_deviation, + is_present_capsense_calibrated, is_present_capsense2_calibrated, ) @@ -99,3 +102,69 @@ def test_short_or_missing_values_read_absent(self): {"left": {"values": [1.0, 2.0, 3.0]}}, "left", PROFILE) is False assert is_present_capsense2_calibrated({"left": {}}, "left", PROFILE) is False assert is_present_capsense2_calibrated({}, "left", PROFILE) is False + + +# Pod 3/4 capSense. Channel means are representative Pod 4 empty-bed levels. +# Legacy profiles carried a z-score +# `threshold: 6.0` with std floored at 5 β€” ~30 raw units of drift read as +# occupied, holding sessions open for 13-16h after the sleeper got up. +CAP_PROFILE = { + "format": "capSense", + "threshold": CAPSENSE_PRESENCE_THRESHOLD, + "channels": { + "out": {"mean": 1142.0, "std": 5.0}, + "cen": {"mean": 1582.0, "std": 5.0}, + "in": {"mean": 1673.0, "std": 5.0}, + }, +} +LEGACY_CAP_PROFILE = {"threshold": 6.0, "channels": CAP_PROFILE["channels"]} + + +def _cap(out, cen, inner): + return {"left": {"out": out, "cen": cen, "in": inner, "status": "good"}} + + +class TestCapSenseRawUnitPath: + def test_occupant_reads_present(self): + # Asleep on the left: ~+600/channel over the empty level. + assert is_present_capsense_calibrated(_cap(1742, 2182, 2273), "left", CAP_PROFILE) is True + + def test_smallest_observed_occupant_reads_present(self): + # A weak but real occupancy (edge of the bed): +150/channel (+450). + assert is_present_capsense_calibrated(_cap(1292, 1732, 1823), "left", CAP_PROFILE) is True + + def test_empty_bed_drift_reads_absent(self): + # The regression: slow empty-bed drift of a few tens of units per + # channel must not count as a person. + assert is_present_capsense_calibrated(_cap(1175, 1615, 1706), "left", CAP_PROFILE) is False + + def test_drop_below_baseline_reads_absent(self): + # A body only adds capacitance. A baseline taken while someone was in + # bed leaves the empty bed far BELOW it β€” the old |z| check read that + # as occupied and stuck present all day. + assert is_present_capsense_calibrated(_cap(542, 982, 1073), "left", CAP_PROFILE) is False + + def test_threshold_boundary_is_exclusive(self): + assert is_present_capsense_calibrated(_cap(1242, 1682, 1773), "left", CAP_PROFILE) is False # 300 + assert is_present_capsense_calibrated(_cap(1242, 1682, 1774), "left", CAP_PROFILE) is True # 301 + + def test_legacy_z_score_threshold_is_not_read_as_raw_units(self): + # 6.0 was a z-score; as raw units it would flag +3/channel drift. + assert is_present_capsense_calibrated(_cap(1145, 1585, 1676), "left", LEGACY_CAP_PROFILE) is False + assert is_present_capsense_calibrated(_cap(1292, 1732, 1823), "left", LEGACY_CAP_PROFILE) is True + + def test_profile_threshold_is_respected(self): + profile = dict(CAP_PROFILE, threshold=500.0) + assert is_present_capsense_calibrated(_cap(1292, 1732, 1823), "left", profile) is False + assert is_present_capsense_calibrated(_cap(1342, 1782, 1873), "left", profile) is True + + def test_deviation_is_signed_sum(self): + assert capsense_deviation(_cap(1152, 1572, 1675), "left", CAP_PROFILE) == 2.0 + assert capsense_deviation({"right": {}}, "left", CAP_PROFILE) is None + + def test_missing_side_reads_absent(self): + assert is_present_capsense_calibrated({"right": {"out": 9999}}, "left", CAP_PROFILE) is False + + def test_no_profile_falls_back_to_raw_sum(self): + assert is_present_capsense_calibrated(_cap(600, 500, 401), "left", None) is True + assert is_present_capsense_calibrated(_cap(500, 500, 500), "left", None) is False diff --git a/modules/common/test_calibration_algorithms.py b/modules/common/test_calibration_algorithms.py index fb74ec402..1bc6595ce 100644 --- a/modules/common/test_calibration_algorithms.py +++ b/modules/common/test_calibration_algorithms.py @@ -35,7 +35,7 @@ def test_cap_baseline_scans_final_window_and_floors_std(): result = CapCalibrator().calibrate(records, "left") assert (result.window_start, result.window_end, result.samples_used) == (1001, 1300, 300) assert result.quality_score == 1 - assert result.params == {"threshold": 6.0, "channels": { + assert result.params == {"threshold": 300.0, "format": "capSense", "channels": { "out": {"mean": 100, "std": 5}, "cen": {"mean": 200, "std": 5}, "in": {"mean": 300, "std": 5}, }} diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index 1eb454b30..d56de8ec5 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -11,8 +11,9 @@ Detection logic: Each capacitance record contains three channels per side (out, cen, in). - Presence is determined via calibrated z-score thresholds when a calibration - profile is available, falling back to a fixed sum threshold otherwise. + Presence is the summed signed raw-unit rise above the calibrated per-channel + baseline (a body only adds capacitance), falling back to a fixed sum + threshold when no calibration profile is available. A session starts on the first present sample and ends after ABSENCE_TIMEOUT_S consecutive absent samples. From ae89830dad2b953994e3745a3acd352ba85387fa Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Thu, 24 Sep 2026 17:40:01 -0400 Subject: [PATCH 013/119] feat(sleep-detector): persist in-progress sessions across restarts An open session lived only in memory, so a service restart or pod reboot before the sleeper's absence closed it dropped the whole night. The tracker state is now checkpointed to sleep-detector-state.json next to biometrics.db (tmp + fsync + rename) every 60s and immediately on session start, bed-exit and close. On startup a recent session resumes; if absence is committed before any presence is seen, the exit is dated at the last pre-restart presence. A save older than 30 min is closed at the last presence instead of resumed. Samples the RAW follower replays from the current file's start are skipped. Co-Authored-By: Claude Opus 5.5 --- docs/sleep-detector.md | 10 ++ modules/sleep-detector/main.py | 147 ++++++++++++++++++++++++++++ modules/sleep-detector/test_main.py | 144 +++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) diff --git a/docs/sleep-detector.md b/docs/sleep-detector.md index 8d505a9ea..cfaf490a1 100644 --- a/docs/sleep-detector.md +++ b/docs/sleep-detector.md @@ -135,6 +135,14 @@ The former z-score check (sum of `|val - mean| / std`, std floored at 5) flagged A session starts on the first present sample and ends after `ABSENCE_TIMEOUT_S` (120s) of consecutive absence. Sessions shorter than `MIN_SESSION_S` (300s = 5 min) are discarded as false positives. +### Surviving restarts + +An open session lives in memory, so it is checkpointed to `sleep-detector-state.json` next to `biometrics.db` (override with `SLEEP_DETECTOR_STATE_PATH`) every `STATE_SAVE_INTERVAL_S` and immediately on session start, bed-exit, and close. The write is tmp + fsync + rename. On startup the detector: + +- **resumes** the session when the last saved sample is recent. If the occupant is still in bed the downtime counts as sleep; if absence is committed before any presence is seen, the exit is dated at the last pre-restart presence (they left while the detector was down). +- **closes** it at the last presence when the gap exceeds `STATE_MAX_GAP_S` (30 min), since the downtime can't be attributed to sleep. +- **skips replayed samples**: the RAW follower re-reads the current file from offset 0, so samples at or before the saved `last_ts` are ignored. + Session records include: - Entry/exit timestamps - Duration @@ -186,6 +194,8 @@ This filters phantom-session flicker (1-3 scattered non-still epochs per bucket) | `MOVEMENT_INTERVAL_S` | 60 s | One movement score per minute; matches AASM epoch length | | `PRESENCE_THRESHOLD` | 1500 | Fallback for uncalibrated capSense (Pod 3) | | `CALIBRATION_RELOAD_S` | 60 s | Poll calibration_profiles for updates | +| `STATE_SAVE_INTERVAL_S` | 60 s | Checkpoint an open session to the state file | +| `STATE_MAX_GAP_S` | 30 min | Longer downtime closes a restored session instead of resuming it | | Movement scale (capSense2) | 10x | Pod 5 float channels, deltas ~0.05-5.0 | | Movement scale (capSense) | 0.5x | Pod 3 int ADC channels, deltas ~1-50 | | Movement cap | 1000 | Prevents outlier scores from sensor glitches | diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index d56de8ec5..99dd656b0 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -110,6 +110,17 @@ PRESENCE_THRESHOLD = 1500 # How often to reload calibration profiles (seconds) CALIBRATION_RELOAD_S = 60 +# In-progress session state survives restarts/reboots via this file. Without +# it a reboot or service restart mid-session silently dropped the whole night. +STATE_PATH = Path(os.environ.get( + "SLEEP_DETECTOR_STATE_PATH", str(BIOMETRICS_DB.parent / "sleep-detector-state.json"))) +STATE_VERSION = 1 +# How often to checkpoint an open session (seconds). Session start/close and +# bed-exits checkpoint immediately. +STATE_SAVE_INTERVAL_S = 60 +# A restored session whose last sample is older than this is closed at the +# last presence instead of resumed β€” the downtime can't be attributed to sleep. +STATE_MAX_GAP_S = 30 * 60 # Earliest ts considered a valid wall-clock timestamp (2020-01-01 UTC). # RAW frames very rarely arrive with a tiny relative ts (e.g. 3s after some # synthetic origin) before the firmware has a real wall-clock reference. @@ -295,6 +306,42 @@ def write_movement(holder: "DBHolder", side: str, return False +def load_state(path: Path) -> dict: + """Read the persisted per-side tracker state; {} when missing or unusable.""" + try: + state = json.loads(path.read_text()) + except FileNotFoundError: + return {} + except (OSError, ValueError) as e: + log.warning("Ignoring unreadable state file %s: %s", path, e) + return {} + if not isinstance(state, dict) or state.get("version") != STATE_VERSION: + log.warning("Ignoring state file %s with unexpected shape/version", path) + return {} + return state + + +def save_state(path: Path, trackers) -> bool: + """Atomically write every tracker's state. Returns True on success. + + tmp + fsync + rename so a power cut leaves either the previous or the new + file, never a truncated one.""" + state = {"version": STATE_VERSION} + for t in trackers: + state[t.side] = t.snapshot() + tmp = path.with_name(path.name + ".tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(state, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + return True + except OSError as e: + log.warning("Could not save state to %s: %s", path, e) + return False + + def report_health(status: str, message: str) -> None: try: conn = sqlite3.connect(str(SLEEPYPOD_DB), timeout=2.0) @@ -649,8 +696,81 @@ class SessionTracker: # (absence-timeout) close. Two in a row means the presence signal never # dropped for 32+ hours β€” a stuck level signal, not a sleeper. _consecutive_cap_closes: int = 0 + # ts of the latest processed sample, persisted so a restore can tell how + # long the detector was down. + _last_ts: Optional[float] = None + # Set by restore() when the occupant was present at shutdown; cleared by + # the first present sample afterwards. If absence is committed first, the + # occupant left during the downtime, so the exit is dated at the last + # pre-restart presence instead of the first post-restart sample. + _resumed_last_present: Optional[float] = None + # The RAW follower re-reads the current file from offset 0 on startup, so + # up to ~15 min of already-processed samples replay after a restore; + # samples at or before this ts are skipped. + _replay_until_ts: Optional[float] = None + # Session start/close or a bed-exit happened since the last checkpoint. + state_dirty: bool = False + + def snapshot(self) -> dict: + """JSON-serializable state needed to resume after a restart.""" + return { + "session_start": self._session_start.timestamp() if self._session_start else None, + "last_present_ts": self._last_present_ts, + "present_intervals": self._present_intervals, + "absent_intervals": self._absent_intervals, + "interval_start": self._interval_start, + "was_present": self._was_present, + "exit_count": self._exit_count, + "debounced_present": self._debounced_present, + "state_since": self._state_since, + "consecutive_cap_closes": self._consecutive_cap_closes, + "last_ts": self._last_ts, + } + + def restore(self, state: Optional[dict], now: float) -> None: + """Resume an in-progress session saved by snapshot(). + + A session whose last sample is older than STATE_MAX_GAP_S is closed at + the last presence rather than resumed.""" + if not isinstance(state, dict): + return + try: + self._consecutive_cap_closes = int(state.get("consecutive_cap_closes") or 0) + start = state.get("session_start") + if start is None: + return + self._session_start = datetime.fromtimestamp(float(start), tz=timezone.utc) + self._last_present_ts = state.get("last_present_ts") + self._present_intervals = list(state.get("present_intervals") or []) + self._absent_intervals = list(state.get("absent_intervals") or []) + self._interval_start = state.get("interval_start") + self._was_present = bool(state.get("was_present")) + self._exit_count = int(state.get("exit_count") or 0) + self._debounced_present = bool(state.get("debounced_present")) + self._state_since = state.get("state_since") + self._last_ts = state.get("last_ts") + self._replay_until_ts = self._last_ts + except (TypeError, ValueError, OverflowError, OSError) as e: + log.warning("%s: ignoring corrupt saved session: %s", self.side, e) + self._reset_session() + return + + last_seen = self._last_ts or self._last_present_ts or float(start) + if now - last_seen > STATE_MAX_GAP_S: + log.info("%s: saved session stale (down %.0f min) β€” closing at last presence", + self.side, (now - last_seen) / 60) + self._close_session(self._last_present_ts or last_seen) + return + + if self._debounced_present: + self._resumed_last_present = self._last_present_ts + log.info("%s: resumed session started at %s", self.side, self._session_start.isoformat()) def process(self, ts: float, record: dict) -> None: + if self._replay_until_ts is not None: + if ts <= self._replay_until_ts: + return # already processed before the restart + self._replay_until_ts = None baselines = self.calibration.get_baselines(self.side) rtype = record.get("type", "") # Only use baselines if they match the record format @@ -727,14 +847,22 @@ def _apply_debounce(self, ts: float, raw_present: bool) -> bool: return False def _update(self, ts: float, present: bool, movement: float) -> None: + self._last_ts = ts self._movement_buf.append(movement) self._flush_movement(ts) + if present: + self._resumed_last_present = None + # Debounce raw presence so brief capSense dropouts don't fragment the # session or inflate times_exited_bed (pod 88 field debug 2026-06-10). changed = self._apply_debounce(ts, present) # Timestamp of the true transition when one just committed, else `ts`. edge_ts = self._state_since if changed and self._state_since is not None else ts + if changed and not self._debounced_present and self._resumed_last_present is not None: + # Never seen present since the restart: they left during the downtime. + edge_ts = self._resumed_last_present + self._resumed_last_present = None if self._debounced_present: if self._session_start is None: @@ -742,6 +870,7 @@ def _update(self, ts: float, present: bool, movement: float) -> None: self._session_start = datetime.fromtimestamp(edge_ts, tz=timezone.utc) self._interval_start = edge_ts self._was_present = True + self.state_dirty = True log.info("%s: session started at %s", self.side, self._session_start.isoformat()) elif changed and self._interval_start is not None: @@ -758,6 +887,7 @@ def _update(self, ts: float, present: bool, movement: float) -> None: self._present_intervals.append([self._interval_start, edge_ts]) self._interval_start = edge_ts self._exit_count += 1 + self.state_dirty = True self._was_present = False @@ -829,6 +959,10 @@ def _close_session(self, left_ts: float) -> None: if not wrote: return + self._reset_session() + self.state_dirty = True + + def _reset_session(self) -> None: self._session_start = None self._last_present_ts = None self._present_intervals = [] @@ -845,6 +979,7 @@ def _close_session(self, left_ts: float) -> None: self._epoch_scores.clear() self._median_buf.clear() self._pump_gated_samples = 0 + self._resumed_last_present = None def _flush_movement(self, ts: float) -> None: if ts - self._last_movement_write < MOVEMENT_INTERVAL_S: @@ -920,6 +1055,11 @@ def main() -> None: # side is observed by the other on its next write (no orphaned handles). left = SessionTracker(side="left", db=db_holder, calibration=cal_cache, pump_gate=pump_gate) right = SessionTracker(side="right", db=db_holder, calibration=cal_cache, pump_gate=pump_gate) + trackers = (left, right) + saved = load_state(STATE_PATH) + for t in trackers: + t.restore(saved.get(t.side), time.time()) + last_save = time.monotonic() # Source selected once at startup: NatsFollower on new-firmware pods (NATS # reachable), else the unchanged .RAW tailer. Same decoded-record contract. follower = create_follower(RAW_DATA_DIR, _shutdown, poll_interval=0.5) @@ -960,11 +1100,18 @@ def main() -> None: left.process(ts, record) right.process(ts, record) + if (left.state_dirty or right.state_dirty + or time.monotonic() - last_save >= STATE_SAVE_INTERVAL_S): + if save_state(STATE_PATH, trackers): + left.state_dirty = right.state_dirty = False + last_save = time.monotonic() + except Exception as e: log.exception("Fatal error in main loop: %s", e) report_health("down", str(e)) sys.exit(1) finally: + save_state(STATE_PATH, trackers) cal_store.close() db_holder.conn.close() log.info("Shutdown complete") diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index ac5492722..ab2651c5f 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -433,3 +433,147 @@ def test_natural_exit_resets_cap_close_streak(self, caplog): assert len(caps) == 2 assert all("(1 consecutive)" in r.getMessage() for r in caps) assert [r for r in caplog.records if "stuck-occupied" in r.getMessage()] == [] + + +def _restart(old, now): + """Round-trip old's snapshot through JSON into a fresh tracker on the same + DB β€” what a service restart or pod reboot does via the state file.""" + import json + t = main.SessionTracker(side=old.side, db=old.db, calibration=None, pump_gate=None) + t.restore(json.loads(json.dumps(old.snapshot())), now) + return t + + +def _rows_full(t): + return t.db.conn.execute( + "SELECT entered_bed_at, left_bed_at, sleep_duration_seconds, times_exited_bed " + "FROM sleep_records").fetchall() + + +class TestSessionPersistence: + """A reboot or restart mid-session used to drop the open session β€” it + only lived in memory, so the night never reached sleep_records.""" + + BASE = 1_777_000_000.0 + + def _asleep(self, hours=6): + t = _tracker() + ts = self.BASE + samples = [(ts, True), (ts + 31, True)] + while ts < self.BASE + hours * 3600: + ts += 60 + samples.append((ts, True)) + _feed(t, samples) + return t, ts + + def test_reboot_mid_sleep_resumes_one_session(self): + t, ts = self._asleep() + t = _restart(t, now=ts + 300) # 5 min reboot, still in bed + wake = ts + 300 + 2 * 3600 + samples = [(ts + 300 + i * 60, True) for i in range(1, 121)] + samples += [(wake, False), (wake + 31, False), (wake + 200, False)] + _feed(t, samples) + + rows = _rows_full(t) + assert len(rows) == 1 + entered, left_at, duration_s, exits = rows[0] + assert entered == int(self.BASE) + assert left_at == int(wake) + assert exits == 1 + assert duration_s == int(wake - self.BASE) + + def test_left_bed_during_downtime_closes_at_last_presence(self): + t, ts = self._asleep() + t = _restart(t, now=ts + 600) # back up 10 min later, bed empty + _feed(t, [(ts + 600, False), (ts + 631, False), (ts + 800, False)]) + + rows = _rows_full(t) + assert len(rows) == 1 + _entered, left_at, duration_s, exits = rows[0] + # Dated at the last pre-restart presence, not the first sample after. + assert left_at == int(ts) + assert duration_s == int(ts - self.BASE) + assert exits == 1 + + def test_stale_saved_session_is_closed_not_resumed(self): + t, ts = self._asleep() + t = _restart(t, now=ts + main.STATE_MAX_GAP_S + 1) + + rows = _rows_full(t) + assert len(rows) == 1 + assert rows[0][1] == int(ts) # closed at last presence + assert t.snapshot()["session_start"] is None + assert t.state_dirty is True + + def test_replayed_samples_are_skipped_after_restore(self): + class _NoCal: + def get_baselines(self, side): + return None + + t, ts = self._asleep(hours=1) + t = _restart(t, now=ts + 60) + t.calibration = _NoCal() + t.pump_gate = main.PumpGateCapSense() + rec = {"type": "capSense", "left": {"out": 1, "cen": 1, "in": 1}} + + t.process(ts - 600, rec) # replay from the RAW file start + assert t._last_ts == ts + t.process(ts, rec) + assert t._last_ts == ts + t.process(ts + 1, rec) # first genuinely new sample + assert t._last_ts == ts + 1 + assert t._replay_until_ts is None + + def test_idle_tracker_keeps_cap_close_streak_only(self): + t = _tracker() + t._consecutive_cap_closes = 2 + t = _restart(t, now=self.BASE) + assert t._consecutive_cap_closes == 2 + assert t.snapshot()["session_start"] is None + + def test_corrupt_saved_session_is_ignored(self): + t = _tracker() + t.restore({"session_start": "not-a-number"}, now=self.BASE) + assert t.snapshot()["session_start"] is None + t.restore(None, now=self.BASE) + t.restore(["not", "a", "dict"], now=self.BASE) + assert _rows(t) == [] + + def test_session_start_close_and_exit_mark_state_dirty(self): + t = _tracker() + _feed(t, [(self.BASE, True), (self.BASE + 31, True)]) + assert t.state_dirty is True + t.state_dirty = False + leave = self.BASE + 4000 + _feed(t, [(leave, False), (leave + 31, False)]) + assert t.state_dirty is True # bed-exit + t.state_dirty = False + _feed(t, [(leave + 200, False)]) + assert t.state_dirty is True # session closed + + +class TestStateFile: + def test_round_trip(self, tmp_path): + t = _tracker() + _feed(t, [(1_777_000_000.0, True), (1_777_000_031.0, True)]) + path = tmp_path / "state.json" + assert main.save_state(path, (t,)) is True + state = main.load_state(path) + assert state["version"] == main.STATE_VERSION + assert state["left"]["session_start"] == 1_777_000_000.0 + assert not (tmp_path / "state.json.tmp").exists() + + def test_missing_file_is_empty(self, tmp_path): + assert main.load_state(tmp_path / "nope.json") == {} + + def test_corrupt_or_foreign_file_is_empty(self, tmp_path): + path = tmp_path / "state.json" + path.write_text("{truncated") + assert main.load_state(path) == {} + path.write_text('{"version": 999, "left": {}}') + assert main.load_state(path) == {} + path.write_text("[1, 2]") + assert main.load_state(path) == {} + + def test_unwritable_path_reports_failure(self, tmp_path): + assert main.save_state(tmp_path / "missing-dir" / "state.json", (_tracker(),)) is False From 5b474cd9db791df0b73aea8a00882b60c543da70 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Thu, 24 Sep 2026 17:48:41 -0400 Subject: [PATCH 014/119] feat(sleep-detector): self-adjusting presence baseline, no scheduled cap calibration Scheduled capacitance calibration picks the "quietest 5-minute window", which cannot tell an empty bed from a motionless sleeper. The daily fallback runs at a fixed UTC hour, which is mid-night in many timezones; when it captured the sleeper as "empty", the bed read occupied until the next recalibration. The sleep-detector now keeps its own per-side empty-bed baseline: - presence enters above the raw-unit threshold and exits below half of it - while empty and within the exit threshold it follows drift (30 min tau) - any reading below baseline pulls it down fast (2 min tau), so a baseline captured with someone in bed recovers minutes after they get up - a session force-closed at the 16h cap makes the current level the new empty level (a load that isn't a sleeper) - seeded from saved state, else the calibration profile, else the first frame; a newer calibration profile (manual recalibration) is adopted - persisted in the state file and published to calibration_profiles every 15 min (source "adaptive") so Node's capSense2 occupancy and the UI agree The calibrator no longer calibrates capacitance on startup, retry, the daily fallback, or the pre-prime trigger (now tagged source "scheduled"); a manual request still does. Piezo and temperature are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0014-sensor-calibration.md | 10 + docs/sleep-detector.md | 23 +- modules/calibrator/main.py | 34 ++- modules/calibrator/test_main.py | 41 +++- modules/common/calibration.py | 70 ++++-- modules/sleep-detector/main.py | 299 ++++++++++++++++++++++--- modules/sleep-detector/test_main.py | 238 +++++++++++++++++++- src/scheduler/jobManager.ts | 4 + src/scheduler/tests/jobManager.test.ts | 1 + 9 files changed, 651 insertions(+), 69 deletions(-) diff --git a/docs/adr/0014-sensor-calibration.md b/docs/adr/0014-sensor-calibration.md index 756d75afb..5de3e5be9 100644 --- a/docs/adr/0014-sensor-calibration.md +++ b/docs/adr/0014-sensor-calibration.md @@ -98,6 +98,16 @@ Use gRPC for structured communication between the Node.js core app and Python mo - Calibration is primarily scheduled 30 minutes before the pod's configured prime time. The bed should be empty after the pre-prime reboot (1h before). Users who disable priming fall back to the calibrator's internal 25-hour timer. The calibrator reads RAW data read-only, so running during sleep does not affect processing β€” the algorithm selects the quietest 5-minute window from the available data. +## Amendment: capacitance baseline is self-adjusting + +Scheduled capacitance calibration is retired. The 25-hour fallback runs at a fixed UTC hour (06:00 by default), which is the middle of the night in many timezones; when priming is disabled it is the only scheduled calibration, and its "quietest window" can be a sleeper's still stretch. With the z-score presence check of the time, a baseline captured that way made the empty bed read occupied until the next recalibration or the 16 h cap. The "quietest window" heuristic cannot tell an empty bed from a motionless sleeper. + +- The **sleep-detector owns the presence baseline** (`AdaptiveBaseline` in `modules/sleep-detector/main.py`): it follows slow empty-bed drift, drops quickly whenever the reading falls below baseline (a body only adds capacitance), resets after a session capped at 16 h (a load that isn't a sleeper), and persists across restarts. It publishes the baseline to `calibration_profiles` every 15 min with `source: "adaptive"` so Node (`src/lib/occupancy.ts`, capSense2) and the UI read the same level. +- The **calibrator runs capacitance only on a manual request**. Startup, retry, the daily fallback, and the pre-prime trigger (now tagged `source: "scheduled"`) calibrate piezo and temperature only. A manual capacitance calibration is adopted by the sleep-detector as a reseed. +- **Presence is a raw-unit rise over baseline** for both capSense (default 300, summed over out/cen/in) and capSense2 (profile threshold, default 6.0), exiting at half the threshold. + +Piezo and temperature scheduled calibration are unchanged; the piezo "quietest window" has the same blind spot and may deserve the same treatment. + ## Medical Threshold Rationale The following table documents every threshold change from free-sleep's values, with the medical or engineering rationale for each. diff --git a/docs/sleep-detector.md b/docs/sleep-detector.md index cfaf490a1..5d0079eee 100644 --- a/docs/sleep-detector.md +++ b/docs/sleep-detector.md @@ -127,10 +127,23 @@ The median filter output is clamped to [0, 1000] before writing to the database. ## Presence Detection -Presence is the summed **signed** raw-unit deviation of the sensing channels from the calibrated per-channel means, compared against the profile threshold in raw units (`CAPSENSE_PRESENCE_THRESHOLD = 300` for capSense, the profile's `threshold` for capSense2). Only a rise counts: a body adds capacitance, so a reading below baseline is an empty bed. Legacy capSense profiles without `format: "capSense"` stored a z-score threshold (6.0) and use the raw-unit default instead. Without any profile the detector falls back to a fixed sum threshold (`PRESENCE_THRESHOLD = 1500` for capSense, `60.0` for capSense2). Calibration profiles are reloaded every 60 seconds. +Presence is the summed **signed** raw-unit rise of the sensing channels over a per-side empty-bed baseline. A body only adds capacitance, so only a rise counts. Occupancy enters above the threshold (`CAPSENSE_PRESENCE_THRESHOLD = 300` summed over out/cen/in for capSense, the profile threshold, default `6.0`, for capSense2) and exits below `PRESENCE_EXIT_FRACTION` (half) of it, so a reading hovering at the threshold can't flap. capSense2 values are reference-compensated before comparison. The former z-score check (sum of `|val - mean| / std`, std floored at 5) flagged ~30 raw units of thermal drift in either direction as occupied, holding sessions open until the 16 h cap or the next recalibration. +### Self-adjusting baseline + +The baseline is maintained by the detector itself (`AdaptiveBaseline`), not by scheduled calibration β€” a scheduled snapshot could not tell a motionless sleeper from an empty bed (ADR-0014 amendment). + +- **Drift:** while the bed is empty and the reading is within the exit threshold, the baseline follows it with time constant `BASELINE_UP_TAU_S` (30 min). A load between the exit and enter thresholds is never learned as empty. +- **Contamination:** any reading below baseline pulls it down with `BASELINE_DOWN_TAU_S` (2 min), so a baseline captured with someone in bed recovers minutes after they get up. +- **Stuck load:** after a session force-closed at `MAX_SESSION_S`, the current level becomes the new empty level. +- **Seeding:** the saved state file, else the active calibration profile (legacy capSense profiles with a z-score threshold get the raw-unit default), else the first frame. A calibration profile newer than any seen β€” a manual recalibration β€” is adopted as a reseed. +- **Publishing:** every `BASELINE_PUBLISH_S` (15 min) the baseline is upserted to `calibration_profiles` with `source: "adaptive"`, in the calibrators' shape, so Node's occupancy check and the UI see the same level. Profiles marked adaptive are never re-adopted. +- Per-sample tracking time is capped at `BASELINE_MAX_STEP_S`, so a gap or restart can't move the baseline in one step. Frames with a missing side or capSense2 sentinels are ignored. + +Replaying recorded capSense data starting from a baseline captured with the sleeper in bed, the baseline recovers within minutes of them getting up, sessions end at the real wake time rather than at the next recalibration, and a bedding shift of a few tens of units per channel produces no session. + ## Sleep Sessions A session starts on the first present sample and ends after `ABSENCE_TIMEOUT_S` (120s) of consecutive absence. Sessions shorter than `MIN_SESSION_S` (300s = 5 min) are discarded as false positives. @@ -192,7 +205,11 @@ This filters phantom-session flicker (1-3 scattered non-still epochs per bucket) | `ABSENCE_TIMEOUT_S` | 120 s | Bathroom trips < 2 min don't split sessions | | `MIN_SESSION_S` | 300 s | Shorter periods are likely false positives | | `MOVEMENT_INTERVAL_S` | 60 s | One movement score per minute; matches AASM epoch length | -| `PRESENCE_THRESHOLD` | 1500 | Fallback for uncalibrated capSense (Pod 3) | +| `CAPSENSE_PRESENCE_THRESHOLD` | 300 | Raw-unit rise (summed) that means occupied, capSense | +| `PRESENCE_EXIT_FRACTION` | 0.5 | Exit threshold as a fraction of the enter threshold | +| `BASELINE_UP_TAU_S` | 30 min | Empty-bed drift tracking time constant | +| `BASELINE_DOWN_TAU_S` | 2 min | Recovery when the reading falls below baseline | +| `BASELINE_PUBLISH_S` | 15 min | Baseline written back to calibration_profiles | | `CALIBRATION_RELOAD_S` | 60 s | Poll calibration_profiles for updates | | `STATE_SAVE_INTERVAL_S` | 60 s | Checkpoint an open session to the state file | | `STATE_MAX_GAP_S` | 30 min | Longer downtime closes a restored session instead of resuming it | @@ -236,4 +253,4 @@ This filters phantom-session flicker (1-3 scattered non-still epochs per bucket) 8. **Median filter smoothing behavior.** The 3-epoch median filter is causal (trailing window), so it does not depend on future epochs. It may still soften abrupt transitions, which is acceptable since movement data is not used for real-time alerting. -9. **Calibrator RAW path coupling (Pod 5).** The calibrator reads RAW files from `RAW_DATA_DIR`, which must match the tmpfs path created by `sleepypod-tmpfs-prep` (`/persistent/biometrics`, per ADR-0018). A mismatch causes every daily run to fail with "No capSense records available" and the detector silently falls back to `PRESENCE_THRESHOLD = 60.0`, producing severe presence chatter (`times_exited_bed` > 100 per session). The calibrator unit file declares `RequiresMountsFor=/persistent/biometrics` to surface this as a startup failure rather than a silent runtime degradation. +9. **Calibrator RAW path coupling (Pod 5).** The calibrator reads RAW files from `RAW_DATA_DIR`, which must match the tmpfs path created by `sleepypod-tmpfs-prep` (`/persistent/biometrics`, per ADR-0018). A mismatch makes every reader see an empty directory, so the detector (and piezo/temperature calibration) receives no frames at all. The calibrator unit file declares `RequiresMountsFor=/persistent/biometrics` to surface this as a startup failure rather than a silent runtime degradation. diff --git a/modules/calibrator/main.py b/modules/calibrator/main.py index 958d13435..4a97f780f 100644 --- a/modules/calibrator/main.py +++ b/modules/calibrator/main.py @@ -4,7 +4,8 @@ Runs sensor calibration either on a daily schedule or on-demand (triggered by the iOS app via tRPC β†’ trigger file IPC). Computes baselines for: - - Capacitance sensors (per-channel mean+std for presence detection) + - Capacitance sensors (per-channel mean+std for presence detection) β€” + manual requests only; see SCHEDULED_SENSOR_TYPES - Piezo sensors (noise floor RMS for presence threshold) - Temperature sensors (per-thermistor offsets vs ambient reference) @@ -48,6 +49,13 @@ CAL_SIDES = ("left", "right") CAL_SENSOR_TYPES = ("capacitance", "piezo", "temperature") +# Sensor types calibrated without a user asking (startup, retry, daily, +# pre-prime). Capacitance is excluded: the sleep-detector keeps a +# self-adjusting empty-bed baseline, and a scheduled snapshot kept capturing +# a sleeper as "empty" (the fixed-UTC-hour fallback can land mid-night), +# making the empty bed read occupied all day. A manual recalibration still runs it, and the +# sleep-detector adopts the result. +SCHEDULED_SENSOR_TYPES = ("piezo", "temperature") # How often to re-attempt still-missing/failed profiles. On a NATS-only pod # the live buffer starts empty, so the first capacitance/piezo attempt fails # ("No capSense records available"); it must retry as samples accrue rather @@ -223,7 +231,7 @@ def compute_pending(store: CalibrationStore, now: float) -> set: expired and therefore still needs (re)calibration.""" pending = set() for side in CAL_SIDES: - for sensor_type in CAL_SENSOR_TYPES: + for sensor_type in SCHEDULED_SENSOR_TYPES: profile = store.get_active(side, sensor_type) needs = profile is None if profile and profile.get("expires_at"): @@ -248,6 +256,16 @@ def run_pending_calibrations(store: CalibrationStore, now: float, return compute_pending(store, time.time()) +def trigger_sensor_types(trigger: dict) -> tuple: + """Sensor types a trigger file asks for. Triggers the scheduler writes + (source "scheduled", e.g. pre-prime) never recalibrate capacitance.""" + t_type = trigger.get("sensor_type", "all") + types = CAL_SENSOR_TYPES if t_type == "all" else (t_type,) + if trigger.get("source") == "scheduled": + types = tuple(t for t in types if t in SCHEDULED_SENSOR_TYPES) + return types + + def should_run_daily(store: CalibrationStore, now: float, last_run: float) -> bool: """Fallback daily calibration if scheduler trigger didn't fire. @@ -265,8 +283,8 @@ def should_run_daily(store: CalibrationStore, now: float, last_run: float) -> bo return False if time.gmtime(now).tm_hour != DAILY_HOUR: return False - for side in ("left", "right"): - for sensor_type in ("capacitance", "piezo", "temperature"): + for side in CAL_SIDES: + for sensor_type in SCHEDULED_SENSOR_TYPES: age = store.get_profile_age_hours(side, sensor_type) if age is None or age >= 25: return True @@ -314,14 +332,14 @@ def main() -> None: trigger = watcher.check_trigger() if trigger: t_side = trigger.get("side", "all") - t_type = trigger.get("sensor_type", "all") sides = CAL_SIDES if t_side == "all" else (t_side,) - types = CAL_SENSOR_TYPES if t_type == "all" else (t_type,) + types = trigger_sensor_types(trigger) + source = "scheduled" if trigger.get("source") == "scheduled" else "manual" for s in sides: for st in types: - run_calibration(store, s, st, triggered_by="manual", + run_calibration(store, s, st, triggered_by=source, buffer=nats_buffer) watcher.clear_trigger() @@ -332,7 +350,7 @@ def main() -> None: if should_run_daily(store, now, daily_last_run): log.info("Running daily calibration") for side in CAL_SIDES: - for st in CAL_SENSOR_TYPES: + for st in SCHEDULED_SENSOR_TYPES: run_calibration(store, side, st, triggered_by="daily", buffer=nats_buffer) daily_last_run = now diff --git a/modules/calibrator/test_main.py b/modules/calibrator/test_main.py index d25ea170a..0d434ce5b 100644 --- a/modules/calibrator/test_main.py +++ b/modules/calibrator/test_main.py @@ -79,6 +79,16 @@ def get_profile_age_hours(self, side, sensor_type): assert should_run_daily(ExplodingStore(), now, last_run=now - 3600) is False + def test_stale_capacitance_alone_does_not_run(self): + """The fixed-UTC-hour fallback can land mid-night and recalibrate + capacitance on a sleeper; the sleep-detector now owns the presence + baseline, so its age never schedules a run.""" + now = _ts_at_hour(DAILY_HOUR) + ages = _fresh_ages(hours=1.0) + ages[("left", "capacitance")] = 30.0 + del ages[("right", "capacitance")] + assert should_run_daily(FakeStore(ages), now, last_run=0.0) is False + def test_single_stale_sensor_triggers_run(self): now = _ts_at_hour(DAILY_HOUR) ages = _fresh_ages(hours=1.0) @@ -89,10 +99,11 @@ def test_single_stale_sensor_triggers_run(self): from main import ( # noqa: E402 CAL_SIDES, - CAL_SENSOR_TYPES, + SCHEDULED_SENSOR_TYPES, compute_pending, load_recent_records, run_pending_calibrations, + trigger_sensor_types, ) @@ -113,7 +124,7 @@ def expire(self, side, sensor_type): self.profiles[(side, sensor_type)] = {"expires_at": self._now - 1} -_ALL = {(s, st) for s in CAL_SIDES for st in CAL_SENSOR_TYPES} +_ALL = {(s, st) for s in CAL_SIDES for st in SCHEDULED_SENSOR_TYPES} class TestComputePending: @@ -124,9 +135,9 @@ def test_empty_store_marks_everything_pending(self): def test_completed_profile_is_not_pending(self): now = time.time() store = ProfileStore(now) - store.complete("left", "capacitance") + store.complete("left", "piezo") pending = compute_pending(store, now) - assert ("left", "capacitance") not in pending + assert ("left", "piezo") not in pending assert len(pending) == len(_ALL) - 1 def test_expired_profile_is_pending(self): @@ -164,7 +175,7 @@ def fake_run(store_, side, st, triggered_by, buffer=None): def test_only_unfilled_profiles_remain_pending(self, monkeypatch): now = time.time() store = ProfileStore(now) - # Temperature succeeds (DB-backed); capacitance/piezo still starved. + # Temperature succeeds (DB-backed); piezo still starved. ok = {("left", "temperature"), ("right", "temperature")} def fake_run(store_, side, st, triggered_by, buffer=None): @@ -191,3 +202,23 @@ def snapshot(self): # Missing types default to empty lists (never KeyError downstream). assert recs["capSense2"] == [] assert recs["bedTemp"] == [] + + +class TestCapacitanceIsManualOnly: + def test_missing_capacitance_is_never_pending(self): + now = time.time() + assert all(st != "capacitance" for _side, st in compute_pending(ProfileStore(now), now)) + + def test_manual_all_trigger_includes_capacitance(self): + assert trigger_sensor_types({"side": "all", "sensor_type": "all"}) == ( + "capacitance", "piezo", "temperature") + + def test_manual_capacitance_trigger(self): + assert trigger_sensor_types({"sensor_type": "capacitance"}) == ("capacitance",) + + def test_scheduled_trigger_skips_capacitance(self): + # jobManager's pre-prime job writes source: "scheduled". + assert trigger_sensor_types( + {"side": "all", "sensor_type": "all", "source": "scheduled"}) == ("piezo", "temperature") + assert trigger_sensor_types( + {"sensor_type": "capacitance", "source": "scheduled"}) == () diff --git a/modules/common/calibration.py b/modules/common/calibration.py index a5d769749..690b3f7bf 100644 --- a/modules/common/calibration.py +++ b/modules/common/calibration.py @@ -721,17 +721,26 @@ def capsense_threshold(baselines: dict) -> float: return CAPSENSE_PRESENCE_THRESHOLD -def capsense_deviation(record: dict, side: str, baselines: dict) -> Optional[float]: - """Summed SIGNED raw-unit deviation of a capSense frame from the baseline - channel means, or None when the frame has no data for `side`.""" +def capsense_channel_values(record: dict, side: str) -> Optional[dict]: + """Per-channel raw values {out, cen, in} of a capSense frame, or None when + the frame has no data for `side`.""" data = record.get(side, {}) if not data: return None + return {ch: int(data.get(ch, 0)) for ch in CAPSENSE_CHANNELS} + + +def channel_deviation(values: dict, baselines: dict) -> float: + """Summed SIGNED deviation of per-channel values from the baseline means.""" channels = baselines.get("channels", {}) - return sum( - int(data.get(ch, 0)) - float(channels.get(ch, {}).get("mean", 0)) - for ch in CAPSENSE_CHANNELS - ) + return sum(v - float(channels.get(ch, {}).get("mean", 0)) for ch, v in values.items()) + + +def capsense_deviation(record: dict, side: str, baselines: dict) -> Optional[float]: + """Summed SIGNED raw-unit deviation of a capSense frame from the baseline + channel means, or None when the frame has no data for `side`.""" + values = capsense_channel_values(record, side) + return None if values is None else channel_deviation(values, baselines) def is_present_capsense_calibrated( @@ -769,6 +778,35 @@ def is_present_capsense_calibrated( CAPSENSE2_REF_NOMINAL = 1.16 +CAPSENSE2_SENSE_PAIRS = (("A", 0, 1), ("B", 2, 3), ("C", 4, 5)) +# Emitted by capSense2 firmware on read errors. +CAPSENSE2_SENTINEL = -1.0 + + +def capsense2_channel_values(record: dict, side: str, ref_mean: Optional[float], + skip_sentinels: bool = False) -> Optional[dict]: + """Per-pair averaged capSense2 values {A, B, C}, reference-compensated + when the frame carries the REF pair (indices 6-7): each value has + (ref - ref_mean) subtracted, cancelling drift common to every channel. + + None when the frame has fewer than 6 values, or β€” with skip_sentinels β€” + when a used channel carries the firmware's -1.0 read-error sentinel.""" + data = record.get(side, {}) + vals = data.get("values") if data else None + # Accept 6-value frames (newer firmware drops the optional REF pair). + if not vals or len(vals) < 6: + return None + used = vals[:8] if len(vals) >= 8 else vals[:6] + if skip_sentinels and any(v == CAPSENSE2_SENTINEL for v in used): + return None + ref_delta = 0.0 + if len(vals) >= 8: + nominal = CAPSENSE2_REF_NOMINAL if ref_mean is None else float(ref_mean) + ref_delta = (vals[6] + vals[7]) / 2.0 - nominal + return {name: (vals[ia] + vals[ib]) / 2.0 - ref_delta + for name, ia, ib in CAPSENSE2_SENSE_PAIRS} + + def is_present_capsense2_calibrated( record: dict, side: str, baselines: Optional[dict], fallback_threshold: float = 60.0, @@ -803,22 +841,10 @@ def is_present_capsense2_calibrated( total = sum((vals[i] + vals[i + 1]) / 2.0 for i in (0, 2, 4)) return total > fallback_threshold - ref_delta = 0.0 - if len(vals) >= 8: - ref = (vals[6] + vals[7]) / 2.0 - ref_cal = baselines.get("ref") or {} - ref_delta = ref - float(ref_cal.get("mean", CAPSENSE2_REF_NOMINAL)) - - deviation = 0.0 - channels = baselines.get("channels", {}) - sense_pairs = (("A", 0, 1), ("B", 2, 3), ("C", 4, 5)) - for name, ia, ib in sense_pairs: - val = (vals[ia] + vals[ib]) / 2.0 - mean = float(channels.get(name, {}).get("mean", 0)) - deviation += val - ref_delta - mean - + ref_mean = (baselines.get("ref") or {}).get("mean", CAPSENSE2_REF_NOMINAL) + values = capsense2_channel_values(record, side, ref_mean) threshold = float(baselines.get("threshold", 6.0)) - return deviation > threshold + return channel_deviation(values, baselines) > threshold def is_present_piezo_calibrated( diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index 99dd656b0..e3622d2ff 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -11,9 +11,9 @@ Detection logic: Each capacitance record contains three channels per side (out, cen, in). - Presence is the summed signed raw-unit rise above the calibrated per-channel - baseline (a body only adds capacitance), falling back to a fixed sum - threshold when no calibration profile is available. + Presence is the summed signed raw-unit rise above a self-adjusting + per-channel empty-bed baseline (a body only adds capacitance) β€” see + AdaptiveBaseline. Scheduled calibration no longer sets it. A session starts on the first present sample and ends after ABSENCE_TIMEOUT_S consecutive absent samples. @@ -73,9 +73,11 @@ warn_unknown_type_once, ) from common.calibration import ( + CAPSENSE_PRESENCE_THRESHOLD, CalibrationStore, - is_present_capsense_calibrated, - is_present_capsense2_calibrated, + capsense_channel_values, + capsense_threshold, + capsense2_channel_values, ) # --------------------------------------------------------------------------- @@ -106,10 +108,28 @@ MAX_SESSION_S = 16 * 3600 # How often to write a movement row (seconds) MOVEMENT_INTERVAL_S = 60 -# Fallback presence threshold when uncalibrated -PRESENCE_THRESHOLD = 1500 # How often to reload calibration profiles (seconds) CALIBRATION_RELOAD_S = 60 +# Self-adjusting empty-bed baseline (replaces scheduled capacitance +# calibration, which could capture a motionless sleeper as "empty"). +# Presence enters above the threshold and exits below this fraction of it, +# so a reading hovering at the threshold can't flap. +PRESENCE_EXIT_FRACTION = 0.5 +# While empty and within the exit threshold, the baseline follows slow drift +# (thermal, bedding) with this time constant (seconds). +BASELINE_UP_TAU_S = 30 * 60 +# A reading BELOW baseline is always an empty bed (a body only adds +# capacitance), so a baseline captured with someone in bed drops to the true +# empty level within minutes of them getting up. +BASELINE_DOWN_TAU_S = 120 +# Samples further apart than this don't count as tracking time (gaps, +# restarts), so one sample can't move the baseline all the way. +BASELINE_MAX_STEP_S = 10.0 +# How often the baseline is written back to calibration_profiles so the app +# and Node's occupancy check (capSense2) see the same empty-bed level. +BASELINE_PUBLISH_S = 15 * 60 +# capSense2 profile threshold default (raw float units), as the calibrator. +CAPSENSE2_PRESENCE_THRESHOLD = 6.0 # In-progress session state survives restarts/reboots via this file. Without # it a reboot or service restart mid-session silently dropped the whole night. STATE_PATH = Path(os.environ.get( @@ -433,20 +453,34 @@ def compute_movement_delta(current: list, previous: list) -> float: class CalibrationCache: - """Periodically reloads capacitance calibration profiles for both sides.""" + """Periodically reloads capacitance calibration profiles for both sides, + and writes the self-adjusting baseline back to them.""" def __init__(self, store: CalibrationStore): self._store = store - self._profiles: Dict[str, Optional[dict]] = {"left": None, "right": None} + # side -> (params, created_at) of the active completed profile + self._profiles: Dict[str, Optional[tuple]] = {"left": None, "right": None} self._last_reload = 0.0 - def get_baselines(self, side: str) -> Optional[dict]: - self._maybe_reload() + def get_profile(self, side: str, force: bool = False) -> Optional[tuple]: + """(params, created_at) of the active capacitance profile, or None.""" + self._maybe_reload(force) return self._profiles.get(side) - def _maybe_reload(self) -> None: + def publish(self, side: str, params: dict, window_start: int, + window_end: int, samples: int) -> bool: + """Upsert the adaptive baseline as the side's capacitance profile.""" + try: + self._store.upsert_profile(side, "capacitance", params, 1.0, + window_start, window_end, samples) + return True + except Exception as e: + log.warning("Failed to publish %s baseline: %s", side, e) + return False + + def _maybe_reload(self, force: bool = False) -> None: now = time.time() - if now - self._last_reload < CALIBRATION_RELOAD_S: + if not force and now - self._last_reload < CALIBRATION_RELOAD_S: return self._last_reload = now for side in ("left", "right"): @@ -454,12 +488,165 @@ def _maybe_reload(self) -> None: profile = self._store.get_active(side, "capacitance") if profile: params = profile["parameters"] - self._profiles[side] = json.loads(params) if isinstance(params, str) else params + params = json.loads(params) if isinstance(params, str) else params + self._profiles[side] = (params, profile.get("created_at")) else: self._profiles[side] = None except Exception as e: log.warning("Failed to load calibration for %s: %s", side, e) + +class AdaptiveBaseline: + """Self-adjusting empty-bed level for one side's capacitance channels. + + Replaces scheduled capacitance calibration: a fixed snapshot went stale + with drift, and a snapshot taken while someone slept (the fixed-UTC-hour + fallback can land mid-night) made the empty bed read occupied all day. + + - Presence: summed signed rise over the baseline. Enters above + `threshold`, exits below threshold * PRESENCE_EXIT_FRACTION. + - Drift: while empty and within the exit threshold, follows the reading + with time constant BASELINE_UP_TAU_S. + - Contamination: any reading below baseline pulls it down with + BASELINE_DOWN_TAU_S β€” a body only adds capacitance. + - Seeding: saved state, else the calibration profile (including a manual + recalibration, adopted whenever a newer one appears), else the first + sample. + + capSense tracks raw {out, cen, in}; capSense2 tracks ref-compensated + pair averages {A, B, C} against a fixed ref_mean. + """ + + def __init__(self, fmt: str, means: dict, threshold: float, + ref_mean: Optional[float] = None, source: str = "bootstrap", + profile_seen_at: Optional[float] = None): + self.fmt = fmt + self.means = {ch: float(v) for ch, v in means.items()} + self.threshold = float(threshold) + self.ref_mean = ref_mean + self.source = source + # created_at of the newest external profile already adopted or + # deliberately skipped, so it isn't re-adopted every reload. + self.profile_seen_at = profile_seen_at + self._last_track_ts: Optional[float] = None + + @property + def exit_threshold(self) -> float: + return self.threshold * PRESENCE_EXIT_FRACTION + + @classmethod + def from_profile(cls, params: dict, fmt: str, created_at: Optional[float]): + """Seed from a calibration profile, or None if it doesn't match fmt.""" + channels = params.get("channels") or {} + if fmt == "capSense2": + if params.get("format") != "capSense2": + return None + names = ("A", "B", "C") + threshold = float(params.get("threshold", CAPSENSE2_PRESENCE_THRESHOLD)) + ref_mean = (params.get("ref") or {}).get("mean") + else: + if params.get("format") == "capSense2": + return None + names = ("out", "cen", "in") + threshold = capsense_threshold(params) + ref_mean = None + try: + means = {ch: float(channels[ch]["mean"]) for ch in names} + except (KeyError, TypeError, ValueError): + return None + return cls(fmt, means, threshold, ref_mean=ref_mean, + source=params.get("source", "profile"), + profile_seen_at=created_at) + + @classmethod + def from_state(cls, state: Optional[dict]): + if not isinstance(state, dict): + return None + try: + return cls(str(state["format"]), dict(state["means"]), float(state["threshold"]), + ref_mean=state.get("ref_mean"), source="state", + profile_seen_at=state.get("profile_seen_at")) + except (KeyError, TypeError, ValueError): + return None + + def snapshot(self) -> dict: + return {"format": self.fmt, "means": self.means, "threshold": self.threshold, + "ref_mean": self.ref_mean, "profile_seen_at": self.profile_seen_at} + + def values(self, record: dict, side: str) -> Optional[dict]: + """This format's per-channel values for a frame, or None if unusable.""" + if self.fmt == "capSense2": + return capsense2_channel_values(record, side, self.ref_mean, skip_sentinels=True) + return capsense_channel_values(record, side) + + def deviation(self, values: dict) -> float: + return sum(v - self.means[ch] for ch, v in values.items()) + + def is_present(self, values: dict, currently_present: bool) -> bool: + limit = self.exit_threshold if currently_present else self.threshold + return self.deviation(values) > limit + + def track(self, ts: float, values: dict, occupied: bool) -> None: + """Follow the empty-bed level. Called once per sample after the + presence decision; `occupied` is the committed (debounced) state.""" + dt = 0.0 if self._last_track_ts is None else ts - self._last_track_ts + self._last_track_ts = ts + dt = max(0.0, min(dt, BASELINE_MAX_STEP_S)) + if dt == 0.0: + return + dev = self.deviation(values) + if dev < 0: + tau = BASELINE_DOWN_TAU_S + elif not occupied and dev < self.exit_threshold: + tau = BASELINE_UP_TAU_S + else: + return + alpha = dt / tau + for ch, v in values.items(): + self.means[ch] += alpha * (v - self.means[ch]) + + def reseed(self, values: dict, source: str) -> None: + self.means = {ch: float(v) for ch, v in values.items()} + self.source = source + + def to_params(self) -> dict: + """Calibration-profile params (the shape the calibrators write).""" + if self.fmt == "capSense2": + params = {"format": "capSense2", "threshold": self.threshold, + "channels": {ch: {"mean": round(m, 4), "std": 0.05} + for ch, m in self.means.items()}} + if self.ref_mean is not None: + params["ref"] = {"mean": round(self.ref_mean, 4), "std": 0.001} + else: + params = {"format": "capSense", "threshold": self.threshold, + "channels": {ch: {"mean": round(m, 2), "std": 5.0} + for ch, m in self.means.items()}} + params["source"] = "adaptive" + return params + + +def bootstrap_baseline(record: dict, side: str) -> Optional[AdaptiveBaseline]: + """Seed a baseline from one frame when no state or profile exists. If the + bed happens to be occupied, the occupant reads absent until they get up; + the reading then falls below baseline and the fast downward track takes + it to the true empty level.""" + fmt = record.get("type") + if fmt == "capSense2": + data = record.get(side, {}) + vals = data.get("values") if data else None + ref_mean = None + if vals and len(vals) >= 8 and CAPSENSE2_SENTINEL not in (vals[6], vals[7]): + ref_mean = (vals[6] + vals[7]) / 2.0 + values = capsense2_channel_values(record, side, ref_mean, skip_sentinels=True) + threshold = CAPSENSE2_PRESENCE_THRESHOLD + else: + ref_mean = None + values = capsense_channel_values(record, side) + threshold = CAPSENSE_PRESENCE_THRESHOLD + if values is None: + return None + return AdaptiveBaseline(fmt, values, threshold, ref_mean=ref_mean, source="bootstrap") + # --------------------------------------------------------------------------- # Numeric helpers (no numpy dependency) # --------------------------------------------------------------------------- @@ -710,6 +897,11 @@ class SessionTracker: _replay_until_ts: Optional[float] = None # Session start/close or a bed-exit happened since the last checkpoint. state_dirty: bool = False + # Self-adjusting empty-bed level (see AdaptiveBaseline). + baseline: Optional[AdaptiveBaseline] = None + _cap_closed: bool = False + _last_publish_ts: Optional[float] = None + _tracked_samples: int = 0 def snapshot(self) -> dict: """JSON-serializable state needed to resume after a restart.""" @@ -725,6 +917,7 @@ def snapshot(self) -> dict: "state_since": self._state_since, "consecutive_cap_closes": self._consecutive_cap_closes, "last_ts": self._last_ts, + "baseline": self.baseline.snapshot() if self.baseline is not None else None, } def restore(self, state: Optional[dict], now: float) -> None: @@ -734,6 +927,7 @@ def restore(self, state: Optional[dict], now: float) -> None: the last presence rather than resumed.""" if not isinstance(state, dict): return + self.baseline = AdaptiveBaseline.from_state(state.get("baseline")) try: self._consecutive_cap_closes = int(state.get("consecutive_cap_closes") or 0) start = state.get("session_start") @@ -766,25 +960,64 @@ def restore(self, state: Optional[dict], now: float) -> None: self._resumed_last_present = self._last_present_ts log.info("%s: resumed session started at %s", self.side, self._session_start.isoformat()) + def _sync_baseline(self, record: dict) -> Optional[AdaptiveBaseline]: + """The presence baseline for this frame's format. Adopts a calibration + profile newer than any already seen (e.g. a manual recalibration), + seeds from the first frame when nothing else exists.""" + fmt = "capSense2" if record.get("type") == "capSense2" else "capSense" + b = self.baseline if self.baseline is not None and self.baseline.fmt == fmt else None + profile = self.calibration.get_profile(self.side) if self.calibration else None + if profile is not None: + params, created_at = profile + if isinstance(params, dict) and params.get("source") != "adaptive": + seen = b.profile_seen_at if b is not None else None + if seen is None or (created_at or 0) > seen: + adopted = AdaptiveBaseline.from_profile(params, fmt, created_at) + if adopted is not None: + log.info("%s: presence baseline from calibration profile (created %s)", + self.side, created_at) + b = adopted + elif b is not None: + b.profile_seen_at = created_at # wrong format β€” stop re-checking + if b is None: + b = bootstrap_baseline(record, self.side) + if b is not None: + log.info("%s: presence baseline seeded from live %s reading", self.side, fmt) + self.baseline = b + return b + + def _maybe_publish_baseline(self, ts: float, record: dict) -> None: + if self._last_publish_ts is None: + self._last_publish_ts = ts + return + if ts - self._last_publish_ts < BASELINE_PUBLISH_S or self.calibration is None: + return + self._last_publish_ts = ts + # Re-read first: a manual calibration that finished since the last + # reload must be adopted, not overwritten. + self.calibration.get_profile(self.side, force=True) + b = self._sync_baseline(record) + if b is not None and self.calibration.publish( + self.side, b.to_params(), int(ts - BASELINE_PUBLISH_S), int(ts), + self._tracked_samples): + self._tracked_samples = 0 + def process(self, ts: float, record: dict) -> None: if self._replay_until_ts is not None: if ts <= self._replay_until_ts: return # already processed before the restart self._replay_until_ts = None - baselines = self.calibration.get_baselines(self.side) rtype = record.get("type", "") - # Only use baselines if they match the record format - fmt = baselines.get("format") if baselines else None - if rtype == "capSense2": - cal = baselines if fmt == "capSense2" else None - present = is_present_capsense2_calibrated( - record, self.side, cal, fallback_threshold=60.0, - ) + baseline = self._sync_baseline(record) + values = baseline.values(record, self.side) if baseline is not None else None + if values is None: + # Unusable frame (missing side / capSense2 sentinel): no new evidence. + present = self._debounced_present else: - cal = baselines if fmt != "capSense2" else None - present = is_present_capsense_calibrated( - record, self.side, cal, fallback_threshold=PRESENCE_THRESHOLD, - ) + present = baseline.is_present(values, self._debounced_present) + # Movement's capSense2 common-mode rejection uses the baseline's ref. + baselines = ({"ref": {"mean": baseline.ref_mean}} + if baseline is not None and baseline.ref_mean is not None else None) # Set scale factor based on sensor type (Pod 3 int vs Pod 5 float) if rtype == "capSense" and self._scale_factor != 0.5: @@ -813,6 +1046,19 @@ def process(self, ts: float, record: dict) -> None: self._update(ts, present, delta) + if values is not None: + if self._cap_closed: + # Presence never dropped for MAX_SESSION_S: a load on the bed + # that isn't a sleeper. Make it the new empty level. + baseline.reseed(values, "cap-reset") + log.warning("%s: presence baseline reset to current level after a capped session", + self.side) + else: + baseline.track(ts, values, self._debounced_present) + self._tracked_samples += 1 + self._maybe_publish_baseline(ts, record) + self._cap_closed = False + def _apply_debounce(self, ts: float, raw_present: bool) -> bool: """Fold the raw per-sample presence into the committed (debounced) state. A flip is only committed once the differing raw state has @@ -913,6 +1159,7 @@ def _update(self, ts: float, present: bool, movement: float) -> None: self._close_session(self._session_start.timestamp() + MAX_SESSION_S) if self._session_start is None: # committed β€” count it, don't spam retries self._consecutive_cap_closes += 1 + self._cap_closed = True log.warning( "%s: session force-closed at the %dh cap β€” presence never dropped (%d consecutive)", self.side, MAX_SESSION_S // 3600, self._consecutive_cap_closes) diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index ab2651c5f..2f4e2a059 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -4,10 +4,12 @@ Covers ts sanitization (#327) and DB write resilience (#325). """ +import importlib.util import logging import sqlite3 import sys from datetime import datetime, timezone +from pathlib import Path from unittest.mock import patch # Stub pod-only modules so `import main` works on dev machines. @@ -17,7 +19,6 @@ "common.raw_follower": type(sys)("common.raw_follower"), "common.nats_follower": type(sys)("common.nats_follower"), "common.dialect": type(sys)("common.dialect"), - "common.calibration": type(sys)("common.calibration"), "common.health": type(sys)("common.health"), } _stubs["common.raw_follower"].RawFileFollower = None @@ -25,9 +26,12 @@ _stubs["common.dialect"].KNOWN_RECORD_TYPES = frozenset() _stubs["common.dialect"].warn_unknown_type_once = lambda *a, **kw: None _stubs["common.dialect"].log_capsense_status_once = lambda *a, **kw: None -_stubs["common.calibration"].CalibrationStore = None -_stubs["common.calibration"].is_present_capsense_calibrated = lambda *a, **kw: False -_stubs["common.calibration"].is_present_capsense2_calibrated = lambda *a, **kw: False +# common.calibration is stdlib-only, so load the real presence math rather +# than stubbing it β€” the adaptive baseline tests depend on it. +_cal_spec = importlib.util.spec_from_file_location( + "common.calibration", Path(__file__).resolve().parent.parent / "common" / "calibration.py") +_stubs["common.calibration"] = importlib.util.module_from_spec(_cal_spec) +_cal_spec.loader.exec_module(_stubs["common.calibration"]) _stubs["common.health"].report_health = lambda *a, **kw: None sys.modules.update(_stubs) @@ -507,7 +511,7 @@ def test_stale_saved_session_is_closed_not_resumed(self): def test_replayed_samples_are_skipped_after_restore(self): class _NoCal: - def get_baselines(self, side): + def get_profile(self, side, force=False): return None t, ts = self._asleep(hours=1) @@ -577,3 +581,227 @@ def test_corrupt_or_foreign_file_is_empty(self, tmp_path): def test_unwritable_path_reports_failure(self, tmp_path): assert main.save_state(tmp_path / "missing-dir" / "state.json", (_tracker(),)) is False + + +# --------------------------------------------------------------------------- +# Self-adjusting presence baseline +# --------------------------------------------------------------------------- + +# Representative empty-bed channel levels of a Pod 4. +EMPTY = {"out": 1142, "cen": 1582, "in": 1673} + + +def _cap(rise_per_channel=0.0, side="left", ts=None): + rec = {"type": "capSense", + side: {ch: int(round(v + rise_per_channel)) for ch, v in EMPTY.items()}} + if ts is not None: + rec["ts"] = ts + return rec + + +class _Cal: + """CalibrationCache double: one optional profile, records publishes.""" + + def __init__(self, profile=None): + self.profile = profile + self.published = [] + + def get_profile(self, side, force=False): + return self.profile + + def publish(self, side, params, window_start, window_end, samples): + self.published.append(params) + self.profile = (params, window_end) + return True + + +def _live_tracker(cal=None): + holder = main.DBHolder(_make_db()) + main._db_write_failures = 0 + return main.SessionTracker(side="left", db=holder, calibration=cal or _Cal(), + pump_gate=main.PumpGateCapSense()) + + +def _run(t, start, seconds, rise, step=5.0): + ts = start + while ts < start + seconds: + t.process(ts, _cap(rise)) + ts += step + return ts + + +def _profile(means, created_at, **extra): + params = {"format": "capSense", "threshold": 300.0, + "channels": {ch: {"mean": m, "std": 5.0} for ch, m in means.items()}} + params.update(extra) + return (params, created_at) + + +class TestAdaptiveBaseline: + T0 = 1_777_000_000.0 + + def test_night_in_bed_records_one_session(self): + t = _live_tracker() + ts = _run(t, self.T0, 3600, 0) # empty evening + ts = _run(t, ts, 8 * 3600, 600) # asleep: +600/channel + ts = _run(t, ts, 3600, 0) # up + rows = _rows_full(t) + assert len(rows) == 1 + entered, left_at, duration_s, exits = rows[0] + assert abs(entered - (self.T0 + 3600)) <= 10 + assert abs(left_at - (self.T0 + 9 * 3600)) <= 10 + assert exits == 1 + + def test_slow_drift_is_absorbed_not_occupied(self): + # Regression: slow drift and bedding shifts of a few tens of units + # used to read as occupied, holding sessions open for many hours. + t = _live_tracker() + ts = _run(t, self.T0, 600, 0) + ts = _run(t, ts, 12 * 3600, 33) + assert _rows(t) == [] + assert t._session_start is None + assert abs(t.baseline.means["out"] - (EMPTY["out"] + 33)) < 2 + + def test_baseline_taken_while_occupied_recovers_on_exit(self): + # A scheduled calibration captured a sleeper as "empty". Once they get + # up the reading falls below that level; the fast downward track + # must find the real empty level so the next night is detected. + occupied = {ch: v + 600 for ch, v in EMPTY.items()} + t = _live_tracker(_Cal(_profile(occupied, created_at=self.T0 - 60))) + ts = _run(t, self.T0, 3 * 3600, 600) # asleep, reads "empty" + ts = _run(t, ts, 1800, 0) # gets up + assert abs(t.baseline.means["cen"] - EMPTY["cen"]) < 5 + ts = _run(t, ts, 8 * 3600, 600) # next night + ts = _run(t, ts, 600, 0) + rows = _rows_full(t) + assert len(rows) == 1 + assert rows[0][2] >= 8 * 3600 - 60 + + def test_light_load_below_enter_threshold_is_not_absorbed(self): + # +80/channel (+240 summed) sits between the exit (150) and enter + # (300) thresholds: never occupied, and never learned as empty. + t = _live_tracker() + ts = _run(t, self.T0, 600, 0) + ts = _run(t, ts, 4 * 3600, 80) + assert t._session_start is None + assert abs(t.baseline.means["out"] - EMPTY["out"]) < 1 + + def test_hysteresis_keeps_session_through_partial_dip(self): + # Once in bed, dipping to +70/channel (+210 summed, above the 150 + # exit) must not end the session. + t = _live_tracker() + ts = _run(t, self.T0, 600, 0) + ts = _run(t, ts, 3600, 600) + ts = _run(t, ts, 1800, 70) + ts = _run(t, ts, 3600, 600) + ts = _run(t, ts, 600, 0) + rows = _rows_full(t) + assert len(rows) == 1 + assert rows[0][3] == 1 + + def test_object_left_on_bed_resets_after_capped_session(self): + t = _live_tracker() + ts = _run(t, self.T0, 600, 0) + ts = _run(t, ts, main.MAX_SESSION_S + 3600, 400, step=30.0) + rows = _rows_full(t) + assert len(rows) == 1 + assert rows[0][2] == main.MAX_SESSION_S + assert t.baseline.source == "cap-reset" + assert t._session_start is None # object is the new empty + + def test_manual_recalibration_is_adopted(self): + cal = _Cal() + t = _live_tracker(cal) + ts = _run(t, self.T0, 600, 0) + assert t.baseline.source == "bootstrap" + moved = {ch: v + 40 for ch, v in EMPTY.items()} + cal.profile = _profile(moved, created_at=ts) + ts = _run(t, ts, 5, 0) + assert t.baseline.source == "profile" + assert t.baseline.means["in"] == EMPTY["in"] + 40 + + def test_own_published_profile_is_not_readopted(self): + cal = _Cal() + t = _live_tracker(cal) + ts = _run(t, self.T0, main.BASELINE_PUBLISH_S + 60, 0) + assert len(cal.published) == 1 + params = cal.published[0] + assert params["source"] == "adaptive" + assert params["format"] == "capSense" + assert set(params["channels"]) == {"out", "cen", "in"} + _run(t, ts, 60, 0) + assert t.baseline.source == "bootstrap" + + def test_legacy_profile_seeds_with_raw_unit_threshold(self): + legacy = ({"threshold": 6.0, "channels": {ch: {"mean": v, "std": 5.0} + for ch, v in EMPTY.items()}}, self.T0 - 60) + t = _live_tracker(_Cal(legacy)) + _run(t, self.T0, 60, 0) + assert t.baseline.threshold == 300.0 + assert t.baseline.source == "profile" + + def test_baseline_survives_restart(self): + import json + t = _live_tracker() + ts = _run(t, self.T0, 3 * 3600, 33) + t2 = _live_tracker() + t2.restore(json.loads(json.dumps(t.snapshot())), now=ts) + assert t2.baseline.source == "state" + assert t2.baseline.means == t.baseline.means + + def test_gaps_do_not_jump_the_baseline(self): + t = _live_tracker() + t.process(self.T0, _cap(0)) + t.process(self.T0 + 7200, _cap(-500)) # 2h gap, one low sample + # One step is capped at BASELINE_MAX_STEP_S / BASELINE_DOWN_TAU_S. + moved = EMPTY["out"] - t.baseline.means["out"] + assert 0 < moved <= 500 * main.BASELINE_MAX_STEP_S / main.BASELINE_DOWN_TAU_S + 1e-6 + + def test_unusable_frame_changes_nothing(self): + t = _live_tracker() + ts = _run(t, self.T0, 600, 0) + before = dict(t.baseline.means) + t.process(ts, {"type": "capSense", "right": {"out": 1}}) + assert t.baseline.means == before + assert t._session_start is None + + +class TestAdaptiveBaselineCapSense2: + T0 = 1_777_000_000.0 + + @staticmethod + def _rec(a, b, c, ref=None, side="left"): + values = [a, a, b, b, c, c] + ([ref, ref] if ref is not None else []) + return {"type": "capSense2", side: {"values": values}} + + def test_occupant_detected_and_ref_drift_cancelled(self): + t = _live_tracker() + ts = self.T0 + for _ in range(120): # empty, ref nominal + t.process(ts, self._rec(500.0, 600.0, 700.0, ref=1.16)); ts += 5 + for _ in range(360): # all channels +10 incl. ref + t.process(ts, self._rec(510.0, 610.0, 710.0, ref=11.16)); ts += 5 + assert t._session_start is None + for _ in range(720): # occupant +5/pair = +15 > 6 + t.process(ts, self._rec(505.0, 605.0, 705.0, ref=1.16)); ts += 5 + assert t._session_start is not None + + def test_sentinel_frames_do_not_move_baseline(self): + t = _live_tracker() + t.process(self.T0, self._rec(500.0, 600.0, 700.0, ref=1.16)) + before = dict(t.baseline.means) + t.process(self.T0 + 5, self._rec(-1.0, 600.0, 700.0, ref=1.16)) + assert t.baseline.means == before + + def test_published_profile_matches_node_contract(self): + cal = _Cal() + t = _live_tracker(cal) + ts = self.T0 + while ts < self.T0 + main.BASELINE_PUBLISH_S + 60: + t.process(ts, self._rec(500.0, 600.0, 700.0, ref=1.2)); ts += 5 + params = cal.published[0] + # src/lib/occupancy.ts requires format, threshold and A/B/C means. + assert params["format"] == "capSense2" + assert params["threshold"] == 6.0 + assert {ch: round(v["mean"]) for ch, v in params["channels"].items()} == {"A": 500, "B": 600, "C": 700} + assert params["ref"]["mean"] == 1.2 diff --git a/src/scheduler/jobManager.ts b/src/scheduler/jobManager.ts index aa9e44832..04818fd8d 100644 --- a/src/scheduler/jobManager.ts +++ b/src/scheduler/jobManager.ts @@ -757,9 +757,13 @@ export class JobManager { const ts = Date.now() const target = join(triggerDir, `.calibrate-trigger.${ts}`) const tmp = `${target}.tmp` + // `source: 'scheduled'` makes the calibrator skip capacitance: the + // sleep-detector keeps a self-adjusting presence baseline, and a + // scheduled snapshot can capture a sleeper as the "empty" bed. const payload = JSON.stringify({ side: 'all', sensor_type: 'all', + source: 'scheduled', ts: Math.floor(ts / 1000), }) await writeFile(tmp, payload) diff --git a/src/scheduler/tests/jobManager.test.ts b/src/scheduler/tests/jobManager.test.ts index 19c58a57f..dabf51cab 100644 --- a/src/scheduler/tests/jobManager.test.ts +++ b/src/scheduler/tests/jobManager.test.ts @@ -1881,6 +1881,7 @@ describe('JobManager residual mutation contracts', () => { expect(JSON.parse(payload as string)).toEqual({ side: 'all', sensor_type: 'all', + source: 'scheduled', ts: Math.floor(now.getTime() / 1000), }) From a5625c63b7a0ba06f641f0eabee0193ae0a55e03 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Thu, 24 Sep 2026 18:11:57 -0400 Subject: [PATCH 015/119] feat(biometrics): single-sleeper mode when one side is in away mode A solo sleeper who rolls over or puts a leg on the empty side loads that side's sensors too: its capSense level rises while the sleeper's side is still occupied, and its piezo reports the sleeper's heart rate. That produced phantom sessions and vitals rows on the empty side. Exactly one side in away mode now means one sleeper on the other side (common/side_mode.py reads side_settings.away_mode read-only every 60s): - sleep-detector: the sleeper is in bed while either side reads occupied, so migrating across the bed stays one home-side session; movement from both sides is summed; the away side records nothing but keeps tracking its baseline; a capped session resets both baselines - piezo-processor: each cycle's candidates from both sides are paired and the higher-quality one is written under the home side; an unpaired away-side reading (fully rolled over) is written as the home side Co-Authored-By: Claude Opus 5.5 --- docs/piezo-processor.md | 4 + docs/sleep-detector.md | 15 +++ docs/trpc-api-architecture.md | 3 + modules/common/side_mode.py | 81 ++++++++++++++++ modules/common/test_side_mode.py | 82 +++++++++++++++++ modules/piezo-processor/main.py | 87 +++++++++++++++++- modules/piezo-processor/test_main.py | 133 +++++++++++++++++++++++++++ modules/sleep-detector/main.py | 115 ++++++++++++++++++----- modules/sleep-detector/test_main.py | 118 ++++++++++++++++++++++-- 9 files changed, 605 insertions(+), 33 deletions(-) create mode 100644 modules/common/side_mode.py create mode 100644 modules/common/test_side_mode.py diff --git a/docs/piezo-processor.md b/docs/piezo-processor.md index 8bf846463..47340111c 100644 --- a/docs/piezo-processor.md +++ b/docs/piezo-processor.md @@ -186,6 +186,10 @@ The thresholds `enter=400,000`, `exit=150,000`, and `acr=0.45` were derived from - **Occupied right side:** Median std ranged 300k-900k, autocorrelation quality 0.50-0.85 during normal sleep. - **The gap between 150k and 400k** provides a comfortable margin that accommodates deep sleep (low amplitude) without triggering on empty-bed coupling. +### Single-sleeper mode + +When exactly one side is in away mode, both sides' vitals go through `SingleSleeperVitals`: each cycle's two candidates are paired and only the higher-quality one is written, under the home side; an away-side candidate whose partner doesn't arrive within `VITALS_INTERVAL_S` is written alone as the home side. A solo sleeper who rolls onto the empty side keeps one heart-rate series instead of spilling rows onto the away side. See `docs/sleep-detector.md` Β§ Single-Sleeper Mode. + ## 6. Heart Rate Extraction ### Bandpass: 0.8-8.5 Hz diff --git a/docs/sleep-detector.md b/docs/sleep-detector.md index 5d0079eee..6324891f4 100644 --- a/docs/sleep-detector.md +++ b/docs/sleep-detector.md @@ -144,6 +144,21 @@ The baseline is maintained by the detector itself (`AdaptiveBaseline`), not by s Replaying recorded capSense data starting from a baseline captured with the sleeper in bed, the baseline recovers within minutes of them getting up, sessions end at the real wake time rather than at the next recalibration, and a bedding shift of a few tens of units per channel produces no session. +## Single-Sleeper Mode + +Exactly one side in **away mode** (`side_settings.away_mode`) means one person sleeps in the bed, on the other ("home") side. A solo sleeper who rolls over or puts a leg on the empty side loads that side's sensors too β€” its capSense level rises and its piezo reports the same heartbeat β€” which used to open phantom sessions there. `common/side_mode.py` reads the flag read-only every 60 s; both sides away, or neither, is ordinary per-side operation. + +In this mode (`process_single_sleeper`): + +- The sleeper is in bed while **either** side reads occupied, so migrating across the bed stays one home-side session with no extra bed-exits; a session can also start on the away side. +- Movement deltas from both sides are summed into the home side's epochs. The away side writes no sessions or movement. +- The away side still tracks its own baseline. A session capped at `MAX_SESSION_S` resets both sides' baselines, since either side's load could be holding it open. +- If away mode is switched on while the away side has an open session, that session is closed at its last presence. + +The piezo-processor applies the same mode to vitals (`SingleSleeperVitals`): each cycle's candidates from the two sides are paired, only the higher-quality one is written, under the home side, and an away-side reading with no partner (fully rolled over) is written as the home side. + +A night where the sleeper rolls onto the away side yields one home-side session with a single morning exit, instead of several short sessions on the away side. + ## Sleep Sessions A session starts on the first present sample and ends after `ABSENCE_TIMEOUT_S` (120s) of consecutive absence. Sessions shorter than `MIN_SESSION_S` (300s = 5 min) are discarded as false positives. diff --git a/docs/trpc-api-architecture.md b/docs/trpc-api-architecture.md index 60249bd95..797356a65 100644 --- a/docs/trpc-api-architecture.md +++ b/docs/trpc-api-architecture.md @@ -177,6 +177,9 @@ Device and side configuration management. - Disables scheduled operations for a side - Manual control still works - Use for extended absences +- Exactly one side away = single-sleeper bed: the biometrics modules merge + that side's presence, movement and vitals into the other side's session + (see `docs/sleep-detector.md` Β§ Single-Sleeper Mode) ### Schedules Router (`schedules.*`) diff --git a/modules/common/side_mode.py b/modules/common/side_mode.py new file mode 100644 index 000000000..6d0fbd23c --- /dev/null +++ b/modules/common/side_mode.py @@ -0,0 +1,81 @@ +""" +Single-sleeper mode, shared by the sleep-detector and piezo-processor. + +Exactly one side in away mode (side_settings.away_mode in sleepypod.db) means +one person sleeps in the bed, on the other ("home") side. A solo sleeper who +rolls over or stretches a leg onto the empty side loads that side's sensors +too β€” its capSense level rises and its piezo picks up the same heartbeat β€” +which used to open phantom sessions on the empty side, with a heart rate +matching the sleeper's. +In this mode the away side's readings are merged into the home side instead. + +Both sides away, or neither, is ordinary per-side operation. +""" + +import logging +import sqlite3 +import time +from pathlib import Path +from typing import Callable, Optional + +log = logging.getLogger("side_mode") + +SIDES = ("left", "right") +# How often side_settings is re-read (seconds). Away mode is toggled by a +# person or a scheduled away window, so a minute's lag is harmless. +SIDE_MODE_RELOAD_S = 60 + + +def other_side(side: str) -> str: + return "right" if side == "left" else "left" + + +def home_side_for(away: dict) -> Optional[str]: + """The single sleeper's side, or None unless exactly one side is away.""" + left_away = bool(away.get("left")) + right_away = bool(away.get("right")) + if left_away == right_away: + return None + return "right" if left_away else "left" + + +class SingleSleeperMode: + """Periodically reads side_settings.away_mode (read-only) and reports the + single sleeper's home side. A failed read keeps the last known mode.""" + + def __init__(self, db_path: Path, reload_s: float = SIDE_MODE_RELOAD_S, + clock: Callable[[], float] = time.monotonic): + self._db_path = db_path + self._reload_s = reload_s + self._clock = clock + self._last_read: Optional[float] = None + self._home: Optional[str] = None + + def home_side(self) -> Optional[str]: + now = self._clock() + if self._last_read is None or now - self._last_read >= self._reload_s: + self._last_read = now + self._refresh() + return self._home + + def _refresh(self) -> None: + try: + conn = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True, timeout=2.0) + try: + rows = conn.execute("SELECT side, away_mode FROM side_settings").fetchall() + finally: + conn.close() + except sqlite3.Error as e: + log.warning("Could not read side_settings (keeping %s): %s", + self._describe(self._home), e) + return + home = home_side_for({side: away for side, away in rows}) + if home != self._home: + log.info("Bed mode: %s", self._describe(home)) + self._home = home + + @staticmethod + def _describe(home: Optional[str]) -> str: + if home is None: + return "per-side" + return f"single sleeper on {home} ({other_side(home)} away, merged into {home})" diff --git a/modules/common/test_side_mode.py b/modules/common/test_side_mode.py new file mode 100644 index 000000000..ef15098e2 --- /dev/null +++ b/modules/common/test_side_mode.py @@ -0,0 +1,82 @@ +"""Single-sleeper mode: exactly one side in away mode β†’ the other side is home.""" +import sqlite3 + +import pytest + +from common.side_mode import SingleSleeperMode, home_side_for, other_side + + +@pytest.mark.parametrize("away,home", [ + ({"left": False, "right": True}, "left"), + ({"left": True, "right": False}, "right"), + ({"left": False, "right": False}, None), + ({"left": True, "right": True}, None), # nobody home: plain per-side + ({}, None), + ({"right": 1}, "left"), # SQLite booleans are ints +]) +def test_home_side_for(away, home): + assert home_side_for(away) == home + + +def test_other_side(): + assert other_side("left") == "right" + assert other_side("right") == "left" + + +def _db(tmp_path, left_away, right_away): + path = tmp_path / "sleepypod.db" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE side_settings (side TEXT PRIMARY KEY, away_mode INTEGER)") + conn.executemany("INSERT INTO side_settings VALUES (?, ?)", + [("left", int(left_away)), ("right", int(right_away))]) + conn.commit() + conn.close() + return path + + +def _set_away(path, side, away): + conn = sqlite3.connect(path) + conn.execute("UPDATE side_settings SET away_mode=? WHERE side=?", (int(away), side)) + conn.commit() + conn.close() + + +class Clock: + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + +def test_reads_away_mode_and_reloads_on_interval(tmp_path): + path = _db(tmp_path, left_away=False, right_away=True) + clock = Clock() + mode = SingleSleeperMode(path, reload_s=60, clock=clock) + assert mode.home_side() == "left" + + _set_away(path, "right", False) + clock.t += 30 + assert mode.home_side() == "left" # cached until the interval passes + clock.t += 30 + assert mode.home_side() is None + + +def test_unreadable_db_keeps_last_mode(tmp_path): + path = _db(tmp_path, left_away=True, right_away=False) + clock = Clock() + mode = SingleSleeperMode(path, reload_s=60, clock=clock) + assert mode.home_side() == "right" + path.unlink() + clock.t += 60 + assert mode.home_side() == "right" + + +def test_missing_db_is_per_side(tmp_path): + mode = SingleSleeperMode(tmp_path / "absent.db") + assert mode.home_side() is None + + +def test_read_only_access_never_creates_the_db(tmp_path): + SingleSleeperMode(tmp_path / "absent.db").home_side() + assert not (tmp_path / "absent.db").exists() diff --git a/modules/piezo-processor/main.py b/modules/piezo-processor/main.py index 431717b82..cd1e8ee72 100644 --- a/modules/piezo-processor/main.py +++ b/modules/piezo-processor/main.py @@ -37,13 +37,15 @@ from pathlib import Path from datetime import datetime, timezone from collections import deque -from typing import Optional +from dataclasses import dataclass +from typing import Callable, Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import cbor2 from common.nats_follower import create_follower from common.dialect import KNOWN_RECORD_TYPES, warn_unknown_type_once +from common.side_mode import SingleSleeperMode import numpy as np from scipy.signal import butter, sosfiltfilt, hilbert, find_peaks @@ -202,6 +204,75 @@ def write_vitals(holder: "DBHolder", side: str, ts: datetime, return False +@dataclass +class VitalsCandidate: + """One side's computed vitals for one VITALS_INTERVAL_S cycle.""" + side: str + ts: datetime + heart_rate: Optional[float] + hrv: Optional[float] + breathing_rate: Optional[float] + quality_score: float + flags: Optional[list] + hr_raw: Optional[float] + + def write(self, holder: "DBHolder", side: Optional[str] = None) -> bool: + return write_vitals(holder, side or self.side, self.ts, self.heart_rate, + self.hrv, self.breathing_rate, + quality_score=self.quality_score, flags=self.flags, + hr_raw=self.hr_raw) + + +class SingleSleeperVitals: + """Routes vitals rows when one side is in away mode (single sleeper). + + A solo sleeper who rolls onto the away side is picked up by that side's + piezo too β€” the same heartbeat, within a few bpm. Each cycle's + candidates from the two sides are paired and + only the higher-quality one is written, under the home side, so the + home session shows one heart-rate series with no gaps or duplicates. + A candidate whose partner doesn't arrive within VITALS_INTERVAL_S (the + other side saw no one) is written alone under the home side. + + Outside single-sleeper mode rows are written under their own side. + """ + + def __init__(self, holder: "DBHolder", mode: SingleSleeperMode, + clock: Callable[[], float] = time.monotonic): + self._holder = holder + self._mode = mode + self._clock = clock + self._pending: Optional[VitalsCandidate] = None + self._pending_home: Optional[str] = None + self._pending_at = 0.0 + + def submit(self, cand: VitalsCandidate) -> bool: + """Accept one side's candidate. True means the side may advance its + write cursor (written now, or held for pairing).""" + home = self._mode.home_side() + if home is None: + self.flush() + return cand.write(self._holder) + pending = self._pending + if pending is not None and pending.side != cand.side and self._pending_home == home: + best = cand if cand.quality_score > pending.quality_score else pending + self._pending = None + return best.write(self._holder, side=home) + self.flush() # a same-side repeat means its partner never arrived + self._pending, self._pending_home, self._pending_at = cand, home, self._clock() + return True + + def tick(self) -> None: + """Write a held candidate whose partner didn't arrive in time.""" + if self._pending is not None and self._clock() - self._pending_at >= VITALS_INTERVAL_S: + self.flush() + + def flush(self) -> None: + pending, self._pending = self._pending, None + if pending is not None: + pending.write(self._holder, side=self._pending_home) + + def report_health(status: str, message: str) -> None: """Write module health to sleepypod.db system_health table.""" try: @@ -824,6 +895,9 @@ def __init__(self, side: str, db_holder: DBHolder, self._last_med_std: float = 0.0 # cached for cross-channel comparison self._last_acr_qual: float = 0.0 self._pump_state = pump_state + # Where computed vitals go; None writes them under this side. + # main() routes both sides through SingleSleeperVitals.submit. + self.sink: Optional[Callable[[VitalsCandidate], bool]] = None def ingest(self, samples: np.ndarray) -> None: self._hr_buf.extend(samples) @@ -928,9 +1002,8 @@ def _maybe_write(self) -> None: flags.append("no_br") if med_std < self._presence.enter_threshold: flags.append("low_signal") - wrote = write_vitals(self.db_holder, self.side, ts, hr, hrv, br, - quality_score=quality, flags=flags or None, - hr_raw=hr_raw) + cand = VitalsCandidate(self.side, ts, hr, hrv, br, quality, flags or None, hr_raw) + wrote = self.sink(cand) if self.sink is not None else cand.write(self.db_holder) log.info("vitals %s β€” HR=%.1f HRV=%.1f BR=%.1f q=%.2f", self.side, hr or 0, hrv or 0, br or 0, quality) # Only advance the downsample cursor when the write actually @@ -975,6 +1048,10 @@ def main() -> None: right = SideProcessor("right", db_holder, pump_state=pump_state) left._other = right right._other = left + # One side in away mode: a single sleeper β€” the away side's readings + # (rolled over, leg across) are merged into the home side's series. + vitals_router = SingleSleeperVitals(db_holder, SingleSleeperMode(SLEEPYPOD_DB)) + left.sink = right.sink = vitals_router.submit # Source selected once at startup: NatsFollower on new-firmware pods (NATS # reachable), else the unchanged .RAW tailer. Same decoded-record contract. follower = create_follower(RAW_DATA_DIR, _shutdown, poll_interval=0.01) @@ -1015,12 +1092,14 @@ def main() -> None: left.ingest(l_samples) right.ingest(r_samples) + vitals_router.tick() except Exception as e: log.exception("Fatal error in main loop: %s", e) report_health("down", str(e)) sys.exit(1) finally: + vitals_router.flush() db_holder.conn.close() log.info("Shutdown complete") diff --git a/modules/piezo-processor/test_main.py b/modules/piezo-processor/test_main.py index 4e6786510..62a31411e 100644 --- a/modules/piezo-processor/test_main.py +++ b/modules/piezo-processor/test_main.py @@ -28,6 +28,13 @@ _stubs["common.nats_follower"].create_follower = None _stubs["common.dialect"].KNOWN_RECORD_TYPES = frozenset() _stubs["common.dialect"].warn_unknown_type_once = lambda *a, **kw: None +# common.side_mode is stdlib-only: load the real module for the merge tests. +import importlib.util # noqa: E402 +from pathlib import Path # noqa: E402 +_spec = importlib.util.spec_from_file_location( + "common.side_mode", Path(__file__).resolve().parent.parent / "common" / "side_mode.py") +_stubs["common.side_mode"] = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_stubs["common.side_mode"]) sys.modules.update(_stubs) from main import ( # noqa: E402 @@ -1293,3 +1300,129 @@ def test_handles_malformed_record_safely(self): ps.update({"type": "frzHealth", "left": {"pumpRpm": "abc"}, "right": {"pumpRpm": None}}) assert ps.is_side_pump_active("left") is False assert ps.is_side_pump_active("right") is False + + + +# =================================================================== +# Single-sleeper vitals merge +# =================================================================== + + +class _Mode: + def __init__(self, home): + self.home = home + + def home_side(self): + return self.home + + +class _Clock: + def __init__(self): + self.t = 0.0 + + def __call__(self): + return self.t + + +class TestSingleSleeperVitals: + """A solo left sleeper who rolls onto the empty right side shows up on + the right piezo with the same heartbeat, and those rows used to be + filed under the right side. With the right side in away mode they + belong to the left sleeper's series.""" + + def _router(self, home="left"): + import main + conn = TestWriteVitalsResilience()._make_db() + clock = _Clock() + router = main.SingleSleeperVitals(main.DBHolder(conn), _Mode(home), clock=clock) + return router, conn, clock + + @staticmethod + def _cand(side, hr, q, minute=0): + from datetime import datetime, timezone + import main + ts = datetime.fromtimestamp(1_777_000_000 + minute * 60, tz=timezone.utc) + return main.VitalsCandidate(side, ts, hr, 40.0, 15.0, q, None, hr) + + @staticmethod + def _rows(conn): + return conn.execute( + "SELECT v.side, v.heart_rate, q.quality_score FROM vitals v " + "JOIN vitals_quality q ON q.vitals_id = v.id ORDER BY v.id").fetchall() + + def test_pair_writes_best_quality_under_home(self): + router, conn, clock = self._router() + assert router.submit(self._cand("left", 63.0, 0.3)) is True + clock.t += 20 + assert router.submit(self._cand("right", 62.0, 0.7)) is True + assert self._rows(conn) == [("left", 62.0, 0.7)] + + def test_higher_quality_wins_and_tie_keeps_earlier(self): + router, conn, clock = self._router() + router.submit(self._cand("right", 62.0, 0.5)) + router.submit(self._cand("left", 63.0, 0.5)) + assert self._rows(conn) == [("left", 62.0, 0.5)] # pending kept on tie + router.submit(self._cand("right", 60.0, 0.2)) + router.submit(self._cand("left", 61.0, 0.9)) + assert self._rows(conn)[-1] == ("left", 61.0, 0.9) + + def test_away_side_alone_is_written_as_home(self): + # Fully rolled over: only the away side sees a heartbeat. + router, conn, clock = self._router() + router.submit(self._cand("right", 58.0, 0.6, minute=0)) + clock.t += 60 + router.submit(self._cand("right", 59.0, 0.6, minute=1)) + assert self._rows(conn) == [("left", 58.0, 0.6)] + clock.t += 60 + router.tick() + assert self._rows(conn) == [("left", 58.0, 0.6), ("left", 59.0, 0.6)] + + def test_tick_waits_for_partner_window(self): + router, conn, clock = self._router() + router.submit(self._cand("left", 63.0, 0.4)) + clock.t += 59 + router.tick() + assert self._rows(conn) == [] + clock.t += 1 + router.tick() + assert self._rows(conn) == [("left", 63.0, 0.4)] + + def test_right_home_mirrors(self): + router, conn, clock = self._router(home="right") + router.submit(self._cand("left", 70.0, 0.9)) + router.submit(self._cand("right", 71.0, 0.1)) + assert self._rows(conn) == [("right", 70.0, 0.9)] + + def test_per_side_mode_writes_each_side(self): + router, conn, clock = self._router(home=None) + router.submit(self._cand("left", 63.0, 0.4)) + router.submit(self._cand("right", 58.0, 0.6)) + assert self._rows(conn) == [("left", 63.0, 0.4), ("right", 58.0, 0.6)] + + def test_mode_switch_flushes_held_candidate_to_its_home(self): + router, conn, clock = self._router() + router.submit(self._cand("right", 58.0, 0.6)) + router._mode.home = None + router.submit(self._cand("right", 59.0, 0.6)) + assert self._rows(conn) == [("left", 58.0, 0.6), ("right", 59.0, 0.6)] + + def test_flush_on_shutdown(self): + router, conn, clock = self._router() + router.submit(self._cand("left", 63.0, 0.4)) + router.flush() + assert self._rows(conn) == [("left", 63.0, 0.4)] + router.flush() + assert len(self._rows(conn)) == 1 + + def test_side_processor_routes_through_sink(self): + import main + conn = TestWriteVitalsResilience()._make_db() + proc = main.SideProcessor("right", main.DBHolder(conn)) + seen = [] + proc.sink = lambda cand: seen.append(cand) or True + signal = make_bcg_signal(60, 70) + with patch("main.time.time", return_value=1_000_000.0): + proc._presence.update = lambda *a, **kw: True + proc.ingest(signal) + assert len(seen) == 1 and seen[0].side == "right" + assert self._rows(conn) == [] diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index e3622d2ff..545a36090 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -67,6 +67,7 @@ import cbor2 from common.nats_follower import create_follower +from common.side_mode import SingleSleeperMode from common.dialect import ( KNOWN_RECORD_TYPES, log_capsense_status_once, @@ -851,6 +852,16 @@ def is_gated(self, record: dict, side: str, # Per-side session tracker # --------------------------------------------------------------------------- +@dataclass +class SideObservation: + """One side's reading of one frame (SessionTracker.observe).""" + ts: float + present: Optional[bool] # None: unusable frame, no new evidence + delta: float + values: Optional[dict] + record: dict + + @dataclass class SessionTracker: side: str @@ -900,6 +911,9 @@ class SessionTracker: # Self-adjusting empty-bed level (see AdaptiveBaseline). baseline: Optional[AdaptiveBaseline] = None _cap_closed: bool = False + # Per-sample level decision (with hysteresis), independent of sessions β€” + # kept current even while this side's readings are merged into the other. + _level_present: bool = False _last_publish_ts: Optional[float] = None _tracked_samples: int = 0 @@ -1002,19 +1016,22 @@ def _maybe_publish_baseline(self, ts: float, record: dict) -> None: self._tracked_samples): self._tracked_samples = 0 - def process(self, ts: float, record: dict) -> None: + def observe(self, ts: float, record: dict) -> Optional["SideObservation"]: + """Read one frame for this side: presence evidence, movement delta and + the channel values the baseline tracks. None for a replayed frame. + Does not touch the session β€” see commit().""" if self._replay_until_ts is not None: if ts <= self._replay_until_ts: - return # already processed before the restart + return None # already processed before the restart self._replay_until_ts = None + self._last_ts = ts rtype = record.get("type", "") baseline = self._sync_baseline(record) values = baseline.values(record, self.side) if baseline is not None else None - if values is None: - # Unusable frame (missing side / capSense2 sentinel): no new evidence. - present = self._debounced_present - else: - present = baseline.is_present(values, self._debounced_present) + present: Optional[bool] = None # unusable frame: no new evidence + if values is not None: + present = baseline.is_present(values, self._level_present) + self._level_present = present # Movement's capSense2 common-mode rejection uses the baseline's ref. baselines = ({"ref": {"mean": baseline.ref_mean}} if baseline is not None and baseline.ref_mean is not None else None) @@ -1043,21 +1060,36 @@ def process(self, ts: float, record: dict) -> None: else: # Sentinel or invalid β€” skip delta, keep previous (zero-order hold) delta = 0.0 + return SideObservation(ts, present, delta, values, record) + + def commit(self, ts: float, present: Optional[bool], delta: float) -> bool: + """Advance the session with one sample's presence and movement. + Returns True if the session was just force-closed at MAX_SESSION_S.""" + self._update(ts, self._debounced_present if present is None else present, delta) + capped, self._cap_closed = self._cap_closed, False + return capped + + def settle(self, obs: "SideObservation", reset: bool = False) -> None: + """Baseline upkeep after the session step. `reset` makes the current + level the new empty level: presence never dropped for MAX_SESSION_S, + so the load on the bed isn't a sleeper.""" + if obs.values is None or self.baseline is None: + return + if reset: + self.baseline.reseed(obs.values, "cap-reset") + log.warning("%s: presence baseline reset to current level after a capped session", + self.side) + else: + self.baseline.track(obs.ts, obs.values, self._level_present) + self._tracked_samples += 1 + self._maybe_publish_baseline(obs.ts, obs.record) - self._update(ts, present, delta) - - if values is not None: - if self._cap_closed: - # Presence never dropped for MAX_SESSION_S: a load on the bed - # that isn't a sleeper. Make it the new empty level. - baseline.reseed(values, "cap-reset") - log.warning("%s: presence baseline reset to current level after a capped session", - self.side) - else: - baseline.track(ts, values, self._debounced_present) - self._tracked_samples += 1 - self._maybe_publish_baseline(ts, record) - self._cap_closed = False + def process(self, ts: float, record: dict) -> None: + obs = self.observe(ts, record) + if obs is None: + return + capped = self.commit(ts, obs.present, obs.delta) + self.settle(obs, reset=capped) def _apply_debounce(self, ts: float, raw_present: bool) -> bool: """Fold the raw per-sample presence into the committed (debounced) @@ -1282,6 +1314,34 @@ def _flush_movement(self, ts: float) -> None: self._last_movement_write = ts +def process_single_sleeper(home: SessionTracker, away: SessionTracker, + ts: float, record: dict) -> None: + """One frame in single-sleeper mode (the other side is in away mode). + + The sleeper is in bed while EITHER side reads occupied β€” rolling over or + a leg on the away side keeps one home-side session going instead of + opening a phantom one there. Movement from both sides is summed into the + home side's epochs. The away side keeps tracking its own baseline but + never records a session. + """ + h = home.observe(ts, record) + a = away.observe(ts, record) + if h is None and a is None: + return + if away._session_start is not None: + # Away mode switched on mid-session: end that side's session where + # its occupant was last seen; from now on its readings are merged. + away._close_session(away._last_present_ts or ts) + evidence = [o.present for o in (h, a) if o is not None and o.present is not None] + present = any(evidence) if evidence else None + delta = sum(o.delta for o in (h, a) if o is not None) + capped = home.commit(ts, present, delta) + # A capped session in merged mode may be held open by either side's load. + for tracker, obs in ((home, h), (away, a)): + if obs is not None: + tracker.settle(obs, reset=capped) + + # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- @@ -1303,6 +1363,9 @@ def main() -> None: left = SessionTracker(side="left", db=db_holder, calibration=cal_cache, pump_gate=pump_gate) right = SessionTracker(side="right", db=db_holder, calibration=cal_cache, pump_gate=pump_gate) trackers = (left, right) + # One side in away mode: a single sleeper, whose rollovers onto the away + # side merge into their own session. + bed_mode = SingleSleeperMode(SLEEPYPOD_DB) saved = load_state(STATE_PATH) for t in trackers: t.restore(saved.get(t.side), time.time()) @@ -1344,8 +1407,14 @@ def main() -> None: log_capsense_status_once(record, "sleep-detector") ts = sanitize_ts(record.get("ts")) - left.process(ts, record) - right.process(ts, record) + home_side = bed_mode.home_side() + if home_side is None: + left.process(ts, record) + right.process(ts, record) + elif home_side == "left": + process_single_sleeper(left, right, ts, record) + else: + process_single_sleeper(right, left, ts, record) if (left.state_dirty or right.state_dirty or time.monotonic() - last_save >= STATE_SAVE_INTERVAL_S): diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index 2f4e2a059..edc06453e 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -26,12 +26,14 @@ _stubs["common.dialect"].KNOWN_RECORD_TYPES = frozenset() _stubs["common.dialect"].warn_unknown_type_once = lambda *a, **kw: None _stubs["common.dialect"].log_capsense_status_once = lambda *a, **kw: None -# common.calibration is stdlib-only, so load the real presence math rather -# than stubbing it β€” the adaptive baseline tests depend on it. -_cal_spec = importlib.util.spec_from_file_location( - "common.calibration", Path(__file__).resolve().parent.parent / "common" / "calibration.py") -_stubs["common.calibration"] = importlib.util.module_from_spec(_cal_spec) -_cal_spec.loader.exec_module(_stubs["common.calibration"]) +# common.calibration and common.side_mode are stdlib-only, so load the real +# modules rather than stubbing them β€” the baseline and single-sleeper tests +# depend on their behaviour. +for _name in ("calibration", "side_mode"): + _spec = importlib.util.spec_from_file_location( + f"common.{_name}", Path(__file__).resolve().parent.parent / "common" / f"{_name}.py") + _stubs[f"common.{_name}"] = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_stubs[f"common.{_name}"]) _stubs["common.health"].report_health = lambda *a, **kw: None sys.modules.update(_stubs) @@ -805,3 +807,107 @@ def test_published_profile_matches_node_contract(self): assert params["threshold"] == 6.0 assert {ch: round(v["mean"]) for ch, v in params["channels"].items()} == {"A": 500, "B": 600, "C": 700} assert params["ref"]["mean"] == 1.2 + + +class TestSingleSleeper: + """A solo sleeper on the left who rolls onto the empty right side used + to open phantom right-side sessions while the left kept reading + occupied. With the right side in away mode those readings belong to the + left sleeper's one session.""" + + T0 = 1_777_000_000.0 + + @staticmethod + def _pair(): + holder = main.DBHolder(_make_db()) + main._db_write_failures = 0 + mk = lambda side: main.SessionTracker(side=side, db=holder, calibration=_Cal(), + pump_gate=main.PumpGateCapSense(), + # epochs are due relative to sample ts + _last_movement_write=0.0) + return mk("left"), mk("right") + + @staticmethod + def _frame(left_rise, right_rise): + return {"type": "capSense", + "left": {ch: int(v + left_rise) for ch, v in EMPTY.items()}, + "right": {ch: int(v + right_rise) for ch, v in EMPTY.items()}} + + def _run(self, left, right, start, seconds, lr, rr, merged=True): + ts = start + while ts < start + seconds: + frame = self._frame(lr, rr) + if merged: + main.process_single_sleeper(left, right, ts, frame) + else: + left.process(ts, frame) + right.process(ts, frame) + ts += 5.0 + return ts + + @staticmethod + def _sessions(t): + return t.db.conn.execute( + "SELECT side, entered_bed_at, left_bed_at, times_exited_bed FROM sleep_records" + ).fetchall() + + def _night(self, merged): + left, right = self._pair() + ts = self._run(left, right, self.T0, 600, 0, 0, merged) + ts = self._run(left, right, ts, 3 * 3600, 600, 0, merged) # asleep on the left + ts = self._run(left, right, ts, 3600, 300, 250, merged) # straddling the middle + ts = self._run(left, right, ts, 3600, 0, 600, merged) # fully rolled over + ts = self._run(left, right, ts, 3600, 600, 0, merged) # back on the left + ts = self._run(left, right, ts, 600, 0, 0, merged) # up + return left, right, ts + + def test_without_away_mode_the_rollover_opens_a_right_session(self): + left, _right, _ = self._night(merged=False) + assert {row[0] for row in self._sessions(left)} == {"left", "right"} + + def test_rollover_merges_into_one_home_session(self): + left, right, _ = self._night(merged=True) + rows = self._sessions(left) + assert len(rows) == 1 + side, entered, left_at, exits = rows[0] + assert side == "left" + assert abs(entered - (self.T0 + 600)) <= 10 + assert abs(left_at - (self.T0 + 600 + 6 * 3600)) <= 10 + assert exits == 1 # only the real morning exit + assert right._session_start is None + + def test_movement_is_written_to_home_side_only(self): + left, _right, _ = self._night(merged=True) + sides = {r[0] for r in left.db.conn.execute("SELECT DISTINCT side FROM movement")} + assert sides == {"left"} + + def test_session_can_start_on_the_away_side(self): + left, right = self._pair() + ts = self._run(left, right, self.T0, 600, 0, 0) + ts = self._run(left, right, ts, 2 * 3600, 0, 600) # got in on the right + ts = self._run(left, right, ts, 600, 0, 0) + rows = self._sessions(left) + assert [r[0] for r in rows] == ["left"] + + def test_away_side_baseline_keeps_tracking(self): + left, right = self._pair() + self._run(left, right, self.T0, 6 * 3600, 0, 33) # bedding step on the right + assert abs(right.baseline.means["out"] - (EMPTY["out"] + 33)) < 2 + assert self._sessions(left) == [] + + def test_open_away_side_session_is_closed_when_mode_switches_on(self): + left, right = self._pair() + ts = self._run(left, right, self.T0, 600, 0, 0, merged=False) + ts = self._run(left, right, ts, 3600, 0, 600, merged=False) # right occupant, per-side + assert right._session_start is not None + self._run(left, right, ts, 60, 0, 0, merged=True) # right set to away + assert right._session_start is None + assert [r[0] for r in self._sessions(left)] == ["right"] + + def test_capped_merged_session_resets_both_baselines(self): + left, right = self._pair() + ts = self._run(left, right, self.T0, 600, 0, 0) + ts = self._run(left, right, ts, main.MAX_SESSION_S + 3600, 0, 400) # load on the away side + assert right.baseline.source == "cap-reset" + assert left.baseline.source == "cap-reset" + assert left._session_start is None From df5874b73bca725710d8783084c40d3101c9324c Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Fri, 25 Sep 2026 17:08:09 -0400 Subject: [PATCH 016/119] fix(sleep-detector): restore every side's replay watermark after restart restore() returned for a side with no open session before restoring its last-processed timestamp, so that side replayed the current RAW file from offset 0 after a restart. In single-sleeper mode the sessionless away side feeds the home session: its replayed (older) frames reached home.commit() with timestamps going backwards and could fake an exit, splitting one continuous night into two sessions. The watermark is now restored for every side regardless of session state, and process_single_sleeper drops a frame the home side has already processed instead of committing the away side's reading of it. Co-Authored-By: Claude Opus 5.5 --- modules/sleep-detector/main.py | 20 ++++++++----- modules/sleep-detector/test_main.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index 545a36090..a89357fa6 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -944,6 +944,12 @@ def restore(self, state: Optional[dict], now: float) -> None: self.baseline = AdaptiveBaseline.from_state(state.get("baseline")) try: self._consecutive_cap_closes = int(state.get("consecutive_cap_closes") or 0) + # The replay watermark applies whether or not a session is open: a + # sessionless side (the away side in single-sleeper mode) still + # feeds sessions, and must not replay frames it already processed. + last_ts = state.get("last_ts") + self._last_ts = float(last_ts) if last_ts is not None else None + self._replay_until_ts = self._last_ts start = state.get("session_start") if start is None: return @@ -956,8 +962,6 @@ def restore(self, state: Optional[dict], now: float) -> None: self._exit_count = int(state.get("exit_count") or 0) self._debounced_present = bool(state.get("debounced_present")) self._state_since = state.get("state_since") - self._last_ts = state.get("last_ts") - self._replay_until_ts = self._last_ts except (TypeError, ValueError, OverflowError, OSError) as e: log.warning("%s: ignoring corrupt saved session: %s", self.side, e) self._reset_session() @@ -1326,7 +1330,9 @@ def process_single_sleeper(home: SessionTracker, away: SessionTracker, """ h = home.observe(ts, record) a = away.observe(ts, record) - if h is None and a is None: + if h is None: + # The home session already covered this frame (restart replay); + # the away side's reading of it must not re-enter the session. return if away._session_start is not None: # Away mode switched on mid-session: end that side's session where @@ -1334,12 +1340,12 @@ def process_single_sleeper(home: SessionTracker, away: SessionTracker, away._close_session(away._last_present_ts or ts) evidence = [o.present for o in (h, a) if o is not None and o.present is not None] present = any(evidence) if evidence else None - delta = sum(o.delta for o in (h, a) if o is not None) + delta = h.delta + (a.delta if a is not None else 0.0) capped = home.commit(ts, present, delta) # A capped session in merged mode may be held open by either side's load. - for tracker, obs in ((home, h), (away, a)): - if obs is not None: - tracker.settle(obs, reset=capped) + home.settle(h, reset=capped) + if a is not None: + away.settle(a, reset=capped) # --------------------------------------------------------------------------- diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index edc06453e..65e26b4be 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -911,3 +911,48 @@ def test_capped_merged_session_resets_both_baselines(self): assert right.baseline.source == "cap-reset" assert left.baseline.source == "cap-reset" assert left._session_start is None + + +class TestRestartReplay: + """After a restart the RAW follower replays the current file from offset + 0. Every side must skip what it already processed β€” including a side + with no open session, whose frames feed the home session in + single-sleeper mode.""" + + T0 = 1_777_000_000.0 + + def _restart_pair(self, left, right, now): + import json + holder = left.db + fresh = [] + for old in (left, right): + t = main.SessionTracker(side=old.side, db=holder, calibration=_Cal(), + pump_gate=main.PumpGateCapSense(), _last_movement_write=0.0) + t.restore(json.loads(json.dumps(old.snapshot())), now) + fresh.append(t) + return fresh + + def test_single_sleeper_replay_does_not_split_the_night(self): + s = TestSingleSleeper() + left, right = s._pair() + ts = s._run(left, right, self.T0, 600, 0, 0) + ts = s._run(left, right, ts, 2 * 3600, 600, 0) # asleep on the home side + assert right._session_start is None # away side: no session + left, right = self._restart_pair(left, right, now=ts + 60) + s._run(left, right, ts - 600, 600, 0, 0) # replay: already-processed timestamps + ts = s._run(left, right, ts + 60, 3600, 600, 0) # still asleep + ts = s._run(left, right, ts, 600, 0, 0) # up + rows = s._sessions(left) + assert len(rows) == 1 + assert rows[0][3] == 1 # only the real morning exit + + def test_sessionless_tracker_skips_replayed_frames(self): + import json + t = _live_tracker() + _run(t, self.T0, 600, 0) + last = t._last_ts + t2 = _live_tracker() + t2.restore(json.loads(json.dumps(t.snapshot())), now=last + 30) + t2.process(last - 300, _cap(600)) + assert t2._last_ts == last + assert t2._session_start is None From 65a43ed9a47e247f81168baf2222185318dac427 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Fri, 25 Sep 2026 17:08:35 -0400 Subject: [PATCH 017/119] fix(sleep-detector): persist the presence hysteresis latch Presence enters above the threshold and exits below half of it, using a per-side latch (_level_present) that was not saved. After a restart a reading between the two thresholds (someone lying near the edge) came back as absent, closing a session that was still going and losing the rest of the night. The latch is now saved in the state file and restored for every side, with or without an open session; it applies to both the RAW and NATS sources. Co-Authored-By: Claude Opus 5.5 --- modules/sleep-detector/main.py | 5 +++++ modules/sleep-detector/test_main.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index a89357fa6..64f1b81fb 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -931,6 +931,7 @@ def snapshot(self) -> dict: "state_since": self._state_since, "consecutive_cap_closes": self._consecutive_cap_closes, "last_ts": self._last_ts, + "level_present": self._level_present, "baseline": self.baseline.snapshot() if self.baseline is not None else None, } @@ -950,6 +951,10 @@ def restore(self, state: Optional[dict], now: float) -> None: last_ts = state.get("last_ts") self._last_ts = float(last_ts) if last_ts is not None else None self._replay_until_ts = self._last_ts + # Hysteresis latch: a reading between the exit and enter + # thresholds is occupied only if it already was β€” resetting it + # on restart would close a session that is still going. + self._level_present = bool(state.get("level_present")) start = state.get("session_start") if start is None: return diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index 65e26b4be..e9c2c65a4 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -956,3 +956,33 @@ def test_sessionless_tracker_skips_replayed_frames(self): t2.process(last - 300, _cap(600)) assert t2._last_ts == last assert t2._session_start is None + + +class TestRestartHysteresis: + """The per-side occupied latch (exit below half the enter threshold) must + survive a restart: a reading between the thresholds is occupied only + because it was already occupied.""" + + T0 = 1_777_000_000.0 + + def test_between_thresholds_stays_occupied_across_restart(self): + import json + t = _live_tracker() + ts = _run(t, self.T0, 600, 0) + ts = _run(t, ts, 3600, 600) # in bed + ts = _run(t, ts, 1800, 70) # +210 summed: between exit and enter + assert t._session_start is not None + t2 = _live_tracker() + t2.db = t.db + t2.restore(json.loads(json.dumps(t.snapshot())), now=ts + 30) + ts = _run(t2, ts + 30, 3600, 70) # still there after the restart + assert t2._session_start is not None + assert _rows(t2) == [] + + def test_latch_restored_for_sessionless_side(self): + import json + t = _live_tracker() + t._level_present = True + t2 = _live_tracker() + t2.restore(json.loads(json.dumps(t.snapshot())), now=self.T0) + assert t2._level_present is True From a81777cbbb7265c7e959d348c3e6690e864de60c Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Fri, 25 Sep 2026 17:09:41 -0400 Subject: [PATCH 018/119] fix(piezo-processor): keep single-sleeper vitals until they are written SingleSleeperVitals cleared a held candidate before writing it, so a failed SQLite write (flush, or the paired best-of-two write) dropped a row that the side had already been told was accepted. A held candidate is now cleared only once its write commits. A failed paired write keeps the better reading held for retry; a failed flush keeps it and retries after another VITALS_INTERVAL_S; and a same-side repeat is refused while the older row is unwritten, so its side keeps the new one and resubmits instead of the older row being overwritten. Co-Authored-By: Claude Opus 5.5 --- modules/piezo-processor/main.py | 32 ++++++++++++----- modules/piezo-processor/test_main.py | 51 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/modules/piezo-processor/main.py b/modules/piezo-processor/main.py index cd1e8ee72..879b949a9 100644 --- a/modules/piezo-processor/main.py +++ b/modules/piezo-processor/main.py @@ -248,7 +248,8 @@ def __init__(self, holder: "DBHolder", mode: SingleSleeperMode, def submit(self, cand: VitalsCandidate) -> bool: """Accept one side's candidate. True means the side may advance its - write cursor (written now, or held for pairing).""" + write cursor: the row was written, or is held (for pairing or a + write retry). A held candidate is only dropped once written.""" home = self._mode.home_side() if home is None: self.flush() @@ -256,9 +257,17 @@ def submit(self, cand: VitalsCandidate) -> bool: pending = self._pending if pending is not None and pending.side != cand.side and self._pending_home == home: best = cand if cand.quality_score > pending.quality_score else pending - self._pending = None - return best.write(self._holder, side=home) - self.flush() # a same-side repeat means its partner never arrived + if best.write(self._holder, side=home): + self._pending = None + else: + # Keep the better reading and retry it on a later tick. + self._pending, self._pending_at = best, self._clock() + return True + # A same-side repeat means its partner never arrived: write the + # older one first. If that fails, refuse the new candidate so its + # side keeps it and resubmits, rather than dropping the older row. + if not self.flush(): + return False self._pending, self._pending_home, self._pending_at = cand, home, self._clock() return True @@ -267,10 +276,17 @@ def tick(self) -> None: if self._pending is not None and self._clock() - self._pending_at >= VITALS_INTERVAL_S: self.flush() - def flush(self) -> None: - pending, self._pending = self._pending, None - if pending is not None: - pending.write(self._holder, side=self._pending_home) + def flush(self) -> bool: + """Write the held candidate, if any. On failure it stays held and + the next retry waits another VITALS_INTERVAL_S. True when nothing + is left held.""" + if self._pending is None: + return True + if self._pending.write(self._holder, side=self._pending_home): + self._pending = None + return True + self._pending_at = self._clock() + return False def report_health(status: str, message: str) -> None: diff --git a/modules/piezo-processor/test_main.py b/modules/piezo-processor/test_main.py index 62a31411e..1ad5ddc06 100644 --- a/modules/piezo-processor/test_main.py +++ b/modules/piezo-processor/test_main.py @@ -1426,3 +1426,54 @@ def test_side_processor_routes_through_sink(self): proc.ingest(signal) assert len(seen) == 1 and seen[0].side == "right" assert self._rows(conn) == [] + + +class TestSingleSleeperVitalsRetry: + """A held (accepted) candidate must survive a failed write and be + retried, not dropped.""" + + def _flaky(self, monkeypatch, fails): + import main + real = main.write_vitals + state = {"fails": fails} + + def flaky(*a, **kw): + if state["fails"] > 0: + state["fails"] -= 1 + return False + return real(*a, **kw) + monkeypatch.setattr(main, "write_vitals", flaky) + + def test_flush_failure_keeps_candidate_for_retry(self, monkeypatch): + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + self._flaky(monkeypatch, fails=1) + router.submit(t._cand("right", 58.0, 0.6)) + clock.t += 60 + router.tick() # write fails + assert t._rows(conn) == [] + clock.t += 60 + router.tick() # retried + assert t._rows(conn) == [("left", 58.0, 0.6)] + + def test_paired_write_failure_keeps_best_for_retry(self, monkeypatch): + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + self._flaky(monkeypatch, fails=1) + router.submit(t._cand("left", 63.0, 0.3)) + assert router.submit(t._cand("right", 62.0, 0.7)) is True # accepted, held + assert t._rows(conn) == [] + clock.t += 60 + router.tick() + assert t._rows(conn) == [("left", 62.0, 0.7)] + + def test_same_side_repeat_is_refused_while_older_is_unwritten(self, monkeypatch): + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + self._flaky(monkeypatch, fails=1) + router.submit(t._cand("right", 58.0, 0.6, minute=0)) + # Flushing the older held row fails: refuse the new one so the + # side keeps it and resubmits, instead of dropping the older row. + assert router.submit(t._cand("right", 59.0, 0.6, minute=1)) is False + assert router.submit(t._cand("right", 59.0, 0.6, minute=1)) is True + assert t._rows(conn) == [("left", 58.0, 0.6)] From d4344a3dc8474194514de0097cbae9803180ad14 Mon Sep 17 00:00:00 2001 From: Jason LaRue Date: Fri, 25 Sep 2026 17:10:30 -0400 Subject: [PATCH 019/119] fix(sleep-detector): seed from the published baseline when state is lost _sync_baseline ignored profiles marked source "adaptive" (the detector's own published baseline) so it would never re-adopt its own output over a live baseline. It also ignored them when there was no baseline at all, so with the state file missing or rejected the first frame became the empty level even when that frame was occupied and a valid published baseline existed. An adaptive profile now seeds the baseline when none exists; it still never replaces a live one. Docs note this and the restart behaviour of the replay watermark and hysteresis latch. Co-Authored-By: Claude Opus 5.5 --- docs/sleep-detector.md | 6 +++--- modules/sleep-detector/main.py | 9 ++++++--- modules/sleep-detector/test_main.py | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/sleep-detector.md b/docs/sleep-detector.md index 6324891f4..0362f3c45 100644 --- a/docs/sleep-detector.md +++ b/docs/sleep-detector.md @@ -138,8 +138,8 @@ The baseline is maintained by the detector itself (`AdaptiveBaseline`), not by s - **Drift:** while the bed is empty and the reading is within the exit threshold, the baseline follows it with time constant `BASELINE_UP_TAU_S` (30 min). A load between the exit and enter thresholds is never learned as empty. - **Contamination:** any reading below baseline pulls it down with `BASELINE_DOWN_TAU_S` (2 min), so a baseline captured with someone in bed recovers minutes after they get up. - **Stuck load:** after a session force-closed at `MAX_SESSION_S`, the current level becomes the new empty level. -- **Seeding:** the saved state file, else the active calibration profile (legacy capSense profiles with a z-score threshold get the raw-unit default), else the first frame. A calibration profile newer than any seen β€” a manual recalibration β€” is adopted as a reseed. -- **Publishing:** every `BASELINE_PUBLISH_S` (15 min) the baseline is upserted to `calibration_profiles` with `source: "adaptive"`, in the calibrators' shape, so Node's occupancy check and the UI see the same level. Profiles marked adaptive are never re-adopted. +- **Seeding:** the saved state file, else the active calibration profile β€” including this detector's own published baseline when the state file is lost (legacy capSense profiles with a z-score threshold get the raw-unit default) β€” else the first frame. A calibration profile newer than any seen β€” a manual recalibration β€” is adopted as a reseed. +- **Publishing:** every `BASELINE_PUBLISH_S` (15 min) the baseline is upserted to `calibration_profiles` with `source: "adaptive"`, in the calibrators' shape, so Node's occupancy check and the UI see the same level. Profiles marked adaptive never replace a live baseline; they only seed one when there is none. - Per-sample tracking time is capped at `BASELINE_MAX_STEP_S`, so a gap or restart can't move the baseline in one step. Frames with a missing side or capSense2 sentinels are ignored. Replaying recorded capSense data starting from a baseline captured with the sleeper in bed, the baseline recovers within minutes of them getting up, sessions end at the real wake time rather than at the next recalibration, and a bedding shift of a few tens of units per channel produces no session. @@ -169,7 +169,7 @@ An open session lives in memory, so it is checkpointed to `sleep-detector-state. - **resumes** the session when the last saved sample is recent. If the occupant is still in bed the downtime counts as sleep; if absence is committed before any presence is seen, the exit is dated at the last pre-restart presence (they left while the detector was down). - **closes** it at the last presence when the gap exceeds `STATE_MAX_GAP_S` (30 min), since the downtime can't be attributed to sleep. -- **skips replayed samples**: the RAW follower re-reads the current file from offset 0, so samples at or before the saved `last_ts` are ignored. +- **skips replayed samples**: the RAW follower re-reads the current file from offset 0, so samples at or before the saved `last_ts` are ignored β€” for every side, with or without an open session. The presence hysteresis latch is restored too, so a reading between the exit and enter thresholds keeps its state. Session records include: - Entry/exit timestamps diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index 64f1b81fb..6d725c22c 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -985,14 +985,17 @@ def restore(self, state: Optional[dict], now: float) -> None: def _sync_baseline(self, record: dict) -> Optional[AdaptiveBaseline]: """The presence baseline for this frame's format. Adopts a calibration - profile newer than any already seen (e.g. a manual recalibration), - seeds from the first frame when nothing else exists.""" + profile newer than any already seen (e.g. a manual recalibration). + With no baseline at all (state file missing), this detector's own + published baseline is a better seed than the first frame, which may + be occupied. Seeds from the first frame only when nothing else exists.""" fmt = "capSense2" if record.get("type") == "capSense2" else "capSense" b = self.baseline if self.baseline is not None and self.baseline.fmt == fmt else None profile = self.calibration.get_profile(self.side) if self.calibration else None if profile is not None: params, created_at = profile - if isinstance(params, dict) and params.get("source") != "adaptive": + own = isinstance(params, dict) and params.get("source") == "adaptive" + if isinstance(params, dict) and (not own or b is None): seen = b.profile_seen_at if b is not None else None if seen is None or (created_at or 0) > seen: adopted = AdaptiveBaseline.from_profile(params, fmt, created_at) diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index e9c2c65a4..b442e7d26 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -986,3 +986,27 @@ def test_latch_restored_for_sessionless_side(self): t2 = _live_tracker() t2.restore(json.loads(json.dumps(t.snapshot())), now=self.T0) assert t2._level_present is True + + +class TestAdaptiveProfileSeed: + T0 = 1_777_000_000.0 + + def test_own_published_baseline_seeds_when_state_is_lost(self): + # State file missing, a published adaptive baseline exists, and the + # first frame is already occupied: seed from the profile, not the frame. + adaptive = _profile(EMPTY, created_at=self.T0 - 600, source="adaptive") + t = _live_tracker(_Cal(adaptive)) + ts = _run(t, self.T0, 2 * 3600, 600) + assert t.baseline.source == "adaptive" + assert t.baseline.means["out"] == EMPTY["out"] + assert t._session_start is not None # the occupant is detected + + def test_own_published_baseline_does_not_override_live_one(self): + cal = _Cal() + t = _live_tracker(cal) + ts = _run(t, self.T0, 600, 0) + before = dict(t.baseline.means) + cal.profile = _profile({ch: v + 200 for ch, v in EMPTY.items()}, + created_at=ts, source="adaptive") + _run(t, ts, 60, 0) + assert t.baseline.means == before From 61ce9ba779dd315ef2ff67bd3d2c835ff9a596ec Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sat, 26 Sep 2026 15:49:25 -0700 Subject: [PATCH 020/119] fix(biometrics): preserve vitals retries and flush during idle input --- docs/piezo-processor.md | 2 + modules/common/nats_follower.py | 11 +++-- modules/common/raw_follower.py | 8 +++- modules/common/test_nats_follower.py | 20 +++++++++ modules/common/test_raw_follower.py | 23 ++++++++++ modules/piezo-processor/main.py | 34 +++++++++------ modules/piezo-processor/test_main.py | 65 ++++++++++++++++++++++++++++ 7 files changed, 144 insertions(+), 19 deletions(-) diff --git a/docs/piezo-processor.md b/docs/piezo-processor.md index 47340111c..f860a2fa1 100644 --- a/docs/piezo-processor.md +++ b/docs/piezo-processor.md @@ -190,6 +190,8 @@ The thresholds `enter=400,000`, `exit=150,000`, and `acr=0.45` were derived from When exactly one side is in away mode, both sides' vitals go through `SingleSleeperVitals`: each cycle's two candidates are paired and only the higher-quality one is written, under the home side; an away-side candidate whose partner doesn't arrive within `VITALS_INTERVAL_S` is written alone as the home side. A solo sleeper who rolls onto the empty side keeps one heart-rate series instead of spilling rows onto the away side. See `docs/sleep-detector.md` Β§ Single-Sleeper Mode. +Pending single-sleeper rows expire on the reader thread even when RAW/NATS input is idle or pump gating skips records. A failed write retains the completed interval separately from candidates awaiting a partner; it must commit before another candidate is accepted, so later intervals cannot replace it. Pending rows remain in memory and do not survive a process crash. + ## 6. Heart Rate Extraction ### Bandpass: 0.8-8.5 Hz diff --git a/modules/common/nats_follower.py b/modules/common/nats_follower.py index f04bd71bd..afd08ea8f 100644 --- a/modules/common/nats_follower.py +++ b/modules/common/nats_follower.py @@ -237,15 +237,20 @@ def _ensure_started(self) -> None: target=self._run, name="nats-follower", daemon=True) self._thread.start() - def read_records(self): + def read_records(self, on_poll=None): """Yield decoded CBOR records as they arrive from NATS. - Blocks on the internal queue. Raises NatsFollowerError if the client - exhausts its reconnect budget, so the caller exits and systemd + Calls optional on_poll on the reader thread before each queue poll, + including while idle. Blocks on the internal queue. Raises + NatsFollowerError if the client exhausts its reconnect budget, so the caller exits and systemd restarts the module (re-running source selection). """ self._ensure_started() while not self._shutdown.is_set(): + if on_poll is not None: + on_poll() + if self._shutdown.is_set(): + break try: item = self._queue.get(timeout=0.5) except queue.Empty: diff --git a/modules/common/raw_follower.py b/modules/common/raw_follower.py index dff410c13..bcc7b37b8 100644 --- a/modules/common/raw_follower.py +++ b/modules/common/raw_follower.py @@ -79,9 +79,13 @@ def _scan_for_next_magic(self, start: int) -> int: return start + 1 + idx return start + 1 + len(chunk) - def read_records(self): - """Yield decoded CBOR records as they arrive, sleeping between poll attempts.""" + def read_records(self, on_poll=None): + """Yield records; call optional on_poll on the reader thread even while idle.""" while not self._shutdown.is_set(): + if on_poll is not None: + on_poll() + if self._shutdown.is_set(): + break latest = self._find_latest() if latest is None: time.sleep(1) diff --git a/modules/common/test_nats_follower.py b/modules/common/test_nats_follower.py index fbc946f62..c60d8212f 100644 --- a/modules/common/test_nats_follower.py +++ b/modules/common/test_nats_follower.py @@ -321,3 +321,23 @@ def records(): assert [r['i'] for r in snap['capSense']] == [2, 3] assert snap['piezo-dual'] == [{'type': 'piezo-dual'}] assert buf.total() == 3 + + +def test_poll_callback_runs_while_nats_queue_idle(monkeypatch): + import queue + ev = threading.Event() + follower = NatsFollower(ev) + follower._started = True + calls = [] + + def poll(): + calls.append(threading.get_ident()) + if len(calls) == 2: + ev.set() + + def empty(**kwargs): + raise queue.Empty + + monkeypatch.setattr(follower._queue, "get", empty) + assert list(follower.read_records(on_poll=poll)) == [] + assert calls == [threading.get_ident()] * 2 diff --git a/modules/common/test_raw_follower.py b/modules/common/test_raw_follower.py index eebb535cb..9f6273c45 100644 --- a/modules/common/test_raw_follower.py +++ b/modules/common/test_raw_follower.py @@ -245,3 +245,26 @@ def fake_read(f): assert follower._path == real_path if follower._file: follower._file.close() + + +@pytest.mark.parametrize("existing_file", [False, True]) +def test_poll_callback_runs_while_raw_input_idle(tmp_path, monkeypatch, existing_file): + from common import raw_follower + if existing_file: + (tmp_path / "empty.RAW").touch() + follower = _make_follower(tmp_path) + calls = [] + + def poll(): + calls.append(threading.get_ident()) + if len(calls) == 2: + follower._shutdown.set() + + def eof(_): + raise EOFError + + monkeypatch.setattr(raw_follower, "read_raw_record", eof) + monkeypatch.setattr(raw_follower.time, "sleep", lambda _: None) + assert list(follower.read_records(on_poll=poll)) == [] + assert calls == [threading.get_ident()] * 2 + assert follower._file is None diff --git a/modules/piezo-processor/main.py b/modules/piezo-processor/main.py index 879b949a9..a9c718509 100644 --- a/modules/piezo-processor/main.py +++ b/modules/piezo-processor/main.py @@ -243,6 +243,8 @@ def __init__(self, holder: "DBHolder", mode: SingleSleeperMode, self._mode = mode self._clock = clock self._pending: Optional[VitalsCandidate] = None + self._retry: Optional[VitalsCandidate] = None + self._retry_home: Optional[str] = None self._pending_home: Optional[str] = None self._pending_at = 0.0 @@ -250,18 +252,19 @@ def submit(self, cand: VitalsCandidate) -> bool: """Accept one side's candidate. True means the side may advance its write cursor: the row was written, or is held (for pairing or a write retry). A held candidate is only dropped once written.""" + # Completed intervals must never pair with a later candidate. + if self._retry is not None and not self.flush(): + return False home = self._mode.home_side() if home is None: - self.flush() + if not self.flush(): + return False return cand.write(self._holder) pending = self._pending if pending is not None and pending.side != cand.side and self._pending_home == home: best = cand if cand.quality_score > pending.quality_score else pending - if best.write(self._holder, side=home): - self._pending = None - else: - # Keep the better reading and retry it on a later tick. - self._pending, self._pending_at = best, self._clock() + self._pending = best + self.flush() return True # A same-side repeat means its partner never arrived: write the # older one first. If that fails, refuse the new candidate so its @@ -273,17 +276,21 @@ def submit(self, cand: VitalsCandidate) -> bool: def tick(self) -> None: """Write a held candidate whose partner didn't arrive in time.""" - if self._pending is not None and self._clock() - self._pending_at >= VITALS_INTERVAL_S: + held = self._pending is not None or self._retry is not None + if held and self._clock() - self._pending_at >= VITALS_INTERVAL_S: self.flush() def flush(self) -> bool: """Write the held candidate, if any. On failure it stays held and - the next retry waits another VITALS_INTERVAL_S. True when nothing - is left held.""" - if self._pending is None: - return True - if self._pending.write(self._holder, side=self._pending_home): + idle retries wait VITALS_INTERVAL_S; a new submission retries first. + True when nothing is left held.""" + if self._retry is None: + self._retry, self._retry_home = self._pending, self._pending_home self._pending = None + if self._retry is None: + return True + if self._retry.write(self._holder, side=self._retry_home): + self._retry = None return True self._pending_at = self._clock() return False @@ -1075,7 +1082,7 @@ def main() -> None: report_health("healthy", "piezo-processor v2 started") try: - for record in follower.read_records(): + for record in follower.read_records(on_poll=vitals_router.tick): rtype = record.get("type") # Surface genuinely-new firmware types once (blanketReadings, log, @@ -1108,7 +1115,6 @@ def main() -> None: left.ingest(l_samples) right.ingest(r_samples) - vitals_router.tick() except Exception as e: log.exception("Fatal error in main loop: %s", e) diff --git a/modules/piezo-processor/test_main.py b/modules/piezo-processor/test_main.py index 1ad5ddc06..775369508 100644 --- a/modules/piezo-processor/test_main.py +++ b/modules/piezo-processor/test_main.py @@ -1477,3 +1477,68 @@ def test_same_side_repeat_is_refused_while_older_is_unwritten(self, monkeypatch) assert router.submit(t._cand("right", 59.0, 0.6, minute=1)) is False assert router.submit(t._cand("right", 59.0, 0.6, minute=1)) is True assert t._rows(conn) == [("left", 58.0, 0.6)] + + @pytest.mark.parametrize("next_quality", [0.2, 0.9]) + def test_failed_pair_is_not_paired_with_next_interval(self, monkeypatch, next_quality): + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + self._flaky(monkeypatch, fails=1) + router.submit(t._cand("left", 63.0, 0.3)) + router.submit(t._cand("right", 62.0, 0.7)) # completed pair fails + clock.t += 60 + assert router.submit(t._cand("left", 70.0, next_quality, minute=1)) + router.flush() + assert t._rows(conn) == [("left", 62.0, 0.7), ("left", 70.0, next_quality)] + + def test_failed_retry_refuses_next_interval(self, monkeypatch): + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + self._flaky(monkeypatch, fails=2) + router.submit(t._cand("left", 63.0, 0.3)) + router.submit(t._cand("right", 62.0, 0.7)) + newer = t._cand("left", 70.0, 0.9, minute=1) + assert router.submit(newer) is False + assert t._rows(conn) == [] + assert router.submit(newer) is True + router.flush() + assert t._rows(conn) == [("left", 62.0, 0.7), ("left", 70.0, 0.9)] + + def test_failed_timeout_is_not_repaired_with_late_partner(self, monkeypatch): + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + self._flaky(monkeypatch, fails=1) + router.submit(t._cand("right", 62.0, 0.7)) + clock.t += 60 + router.tick() + router.submit(t._cand("left", 70.0, 0.9, minute=1)) + router.flush() + assert t._rows(conn) == [("left", 62.0, 0.7), ("left", 70.0, 0.9)] + + +@pytest.mark.parametrize("pump_gated", [False, True]) +def test_main_flushes_pending_vitals_during_idle_poll(monkeypatch, tmp_path, pump_gated): + """The follower can flush a lone row without yielding another record.""" + import main + t = TestSingleSleeperVitals() + router, conn, clock = t._router() + router.submit(t._cand("right", 62.0, 0.7)) + monkeypatch.setattr(main, "BIOMETRICS_DB", tmp_path / "biometrics.db") + monkeypatch.setattr(main, "open_biometrics_db", lambda: conn) + monkeypatch.setattr(main, "SingleSleeperVitals", lambda *args: router) + monkeypatch.setattr(main, "report_health", lambda *args: None) + + monkeypatch.setattr(main, "KNOWN_RECORD_TYPES", {"piezo-dual"}) + monkeypatch.setattr(main.PumpGate, "check", lambda *args: True) + + class IdleFollower: + def read_records(self, on_poll): + if pump_gated: + on_poll() + yield {"type": "piezo-dual", "left1": b"\x00" * 4, "right1": b"\x00" * 4} + clock.t += 60 + on_poll() + # Assert before main's shutdown flush can mask missing idle work. + assert t._rows(conn) == [("left", 62.0, 0.7)] + + monkeypatch.setattr(main, "create_follower", lambda *a, **kw: IdleFollower()) + main.main() From 08c55fae2a86c4f28fe39c4f781393d654ab5a87 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sat, 26 Sep 2026 16:11:58 -0700 Subject: [PATCH 021/119] test(biometrics): cover single-sleeper page routing (#721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release promotion exposed missing coverage for single-sleeper UI routing. Add page-level tests using the real VitalsPanel and useBiometricsSide hook to verify both home sides, disabled away-side queries, exclusion of cached away-side sessions, and normal two-side comparisons. Validation: three targeted tests, typecheck, lint, and pre-push checks pass. No production behavior changes. ## Summary by CodeRabbit * **Tests** * Added coverage for single-sleeper routing and the default both-sides view, including displayed sleep data, query behavior, and side selection.
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update codex/promotion-ui-coverage ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/codex/promotion-ui-coverage/scripts/install \ | sudo bash -s -- --branch codex/promotion-ui-coverage ``` 🎯 **Pin to this exact build** ([run 36278522554](https://github.com/sleepypod/core/actions/runs/36278522554) Β· `430d43e`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/430d43edbb4c2f48fa9e7206aca9e3483ae97344/scripts/install \ | sudo bash -s -- \ --branch codex/promotion-ui-coverage \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36278522554/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36278522554/artifacts/10918331121) Β· self-destructs in 30 days πŸ’₯
--- .../biometrics/tests/DataPage.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/components/biometrics/tests/DataPage.test.tsx diff --git a/src/components/biometrics/tests/DataPage.test.tsx b/src/components/biometrics/tests/DataPage.test.tsx new file mode 100644 index 000000000..15a1f8775 --- /dev/null +++ b/src/components/biometrics/tests/DataPage.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DataPage from '@/app/[lang]/data/page' + +const state = vi.hoisted(() => ({ + singleSleeperSide: null as 'left' | 'right' | null, + selectSide: vi.fn(), + sleep: vi.fn(), + vitals: vi.fn(), + baseline: vi.fn(), +})) +vi.mock('@/src/providers/SideProvider', () => ({ + useSide: () => ({ + selectedSide: 'both', primarySide: 'left', activeSides: ['left', 'right'], + singleSleeperSide: state.singleSleeperSide, selectSide: state.selectSide, + }), +})) +vi.mock('@/src/hooks/useWeekNavigator', () => ({ + useWeekNavigator: () => ({ + weekStart: new Date('2026-09-20'), weekEnd: new Date('2026-09-27'), + label: 'This week', isCurrentWeek: true, + goToPreviousWeek: vi.fn(), goToNextWeek: vi.fn(), goToCurrentWeek: vi.fn(), + }), +})) +vi.mock('@/src/utils/trpc', () => ({ + trpc: { biometrics: { + getSleepRecords: { useQuery: state.sleep }, + getVitals: { useQuery: state.vitals }, + getVitalsBaseline: { useQuery: state.baseline }, + } }, +})) +vi.mock('@/src/components/biometrics/SleepSummaryCard', () => ({ + SleepSummaryCard: ({ records }: { records: Array<{ side: string }> }) => ( +
{records.map(r => r.side).join(',')}
+ ), +})) +vi.mock('@/src/components/SleepStages/SleepStagesCard', () => ({ + SleepStagesCard: ({ side }: { side: string }) =>
{side}
, +})) +vi.mock('@/src/components/MovementChart/MovementChart', () => ({ MovementChart: () => null })) +vi.mock('@/src/components/biometrics/VitalsGrid', () => ({ VitalsGrid: () => null })) +vi.mock('@/src/components/biometrics/RawDataButton', () => ({ RawDataButton: () => null })) +vi.mock('@/src/components/VitalsChart/VitalsChart', () => ({ VitalsChart: () => null })) + +beforeEach(() => { + vi.clearAllMocks() + state.singleSleeperSide = null + // Disabled queries can still expose cached data. The away side must not leak + // into summaries even when that cache contains an older session. + state.sleep.mockImplementation(({ side, limit }) => ({ + data: limit === 7 ? [{ id: side === 'left' ? 1 : 2, enteredBedAt: new Date('2026-09-25') }] : [], + })) + state.vitals.mockReturnValue({ data: [], isLoading: false }) + state.baseline.mockReturnValue({ data: undefined }) +}) +afterEach(cleanup) + +describe('DataPage single-sleeper routing', () => { + it.each(['left', 'right'] as const)('shows only the %s sleeper despite both-side controls and cached away data', (home) => { + state.singleSleeperSide = home + render() + const away = home === 'left' ? 'right' : 'left' + expect(screen.getByTestId('sleep-summary').textContent).toBe(home) + expect(screen.getAllByTestId('stages').map(el => el.textContent)).toEqual([home, home]) + expect(screen.queryByRole('button', { name: /tap to switch/ })).toBeNull() + expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: home, limit: 7 }), { enabled: true }) + expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: away, limit: 7 }), { enabled: false }) + expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: home, limit: 100 })) + expect(state.baseline).toHaveBeenCalledWith({ side: home, days: 30 }) + expect(state.vitals).toHaveBeenCalledWith(expect.objectContaining({ side: home })) + expect(state.vitals).toHaveBeenCalledWith(expect.objectContaining({ side: away }), { enabled: false }) + }) + + it('keeps both summaries and comparison vitals when neither side is away', () => { + render() + expect(screen.getAllByTestId('sleep-summary').map(el => el.textContent)).toEqual(['left', 'right']) + expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: 'left', limit: 7 }), { enabled: true }) + expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: 'right', limit: 7 }), { enabled: true }) + expect(state.vitals).toHaveBeenCalledWith(expect.objectContaining({ side: 'right' }), { enabled: true }) + fireEvent.click(screen.getByRole('button', { name: /tap to switch/ })) + expect(state.selectSide).toHaveBeenCalledWith('right') + }) +}) From 4dac1238ad3eeebf94960d1193c55018bf120ee3 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sat, 26 Sep 2026 16:21:38 -0700 Subject: [PATCH 022/119] test(biometrics): cover side-toggle navigation branches (#722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the page routing regressions to both normal side selections and the standalone VitalsPanel. Verify both toggle directions and that single-sleeper mode hides the standalone side switch. This covers the remaining navigation branches reported by the main promotion coverage gate. Validation: all eight page/navigation tests pass, plus typecheck and pre-push quality gates. Tests only; no production behavior changes.
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update test/promotion-side-toggle ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/test/promotion-side-toggle/scripts/install \ | sudo bash -s -- --branch test/promotion-side-toggle ``` 🎯 **Pin to this exact build** ([run 36279027799](https://github.com/sleepypod/core/actions/runs/36279027799) Β· `76d6071`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/76d6071ca8ba964b5ffab719be73d1b61aa32e31/scripts/install \ | sudo bash -s -- \ --branch test/promotion-side-toggle \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36279027799/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36279027799/artifacts/10918551605) Β· self-destructs in 30 days πŸ’₯
--- .../biometrics/tests/DataPage.test.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/biometrics/tests/DataPage.test.tsx b/src/components/biometrics/tests/DataPage.test.tsx index 15a1f8775..bec6db4f3 100644 --- a/src/components/biometrics/tests/DataPage.test.tsx +++ b/src/components/biometrics/tests/DataPage.test.tsx @@ -1,8 +1,10 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DataPage from '@/app/[lang]/data/page' +import { VitalsPanel } from '@/src/components/VitalsPanel/VitalsPanel' const state = vi.hoisted(() => ({ + primarySide: 'left' as 'left' | 'right', singleSleeperSide: null as 'left' | 'right' | null, selectSide: vi.fn(), sleep: vi.fn(), @@ -11,7 +13,7 @@ const state = vi.hoisted(() => ({ })) vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => ({ - selectedSide: 'both', primarySide: 'left', activeSides: ['left', 'right'], + selectedSide: 'both', primarySide: state.primarySide, activeSides: ['left', 'right'], singleSleeperSide: state.singleSleeperSide, selectSide: state.selectSide, }), })) @@ -45,6 +47,7 @@ vi.mock('@/src/components/VitalsChart/VitalsChart', () => ({ VitalsChart: () => beforeEach(() => { vi.clearAllMocks() state.singleSleeperSide = null + state.primarySide = 'left' // Disabled queries can still expose cached data. The away side must not leak // into summaries even when that cache contains an older session. state.sleep.mockImplementation(({ side, limit }) => ({ @@ -71,13 +74,31 @@ describe('DataPage single-sleeper routing', () => { expect(state.vitals).toHaveBeenCalledWith(expect.objectContaining({ side: away }), { enabled: false }) }) - it('keeps both summaries and comparison vitals when neither side is away', () => { + it.each(['left', 'right'] as const)('keeps both summaries with %s selected when neither side is away', (side) => { + state.primarySide = side + const other = side === 'left' ? 'right' : 'left' render() expect(screen.getAllByTestId('sleep-summary').map(el => el.textContent)).toEqual(['left', 'right']) expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: 'left', limit: 7 }), { enabled: true }) expect(state.sleep).toHaveBeenCalledWith(expect.objectContaining({ side: 'right', limit: 7 }), { enabled: true }) - expect(state.vitals).toHaveBeenCalledWith(expect.objectContaining({ side: 'right' }), { enabled: true }) + expect(state.vitals).toHaveBeenCalledWith(expect.objectContaining({ side: other }), { enabled: true }) fireEvent.click(screen.getByRole('button', { name: /tap to switch/ })) - expect(state.selectSide).toHaveBeenCalledWith('right') + expect(state.selectSide).toHaveBeenCalledWith(other) + }) +}) + +describe('VitalsPanel side navigation', () => { + it.each(['left', 'right'] as const)('switches from %s when shown outside the data page', (side) => { + state.primarySide = side + render() + fireEvent.click(screen.getByRole('button', { name: side === 'left' ? 'leftL' : 'rightR' })) + expect(state.selectSide).toHaveBeenCalledWith(side === 'left' ? 'right' : 'left') + }) + + it.each(['left', 'right'] as const)('hides side switching for the %s single sleeper', (home) => { + state.singleSleeperSide = home + render() + expect(screen.queryByRole('button', { name: /^(leftL|rightR)$/ })).toBeNull() + expect(state.baseline).toHaveBeenCalledWith({ side: home, days: 30 }) }) }) From 3d92989204b41f374c5b47a81209a1df4309c241 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sat, 26 Sep 2026 22:06:09 -0700 Subject: [PATCH 023/119] Run CI on merge_group so the dev merge queue can gate on required checks (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Enabling a merge queue on `dev` requires the required status checks to run on the temporary merge-group branch, otherwise every queue entry times out. This adds the `merge_group` trigger to the CI workflow. For the Lint task the diff base now comes from `BASE_REF`, which is the PR base on pull requests and `refs/heads/` on merge-group runs (stripped in the shell). Push runs are unchanged. ## Test plan - [ ] CI runs on this PR as before (Lint, Typecheck, Unit Tests, codecov/patch). - [ ] After merge, the `dev` ruleset gains a merge queue rule; the first queued PR shows the same checks reporting on the merge group. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Chores** * Automated checks now run for changes evaluated through the merge queue. * Lint checks now compare changes against the appropriate target branch, including when a merge-queue target is used. This keeps validation aligned with the branch the changes are intended to join.
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update ci/merge-group-trigger ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/ci/merge-group-trigger/scripts/install \ | sudo bash -s -- --branch ci/merge-group-trigger ``` 🎯 **Pin to this exact build** ([run 36296076828](https://github.com/sleepypod/core/actions/runs/36296076828) Β· `936ea61`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/936ea613c965e640f237995ec4c8301254f23535/scripts/install \ | sudo bash -s -- \ --branch ci/merge-group-trigger \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36296076828/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36296076828/artifacts/10923204552) Β· self-destructs in 30 days πŸ’₯
Co-authored-by: Claude Fable 5.1 --- .github/workflows/test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c392c0ebd..b339668e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,9 @@ on: branches: [dev] pull_request: types: [opened, synchronize, reopened] + # Merge queue: the required checks must also report on the temporary + # merge-group branch or every queue entry fails. + merge_group: workflow_dispatch: concurrency: @@ -23,7 +26,7 @@ jobs: fail-fast: false matrix: task: - - { name: "Lint", cmd: "CHANGED=$(git diff --name-only --diff-filter=d origin/${{ github.event.pull_request.base.ref }}...HEAD -- '*.ts' '*.tsx' | head -200); if [ -n \"$CHANGED\" ]; then echo \"$CHANGED\" | xargs -d '\\n' pnpm eslint --; else echo 'No TS/TSX files changed'; fi" } + - { name: "Lint", cmd: "CHANGED=$(git diff --name-only --diff-filter=d origin/${BASE_REF#refs/heads/}...HEAD -- '*.ts' '*.tsx' | head -200); if [ -n \"$CHANGED\" ]; then echo \"$CHANGED\" | xargs -d '\\n' pnpm eslint --; else echo 'No TS/TSX files changed'; fi" } - { name: "Typecheck", cmd: "pnpm tsc" } - { name: "Unit Tests", cmd: "pnpm test run --coverage --passWithNoTests" } @@ -61,6 +64,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_ENV: test + # A PR carries base.ref; a merge-group run carries refs/heads/. + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} - name: Upload coverage reports to Codecov if: matrix.task.name == 'Unit Tests' && !cancelled() From 52f697941f0a93989e24bee67ee69a3d53c8c47a Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 27 Sep 2026 08:22:13 +0000 Subject: [PATCH 024/119] ci: stop failing the test gate on Codecov upload errors (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary PR #723's Unit Tests job failed with `Error: read ECONNRESET` in the "Upload test results to Codecov" step even though every test passed. That step uses `codecov/test-results-action@v1`, which is deprecated and logs a warning pointing to `codecov-action@v5` with `report_type: test_results`. ## Changes - `.github/workflows/test.yml` and `.github/workflows/python-modules.yml`: replace `codecov/test-results-action@v1` with `codecov/codecov-action@v5` + `report_type: test_results`, keeping the same OIDC auth, slug, flags, and files. - Set `fail_ci_if_error: false` on every Codecov upload (coverage and test results) so a Codecov network or service blip cannot turn a green test run red. Codecov's own status checks (`codecov/patch`) still report coverage independently. ## Test plan - [ ] Both workflows run green on this PR (`Unit Tests` and the Python module matrix) - [ ] Codecov shows test results and coverage for this PR's commit under the `javascript` and `python,*` flags - [ ] No deprecation warning from the test-results step in the job log πŸ€– Generated with [Claude Code](https://claude.com/claude-code)
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update ci/codecov-test-results-v5 ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/ci/codecov-test-results-v5/scripts/install \ | sudo bash -s -- --branch ci/codecov-test-results-v5 ``` 🎯 **Pin to this exact build** ([run 36296207849](https://github.com/sleepypod/core/actions/runs/36296207849) Β· `7eeb761`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/7eeb7614d88f777ca9c367034ae81e636e9e007b/scripts/install \ | sudo bash -s -- \ --branch ci/codecov-test-results-v5 \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36296207849/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36296207849/artifacts/10923932850) Β· self-destructs in 30 days πŸ’₯
Co-authored-by: Claude Fable 5.1 --- .github/workflows/python-modules.yml | 5 ++++- .github/workflows/test.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-modules.yml b/.github/workflows/python-modules.yml index 5e307b39a..426e2c237 100644 --- a/.github/workflows/python-modules.yml +++ b/.github/workflows/python-modules.yml @@ -123,6 +123,7 @@ jobs: uses: codecov/codecov-action@v5 with: use_oidc: true + fail_ci_if_error: false slug: sleepypod/core flags: python,${{ matrix.module.name }} files: ${{ matrix.module.workdir }}/coverage.xml @@ -132,9 +133,11 @@ jobs: - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 with: + report_type: test_results use_oidc: true + fail_ci_if_error: false slug: sleepypod/core flags: python,${{ matrix.module.name }} files: ${{ matrix.module.workdir }}/junit.xml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b339668e9..269578e75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,14 +72,17 @@ jobs: uses: codecov/codecov-action@v5 with: use_oidc: true + fail_ci_if_error: false slug: sleepypod/core flags: javascript - name: Upload test results to Codecov if: matrix.task.name == 'Unit Tests' && !cancelled() - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 with: + report_type: test_results use_oidc: true + fail_ci_if_error: false slug: sleepypod/core files: test-results/junit.xml flags: javascript \ No newline at end of file From a7e33ab9bfb4fc24dbc50a429b376b06dc9aac3c Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 27 Sep 2026 08:22:32 +0000 Subject: [PATCH 025/119] docs: consolidate project guidance in AGENTS.md (#723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Agent guidance was split across two overlapping files: `CLAUDE.md` (build/test, architecture, conventions, debugging, plus a Yggdrasil block) and `AGENTS.md` (wiki usage, a second Yggdrasil block, release rules, session completion). This PR collapses both into a single, tool-agnostic `AGENTS.md` and deletes `CLAUDE.md`. ## Changes - **`AGENTS.md`** rewritten as the one project guide: - Knowledge base (wiki-first workflow, never edit generated wiki files) - Worktrees (reuse, naming under `.codex/worktrees/`, local `info/exclude` rule) - Architecture (`src/hardware`, `src/server`, `src/scheduler`, `src/automation`, `src/streaming`, `src/homekit`, `src/db`, `modules/`, UI) - Invariants and validation (`withSideLock`, `globalThis` singletons, sequential DAC protocol, Stryker/Vitest/pytest expectations, uv-locked Python deps, migration journal rules) - Releases (PR-to-`main` title gate) and pod debugging pointers - **`CLAUDE.md`** removed. Its content is either carried into `AGENTS.md` or was tooling-specific boilerplate (Yggdrasil hook docs, RTK) that the `ygg` integration re-injects at session start. Net: 69 insertions, 231 deletions across the two files. ## Notes for reviewers - The Yggdrasil `` blocks were dropped from both files. If `ygg` relies on that marker for re-stamping, it will re-add a block on next init; nothing in `.github/` or scripts references the marker. - The Vercel/RTK guidance was never in these files and is unaffected. ## Test plan - [ ] `AGENTS.md` renders cleanly on GitHub and every referenced path exists (`docs/wiki/INDEX.md`, `docs/DEBUGGING.md`, `src/server/openapi.ts`, `.github/workflows/python-modules.yml`) - [ ] Start a fresh Claude Code / Codex session in the repo and confirm the agent picks up `AGENTS.md` guidance - [ ] Confirm no CI workflow or script greps for `CLAUDE.md` πŸ€– Generated with [Claude Code](https://claude.com/claude-code)
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update docs/consolidate-agent-guidance ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/docs/consolidate-agent-guidance/scripts/install \ | sudo bash -s -- --branch docs/consolidate-agent-guidance ``` 🎯 **Pin to this exact build** ([run 36295924382](https://github.com/sleepypod/core/actions/runs/36295924382) Β· `95e661c`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/95e661cbcbdff96b0b914cc0a5413b885191a600/scripts/install \ | sudo bash -s -- \ --branch docs/consolidate-agent-guidance \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36295924382/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36295924382/artifacts/10924395055) Β· self-destructs in 30 days πŸ’₯
--- AGENTS.md | 144 ++++++++++++++++++++++++------------------------- CLAUDE.md | 156 ------------------------------------------------------ 2 files changed, 69 insertions(+), 231 deletions(-) delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index f35abfa0a..cccf58f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,75 +1,69 @@ -## Knowledge Base - -A compiled knowledge wiki is available at `docs/wiki/`. - -**Session startup:** Read `wiki/INDEX.md` for a topic overview, then read -specific topic articles relevant to your current task. - -**Using coverage indicators:** Each section has a coverage tag: -- `[coverage: high]` -- trust this section, skip the raw files. -- `[coverage: medium]` -- good overview, check raw sources for granular questions. -- `[coverage: low]` -- read the raw sources listed in that section directly. - -**When you need depth:** Check the article's Sources section for links to -raw files. Only read raw sources for medium/low coverage sections or when -you need very specific detail. - -**Never modify wiki files directly** -- they are regenerated by `/wiki-compile`. - - -## Yggdrasil Coordination - -This project uses **Yggdrasil** (`ygg`) for cross-session memory and -coordination. Hooks fire at Claude Code lifecycle events; you do not invoke -them manually. Above each user prompt you will see `[ygg memory | ... ]` lines β€” -those are real prior context surfaced by similarity. - -### Quick Reference - -```bash -ygg task ready # Unblocked tasks in this repo -ygg task list [--all] [--status <...>] # All tasks in this repo (or everywhere) -ygg task create "title" # New task -ygg task claim # Take a task -ygg task close # Complete a task -ygg task dep # Record dependency -ygg remember "..." # Durable note; retriever can surface later - -ygg status # Agents + outstanding locks -ygg lock acquire / release / list -ygg spawn --task "..." # Parallel agent in a new tmux window -ygg interrupt take-over --agent # Take over another agent -ygg logs --follow # Live event stream -``` - -### Rules - -- Acquire a lock before editing a resource another agent might touch. Release when done. -- Prefer `ygg spawn` over a native Task/Agent tool for parallel work. -- Read `[ygg memory | ...]` hints β€” real prior context. -- Check `ygg status` before assuming you're working alone. -- Use `ygg task` for cross-session work tracking; `ygg remember` for durable notes. -- Do NOT use `bd` / beads. - -## PRs to `main` - -Every PR to `main` is a release promotion. Title MUST start with -`feat:` / `fix:` / `perf:` / `revert:` (optional `(scope)`, optional `!` -for breaking) β€” these are the only types semantic-release acts on. Other -prefixes (`Release:`, `chore:`, `docs:`, bare `Bump…`) are blocked by -`.github/workflows/pr-title-gate.yml` and would otherwise leave -`releases/latest` stale. - -## Session Completion - -Work is not complete until `git push` succeeds. Release held locks, run quality gates, rebase, push, verify `git status` shows up-to-date. - -## Non-Interactive Shell Commands - -Some systems alias `cp`/`mv`/`rm` to interactive mode which hangs agents. Use: - -```bash -cp -f src dst mv -f src dst rm -f file rm -rf dir cp -rf src dst -# scp / ssh: -o BatchMode=yes apt-get: -y brew: HOMEBREW_NO_AUTO_UPDATE=1 -``` - +# Project guidance + +## Knowledge base + +Start with `docs/wiki/INDEX.md`, then read the topics relevant to the task. +Trust high-coverage sections; consult raw sources for medium/low coverage or +specific implementation details. Never edit generated wiki files directly; +they are regenerated by `/wiki-compile`. + +## Worktrees + +- Check existing worktrees first and reuse a suitable one without nesting. +- Create uniquely named worktrees with Git under + `/.codex/worktrees/` by default. +- Work and test inside the selected worktree. Preserve unrelated changes and + existing worktrees. +- Ignore only `/.codex/worktrees/` through Git's local `info/exclude`; keep + other `.codex/` files visible. + +## Architecture + +Next.js App Router runs on the pod and controls its hardware: + +- `src/hardware/`: DAC Unix-socket transport to frankenfirmware, status polling, + gestures, pump-stall protection, and snooze. +- `src/server/`: tRPC routers and REST endpoints at `/api/*`. The API uses a + LAN-only trust model without authentication; see `src/server/openapi.ts`. +- `src/scheduler/`: JobManager for temperature, power, alarms, and system jobs. +- `src/automation/`: Autopilot rules evaluated in the device timezone. +- `src/streaming/`: WebSocket sensor stream on port 3001, frame normalization, + and the optional MQTT bridge with Home Assistant discovery. +- `src/homekit/`: HAP thermostats, snooze switches, and sensors; hardware writes + pass through `sideController` and `sideLock`. +- `src/db/`: Drizzle schemas for `sleepypod.db` (configuration/state) and + `biometrics.db` (time series), with separate migration directories. +- `modules/`: Python sensor-processing daemons; `modules/common/` is shared. +- `app/`, `src/components/`, `src/hooks/`: UI. Device status prefers WebSocket + updates with a tRPC fallback through `useDeviceStatus`. + +## Invariants and validation + +- Per-side hardware writes must use `withSideLock`. +- Shared singletons belong on `globalThis`; Turbopack can load multiple module + instances. +- DAC commands are strictly sequential because the protocol has no correlation + IDs. Read only the response to the command just sent. +- Add behavioral regression tests for fixes; Stryker tracks surviving mutants. + Vitest tests live beside the code in `tests/`; Python tests live within each + module and stub pod-only imports when needed. +- Use each Python module's locked uv dependencies. Unpinned NumPy/SciPy versions + can change signal-processing results. CI's module matrix is defined in + `.github/workflows/python-modules.yml`. +- For schema changes, edit `src/db/*schema.ts` and generate migrations for the + appropriate database. Never hand-edit migration journals; their `when` + values must remain strictly increasing. +- Run the relevant tests and quality gates before pushing. Keep commits + focused and use conventional commit subjects. + +## Releases + +Every PR to `main` is a release promotion. Its title must start with `feat:`, +`fix:`, `perf:`, or `revert:` (optional scope and breaking-change `!`). The +PR title gate enforces these prefixes so the promotion triggers a release. +Complete requested publishing work by pushing and verifying the remote state. + +## Pod debugging + +Use `docs/DEBUGGING.md` for SSH access, data paths, biometrics failures, and +pump-stall diagnosis. The `/debug` console exposes live diagnostics. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 83c5de127..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,156 +0,0 @@ -# Project Instructions for AI Agents - -This file provides instructions and context for AI coding agents working on this project. - -## Build & Test - -```bash -pnpm install # deps (pnpm workspace) -pnpm dev # Next.js dev server -pnpm build # production build (standalone output) -pnpm tsc # type-check (quality gate) -pnpm lint # eslint (quality gate) -pnpm test # vitest unit suite (quality gate) -pnpm test:mutation # Stryker mutation testing (slow; CI tracks score) - -# Python modules (each modules// is a uv project): -cd modules/ && uv run --with pytest pytest test_main.py -# Use the module's locked deps (uv run), NOT an ad-hoc venv β€” unpinned -# numpy/scipy versions change signal-processing results. -``` - -Quality gates (`pnpm tsc && pnpm lint && pnpm test`) also run in the pre-push -hook. CI runs the Python module matrix in -`.github/workflows/python-modules.yml`. - -## Architecture Overview - -Next.js (App Router) server that runs ON the pod and controls it: - -- `src/hardware/` β€” DAC transport over Unix socket to frankenfirmware - (`dacTransport.ts` prod server-mode, `socketClient.ts` dev client-mode), - `dacMonitor` status polling, gesture detection, pump-stall guard, snooze. -- `src/server/` β€” tRPC routers (also exposed REST-style via trpc-to-openapi - at `/api/*`). **No auth: LAN-only trust model** β€” see `openapi.ts`. -- `src/scheduler/` β€” node-schedule JobManager (temperature/power/alarm - schedules from sleepypod.db plus prime/reboot/LED/run-once system jobs). -- `src/automation/` β€” Autopilot engine: signal-driven rules with tick / - timeOfDay / signalChange triggers, evaluated in the device timezone. -- `src/streaming/` β€” WebSocket sensor stream on port 3001 (`piezoStream`), - frame normalization, optional MQTT bridge (Home Assistant discovery). -- `src/homekit/` β€” HAP bridge (thermostat, power/snooze switches, sensors); - hardware writes serialize through `sideController` + `sideLock`. -- `src/db/` β€” two SQLite DBs via drizzle: `sleepypod.db` (config/state, - `migrations/`) and `biometrics.db` (time-series, `biometrics-migrations/`); - `retention.ts` prunes time-series tables. -- `modules/` β€” Python daemons reading CBOR `*.RAW` sensor files and writing - biometrics.db: piezo-processor (vitals), sleep-detector (presence/sessions), - environment-monitor (temps), calibrator, cover-buttons; `modules/common/` - is their shared library. -- `app/` β€” routes/UI; `src/components/` + `src/hooks/` β€” frontend. Device - status prefers the WS stream with tRPC HTTP fallback (`useDeviceStatus`). - -Cross-cutting invariants: -- Per-side hardware writes MUST go through `withSideLock` (globalThis-backed). -- Singletons live on `globalThis` (Turbopack can duplicate module instances). -- DAC protocol has no correlation ids β€” commands are strictly sequential; - never read a response you didn't just send a command for. - -## Conventions & Patterns - -- One commit per fix/feature; conventional-commit subjects (semantic-release). -- Vitest tests live in `tests/` dirs beside the code (`src/**/tests/*.test.ts`); - Python tests are `modules//test_main.py` with pod-only imports stubbed - via `sys.modules` so they run on dev machines. -- Stryker mutation testing is active β€” behavioral fixes need a pinning test - or they surface as surviving mutants. -- DB schema changes: edit `src/db/*schema.ts`, then `pnpm db:generate` / - `pnpm db:biometrics:generate`. Never hand-edit migration journals β€” entry - `when` values must stay strictly increasing (enforced by a test). - -## Debugging a pod - -Field debugging runbook β€” SSH access, data paths, "biometrics not writing" and -"stalled pump" symptom flows: **`docs/DEBUGGING.md`**. The desktop diagnostics -console (`/debug`) surfaces most of these signals live. - - -## Yggdrasil Agent Coordination - -This project uses **Yggdrasil** (`ygg`) for cross-session memory, resource -coordination, and issue tracking. The SessionStart, UserPromptSubmit, Stop, -PreCompact, and PreToolUse hooks are active β€” they auto-prime context, inject -similar past nodes, digest transcripts, and track state in Postgres. You will -see their output at the top of each session (``) and above -each user prompt (`[ygg memory | | | sim=%]`). - -### Quick Reference - -```bash -ygg task ready # Unblocked tasks in the current repo -ygg task list [--all] [--status <...>] # All tasks in this repo (or everywhere) -ygg task create "title" --kind --priority <0-4> # See priority/kind values below -ygg task claim # Take a task (assign + in_progress) -ygg task show # Full detail for -NNN or UUID -ygg task close [--reason "..."] # Complete a task -ygg task dep # Record dependency -ygg remember "..." # Durable note; similarity retriever can surface later -``` - -### Task field values (important β€” no guessing) - -- `--priority <0..4>` β€” **0 = critical, 1 = high, 2 = medium, 3 = low, 4 = backlog**. - Also accepts `P0`..`P4`. Do NOT pass strings like "high" / "medium" / "low". -- `--kind ` β€” one of these five. Default is `task`. -- `--status ` β€” for filtering / transitions. -- `--label ` β€” comma-separated labels. Repeatable. -- `` is either `-` (e.g. `yggdrasil-42`) or a UUID. - -Example: -```bash -ygg task create "fix migration ordering" --kind bug --priority 1 --label migrations,sqlx - -ygg status # See all agents' state, locks, recent activity -ygg lock acquire # Lease a shared resource before editing -ygg lock release # Release when done -ygg lock list # See outstanding locks -ygg spawn --task "..." # Spawn a parallel agent in a new tmux window -ygg interrupt take-over --agent # Take over / steer another agent -ygg logs --follow # Live event stream -``` - -### Rules - -- **Before editing a resource another agent might touch** (shared file, branch, migration, config), acquire a lock: `ygg lock acquire `. Release when done. This is Yggdrasil's core contract β€” bypassing it defeats the coordination layer. -- **For parallel work** that warrants its own context window, prefer `ygg spawn` over the native Task/Agent tool. Spawned agents are tracked in the DB, get their own prime context, and participate in lock/memory coordination. -- **Read `[ygg memory | ...]` injections** at the top of each user turn. They are real context from prior conversations (same or other agents) surfaced by similarity. Treat as relevant unless the content clearly refutes that. -- **Before assuming you're alone**, check `ygg status`. Other agents may hold locks or be mid-task on related work. -- **Task tracking** β€” use `ygg task` for anything that outlives the current session: creating work, recording dependencies, claiming, closing. Intra-turn checklists can stay in a native TodoList; cross-session work lives in `ygg task`. -- **Durable notes** β€” `ygg remember "..."` writes a directive node the similarity retriever will surface in future sessions (scoped to the current repo when detectable). Prefer this over scratch `.md` files. -- **Do NOT** use `bd` / beads. This project uses `ygg task` / `ygg remember` instead. - -## Session Completion - -Work is NOT complete until `git push` succeeds. - -1. Run quality gates if code changed (tests, linters, build/type-check). -2. Release any locks you still hold (`ygg lock list` β†’ `ygg lock release `). -3. Push: - ```bash - git pull --rebase - git push - git status # MUST show "up to date with origin" - ``` -4. If push fails, resolve and retry until it succeeds. - -**Never** stop before pushing; **never** say "ready to push when you are" β€” you push. - -## Non-Interactive Shell Commands - -Some systems alias `cp`/`mv`/`rm` to interactive mode which hangs agents. Use: - -```bash -cp -f src dst mv -f src dst rm -f file rm -rf dir cp -rf src dst -# scp / ssh: -o BatchMode=yes apt-get: -y brew: HOMEBREW_NO_AUTO_UPDATE=1 -``` - From 3916398b07307dcf632fd33666185675c5943911 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:04:52 +0000 Subject: [PATCH 026/119] chore(deps): Update @tanstack/react-query all non-major dependencies (#728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@tanstack/react-query](https://tanstack.com/query) ([source](https://redirect.github.com/TanStack/query/tree/HEAD/packages/react-query)) | [`5.103.1` β†’ `5.103.2`](https://renovatebot.com/diffs/npm/@tanstack%2freact-query/5.103.1/5.103.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@tanstack%2freact-query/5.103.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@tanstack%2freact-query/5.103.1/5.103.2?slim=true) | | [@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | [`25.9.7` β†’ `25.9.8`](https://renovatebot.com/diffs/npm/@types%2fnode/25.9.7/25.9.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/25.9.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/25.9.7/25.9.8?slim=true) | | [@xyflow/react](https://reactflow.dev) ([source](https://redirect.github.com/xyflow/xyflow/tree/HEAD/packages/react)) | [`12.11.6` β†’ `12.12.0`](https://renovatebot.com/diffs/npm/@xyflow%2freact/12.11.6/12.12.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@xyflow%2freact/12.12.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@xyflow%2freact/12.11.6/12.12.0?slim=true) | | [drizzle-kit](https://orm.drizzle.team) ([source](https://redirect.github.com/drizzle-team/drizzle-orm)) | [`0.31.10` β†’ `0.31.11`](https://renovatebot.com/diffs/npm/drizzle-kit/0.31.10/0.31.11) | ![age](https://developer.mend.io/api/mc/badges/age/npm/drizzle-kit/0.31.11?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/drizzle-kit/0.31.10/0.31.11?slim=true) | | [drizzle-orm](https://orm.drizzle.team) ([source](https://redirect.github.com/drizzle-team/drizzle-orm)) | [`0.45.2` β†’ `0.45.3`](https://renovatebot.com/diffs/npm/drizzle-orm/0.45.2/0.45.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/drizzle-orm/0.45.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/drizzle-orm/0.45.2/0.45.3?slim=true) | | [eslint-config-next](https://nextjs.org/docs/app/api-reference/config/eslint) ([source](https://redirect.github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next)) | [`16.3.5` β†’ `16.3.6`](https://renovatebot.com/diffs/npm/eslint-config-next/16.3.5/16.3.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-config-next/16.3.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-config-next/16.3.5/16.3.6?slim=true) | | [lucide-react](https://lucide.dev) ([source](https://redirect.github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react)) | [`1.47.0` β†’ `1.48.0`](https://renovatebot.com/diffs/npm/lucide-react/1.47.0/1.48.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/lucide-react/1.48.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/lucide-react/1.47.0/1.48.0?slim=true) | | [next](https://nextjs.org) ([source](https://redirect.github.com/vercel/next.js)) | [`16.3.5` β†’ `16.3.6`](https://renovatebot.com/diffs/npm/next/16.3.5/16.3.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/next/16.3.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/next/16.3.5/16.3.6?slim=true) | | [tsx](https://tsx.hirok.io) ([source](https://redirect.github.com/privatenumber/tsx)) | [`4.23.13` β†’ `4.23.15`](https://renovatebot.com/diffs/npm/tsx/4.23.13/4.23.15) | ![age](https://developer.mend.io/api/mc/badges/age/npm/tsx/4.23.15?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/tsx/4.23.13/4.23.15?slim=true) | | [typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint) ([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)) | [`8.70.0` β†’ `8.70.1`](https://renovatebot.com/diffs/npm/typescript-eslint/8.70.0/8.70.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/typescript-eslint/8.70.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/typescript-eslint/8.70.0/8.70.1?slim=true) | --- ### Release Notes
TanStack/query (@​tanstack/react-query) ### [`v5.103.2`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51032) [Compare Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.103.1...@tanstack/react-query@5.103.2) ##### Patch Changes - Updated dependencies \[[`8a28904`](https://redirect.github.com/TanStack/query/commit/8a28904aaccb6bc26398b751400a181c0f6d7f0e)]: - [@​tanstack/query-core](https://redirect.github.com/tanstack/query-core)@5.103.2
xyflow/xyflow (@​xyflow/react) ### [`v12.12.0`](https://redirect.github.com/xyflow/xyflow/blob/HEAD/packages/react/CHANGELOG.md#12120) [Compare Source](https://redirect.github.com/xyflow/xyflow/compare/@xyflow/react@12.11.6...@xyflow/react@12.12.0) ##### Minor Changes - [#​5998](https://redirect.github.com/xyflow/xyflow/pull/5998) [`91a4adf`](https://redirect.github.com/xyflow/xyflow/commit/91a4adf4f901d2c59ed77486bc0d3ec6d3843423) - Always fire onResizeEnd after onResizeStart ##### Patch Changes - [#​5998](https://redirect.github.com/xyflow/xyflow/pull/5998) [`91a4adf`](https://redirect.github.com/xyflow/xyflow/commit/91a4adf4f901d2c59ed77486bc0d3ec6d3843423) - Fix incorrect values during resize when shouldResize returns false - Updated dependencies \[[`91a4adf`](https://redirect.github.com/xyflow/xyflow/commit/91a4adf4f901d2c59ed77486bc0d3ec6d3843423), [`91a4adf`](https://redirect.github.com/xyflow/xyflow/commit/91a4adf4f901d2c59ed77486bc0d3ec6d3843423)]: - [@​xyflow/system](https://redirect.github.com/xyflow/system)@0.0.83
drizzle-team/drizzle-orm (drizzle-kit) ### [`v0.31.11`](https://redirect.github.com/drizzle-team/drizzle-orm/releases/tag/drizzle-kit%400.31.11) [Compare Source](https://redirect.github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.31.10...drizzle-kit@0.31.11) - Warn about silent migration skips on drizzle-kit checks - thanks [@​shaneikennedy](https://redirect.github.com/shaneikennedy)
vercel/next.js (eslint-config-next) ### [`v16.3.6`](https://redirect.github.com/vercel/next.js/compare/v16.3.5...v16.3.6) [Compare Source](https://redirect.github.com/vercel/next.js/compare/v16.3.5...v16.3.6)
lucide-icons/lucide (lucide-react) ### [`v1.48.0`](https://redirect.github.com/lucide-icons/lucide/releases/tag/1.48.0): Version 1.48.0 [Compare Source](https://redirect.github.com/lucide-icons/lucide/compare/1.47.0...1.48.0) #### What's Changed - feat(icons): added `briefcase-plus` icon by [@​tylerkade](https://redirect.github.com/tylerkade) in [#​4757](https://redirect.github.com/lucide-icons/lucide/pull/4757) - feat(icons): added `square-sparkles` icon by [@​nananecy](https://redirect.github.com/nananecy) in [#​3610](https://redirect.github.com/lucide-icons/lucide/pull/3610) - feat(icons): added `line-dot-left-horizontal` icon by [@​nathan-de-pachtere](https://redirect.github.com/nathan-de-pachtere) in [#​3855](https://redirect.github.com/lucide-icons/lucide/pull/3855) - fix(packages/svelte,solid): fix shared type imports in Solid and Svelte by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4846](https://redirect.github.com/lucide-icons/lucide/pull/4846) - chore(deps-dev): bump react-native from 0.76.9 to 0.87.1 in the react-native-deps group across 1 directory by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4673](https://redirect.github.com/lucide-icons/lucide/pull/4673) - feat(packages): export \_\_iconNode data across framework packages by [@​lx3133584](https://redirect.github.com/lx3133584) in [#​4761](https://redirect.github.com/lucide-icons/lucide/pull/4761) - fix(icons): Tweak `card-sim` chip by [@​danielbayley](https://redirect.github.com/danielbayley) in [#​3649](https://redirect.github.com/lucide-icons/lucide/pull/3649) - feat(icons): added `line-dot-top-vertical` icon by [@​nathan-de-pachtere](https://redirect.github.com/nathan-de-pachtere) in [#​3856](https://redirect.github.com/lucide-icons/lucide/pull/3856) - feat(icons): added `line-dot-bottom-vertical` icon by [@​nathan-de-pachtere](https://redirect.github.com/nathan-de-pachtere) in [#​3857](https://redirect.github.com/lucide-icons/lucide/pull/3857) - fix(packages/react-native): pass testID to the rendered Svg element by [@​OlegBezr](https://redirect.github.com/OlegBezr) in [#​4881](https://redirect.github.com/lucide-icons/lucide/pull/4881) - chore([@​lucide/vue](https://redirect.github.com/lucide/vue)): Fix types `@lucide/vue` package and added workflow for it. by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4883](https://redirect.github.com/lucide-icons/lucide/pull/4883) - test(packages/shared): cover buildLucideIconForReact by [@​vugarbbakhishov-hub](https://redirect.github.com/vugarbbakhishov-hub) in [#​4872](https://redirect.github.com/lucide-icons/lucide/pull/4872) - feat(site): Better icon detail page and add unreleased flag by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4877](https://redirect.github.com/lucide-icons/lucide/pull/4877) - fix(icons): changed `map-pinned` icon by [@​jguddas](https://redirect.github.com/jguddas) in [#​4880](https://redirect.github.com/lucide-icons/lucide/pull/4880) - fix(icons): changed `mail-pen` by [@​karsa-mistmere](https://redirect.github.com/karsa-mistmere) in [#​4899](https://redirect.github.com/lucide-icons/lucide/pull/4899) - chore(deps-dev): bump the angular-deps group across 1 directory with 14 updates by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4895](https://redirect.github.com/lucide-icons/lucide/pull/4895) - chore(deps): bump the vue-deps group with 3 updates by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​4893](https://redirect.github.com/lucide-icons/lucide/pull/4893) - chore(typchecking): More typecheck jobs for all packages by [@​ericfennis](https://redirect.github.com/ericfennis) in [#​4885](https://redirect.github.com/lucide-icons/lucide/pull/4885) - feat(icons): add house-cog icon by [@​ajaxjiang96](https://redirect.github.com/ajaxjiang96) in [#​4904](https://redirect.github.com/lucide-icons/lucide/pull/4904) #### New Contributors - [@​nananecy](https://redirect.github.com/nananecy) made their first contribution in [#​3610](https://redirect.github.com/lucide-icons/lucide/pull/3610) - [@​OlegBezr](https://redirect.github.com/OlegBezr) made their first contribution in [#​4881](https://redirect.github.com/lucide-icons/lucide/pull/4881) - [@​vugarbbakhishov-hub](https://redirect.github.com/vugarbbakhishov-hub) made their first contribution in [#​4872](https://redirect.github.com/lucide-icons/lucide/pull/4872) - [@​ajaxjiang96](https://redirect.github.com/ajaxjiang96) made their first contribution in [#​4904](https://redirect.github.com/lucide-icons/lucide/pull/4904) **Full Changelog**:
vercel/next.js (next) ### [`v16.3.6`](https://redirect.github.com/vercel/next.js/compare/v16.3.5...v16.3.6) [Compare Source](https://redirect.github.com/vercel/next.js/compare/v16.3.5...v16.3.6)
privatenumber/tsx (tsx) ### [`v4.23.15`](https://redirect.github.com/privatenumber/tsx/releases/tag/v4.23.15) [Compare Source](https://redirect.github.com/privatenumber/tsx/compare/v4.23.14...v4.23.15) ##### Bug Fixes - exclude bare builtins from namespace inheritance ([38e1588](https://redirect.github.com/privatenumber/tsx/commit/38e158857e50bca311be7c232a5057e5a2e5347a)) - expose require.cache and require.extensions to tsImport CommonJS modules ([2da3407](https://redirect.github.com/privatenumber/tsx/commit/2da34075afaed43e2b7fd0aca5fbebaaf337ff3a)) - make namespaced register() overloads portable for declaration emit ([562c434](https://redirect.github.com/privatenumber/tsx/commit/562c434a5c8695e74327bbeb51cfeb9b86fc7e15)) *** This release is also available on: - [npm package (@​latest dist-tag)](https://www.npmjs.com/package/tsx/v/4.23.15) ### [`v4.23.14`](https://redirect.github.com/privatenumber/tsx/compare/v4.23.13...6e5236b065738d3687a06396d064774cfede390f) [Compare Source](https://redirect.github.com/privatenumber/tsx/compare/v4.23.13...v4.23.14)
typescript-eslint/typescript-eslint (typescript-eslint) ### [`v8.70.1`](https://redirect.github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8701-2026-09-21) [Compare Source](https://redirect.github.com/typescript-eslint/typescript-eslint/compare/v8.70.0...v8.70.1) This was a version bump only for typescript-eslint to align it with other projects, there were no code changes. See [GitHub Releases](https://redirect.github.com/typescript-eslint/typescript-eslint/releases/tag/v8.70.1) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.
--- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - "before 6am on monday" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ‘» **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/sleepypod/core).
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update renovate/all-minor-patch ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/renovate/all-minor-patch/scripts/install \ | sudo bash -s -- --branch renovate/all-minor-patch ``` 🎯 **Pin to this exact build** ([run 36389425165](https://github.com/sleepypod/core/actions/runs/36389425165) Β· `0bedb93`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/0bedb9350dfb70b24ac34f462c8e59826ecbd5fe/scripts/install \ | sudo bash -s -- \ --branch renovate/all-minor-patch \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36389425165/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36389425165/artifacts/10955398677) Β· self-destructs in 30 days πŸ’₯
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 1168 ++++++++++++++++++++++++------------------------ 1 file changed, 583 insertions(+), 585 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ceba87d7..873d33561 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@ajayche/trpc-panel': specifier: ^2.0.4 - version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@base-ui/react': specifier: ^1.2.0 version: 1.8.0(@date-fns/tz@1.4.1)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -31,22 +31,22 @@ importers: version: 3.4.0 '@tanstack/react-query': specifier: ^5.90.21 - version: 5.103.1(react@19.3.0) + version: 5.103.2(react@19.3.0) '@trpc/client': specifier: ^11.10.0 version: 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/next': specifier: ^11.10.0 - version: 11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) '@trpc/react-query': specifier: ^11.10.0 - version: 11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) '@trpc/server': specifier: ^11.10.0 version: 11.19.0(typescript@6.0.3) '@xyflow/react': specifier: ^12.10.1 - version: 12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 12.12.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) better-sqlite3: specifier: ^12.6.2 version: 12.11.1 @@ -70,13 +70,13 @@ importers: version: 17.4.2 drizzle-orm: specifier: ^0.45.1 - version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) + version: 0.45.3(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) hap-nodejs: specifier: 1.2.0 version: 1.2.0 lucide-react: specifier: ^1.0.0 - version: 1.47.0(react@19.3.0) + version: 1.48.0(react@19.3.0) mqtt: specifier: ^5.15.1 version: 5.16.0 @@ -85,7 +85,7 @@ importers: version: 1.1.0 next: specifier: ^16.1.6 - version: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) node-schedule: specifier: ^2.1.1 version: 2.1.1 @@ -131,7 +131,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) '@codecov/nextjs-webpack-plugin': specifier: ^2.0.1 - version: 2.0.1(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0)) + version: 2.0.1(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0)) '@eslint/css': specifier: ^1.0.0 version: 1.4.0 @@ -152,10 +152,10 @@ importers: version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(webpack@5.104.1(lightningcss@1.32.0)) '@lingui/swc-plugin': specifier: ^5.11.0 - version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) '@lingui/vite-plugin': specifier: ^5.9.2 - version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) '@semantic-release/commit-analyzer': specifier: ^13.0.1 version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) @@ -164,13 +164,13 @@ importers: version: 10.0.1(semantic-release@25.0.9(typescript@6.0.3)) '@stryker-mutator/core': specifier: ^9.6.1 - version: 9.6.1(@types/node@25.9.7) + version: 9.6.1(@types/node@25.9.8) '@stryker-mutator/typescript-checker': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(typescript@6.0.3) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.8))(typescript@6.0.3) '@stryker-mutator/vitest-runner': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(vitest@4.1.11) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.8))(vitest@4.1.11) '@stylistic/eslint-plugin': specifier: ^5.9.0 version: 5.10.0(eslint@9.39.5(jiti@2.7.0)) @@ -200,7 +200,7 @@ importers: version: 0.6.5 '@types/node': specifier: ^25.3.0 - version: 25.9.7 + version: 25.9.8 '@types/node-schedule': specifier: ^2.1.8 version: 2.1.8 @@ -218,7 +218,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.2.0(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.2.0(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 4.1.11 version: 4.1.11(vitest@4.1.11) @@ -227,13 +227,13 @@ importers: version: 9.3.1 drizzle-kit: specifier: ^0.31.9 - version: 0.31.10 + version: 0.31.11 eslint: specifier: ^9.39.3 version: 9.39.5(jiti@2.7.0) eslint-config-next: specifier: ^16.1.6 - version: 16.3.5(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 16.3.6(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -257,19 +257,19 @@ importers: version: 4.3.3 tsx: specifier: ^4.21.0 - version: 4.23.13 + version: 4.23.15 typescript: specifier: ^6.0.0 version: 6.0.3 typescript-eslint: specifier: ^8.56.1 - version: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + version: 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) vitest: specifier: ^4.0.18 - version: 4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) packages: @@ -1469,160 +1469,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1974,60 +1974,60 @@ packages: resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} engines: {node: '>= 18.0.0'} - '@next/env@16.3.5': - resolution: {integrity: sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==} + '@next/env@16.3.6': + resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} - '@next/eslint-plugin-next@16.3.5': - resolution: {integrity: sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==} + '@next/eslint-plugin-next@16.3.6': + resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==} - '@next/swc-darwin-arm64@16.3.5': - resolution: {integrity: sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==} + '@next/swc-darwin-arm64@16.3.6': + resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.5': - resolution: {integrity: sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==} + '@next/swc-darwin-x64@16.3.6': + resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.5': - resolution: {integrity: sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==} + '@next/swc-linux-arm64-gnu@16.3.6': + resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.5': - resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} + '@next/swc-linux-arm64-musl@16.3.6': + resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.5': - resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} + '@next/swc-linux-x64-gnu@16.3.6': + resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.5': - resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} + '@next/swc-linux-x64-musl@16.3.6': + resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.5': - resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} + '@next/swc-win32-arm64-msvc@16.3.6': + resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.5': - resolution: {integrity: sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==} + '@next/swc-win32-x64-msvc@16.3.6': + resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2162,98 +2162,98 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.63.4': - resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + '@rollup/rollup-android-arm-eabi@4.63.5': + resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.63.4': - resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + '@rollup/rollup-android-arm64@4.63.5': + resolution: {integrity: sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.63.4': - resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + '@rollup/rollup-darwin-arm64@4.63.5': + resolution: {integrity: sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.63.4': - resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} + '@rollup/rollup-darwin-x64@4.63.5': + resolution: {integrity: sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.63.4': - resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + '@rollup/rollup-freebsd-arm64@4.63.5': + resolution: {integrity: sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.63.4': - resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + '@rollup/rollup-freebsd-x64@4.63.5': + resolution: {integrity: sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.63.4': - resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} + '@rollup/rollup-linux-arm-gnueabihf@4.63.5': + resolution: {integrity: sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.63.4': - resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + '@rollup/rollup-linux-arm-musleabihf@4.63.5': + resolution: {integrity: sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.63.4': - resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} + '@rollup/rollup-linux-arm64-gnu@4.63.5': + resolution: {integrity: sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.63.4': - resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + '@rollup/rollup-linux-arm64-musl@4.63.5': + resolution: {integrity: sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.63.4': - resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} + '@rollup/rollup-linux-loong64-gnu@4.63.5': + resolution: {integrity: sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.63.4': - resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + '@rollup/rollup-linux-loong64-musl@4.63.5': + resolution: {integrity: sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.63.4': - resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} + '@rollup/rollup-linux-ppc64-gnu@4.63.5': + resolution: {integrity: sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.63.4': - resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + '@rollup/rollup-linux-ppc64-musl@4.63.5': + resolution: {integrity: sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.63.4': - resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} + '@rollup/rollup-linux-riscv64-gnu@4.63.5': + resolution: {integrity: sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.63.4': - resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + '@rollup/rollup-linux-riscv64-musl@4.63.5': + resolution: {integrity: sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.63.4': - resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} + '@rollup/rollup-linux-s390x-gnu@4.63.5': + resolution: {integrity: sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==} cpu: [s390x] os: [linux] libc: [glibc] @@ -2264,45 +2264,45 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.63.4': - resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + '@rollup/rollup-linux-x64-gnu@4.63.5': + resolution: {integrity: sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.63.4': - resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} + '@rollup/rollup-linux-x64-musl@4.63.5': + resolution: {integrity: sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.63.4': - resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + '@rollup/rollup-openbsd-x64@4.63.5': + resolution: {integrity: sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.63.4': - resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} + '@rollup/rollup-openharmony-arm64@4.63.5': + resolution: {integrity: sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.63.4': - resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + '@rollup/rollup-win32-arm64-msvc@4.63.5': + resolution: {integrity: sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.63.4': - resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} + '@rollup/rollup-win32-ia32-msvc@4.63.5': + resolution: {integrity: sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.63.4': - resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + '@rollup/rollup-win32-x64-gnu@4.63.5': + resolution: {integrity: sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.63.4': - resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + '@rollup/rollup-win32-x64-msvc@4.63.5': + resolution: {integrity: sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==} cpu: [x64] os: [win32] @@ -2505,11 +2505,11 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tanstack/query-core@5.103.1': - resolution: {integrity: sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==} + '@tanstack/query-core@5.103.2': + resolution: {integrity: sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==} - '@tanstack/react-query@5.103.1': - resolution: {integrity: sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==} + '@tanstack/react-query@5.103.2': + resolution: {integrity: sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==} peerDependencies: react: ^18 || ^19 @@ -2626,8 +2626,8 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - '@types/d3-selection@3.0.11': - resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -2698,9 +2698,6 @@ packages: '@types/node-schedule@2.1.8': resolution: {integrity: sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==} - '@types/node@25.9.7': - resolution: {integrity: sha512-Y+axoNWOFLvDNFxNbDA+kqqV05imHwTu5bZWWO6tQM3Y2NX1jvzUJkYlf4nYnMCnvu4wQBKW6Nr+ok6yHWA9aQ==} - '@types/node@25.9.8': resolution: {integrity: sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==} @@ -2754,39 +2751,39 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.70.0': - resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} + '@typescript-eslint/eslint-plugin@8.70.1': + resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.70.0 + '@typescript-eslint/parser': ^8.70.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.70.0': - resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} + '@typescript-eslint/parser@8.70.1': + resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.70.0': - resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} + '@typescript-eslint/project-service@8.70.1': + resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.70.0': - resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} + '@typescript-eslint/scope-manager@8.70.1': + resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.70.0': - resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} + '@typescript-eslint/tsconfig-utils@8.70.1': + resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.70.0': - resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} + '@typescript-eslint/type-utils@8.70.1': + resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2796,25 +2793,25 @@ packages: resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.70.0': - resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} + '@typescript-eslint/types@8.70.1': + resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.70.0': - resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} + '@typescript-eslint/typescript-estree@8.70.1': + resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.70.0': - resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} + '@typescript-eslint/utils@8.70.1': + resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.70.0': - resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} + '@typescript-eslint/visitor-keys@8.70.1': + resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.1': @@ -3035,8 +3032,8 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@xyflow/react@12.11.6': - resolution: {integrity: sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==} + '@xyflow/react@12.12.0': + resolution: {integrity: sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==} peerDependencies: '@types/react': '>=17' '@types/react-dom': '>=17' @@ -3048,8 +3045,8 @@ packages: '@types/react-dom': optional: true - '@xyflow/system@0.0.82': - resolution: {integrity: sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ==} + '@xyflow/system@0.0.83': + resolution: {integrity: sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==} abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -3277,8 +3274,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.25: - resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -3327,14 +3324,14 @@ packages: brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} - engines: {node: 20 || >=22} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -3347,8 +3344,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.29.0: - resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + browserslist@4.29.1: + resolution: {integrity: sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3397,8 +3394,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001810: - resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + caniuse-lite@1.0.30001812: + resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} cbor-extract@2.2.2: resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} @@ -3969,12 +3966,12 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - drizzle-kit@0.31.10: - resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} hasBin: true - drizzle-orm@0.45.2: - resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + drizzle-orm@0.45.3: + resolution: {integrity: sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -3982,6 +3979,7 @@ packages: '@libsql/client': '>=0.10.0' '@libsql/client-wasm': '>=0.10.0' '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' '@op-engineering/op-sqlite': '>=2' '@opentelemetry/api': ^1.4.1 '@planetscale/database': '>=1.13' @@ -4018,6 +4016,8 @@ packages: optional: true '@neondatabase/serverless': optional: true + '@netlify/db': + optional: true '@op-engineering/op-sqlite': optional: true '@opentelemetry/api': @@ -4081,8 +4081,8 @@ packages: electron-to-chromium@1.5.422: resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} - electron-to-chromium@1.5.433: - resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} + electron-to-chromium@1.5.439: + resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4219,8 +4219,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.3.5: - resolution: {integrity: sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==} + eslint-config-next@16.3.6: + resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4637,9 +4637,6 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} @@ -4842,8 +4839,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.9: - resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} immer@11.1.15: @@ -5493,8 +5490,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.47.0: - resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} + lucide-react@1.48.0: + resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5817,8 +5814,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@16.3.5: - resolution: {integrity: sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==} + next@16.3.6: + resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5864,8 +5861,8 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} - node-releases@2.0.56: - resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} node-schedule@2.1.1: @@ -6612,8 +6609,8 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.11.11: - resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rettime@0.11.12: + resolution: {integrity: sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -6622,8 +6619,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.63.4: - resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + rollup@4.63.5: + resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6738,8 +6735,8 @@ packages: engines: {node: '>=20.18.1'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -7184,15 +7181,15 @@ packages: tldts-core@7.0.30: resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts-core@7.4.13: - resolution: {integrity: sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==} + tldts-core@7.4.16: + resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==} tldts@7.0.30: resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true - tldts@7.4.13: - resolution: {integrity: sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==} + tldts@7.4.16: + resolution: {integrity: sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==} hasBin: true to-regex-range@5.0.1: @@ -7266,8 +7263,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.13: - resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true @@ -7347,8 +7344,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.70.0: - resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} + typescript-eslint@8.70.1: + resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -7509,6 +7506,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7642,8 +7644,8 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} - webpack-sources@3.5.1: - resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} + webpack-sources@3.6.0: + resolution: {integrity: sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==} engines: {node: '>=10.13.0'} webpack-virtual-modules@0.6.2: @@ -7916,13 +7918,13 @@ snapshots: '@actions/io@3.0.2': {} - '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': + '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': dependencies: '@microlink/react-json-view': 1.31.20(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@trpc/server': 11.19.0(typescript@6.0.3) clsx: 2.1.1 fuzzysort: 2.0.4 - nuqs: 2.8.9(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + nuqs: 2.8.9(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) path: 0.12.7 pretty-bytes: 6.1.1 pretty-ms: 8.0.0 @@ -8376,11 +8378,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0))': + '@codecov/nextjs-webpack-plugin@2.0.1(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.104.1(lightningcss@1.32.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 '@codecov/webpack-plugin': 2.0.1(webpack@5.104.1(lightningcss@1.32.0)) - next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) unplugin: 1.16.1 webpack: 5.104.1(lightningcss@1.32.0) @@ -8472,7 +8474,7 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.13.6 + get-tsconfig: 4.14.3 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -8933,108 +8935,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@inquirer/ansi@2.0.5': {} @@ -9042,150 +9044,150 @@ snapshots: '@inquirer/ansi@2.0.8': optional: true - '@inquirer/checkbox@5.1.4(@types/node@25.9.7)': + '@inquirer/checkbox@5.1.4(@types/node@25.9.8)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/confirm@6.0.12(@types/node@25.9.7)': + '@inquirer/confirm@6.0.12(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/confirm@6.3.2(@types/node@25.9.7)': + '@inquirer/confirm@6.3.2(@types/node@25.9.8)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.7) - '@inquirer/type': 4.1.1(@types/node@25.9.7) + '@inquirer/core': 12.0.3(@types/node@25.9.8) + '@inquirer/type': 4.1.1(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 optional: true - '@inquirer/core@11.1.9(@types/node@25.9.7)': + '@inquirer/core@11.1.9(@types/node@25.9.8)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.8) cli-width: 4.1.0 fast-wrap-ansi: 0.2.0 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/core@12.0.3(@types/node@25.9.7)': + '@inquirer/core@12.0.3(@types/node@25.9.8)': dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.7) + '@inquirer/type': 4.1.1(@types/node@25.9.8) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 optional: true - '@inquirer/editor@5.1.1(@types/node@25.9.7)': + '@inquirer/editor@5.1.1(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/external-editor': 3.0.0(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/external-editor': 3.0.0(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/expand@5.0.13(@types/node@25.9.7)': + '@inquirer/expand@5.0.13(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/external-editor@3.0.0(@types/node@25.9.7)': + '@inquirer/external-editor@3.0.0(@types/node@25.9.8)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@inquirer/figures@2.0.5': {} '@inquirer/figures@2.0.9': optional: true - '@inquirer/input@5.0.12(@types/node@25.9.7)': + '@inquirer/input@5.0.12(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/number@4.0.12(@types/node@25.9.7)': + '@inquirer/number@4.0.12(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/password@5.0.12(@types/node@25.9.7)': + '@inquirer/password@5.0.12(@types/node@25.9.8)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 - - '@inquirer/prompts@8.4.2(@types/node@25.9.7)': - dependencies: - '@inquirer/checkbox': 5.1.4(@types/node@25.9.7) - '@inquirer/confirm': 6.0.12(@types/node@25.9.7) - '@inquirer/editor': 5.1.1(@types/node@25.9.7) - '@inquirer/expand': 5.0.13(@types/node@25.9.7) - '@inquirer/input': 5.0.12(@types/node@25.9.7) - '@inquirer/number': 4.0.12(@types/node@25.9.7) - '@inquirer/password': 5.0.12(@types/node@25.9.7) - '@inquirer/rawlist': 5.2.8(@types/node@25.9.7) - '@inquirer/search': 4.1.8(@types/node@25.9.7) - '@inquirer/select': 5.1.4(@types/node@25.9.7) + '@types/node': 25.9.8 + + '@inquirer/prompts@8.4.2(@types/node@25.9.8)': + dependencies: + '@inquirer/checkbox': 5.1.4(@types/node@25.9.8) + '@inquirer/confirm': 6.0.12(@types/node@25.9.8) + '@inquirer/editor': 5.1.1(@types/node@25.9.8) + '@inquirer/expand': 5.0.13(@types/node@25.9.8) + '@inquirer/input': 5.0.12(@types/node@25.9.8) + '@inquirer/number': 4.0.12(@types/node@25.9.8) + '@inquirer/password': 5.0.12(@types/node@25.9.8) + '@inquirer/rawlist': 5.2.8(@types/node@25.9.8) + '@inquirer/search': 4.1.8(@types/node@25.9.8) + '@inquirer/select': 5.1.4(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/rawlist@5.2.8(@types/node@25.9.7)': + '@inquirer/rawlist@5.2.8(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/search@4.1.8(@types/node@25.9.7)': + '@inquirer/search@4.1.8(@types/node@25.9.8)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/select@5.1.4(@types/node@25.9.7)': + '@inquirer/select@5.1.4(@types/node@25.9.8)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.9.7) + '@inquirer/core': 11.1.9(@types/node@25.9.8) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.7) + '@inquirer/type': 4.0.5(@types/node@25.9.8) optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/type@4.0.5(@types/node@25.9.7)': + '@inquirer/type@4.0.5(@types/node@25.9.8)': optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 - '@inquirer/type@4.1.1(@types/node@25.9.7)': + '@inquirer/type@4.1.1(@types/node@25.9.8)': optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 optional: true '@isaacs/cliui@9.0.0': {} @@ -9199,7 +9201,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9341,17 +9343,17 @@ snapshots: '@lingui/babel-plugin-lingui-macro': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) babel-plugin-macros: 3.1.0 - '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': + '@lingui/swc-plugin@5.11.0(@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': dependencies: '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3))(babel-plugin-macros@3.1.0) optionalDependencies: - next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@lingui/vite-plugin@5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: '@lingui/cli': 5.9.5(babel-plugin-macros@3.1.0)(typescript@6.0.3) '@lingui/conf': 5.9.5(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -9430,37 +9432,37 @@ snapshots: '@nats-io/nkeys': 2.0.3 '@nats-io/nuid': 3.0.0 - '@next/env@16.3.5': {} + '@next/env@16.3.6': {} - '@next/eslint-plugin-next@16.3.5(eslint@9.39.5(jiti@2.7.0))': + '@next/eslint-plugin-next@16.3.6(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.5': + '@next/swc-darwin-arm64@16.3.6': optional: true - '@next/swc-darwin-x64@16.3.5': + '@next/swc-darwin-x64@16.3.6': optional: true - '@next/swc-linux-arm64-gnu@16.3.5': + '@next/swc-linux-arm64-gnu@16.3.6': optional: true - '@next/swc-linux-arm64-musl@16.3.5': + '@next/swc-linux-arm64-musl@16.3.6': optional: true - '@next/swc-linux-x64-gnu@16.3.5': + '@next/swc-linux-x64-gnu@16.3.6': optional: true - '@next/swc-linux-x64-musl@16.3.5': + '@next/swc-linux-x64-musl@16.3.6': optional: true - '@next/swc-win32-arm64-msvc@16.3.5': + '@next/swc-win32-arm64-msvc@16.3.6': optional: true - '@next/swc-win32-x64-msvc@16.3.5': + '@next/swc-win32-x64-msvc@16.3.6': optional: true '@noble/hashes@1.8.0': @@ -9609,82 +9611,82 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.63.4': + '@rollup/rollup-android-arm-eabi@4.63.5': optional: true - '@rollup/rollup-android-arm64@4.63.4': + '@rollup/rollup-android-arm64@4.63.5': optional: true - '@rollup/rollup-darwin-arm64@4.63.4': + '@rollup/rollup-darwin-arm64@4.63.5': optional: true - '@rollup/rollup-darwin-x64@4.63.4': + '@rollup/rollup-darwin-x64@4.63.5': optional: true - '@rollup/rollup-freebsd-arm64@4.63.4': + '@rollup/rollup-freebsd-arm64@4.63.5': optional: true - '@rollup/rollup-freebsd-x64@4.63.4': + '@rollup/rollup-freebsd-x64@4.63.5': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + '@rollup/rollup-linux-arm-gnueabihf@4.63.5': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.63.4': + '@rollup/rollup-linux-arm-musleabihf@4.63.5': optional: true - '@rollup/rollup-linux-arm64-gnu@4.63.4': + '@rollup/rollup-linux-arm64-gnu@4.63.5': optional: true - '@rollup/rollup-linux-arm64-musl@4.63.4': + '@rollup/rollup-linux-arm64-musl@4.63.5': optional: true - '@rollup/rollup-linux-loong64-gnu@4.63.4': + '@rollup/rollup-linux-loong64-gnu@4.63.5': optional: true - '@rollup/rollup-linux-loong64-musl@4.63.4': + '@rollup/rollup-linux-loong64-musl@4.63.5': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.63.4': + '@rollup/rollup-linux-ppc64-gnu@4.63.5': optional: true - '@rollup/rollup-linux-ppc64-musl@4.63.4': + '@rollup/rollup-linux-ppc64-musl@4.63.5': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.63.4': + '@rollup/rollup-linux-riscv64-gnu@4.63.5': optional: true - '@rollup/rollup-linux-riscv64-musl@4.63.4': + '@rollup/rollup-linux-riscv64-musl@4.63.5': optional: true - '@rollup/rollup-linux-s390x-gnu@4.63.4': + '@rollup/rollup-linux-s390x-gnu@4.63.5': optional: true '@rollup/rollup-linux-x64-gnu@4.6.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.63.4': + '@rollup/rollup-linux-x64-gnu@4.63.5': optional: true - '@rollup/rollup-linux-x64-musl@4.63.4': + '@rollup/rollup-linux-x64-musl@4.63.5': optional: true - '@rollup/rollup-openbsd-x64@4.63.4': + '@rollup/rollup-openbsd-x64@4.63.5': optional: true - '@rollup/rollup-openharmony-arm64@4.63.4': + '@rollup/rollup-openharmony-arm64@4.63.5': optional: true - '@rollup/rollup-win32-arm64-msvc@4.63.4': + '@rollup/rollup-win32-arm64-msvc@4.63.5': optional: true - '@rollup/rollup-win32-ia32-msvc@4.63.4': + '@rollup/rollup-win32-ia32-msvc@4.63.5': optional: true - '@rollup/rollup-win32-x64-gnu@4.63.4': + '@rollup/rollup-win32-x64-gnu@4.63.5': optional: true - '@rollup/rollup-win32-x64-msvc@4.63.4': + '@rollup/rollup-win32-x64-msvc@4.63.5': optional: true '@rtsao/scc@1.1.0': {} @@ -9801,9 +9803,9 @@ snapshots: tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@25.9.7)': + '@stryker-mutator/core@9.6.1(@types/node@25.9.8)': dependencies: - '@inquirer/prompts': 8.4.2(@types/node@25.9.7) + '@inquirer/prompts': 8.4.2(@types/node@25.9.8) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/instrumenter': 9.6.1 '@stryker-mutator/util': 9.6.1 @@ -9850,24 +9852,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(typescript@6.0.3)': + '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.8))(typescript@6.0.3)': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.7) + '@stryker-mutator/core': 9.6.1(@types/node@25.9.8) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 typescript: 6.0.3 '@stryker-mutator/util@9.6.1': {} - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.7))(vitest@4.1.11)': + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.8))(vitest@4.1.11)': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.7) + '@stryker-mutator/core': 9.6.1(@types/node@25.9.8) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 tslib: 2.8.1 - vitest: 4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': dependencies: @@ -9952,11 +9954,11 @@ snapshots: postcss: 8.5.21 tailwindcss: 4.3.3 - '@tanstack/query-core@5.103.1': {} + '@tanstack/query-core@5.103.2': {} - '@tanstack/react-query@5.103.1(react@19.3.0)': + '@tanstack/react-query@5.103.2(react@19.3.0)': dependencies: - '@tanstack/query-core': 5.103.1 + '@tanstack/query-core': 5.103.2 react: 19.3.0 '@testing-library/dom@10.4.2': @@ -9985,21 +9987,21 @@ snapshots: '@trpc/server': 11.19.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/next@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': + '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': dependencies: '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.19.0(typescript@6.0.3) - next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) typescript: 6.0.3 optionalDependencies: - '@tanstack/react-query': 5.103.1(react@19.3.0) - '@trpc/react-query': 11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) + '@tanstack/react-query': 5.103.2(react@19.3.0) + '@trpc/react-query': 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) - '@trpc/react-query@11.19.0(@tanstack/react-query@5.103.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3)': + '@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3)': dependencies: - '@tanstack/react-query': 5.103.1(react@19.3.0) + '@tanstack/react-query': 5.103.2(react@19.3.0) '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.19.0(typescript@6.0.3) react: 19.3.0 @@ -10047,11 +10049,11 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/binary-split@1.0.3': dependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/chai@5.2.3': dependencies: @@ -10064,7 +10066,7 @@ snapshots: '@types/d3-drag@3.0.7': dependencies: - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3-ease@3.0.2': {} @@ -10078,7 +10080,7 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 - '@types/d3-selection@3.0.11': {} + '@types/d3-selection@3.0.12': {} '@types/d3-shape@3.1.8': dependencies: @@ -10090,12 +10092,12 @@ snapshots: '@types/d3-transition@3.0.9': dependencies: - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3-zoom@3.0.8': dependencies: '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/debug@4.1.13': dependencies: @@ -10135,7 +10137,7 @@ snapshots: '@types/jsdom@28.0.3': dependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/tough-cookie': 4.0.5 parse5: 8.0.1 undici-types: 7.25.0 @@ -10156,11 +10158,7 @@ snapshots: '@types/node-schedule@2.1.8': dependencies: - '@types/node': 25.9.7 - - '@types/node@25.9.7': - dependencies: - undici-types: 7.24.6 + '@types/node': 25.9.8 '@types/node@25.9.8': dependencies: @@ -10173,7 +10171,7 @@ snapshots: '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: @@ -10185,7 +10183,7 @@ snapshots: '@types/readable-stream@4.0.24': dependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/set-cookie-parser@2.4.10': dependencies: @@ -10207,7 +10205,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@types/yargs-parser@21.0.3': {} @@ -10215,57 +10213,57 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.1 eslint: 9.39.5(jiti@2.7.0) - ignore: 7.0.9 + ignore: 7.0.10 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.1 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) - '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.70.0': + '@typescript-eslint/scope-manager@8.70.1': dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 - '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -10275,14 +10273,14 @@ snapshots: '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/types@8.70.0': {} + '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.70.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/project-service': 8.70.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -10292,20 +10290,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.70.0': + '@typescript-eslint/visitor-keys@8.70.1': dependencies: - '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.1': {} @@ -10380,7 +10378,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -10388,7 +10386,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -10404,7 +10402,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -10415,14 +10413,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.6(@types/node@25.9.7)(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + msw: 2.14.6(@types/node@25.9.8)(typescript@6.0.3) + vite: 7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10528,9 +10526,9 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@xyflow/react@12.12.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.15)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@xyflow/system': 0.0.82 + '@xyflow/system': 0.0.83 classcat: 5.0.5 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) @@ -10541,11 +10539,11 @@ snapshots: transitivePeerDependencies: - immer - '@xyflow/system@0.0.82': + '@xyflow/system@0.0.83': dependencies: '@types/d3-drag': 3.0.7 '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 d3-drag: 3.0.0 @@ -10786,7 +10784,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.25: {} + baseline-browser-mapping@2.11.26: {} before-after-hook@4.0.0: {} @@ -10859,11 +10857,11 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.5: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 - brace-expansion@5.0.9: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -10880,19 +10878,19 @@ snapshots: browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001812 electron-to-chromium: 1.5.422 node-releases: 2.0.54 update-browserslist-db: 1.3.2(browserslist@4.28.9) - browserslist@4.29.0: + browserslist@4.29.1: dependencies: - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.433 - node-releases: 2.0.56 - update-browserslist-db: 1.3.3(browserslist@4.29.0) + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001812 + electron-to-chromium: 1.5.439 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.1) buffer-from@1.1.2: {} @@ -10942,7 +10940,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001810: {} + caniuse-lite@1.0.30001812: {} cbor-extract@2.2.2: dependencies: @@ -11493,14 +11491,14 @@ snapshots: dotenv@17.4.2: {} - drizzle-kit@0.31.10: + drizzle-kit@0.31.11: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.13 + tsx: 4.23.15 - drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1): + drizzle-orm@0.45.3(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1): optionalDependencies: '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.11.1 @@ -11521,7 +11519,7 @@ snapshots: electron-to-chromium@1.5.422: {} - electron-to-chromium@1.5.433: {} + electron-to-chromium@1.5.439: {} emoji-regex@10.6.0: {} @@ -11855,18 +11853,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.5(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.3.6(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.3.5(eslint@9.39.5(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.3.6(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -11894,22 +11892,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 @@ -11920,7 +11918,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.17.0 is-glob: 4.0.3 @@ -11932,7 +11930,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -12409,10 +12407,6 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.6: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -12650,7 +12644,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.9: {} + ignore@7.0.10: {} immer@11.1.15: {} @@ -13225,7 +13219,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.47.0(react@19.3.0): + lucide-react@1.48.0(react@19.3.0): dependencies: react: 19.3.0 @@ -13545,7 +13539,7 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.2: dependencies: @@ -13599,9 +13593,9 @@ snapshots: ms@2.1.3: {} - msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3): + msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.3.2(@types/node@25.9.7) + '@inquirer/confirm': 6.3.2(@types/node@25.9.8) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -13612,7 +13606,7 @@ snapshots: outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.11.11 + rettime: 0.11.12 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.2 @@ -13672,26 +13666,26 @@ snapshots: nerf-dart@1.0.0: {} - next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.5 + '@next/env': 16.3.6 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001812 postcss: 8.5.23 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.5 - '@next/swc-darwin-x64': 16.3.5 - '@next/swc-linux-arm64-gnu': 16.3.5 - '@next/swc-linux-arm64-musl': 16.3.5 - '@next/swc-linux-x64-gnu': 16.3.5 - '@next/swc-linux-x64-musl': 16.3.5 - '@next/swc-win32-arm64-msvc': 16.3.5 - '@next/swc-win32-x64-msvc': 16.3.5 - sharp: 0.35.4(@types/node@25.9.7) + '@next/swc-darwin-arm64': 16.3.6 + '@next/swc-darwin-x64': 16.3.6 + '@next/swc-linux-arm64-gnu': 16.3.6 + '@next/swc-linux-arm64-musl': 16.3.6 + '@next/swc-linux-x64-gnu': 16.3.6 + '@next/swc-linux-x64-musl': 16.3.6 + '@next/swc-win32-arm64-msvc': 16.3.6 + '@next/swc-win32-x64-msvc': 16.3.6 + sharp: 0.35.5(@types/node@25.9.8) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -13729,7 +13723,7 @@ snapshots: node-releases@2.0.54: {} - node-releases@2.0.56: {} + node-releases@2.0.57: {} node-schedule@2.1.1: dependencies: @@ -13768,12 +13762,12 @@ snapshots: npm@11.19.0: {} - nuqs@2.8.9(next@16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): + nuqs@2.8.9(next@16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.0.0 react: 19.3.0 optionalDependencies: - next: 16.3.5(@babel/core@7.29.7)(@types/node@25.9.7)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.6(@babel/core@7.29.7)(@types/node@25.9.8)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) object-assign@4.1.1: {} @@ -14467,43 +14461,43 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.11.11: + rettime@0.11.12: optional: true reusify@1.1.0: {} rfdc@1.4.1: {} - rollup@4.63.4: + rollup@4.63.5: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.4 - '@rollup/rollup-android-arm64': 4.63.4 - '@rollup/rollup-darwin-arm64': 4.63.4 - '@rollup/rollup-darwin-x64': 4.63.4 - '@rollup/rollup-freebsd-arm64': 4.63.4 - '@rollup/rollup-freebsd-x64': 4.63.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 - '@rollup/rollup-linux-arm-musleabihf': 4.63.4 - '@rollup/rollup-linux-arm64-gnu': 4.63.4 - '@rollup/rollup-linux-arm64-musl': 4.63.4 - '@rollup/rollup-linux-loong64-gnu': 4.63.4 - '@rollup/rollup-linux-loong64-musl': 4.63.4 - '@rollup/rollup-linux-ppc64-gnu': 4.63.4 - '@rollup/rollup-linux-ppc64-musl': 4.63.4 - '@rollup/rollup-linux-riscv64-gnu': 4.63.4 - '@rollup/rollup-linux-riscv64-musl': 4.63.4 - '@rollup/rollup-linux-s390x-gnu': 4.63.4 - '@rollup/rollup-linux-x64-gnu': 4.63.4 - '@rollup/rollup-linux-x64-musl': 4.63.4 - '@rollup/rollup-openbsd-x64': 4.63.4 - '@rollup/rollup-openharmony-arm64': 4.63.4 - '@rollup/rollup-win32-arm64-msvc': 4.63.4 - '@rollup/rollup-win32-ia32-msvc': 4.63.4 - '@rollup/rollup-win32-x64-gnu': 4.63.4 - '@rollup/rollup-win32-x64-msvc': 4.63.4 + '@rollup/rollup-android-arm-eabi': 4.63.5 + '@rollup/rollup-android-arm64': 4.63.5 + '@rollup/rollup-darwin-arm64': 4.63.5 + '@rollup/rollup-darwin-x64': 4.63.5 + '@rollup/rollup-freebsd-arm64': 4.63.5 + '@rollup/rollup-freebsd-x64': 4.63.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.5 + '@rollup/rollup-linux-arm-musleabihf': 4.63.5 + '@rollup/rollup-linux-arm64-gnu': 4.63.5 + '@rollup/rollup-linux-arm64-musl': 4.63.5 + '@rollup/rollup-linux-loong64-gnu': 4.63.5 + '@rollup/rollup-linux-loong64-musl': 4.63.5 + '@rollup/rollup-linux-ppc64-gnu': 4.63.5 + '@rollup/rollup-linux-ppc64-musl': 4.63.5 + '@rollup/rollup-linux-riscv64-gnu': 4.63.5 + '@rollup/rollup-linux-riscv64-musl': 4.63.5 + '@rollup/rollup-linux-s390x-gnu': 4.63.5 + '@rollup/rollup-linux-x64-gnu': 4.63.5 + '@rollup/rollup-linux-x64-musl': 4.63.5 + '@rollup/rollup-openbsd-x64': 4.63.5 + '@rollup/rollup-openharmony-arm64': 4.63.5 + '@rollup/rollup-win32-arm64-msvc': 4.63.5 + '@rollup/rollup-win32-ia32-msvc': 4.63.5 + '@rollup/rollup-win32-x64-gnu': 4.63.5 + '@rollup/rollup-win32-x64-msvc': 4.63.5 fsevents: 2.3.3 router@2.2.0: @@ -14712,38 +14706,38 @@ snapshots: - supports-color - typescript - sharp@0.35.4(@types/node@25.9.7): + sharp@0.35.5(@types/node@25.9.8): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 - '@types/node': 25.9.7 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 25.9.8 optional: true shebang-command@2.0.0: @@ -15186,16 +15180,16 @@ snapshots: tldts-core@7.0.30: {} - tldts-core@7.4.13: + tldts-core@7.4.16: optional: true tldts@7.0.30: dependencies: tldts-core: 7.0.30 - tldts@7.4.13: + tldts@7.4.16: dependencies: - tldts-core: 7.4.13 + tldts-core: 7.4.16 optional: true to-regex-range@5.0.1: @@ -15210,7 +15204,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.13 + tldts: 7.4.16 optional: true tr46@6.0.0: @@ -15264,7 +15258,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.13: + tsx@4.23.15: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -15364,12 +15358,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.5(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -15495,9 +15489,9 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.3.3(browserslist@4.29.0): + update-browserslist-db@1.3.3(browserslist@4.29.1): dependencies: - browserslist: 4.29.0 + browserslist: 4.29.1 escalade: 3.2.0 picocolors: 1.1.1 @@ -15535,6 +15529,10 @@ snapshots: dependencies: react: 19.3.0 + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + util-deprecate@1.0.2: {} util@0.10.4: @@ -15577,37 +15575,37 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) - vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): + vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 postcss: 8.5.28 - rollup: 4.63.4 + rollup: 4.63.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.51.2 - tsx: 4.23.13 + tsx: 4.23.15 yaml: 2.9.0 - vitest@4.1.11(@types/node@25.9.7)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.7)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@25.9.8)(typescript@6.0.3))(vite@7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15624,10 +15622,10 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.0(@types/node@25.9.7)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 7.3.0(@types/node@25.9.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.7 + '@types/node': 25.9.8 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: @@ -15651,7 +15649,7 @@ snapshots: webidl-conversions@8.0.1: {} - webpack-sources@3.5.1: {} + webpack-sources@3.6.0: {} webpack-virtual-modules@0.6.2: {} @@ -15665,7 +15663,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.29.0 + browserslist: 4.29.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.25.1 es-module-lexer: 2.3.2 @@ -15681,7 +15679,7 @@ snapshots: tapable: 2.3.3 terser-webpack-plugin: 5.6.1(lightningcss@1.32.0)(webpack@5.104.1(lightningcss@1.32.0)) watchpack: 2.5.2 - webpack-sources: 3.5.1 + webpack-sources: 3.6.0 transitivePeerDependencies: - '@minify-html/node' - '@swc/core' @@ -15942,7 +15940,7 @@ snapshots: zustand@4.5.7(@types/react@19.3.0)(immer@11.1.15)(react@19.3.0): dependencies: - use-sync-external-store: 1.6.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) optionalDependencies: '@types/react': 19.3.0 immer: 11.1.15 From 97df464d2714fff6b0d3bd273b501ca527c79fb7 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Mon, 28 Sep 2026 07:24:29 +0000 Subject: [PATCH 027/119] feat: coordinate temperature ownership and persistent manual holds (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning a rotary dial currently competes with schedules and Autopilot, so the next automated write can undo the user's adjustment. This adds a shared per-side controller: manual hold β†’ run-once session β†’ Autopilot β†’ recurring schedule, with safety and explicit shutdown taking precedence. Existing temperature API calls acquire a persisted 30-minute hold. Consumers can choose `holdMinutes`, inspect ownership in status, and explicitly Resume; the temperature screen exposes these controls. Schedule/session clocks keep advancing during holds, and Resume resolves the current point without replaying missed commands. Power-on without an explicit temperature honors the current owner without acquiring a hold. Autopilot publishes bounded requests instead of writing hardware directly. Relative actions, including window expressions, use schedule/session baselines. Policy reactivation does not replay sibling one-shot effects. Hardware cutoffs remain independent of hold expiry and survive SQLite reopen, keepalive, and safety recovery. Two generated migrations add hold storage and the explicit hardware deadline. Service shutdown drains admitted writes and cancels queued reconciliation; rule deactivation cancels obsolete queued shutdowns. Corrupt stored sessions are isolated from manual control/status. Validation: full Vitest suite **4,012 passed / 1 skipped across 174 files**; production build, TypeScript, ESLint and both Drizzle schema checks passed. Final GitHub CI, including coverage, lint, typecheck, builds and branch release, is green. ESLint retains one pre-existing warning in stryker.config.mjs. Tests cover competing producers, hold expiry/refresh, SQLite reopen, queued commands, DST, recovery bounds, rule deactivation, run-once rollback, corrupt-session recovery, and UI Resume/duration controls. Two full/diff Optimizer and Skeptic review rounds plus focused repair verification completed. CodeRabbit's four initial comments were verified and addressed; follow-up review requested after push but CodeRabbit rate-limited that request. Native reviewers used the available GPT-6 session model; the skill's prescribed older models were unavailable. No live Pod deployment or hardware test performed. Raw diagnostic commands remain a documented low-level exception. Consumer contract: [docs/temperature-control.md](https://github.com/sleepypod/core/blob/feat/temperature-controller/docs/temperature-control.md). Architecture decision updated in ADR 0023. --- .../0023-autopilot-reactive-automations.md | 34 +- docs/temperature-control.md | 155 +++ instrumentation.ts | 17 +- package.json | 1 + pnpm-lock.yaml | 3 + src/automation/engine.ts | 365 +++--- src/automation/index.ts | 2 +- src/automation/instance.ts | 93 +- src/automation/tests/controllerHarness.ts | 64 + src/automation/tests/engine.test.ts | 281 +++- src/automation/tests/instance.test.ts | 25 +- src/automation/types.ts | 4 +- src/components/TempScreen/TempScreen.tsx | 20 +- .../TempScreen/TemperatureHoldControls.tsx | 73 ++ .../tests/TemperatureHoldControls.test.tsx | 52 + src/db/migrations/0015_temperature_holds.sql | 6 + src/db/migrations/0016_hardware_deadline.sql | 1 + src/db/migrations/meta/0015_snapshot.json | 1131 ++++++++++++++++ src/db/migrations/meta/0016_snapshot.json | 1138 +++++++++++++++++ src/db/migrations/meta/_journal.json | 14 + src/db/schema.ts | 10 + .../tests/__snapshots__/schema.test.ts.snap | 16 + src/db/tests/schema.test.ts | 11 + src/hardware/dacMonitor.instance.ts | 2 + src/hardware/gestureActionHandler.ts | 41 +- src/hardware/pumpStallGuard.ts | 11 + .../deviceStateSync.stallSuppression.test.ts | 1 + src/hardware/tests/deviceStateSync.test.ts | 1 + .../tests/gestureActionHandler.test.ts | 37 +- src/hardware/tests/pumpStallGuard.test.ts | 1 + src/homekit/accessories/sideController.ts | 18 +- src/homekit/tests/sideController.test.ts | 49 +- src/homekit/tests/thermostat.test.ts | 18 +- src/hooks/useDeviceStatus.ts | 1 + src/hooks/useSensorStream.ts | 2 + src/scheduler/jobManager.ts | 94 +- src/scheduler/tests/jobManager.test.ts | 40 +- src/scheduler/tests/jobOrdering.test.ts | 198 +-- src/server/helpers.ts | 7 + src/server/routers/automations.ts | 4 +- src/server/routers/device.ts | 170 +-- src/server/routers/runOnce.ts | 167 +-- src/server/routers/tests/device.test.ts | 515 ++------ src/server/routers/tests/runOnce.test.ts | 635 +++------ src/server/validation-schemas.ts | 4 + src/services/autoOffWatcher.ts | 33 +- src/services/temperatureKeepalive.ts | 6 +- src/services/tests/autoOffWatcher.test.ts | 71 +- .../tests/temperatureKeepalive.test.ts | 34 +- src/streaming/broadcastMutationStatus.ts | 8 +- .../tests/broadcastMutationStatus.test.ts | 12 + src/temperature/baseline.ts | 61 + src/temperature/controller.ts | 389 ++++++ src/temperature/instance.ts | 193 +++ src/temperature/schema.ts | 12 + src/temperature/tests/baseline.test.ts | 76 ++ src/temperature/tests/controller.test.ts | 336 +++++ src/temperature/tests/databaseFixture.ts | 15 + src/temperature/tests/instance.test.ts | 271 ++++ tests/instrumentation.initialize.test.ts | 17 + 60 files changed, 5262 insertions(+), 1804 deletions(-) create mode 100644 docs/temperature-control.md create mode 100644 src/automation/tests/controllerHarness.ts create mode 100644 src/components/TempScreen/TemperatureHoldControls.tsx create mode 100644 src/components/TempScreen/tests/TemperatureHoldControls.test.tsx create mode 100644 src/db/migrations/0015_temperature_holds.sql create mode 100644 src/db/migrations/0016_hardware_deadline.sql create mode 100644 src/db/migrations/meta/0015_snapshot.json create mode 100644 src/db/migrations/meta/0016_snapshot.json create mode 100644 src/temperature/baseline.ts create mode 100644 src/temperature/controller.ts create mode 100644 src/temperature/instance.ts create mode 100644 src/temperature/schema.ts create mode 100644 src/temperature/tests/baseline.test.ts create mode 100644 src/temperature/tests/controller.test.ts create mode 100644 src/temperature/tests/databaseFixture.ts create mode 100644 src/temperature/tests/instance.test.ts diff --git a/docs/adr/0023-autopilot-reactive-automations.md b/docs/adr/0023-autopilot-reactive-automations.md index 1b372477a..a38755ddb 100644 --- a/docs/adr/0023-autopilot-reactive-automations.md +++ b/docs/adr/0023-autopilot-reactive-automations.md @@ -1,6 +1,6 @@ # ADR 0023: Reactive automations engine (Autopilot) -**Status:** Accepted +**Status:** Accepted; temperature arbitration revised September 2026 **Date:** 2026-06-07 ## Context @@ -123,18 +123,23 @@ effect without a restart (the `schedules.ts β†’ JobManager` pattern). 4. Recurring temperature/power schedules *(existing)* 5. Neutral default -Autopilot sits **above recurring schedules, below run-once and manual.** It is -realized without an explicit coordinator: the engine refuses to write a side -while `hasActiveRunOnceSession(side)` is true or a manual-override hold is -active (both log `skipped`); against recurring schedules it is last-writer-wins -on the shared `withSideLock(side)`, and because a continuous-policy rule -re-asserts its setpoint every tick, it wins the steady state. Constant-policy -rules are therefore the supported way to override a schedule. +Autopilot sits **above recurring schedules, below run-once and manual**. +The shared per-side `TemperatureController` now enforces this order. The +scheduler supplies its current point; the engine publishes bounded numeric +requests as a batch, and manual commands create persisted holds. Only the +controller resolves ordinary temperature writes. Schedule/session clocks +continue while masked, so resume resolves the current target without replay. + +This supersedes P0's last-writer-wins behavior, which allowed a recurring +schedule to overwrite a manual adjustment and briefly fight Autopilot. See +[Temperature ownership and manual holds](../temperature-control.md) for the +request lifetimes, relative-expression baseline, power rules, and consumer API. ### Resolved tunables (P0, 2026-06-07) -- **Manual-override hold** β€” `AUTOMATION_MANUAL_OVERRIDE_MS = 30 min`, in-memory - per side. Cleared on reboot (safe default: autopilot resumes). +- **Manual-override hold** β€” 30 minutes by default, configurable per command, + persisted per side in `temperature_holds`. Restart preserves the original + expiry; Resume releases it explicitly. Both engines respect the same hold. - **Evaluator cadence** β€” a single global 60s tick (`AUTOMATION_TICK_MS`) that also samples signals into the windowed-aggregate ring buffers. 60s matches the ambient/movement cadence and sits well under the thermal slew, so a @@ -202,11 +207,10 @@ existing lock. Have the engine read the schedule table and negotiate who owns a side. -**Rejected for P0.** Last-writer-wins on the shared side lock already produces -the desired "autopilot above schedules" behavior, because a continuous-policy -rule re-asserts every tick. Explicit negotiation adds a stateful coupling -between two subsystems for a case the lock already resolves. Revisit only if a -concrete conflict appears that last-writer-wins gets wrong. +**Originally rejected for P0; superseded.** External manual controls exposed +concrete conflicts between recurring schedules and reactive rules. A shared +controller now owns arbitration. The engines remain independent producers; +they do not negotiate through direct references to one another. ### 6. A global per-user temperature band setting in P0 diff --git a/docs/temperature-control.md b/docs/temperature-control.md new file mode 100644 index 000000000..c313eeb5c --- /dev/null +++ b/docs/temperature-control.md @@ -0,0 +1,155 @@ +# Temperature ownership and manual holds + +The per-side temperature controller resolves manual holds, run-once sessions, +Autopilot requests, and recurring schedules. Producers keep their own clocks; +the controller chooses the current target and serializes writes with +`withSideLock`. Safety protection blocks energizing writes. Ordinary temperature +reconciliation never turns a powered-off side back on. + +Priority is manual hold, run-once session, Autopilot (highest rule priority, +then newest activation), then recurring schedule. Equal activations use stable +request IDs as a final tie-break. A hold affects only its bed side and never +changes a schedule's saved enabled setting. + +## Rotary dial and other REST consumers + +Existing temperature requests acquire a 30-minute hold without a firmware +change: + +```http +POST /api/device/temperature +Content-Type: application/json + +{"side":"left","temperature":75} +``` + +An optional `holdMinutes` chooses 1–1440 minutes. Each accepted adjustment +restarts that side's timer. `temperature` is Fahrenheit. `duration` retains its +separate hardware heating-duration meaning in seconds; it is not the hold timer. +An explicit duration becomes a persisted absolute cutoff. Resume, hold expiry, +keepalive, restart, and safety recovery cannot extend it. A new manual adjustment +without a duration starts a normal session again; a scheduled power-on during a +live hold preserves its cutoff. Duration zero shuts the side off without creating +a hold. + +```json +{"side":"left","temperature":75,"holdMinutes":60} +``` + +Read ownership with `GET /api/device/temperature/control?side=left`: + +```json +{ + "source":"manual", + "requestId":"manual", + "targetTemperature":75, + "holdUntil":1790636400000, + "blocked":null +} +``` + +`holdUntil` is Unix epoch **milliseconds**, or null. `source` is `manual`, +`run-once`, `autopilot`, `schedule`, or null. `blocked` is `safety`, `off`, or +null. The target in this control object is always Fahrenheit, including when +the surrounding device-status request chooses Celsius. The ordinary hardware +status carries the current effective hardware target; a blocked control object +can still describe the target that would win if power were explicitly enabled. + +`GET /api/device/status` and WebSocket `deviceStatus` frames include the same +information under `temperatureControl.left` and `temperatureControl.right`. +The field can be absent before the controller starts. Consumers should tolerate +its absence for compatibility with older core versions. + +Release a hold explicitly: + +```http +POST /api/device/temperature/resume +Content-Type: application/json + +{"side":"left"} +``` + +The response is the updated control object. tRPC consumers use +`device.getTemperatureControl`, `device.resumeTemperature`, and the existing +`device.setTemperature` mutation. + +The dial should submit temperature changes only for user adjustments. Polling +must not re-send the target, because each adjustment renews the hold. Continue +using the existing debounce. Display the hold expiry and offer an explicit +Resume action; no client-side scheduling or later reconnect is required. Upgrade +core before sending the new optional field to an older strict-validation API. + +## Resume, power, and alarms + +Schedule and run-once clocks continue during a hold. On expiry or Resume, core +resolves the applicable point now without replaying earlier points. If no +source has a target, it leaves the current temperature unchanged and releases +ownership. Always-on keepalive can refresh that ownerless target without +creating a new hold. + +A power-on API request without a temperature uses the current owner (or 75Β°F +when none exists) without creating a hold. An explicit power-on temperature +starts a manual hold. + +Explicit shutdown ends the hold. Scheduled shutdown, auto-off, and safety +cutoffs remain independent of temperature ownership. Alarm vibration still +fires; an alarm's temperature participates at recurring-schedule priority. +A pending debounced adjustment superseded by Resume or a power command returns +a conflict instead of executing later and undoing that command. + +Manual holds are stored in SQLite with their original start and expiry times. +Recreating the controller does not renew the hold. The controller singleton and +periodic reconciler live on `globalThis` so separate server bundles share one +authority. Service shutdown cancels queued reconciliation and drains admitted +writes before hardware/database teardown. Disabling an automation also cancels +its shutdown commands that are still waiting for the side lock. + +## Autopilot request lifetime + +Each evaluation publishes the complete Autopilot request set for each side in +one batch. Lower-priority rules cannot briefly write before a higher-priority +rule in the same evaluation. Requests remain available under manual/run-once +ownership, while hardware writes remain gated by the controller. + +Temperature and power-on actions accept `mode: "policy" | "one-shot"` and +optional `holdMinutes`. By default, a time-of-day action or an expression that +references target/current level temperature is a one-shot; other temperature +policies are continuous. Continuous policy conditions are checked on every +engine tick, including between trigger times. False or unknown conditions +withdraw the policy; enabled policies refresh a two-tick lease so a stopped +engine cannot own a side indefinitely. Edits, deletion, disabling a rule, and +the global kill-switch revoke its requests. A signal-change policy can reacquire +its request after an edit, re-enable, or condition recovery without waiting for +a new signal edge. This does not replay sibling notifications or one-shot +actions; those still require an actual trigger. + +One-shot actions resolve a numeric target once and keep it for 30 minutes by +default. Repeated ticks do not compound or extend that active request. A new +trigger after expiry can create another request. Conditions becoming false do +not undo an already-fired one-shot before its expiry; disabling/editing the +rule does. + +In action expressions, per-side `targetTemperature` and legacy +`currentTemperature` references use that referenced side's current +schedule/session baseline, with 75Β°F as the fallback when neither exists. +Windowed action references use a separate history sampled from these baselines; +condition windows still use live observations. They never use the rule's own output. Conditions continue reading the actual +live signals. This defines relative adjustments independently of transient +manual holds and prevents repeated evaluation from ratcheting the target. + +Recovery probes remain the pump guard's bounded diagnostic operation. Final +guard-authorized recovery resolves the current temperature request while +preserving the guard's remaining heating duration; it does not issue a second, +default-duration power-on command. + +## Diagnostic and recovery writers + +Ordinary API, HomeKit, gesture, schedule, run-once, and Autopilot temperature +commands use the shared controller. The raw `/api/device/execute` diagnostic +endpoint remains a low-level bypass, still subject to its existing side locks +and pump-protection checks. It neither acquires nor releases a hold and should +not be used by consumer controls. Subsequent controller reconciliation or +keepalive can replace a diagnostic target with the current owner’s target. +Pump-protection probes and emergency cutoffs remain guard-owned operations; +final recovery uses the current owner and the smaller of the guard’s remaining +duration and the persisted manual hardware cutoff. diff --git a/instrumentation.ts b/instrumentation.ts index 1fa85203e..1b92c391b 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -17,6 +17,7 @@ import { getJobManager, shutdownJobManager } from '@/src/scheduler' import { getAutomationEngine, shutdownAutomationEngine } from '@/src/automation' +import { startTemperatureController, stopTemperatureController } from '@/src/temperature/instance' import { closeDatabase, closeBiometricsDatabase } from '@/src/db' import { startBiometricsRetention, stopBiometricsRetention } from '@/src/db/retention' import { getDacMonitor, shutdownDacMonitor } from '@/src/hardware/dacMonitor.instance' @@ -58,6 +59,7 @@ async function gracefulShutdown(signal: string): Promise { // Step 0: Stop keepalive timers try { + await stopTemperatureController() shutdownKeepalives() } catch (error) { @@ -342,9 +344,6 @@ async function initializeBackgroundServices(): Promise { isInitialized = true - // Initialize temperature keepalive timers for sides with alwaysOn enabled - initializeKeepalives() - // Optional integrations do not hold up HTTP or the remaining services. void startMqttBridge().then(() => { if (isShuttingDown) return shutdownMqttBridge() @@ -374,11 +373,19 @@ async function initializeBackgroundServices(): Promise { // Boot the Autopilot rules engine beside the scheduler (non-blocking). // Shares the same hardware path; no-op until automations are created. - getAutomationEngine().then(() => { + getAutomationEngine().catch((error) => { + console.warn('[automation] engine failed to start:', error instanceof Error ? error.message : error) + }).then(async () => { if (isShuttingDown) return shutdownAutomationEngine() + await startTemperatureController() + if (isShuttingDown) { + await stopTemperatureController() + return + } + initializeKeepalives() }).catch((error) => { console.warn( - '[automation] engine failed to start:', + '[temperature] controller failed to start:', error instanceof Error ? error.message : error, ) }) diff --git a/package.json b/package.json index f3e399589..ed30c0921 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "cbor-x": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cron-parser": "^4.9.0", "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "hap-nodejs": "1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 873d33561..b0a9da045 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cron-parser: + specifier: ^4.9.0 + version: 4.9.0 dotenv: specifier: ^17.3.1 version: 17.4.2 diff --git a/src/automation/engine.ts b/src/automation/engine.ts index e27d3072c..ec16d4489 100644 --- a/src/automation/engine.ts +++ b/src/automation/engine.ts @@ -21,16 +21,16 @@ * the DB, or timers. `src/automation/instance.ts` wires the production deps. */ -import { MAX_TEMP, MIN_TEMP, fahrenheitToLevel } from '@/src/hardware/types' +import { MAX_TEMP, MIN_TEMP } from '@/src/hardware/types' +import type { TemperatureController, TemperatureRequest } from '@/src/temperature/controller' +import { DEFAULT_HOLD_MS } from '@/src/temperature/controller' import { evaluateCondition } from './evaluator' import type { EvalContext } from './expressions' import { evaluateExpr } from './expressions' import { collectWindowSignals, type SignalReader } from './signals' import { - AUTOMATION_ANTI_THRASH_F, AUTOMATION_DEFAULT_USER_MAX, AUTOMATION_DEFAULT_USER_MIN, - AUTOMATION_MANUAL_OVERRIDE_MS, AUTOMATION_MAX_ACTIONS_PER_HOUR, AUTOMATION_TICK_MS, type Action, @@ -45,13 +45,6 @@ import { WindowStore } from './windows' /** How many minutes after a timeOfDay slot a late tick may still fire it. */ const TIME_OF_DAY_GRACE_MIN = 10 -/** Minimal hardware surface the engine writes through (shared client shape). */ -export interface HardwareWriter { - connect: () => Promise - setTemperature: (side: Side, temperature: number, duration?: number) => Promise - setPower: (side: Side, powered: boolean, temperature?: number) => Promise -} - export interface AutomationEngineDeps { signals: SignalReader /** Epoch ms β€” injectable for deterministic tests. */ @@ -59,19 +52,11 @@ export interface AutomationEngineDeps { /** Timezone-aware wall clock. dateKey (local yyyy-mm-dd) keys timeOfDay * "already fired today"; when absent, dayOfWeek is used as a fallback. */ clock: () => { nowMinutes: number, dayOfWeek: DayOfWeek, dateKey?: string } - getHardware: () => HardwareWriter - withSideLock: (side: Side, fn: () => Promise) => Promise - /** Pump stall guard gate β€” energizing writes are skipped while it blocks - * the side (ADR 0022: a tripped side stays parked until acknowledgment - * or successful opt-in auto-recovery). */ - pumpStallShouldBlock: (side: Side) => boolean - broadcast: (side: Side, overlay: Record) => void - markMutated: (side: Side) => void + control: Pick loadRules: () => Promise recordRun: (automationId: number, outcome: RunOutcome, detail: unknown) => Promise /** Persist enabled=false when the runaway guard trips. */ disableRule: (automationId: number) => Promise - hasActiveRunOnceSession: (side: Side) => Promise /** Side-effect for notify actions (e.g. push/log); never touches hardware. */ notify: (automationId: number, message: string) => void log?: (msg: string) => void @@ -96,6 +81,7 @@ export class AutomationEngine { private runtime = new Map() private triggerFingerprints = new Map() private windows = new WindowStore() + private actionWindows = new WindowStore() private windowSignals = new Set() private timer: ReturnType | null = null private ticking = false @@ -106,9 +92,15 @@ export class AutomationEngine { // deviceSettings.autopilotEnabled; the instance restores it at boot. private globalEnabled = true - // Per-side runtime state shared across rules. - private lastAsserted: Record = { left: undefined, right: undefined } - private manualOverrideUntil: Record = { left: 0, right: 0 } + private publishErrors: Partial> = {} + private generation = 0 + private leases: Record> = { + left: new Map(), right: new Map(), + } + + private pendingRuns: Array<{ rule: AutomationRule, results: ActionResult[] }> = [] + private pendingOff = new Set() + private pendingOn: Record = { left: [], right: [] } constructor(deps: AutomationEngineDeps) { this.deps = deps @@ -129,6 +121,7 @@ export class AutomationEngine { /** Reload automations from the source (call after CRUD mutations). */ async reload(): Promise { + const hadLeases = this.leases.left.size + this.leases.right.size > 0 const nextRules = await this.deps.loadRules() const nextIds = new Set() for (const rule of nextRules) { @@ -140,6 +133,14 @@ export class AutomationEngine { this.triggerFingerprints.set(rule.id, nextTrigger) } + this.generation++ + const unchanged = new Set(nextRules.filter(next => next.enabled && !next.dryRun + && this.rules.some(old => old.id === next.id && JSON.stringify(old) === JSON.stringify(next))).map(r => r.id)) + for (const side of ['left', 'right'] as const) { + for (const id of this.leases[side].keys()) { + if (!unchanged.has(Number(id.split(':')[1]))) this.leases[side].delete(id) + } + } this.rules = nextRules this.windowSignals = collectWindowSignals(this.rules) // Drop runtime for rules that no longer exist. @@ -149,33 +150,45 @@ export class AutomationEngine { for (const id of [...this.triggerFingerprints.keys()]) { if (!nextIds.has(id)) this.triggerFingerprints.delete(id) } + if (hadLeases) await this.publishLeases(this.generation) } - stop(): void { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - } - - /** - * Suspend autopilot on a side for the manual-override hold window. A router or - * gesture handler calls this when the user changes the dial directly. - */ - registerManualOverride(side: Side): void { - this.manualOverrideUntil[side] = this.deps.now() + AUTOMATION_MANUAL_OVERRIDE_MS - // The user changed the setpoint out from under us β€” the anti-thrash - // baseline no longer reflects hardware, and keeping it would suppress - // the first re-assertion after the override window expires. - this.lastAsserted[side] = undefined + async stop(): Promise { + if (this.timer) clearInterval(this.timer) + this.timer = null + await this.setGlobalEnabled(false) } - /** Flip the global kill-switch. `false` suspends all evaluation immediately. */ - setGlobalEnabled(on: boolean): void { + /** Flip the persisted kill-switch and immediately revoke every outstanding lease. */ + async setGlobalEnabled(on: boolean): Promise { this.globalEnabled = on + this.generation++ + if (!on) { + this.leases = { left: new Map(), right: new Map() } + await this.publishLeases(this.generation) + } this.deps.log?.(`AutomationEngine global kill-switch ${on ? 'ON (running)' : 'OFF (halted)'}`) } + private async publishLeases(generation: number, allowPowerOn = false) { + const statuses = {} as Record>> + this.publishErrors = {} + for (const side of ['left', 'right'] as const) { + try { + statuses[side] = await this.deps.control.replaceAutopilot( + side, [...this.leases[side].values()], allowPowerOn ? this.pendingOn[side] : [], + () => generation === this.generation, + ) + } + catch (error) { + this.publishErrors[side] = error + statuses[side] = { source: null, requestId: null, targetTemperature: null, holdUntil: null, blocked: null } + this.deps.log?.(`Temperature controller failed on ${side}: ${String(error)}`) + } + } + return statuses + } + /** Whether autopilot is globally enabled (kill-switch not engaged). */ isGloballyEnabled(): boolean { return this.globalEnabled @@ -196,7 +209,16 @@ export class AutomationEngine { if (this.ticking) return // never overlap ticks this.ticking = true try { + const generation = this.generation + this.pendingRuns = [] + this.pendingOff.clear() + this.pendingOn = { left: [], right: [] } const now = this.deps.now() + for (const side of ['left', 'right'] as const) { + for (const [id, lease] of this.leases[side]) { + if (lease.expiresAt <= now) this.leases[side].delete(id) + } + } const snapshot = this.deps.signals.read() const { nowMinutes, dayOfWeek, dateKey } = this.deps.clock() @@ -204,8 +226,12 @@ export class AutomationEngine { for (const key of this.windowSignals) { const v = snapshot[key] if (typeof v === 'number') this.windows.record(key, v, now) + const target = /^(left|right)\.(targetTemperature|currentTemperature)$/.exec(key) + const actionValue = target ? this.deps.control.automationBaseline(target[1] as Side) : v + if (typeof actionValue === 'number') this.actionWindows.record(key, actionValue, now) } this.windows.prune(now, this.maxWindowMinutes()) + this.actionWindows.prune(now, this.maxWindowMinutes()) const ctx: EvalContext = { signal: key => snapshot[key], @@ -217,6 +243,7 @@ export class AutomationEngine { } for (const rule of this.rules) { + if (generation !== this.generation) return if (!rule.enabled) continue try { await this.evaluateRule(rule, ctx, now) @@ -228,6 +255,46 @@ export class AutomationEngine { }) } } + if (generation !== this.generation) return + for (const side of this.pendingOff) { + try { + await this.deps.control.powerOff(side, () => generation === this.generation) + } + catch (error) { + for (const { results } of this.pendingRuns) { + for (const result of results) { + if (result.side === side && result.on === false) result.error = String(error) + } + } + } + this.pendingOn[side] = [] // shutdown wins over temperature actions this tick + } + const statuses = await this.publishLeases(generation, true) + if (generation !== this.generation) return + for (const { rule, results } of this.pendingRuns) { + for (const result of results) { + if (result.kind === 'setPower' && result.on === false && result.sent && !result.error && !result.dryRun) { + // Coalesced shutdowns count for each rule that requested a successful + // side cutoff. Failed/dry-run commands consume no runaway budget. + this.getRuntime(rule.id).actionTimes.push(now) + } + if (!result.requestId || !result.side || result.dryRun) continue + if (this.publishErrors[result.side] !== undefined) { + result.error = String(this.publishErrors[result.side]) + continue + } + const status = statuses[result.side] + if (!status.blocked && status.requestId === result.requestId) { + result.sent = !result.antiThrash + if (result.sent) this.getRuntime(rule.id).actionTimes.push(now) + } + else { + result.antiThrash = false + result.skipped = status.blocked ?? (status.source === 'manual' ? 'manual-hold' : status.source ?? 'superseded') + } + } + await this.deps.recordRun(rule.id, aggregateOutcome(rule.dryRun, results), { actions: results }) + } } finally { this.ticking = false @@ -288,18 +355,43 @@ export class AutomationEngine { } private async evaluateRule(rule: AutomationRule, ctx: EvalContext, now: number): Promise { + const generation = this.generation const rt = this.getRuntime(rule.id) - if (!this.triggerActive(rule, ctx, now, rt)) return // not an eval; no audit row + const firstSignalSample = !rt.lastTriggerSeen + const triggered = this.triggerActive(rule, ctx, now, rt) + // A live signal policy can reacquire ownership after edits, enable/disable, + // condition recovery, or lease expiry without inventing a signal edge. + // Synthetic activation must never replay a sibling notify/one-shot action. + const unownedPolicies = new Set() + if (rule.trigger.kind === 'signalChange' && ctx.signal(rule.trigger.signal) !== undefined + && (!rule.dryRun || firstSignalSample)) { + rule.actions.forEach((action, index) => { + if (!isPolicyAction(rule, action)) return + const sides: Side[] = action.kind !== 'notify' && action.side ? [action.side] : rule.side ? [rule.side] : ['left', 'right'] + if (sides.some(side => !this.leases[side].has(`rule:${rule.id}:${index}`))) unownedPolicies.add(index) + }) + } // IF β€” three-valued. unknown/false both skip (never fire on missing data). const cond = evaluateCondition(rule.conditions, ctx) if (cond !== true) { + this.removeLeases(rule.id, true) + if (!triggered) return await this.deps.recordRun(rule.id, 'skipped', { reason: cond === undefined ? 'condition-unknown' : 'condition-false', }) return } + // Policies remain valid between triggers, but a stopped engine cannot keep + // owning a side forever. Conditions are checked on every evaluator tick. + for (const side of ['left', 'right'] as const) { + for (const [id, lease] of this.leases[side]) { + if (id.startsWith(`rule:${rule.id}:`) && lease.policy) lease.expiresAt = now + 2 * AUTOMATION_TICK_MS + } + } + if (!triggered && !unownedPolicies.size) return + // Cooldown gate. if (rule.cooldownMin != null && rt.lastFiredMs != null && now - rt.lastFiredMs < rule.cooldownMin * 60_000) { @@ -311,7 +403,9 @@ export class AutomationEngine { rt.actionTimes = rt.actionTimes.filter(ts => now - ts < 3_600_000) if (rt.actionTimes.length >= AUTOMATION_MAX_ACTIONS_PER_HOUR) { await this.deps.disableRule(rule.id) + if (generation !== this.generation) return rule.enabled = false + this.removeLeases(rule.id) await this.deps.recordRun(rule.id, 'error', { reason: 'runaway-disabled', actionsLastHour: rt.actionTimes.length, @@ -322,137 +416,96 @@ export class AutomationEngine { // THEN β€” run actions, tracking the aggregate outcome. const results: ActionResult[] = [] - for (const action of rule.actions) { - results.push(...(await this.runAction(rule, action, ctx, now, rt))) + for (const [index, action] of rule.actions.entries()) { + if (!triggered && !unownedPolicies.has(index)) continue + if (generation !== this.generation) return + results.push(...(await this.runAction(rule, action, index, ctx, now))) } - const outcome = aggregateOutcome(rule.dryRun, results) - if (outcome === 'fired' || outcome === 'clamped' || outcome === 'dry_run') { - rt.lastFiredMs = now + if (results.some(r => r.requestId || r.notified || r.sent || r.dryRun)) rt.lastFiredMs = now + this.pendingRuns.push({ rule, results }) + } + + private removeLeases(ruleId: number, policiesOnly = false): void { + for (const side of ['left', 'right'] as const) { + for (const [id, lease] of this.leases[side]) { + if (id.startsWith(`rule:${ruleId}:`) && (!policiesOnly || lease.policy)) this.leases[side].delete(id) + } } - await this.deps.recordRun(rule.id, outcome, { actions: results }) } private async runAction( rule: AutomationRule, action: Action, + index: number, ctx: EvalContext, now: number, - rt: RuleRuntime, ): Promise { if (action.kind === 'notify') { this.deps.notify(rule.id, action.message) return [{ kind: 'notify', notified: true }] } - - // A null rule side (the builder's "both") fans a hardware action out to both - // sides. The temp expression's signal keys are already side-resolved at build - // time β€” a "both" rule reads the left side (builderModel.toAST) β€” so the - // resolved setpoint is shared; only the write target differs per side. const sides: Side[] = action.side ? [action.side] : (rule.side ? [rule.side] : ['left', 'right']) - - // Resolve the target temperature (setPower may have none β†’ hardware default). - let raw: number | undefined - if (action.kind === 'setTemperature') raw = evaluateExpr(action.temp, ctx) - else if (action.temp) raw = evaluateExpr(action.temp, ctx) - - if (action.kind === 'setTemperature' && raw === undefined) { - return sides.map(side => ({ kind: action.kind, side, skipped: 'temp-unknown' })) - } - if (action.kind === 'setPower' && action.on && action.temp && raw === undefined) { - return sides.map(side => ({ kind: action.kind, side, skipped: 'temp-unknown' })) - } - - // Two-layer clamp (only when a temperature is involved). - let temp: number | undefined - let clamped = false - if (raw !== undefined) { - const userMin = action.kind === 'setTemperature' ? action.clamp?.min ?? AUTOMATION_DEFAULT_USER_MIN : AUTOMATION_DEFAULT_USER_MIN - const userMax = action.kind === 'setTemperature' ? action.clamp?.max ?? AUTOMATION_DEFAULT_USER_MAX : AUTOMATION_DEFAULT_USER_MAX - const layer1 = Math.min(Math.max(raw, userMin), userMax) - const layer2 = Math.min(Math.max(layer1, MIN_TEMP), MAX_TEMP) - temp = layer2 - clamped = layer2 !== raw - } - - const out: ActionResult[] = [] + const results: ActionResult[] = [] for (const side of sides) { - out.push(await this.writeSide(rule, action, side, now, rt, raw, temp, clamped)) - } - return out - } - - /** Apply one resolved hardware action to a single side (gates + write). */ - private async writeSide( - rule: AutomationRule, - action: Exclude, - side: Side, - now: number, - rt: RuleRuntime, - raw: number | undefined, - temp: number | undefined, - clamped: boolean, - ): Promise { - // Side gates apply only to real hardware writes. - if (this.manualOverrideUntil[side] > now) { - return { kind: action.kind, side, skipped: 'manual-override', raw, temp, clamped } - } - if (await this.deps.hasActiveRunOnceSession(side)) { - return { kind: action.kind, side, skipped: 'run-once', raw, temp, clamped } - } - - // Anti-thrash: skip a sub-threshold re-assertion of the same setpoint. - if (action.kind === 'setTemperature' && temp !== undefined) { - const last = this.lastAsserted[side] - if (last !== undefined && Math.abs(temp - last) < AUTOMATION_ANTI_THRASH_F) { - return { kind: action.kind, side, antiThrash: true, raw, temp, clamped } + const id = `rule:${rule.id}:${index}` + if (action.kind === 'setPower' && !action.on) { + if (!rule.dryRun) this.pendingOff.add(side) + results.push({ kind: action.kind, side, on: false, sent: !rule.dryRun, dryRun: rule.dryRun }) + continue } - } - - // Dry-run: log the would-be command but never touch hardware. - if (rule.dryRun) { - return { kind: action.kind, side, dryRun: true, raw, temp, clamped, on: action.kind === 'setPower' ? action.on : undefined } - } - - // Real write through the shared, serialized hardware path. The stall-guard - // check runs inside the lock so a trip while this write is queued still - // blocks it; power-off is the safe direction and is never blocked. - const energizing = action.kind === 'setTemperature' || action.on - let stallBlocked = false - await this.deps.withSideLock(side, async () => { - if (energizing && this.deps.pumpStallShouldBlock(side)) { - stallBlocked = true - return + const expr = action.temp + const policy = isPolicyAction(rule, action) + const previous = this.leases[side].get(id) + // One-shot targets are resolved once, including while masked by a manual + // hold. Reconciliation and repeated trigger ticks cannot compound them. + if (!policy && previous && previous.expiresAt > now) { + results.push({ kind: action.kind, side, requestId: id, temp: previous.temperature, antiThrash: true }) + continue } - const hw = this.deps.getHardware() - await hw.connect() - if (action.kind === 'setTemperature') { - if (temp === undefined) return // unreachable: setTemperature always resolves a temp - await hw.setTemperature(side, temp, action.durationSec) - this.deps.markMutated(side) - this.deps.broadcast(side, { targetTemperature: temp, targetLevel: fahrenheitToLevel(temp) }) - this.lastAsserted[side] = temp + const actionContext: EvalContext = { + ...ctx, + windows: this.actionWindows, + signal: (key) => { + const target = /^(left|right)\.(targetTemperature|currentTemperature)$/.exec(key) + return target ? this.deps.control.automationBaseline(target[1] as Side) : ctx.signal(key) + }, + } + const raw = expr ? evaluateExpr(expr, actionContext) : 75 + if (raw === undefined) { + this.leases[side].delete(id) + results.push({ kind: action.kind, side, skipped: 'temp-unknown' }) + continue + } + const userMin = action.kind === 'setTemperature' ? action.clamp?.min ?? AUTOMATION_DEFAULT_USER_MIN : AUTOMATION_DEFAULT_USER_MIN + const userMax = action.kind === 'setTemperature' ? action.clamp?.max ?? AUTOMATION_DEFAULT_USER_MAX : AUTOMATION_DEFAULT_USER_MAX + const temp = Math.min(MAX_TEMP, Math.max(MIN_TEMP, Math.min(userMax, Math.max(userMin, raw)))) + const clamped = temp !== raw + if (rule.dryRun) { + results.push({ kind: action.kind, side, dryRun: true, raw, temp, clamped }) + continue } - else { - await hw.setPower(side, action.on, temp) - this.deps.markMutated(side) - this.deps.broadcast(side, action.on - ? { targetTemperature: temp ?? 75, targetLevel: fahrenheitToLevel(temp ?? 75) } - : { targetLevel: 0 }) - if (action.on && temp !== undefined) this.lastAsserted[side] = temp - else if (!action.on) this.lastAsserted[side] = undefined + const effectiveTemp = previous && Math.abs(previous.temperature - temp) < 0.5 ? previous.temperature : temp + const lease = { + id, source: 'autopilot' as const, temperature: effectiveTemp, priority: rule.priority, + startsAt: previous?.startsAt ?? now, createdAt: previous?.createdAt ?? now, + expiresAt: now + (policy ? 2 * AUTOMATION_TICK_MS : (action.holdMinutes ?? DEFAULT_HOLD_MS / 60_000) * 60_000), + durationSec: action.kind === 'setTemperature' ? action.durationSec : undefined, + policy, } - }) - if (stallBlocked) { - console.warn(`[automation] skipped ${action.kind}: pump stall guard blocks ${side}`) - return { kind: action.kind, side, skipped: 'pump-stall', raw, temp, clamped } + this.leases[side].set(id, lease) + if (action.kind === 'setPower' && action.on && !previous) this.pendingOn[side].push(id) + results.push({ + kind: action.kind, side, requestId: id, raw, temp, clamped, + antiThrash: previous !== undefined && Math.abs(previous.temperature - temp) < 0.5, + }) } - rt.actionTimes.push(now) - return { kind: action.kind, side, sent: true, raw, temp, clamped, on: action.kind === 'setPower' ? action.on : undefined } + return results } } interface ActionResult { + requestId?: string kind: Action['kind'] side?: Side notified?: boolean @@ -511,3 +564,21 @@ function aggregateOutcome(dryRun: boolean, results: ActionResult[]): RunOutcome if (results.some(r => r.clamped && (r.sent || r.antiThrash))) return 'clamped' return 'fired' } + +/** Feedback-based actions are one-shot unless explicitly configured as policies. */ +function referencesTarget(expr: Expr): boolean { + switch (expr.kind) { + case 'window': + case 'signal': return /\.(targetTemperature|currentTemperature)$/.test(expr.signal) + case 'binary': return referencesTarget(expr.left) || referencesTarget(expr.right) + case 'clamp': return referencesTarget(expr.value) || referencesTarget(expr.min) || referencesTarget(expr.max) + default: return false + } +} + +function isPolicyAction(rule: AutomationRule, action: Action): boolean { + if (action.kind === 'notify' || (action.kind === 'setPower' && !action.on)) return false + return action.mode + ? action.mode === 'policy' + : rule.trigger.kind !== 'timeOfDay' && (!action.temp || !referencesTarget(action.temp)) +} diff --git a/src/automation/index.ts b/src/automation/index.ts index ebe920318..c63bb16b5 100644 --- a/src/automation/index.ts +++ b/src/automation/index.ts @@ -6,7 +6,7 @@ */ export { AutomationEngine } from './engine' -export type { AutomationEngineDeps, HardwareWriter } from './engine' +export type { AutomationEngineDeps } from './engine' export { getAutomationEngine, getAutomationEngineIfRunning, diff --git a/src/automation/instance.ts b/src/automation/instance.ts index 481b2a7de..04b437e46 100644 --- a/src/automation/instance.ts +++ b/src/automation/instance.ts @@ -7,14 +7,10 @@ * JobManager from instrumentation.ts. */ -import { and, eq, gt } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { db } from '@/src/db' -import { automationRuns, automations, deviceSettings, runOnceSessions } from '@/src/db/schema' -import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' -import { markSideMutated } from '@/src/hardware/deviceStateSync' -import { shouldBlock as pumpStallShouldBlock } from '@/src/hardware/pumpStallGuard' -import { withSideLock } from '@/src/hardware/sideLock' -import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' +import { automationRuns, automations, deviceSettings } from '@/src/db/schema' +import { getTemperatureController } from '@/src/temperature/instance' import { AutomationEngine } from './engine' import { BiometricsSignalReader } from './signals.biometrics' import { CompositeSignalReader, DeviceSignalReader, clockInTimezone } from './signals' @@ -23,25 +19,29 @@ import type { AutomationRule, Condition, RunOutcome, - Side, Trigger, } from './types' const DEFAULT_TIMEZONE = 'America/Los_Angeles' -let engineInstance: AutomationEngine | null = null -let engineInitPromise: Promise | null = null -let cachedTimezone: string | null = null -// Read by the engine's clock closure on every tick, so a timezone change -// takes effect without rebuilding the engine. -let activeTimezone: string = DEFAULT_TIMEZONE +const globals = globalThis as typeof globalThis & { + __sp_automationState?: { + engineInstance: AutomationEngine | null + engineInitPromise: Promise | null + cachedTimezone: string | null + activeTimezone: string + } +} +const state = globals.__sp_automationState ??= { + engineInstance: null, engineInitPromise: null, cachedTimezone: null, activeTimezone: DEFAULT_TIMEZONE, +} async function loadTimezone(): Promise { - if (cachedTimezone) return cachedTimezone + if (state.cachedTimezone) return state.cachedTimezone try { const [settings] = await db.select().from(deviceSettings).limit(1) - cachedTimezone = settings?.timezone || DEFAULT_TIMEZONE - return cachedTimezone + state.cachedTimezone = settings?.timezone || DEFAULT_TIMEZONE + return state.cachedTimezone } catch { return DEFAULT_TIMEZONE @@ -85,27 +85,14 @@ async function disableRule(automationId: number): Promise { .where(eq(automations.id, automationId)) } -async function hasActiveRunOnceSession(side: Side): Promise { - const [session] = await db - .select({ id: runOnceSessions.id }) - .from(runOnceSessions) - .where(and( - eq(runOnceSessions.side, side), - eq(runOnceSessions.status, 'active'), - gt(runOnceSessions.expiresAt, new Date()), - )) - .limit(1) - return !!session -} - export async function getAutomationEngine(): Promise { - if (engineInstance) return engineInstance - if (engineInitPromise) return engineInitPromise + if (state.engineInstance) return state.engineInstance + if (state.engineInitPromise) return state.engineInitPromise - engineInitPromise = (async () => { + state.engineInitPromise = (async () => { try { const timezone = await loadTimezone() - activeTimezone = timezone + state.activeTimezone = timezone // DAC status first, biometrics merged on top; the DAC reader stays // authoritative for any overlapping key (e.g. water.low). const reader = new CompositeSignalReader([ @@ -115,16 +102,11 @@ export async function getAutomationEngine(): Promise { const engine = new AutomationEngine({ signals: reader, now: () => Date.now(), - clock: () => clockInTimezone(activeTimezone, new Date()), - getHardware: () => getSharedHardwareClient(), - withSideLock, - pumpStallShouldBlock, - broadcast: (side, overlay) => broadcastMutationStatus(side, overlay), - markMutated: markSideMutated, + clock: () => clockInTimezone(state.activeTimezone, new Date()), + control: getTemperatureController(), loadRules, recordRun, disableRule, - hasActiveRunOnceSession, notify: (id, message) => console.log(`[automation notify] rule ${id}: ${message}`), log: msg => console.log(`[automation] ${msg}`), }) @@ -132,45 +114,46 @@ export async function getAutomationEngine(): Promise { // Restore the global kill-switch from persisted settings (default on). try { const [settings] = await db.select({ on: deviceSettings.autopilotEnabled }).from(deviceSettings).limit(1) - if (settings && settings.on === false) engine.setGlobalEnabled(false) + if (settings && settings.on === false) await engine.setGlobalEnabled(false) } catch { // Settings unreadable (e.g. fresh DB) β€” leave autopilot enabled. } - engineInstance = engine + await engine.tick() + state.engineInstance = engine console.log('AutomationEngine initialized with timezone:', timezone) return engine } finally { - engineInitPromise = null + state.engineInitPromise = null } })() - return engineInitPromise + return state.engineInitPromise } export function getAutomationEngineIfRunning(): AutomationEngine | null { - return engineInstance + return state.engineInstance } /** * Propagate a device-settings timezone change to the running engine. The - * clock closure reads activeTimezone on every tick, so timeOfDay triggers + * clock closure reads state.activeTimezone on every tick, so timeOfDay triggers * evaluate in the new timezone immediately β€” previously the closure kept the - * boot-time tz (and cachedTimezone was never invalidated) until restart. + * boot-time tz (and state.cachedTimezone was never invalidated) until restart. */ export function updateAutomationTimezone(timezone: string): void { - cachedTimezone = timezone - activeTimezone = timezone + state.cachedTimezone = timezone + state.activeTimezone = timezone console.log('[automation] timezone updated to', timezone) } export async function shutdownAutomationEngine(): Promise { - if (engineInstance) { - engineInstance.stop() - engineInstance = null - engineInitPromise = null - cachedTimezone = null + if (state.engineInstance) { + await state.engineInstance.stop() + state.engineInstance = null + state.engineInitPromise = null + state.cachedTimezone = null console.log('AutomationEngine shut down') } } diff --git a/src/automation/tests/controllerHarness.ts b/src/automation/tests/controllerHarness.ts new file mode 100644 index 000000000..ee4fa4841 --- /dev/null +++ b/src/automation/tests/controllerHarness.ts @@ -0,0 +1,64 @@ +import { AutomationEngine, type AutomationEngineDeps } from '../engine' +import type { Side } from '../types' +import { TemperatureController, type TemperatureRequest } from '@/src/temperature/controller' + +/** Existing evaluator fixtures now exercise the real arbiter above their fake transport. */ +export interface EngineFixtureDeps extends Omit { + getHardware: () => { + connect: () => Promise + setTemperature: (side: Side, temp: number, duration?: number) => Promise + setPower: (side: Side, on: boolean, temp?: number) => Promise + } + withSideLock: (side: Side, fn: () => Promise) => Promise + pumpStallShouldBlock: (side: Side) => boolean + broadcast: (side: Side, overlay: Record) => void + markMutated: (side: Side) => void + hasActiveRunOnceSession: (side: Side) => Promise +} + +export function engineFixture(deps: EngineFixtureDeps) { + const deadlines: Record = { left: null, right: null } + const holds: Record = { left: null, right: null } + const runOnce: Record = { left: false, right: false } + const powered: Record = { left: true, right: true } + const control = new TemperatureController({ + now: deps.now, + withSideLock: deps.withSideLock, + readHold: side => holds[side], + writeHold: (side, hold) => { holds[side] = hold }, + readBaseline: side => runOnce[side] + ? [{ + id: 'run-once', source: 'run-once', temperature: 74, startsAt: 0, + expiresAt: Number.MAX_SAFE_INTEGER, priority: 0, createdAt: 0, + }] + : [], + isPowered: side => powered[side], + readCurrentTarget: () => 75, + readHardwareDeadline: side => deadlines[side], + writeHardwareDeadline: (side, deadline) => { deadlines[side] = deadline }, + isBlocked: deps.pumpStallShouldBlock, + connect: () => deps.getHardware().connect(), + apply: async (side, temp, duration) => { + await deps.getHardware().setTemperature(side, temp, duration) + powered[side] = true + deps.markMutated(side) + }, + powerOff: async (side) => { + powered[side] = false + await deps.getHardware().setPower(side, false) + }, + publish: () => {}, + }) + const engine = new AutomationEngine({ + ...deps, + control: { + automationBaseline: side => control.automationBaseline(side), + powerOff: (side, isCurrent) => control.powerOff(side, isCurrent), + replaceAutopilot: async (side, requests, powerOnIds, isCurrent) => { + runOnce[side] = await deps.hasActiveRunOnceSession(side) + return control.replaceAutopilot(side, requests, powerOnIds, isCurrent) + }, + }, + }) + return { engine, control } +} diff --git a/src/automation/tests/engine.test.ts b/src/automation/tests/engine.test.ts index 21f0338c5..9efb6a755 100644 --- a/src/automation/tests/engine.test.ts +++ b/src/automation/tests/engine.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { AutomationEngine, type AutomationEngineDeps } from '../engine' +import type { AutomationEngine } from '../engine' +import { engineFixture, type EngineFixtureDeps as AutomationEngineDeps } from './controllerHarness' +import type { TemperatureController } from '@/src/temperature/controller' import type { SignalSnapshot } from '../signals' import { AUTOMATION_TICK_MS, type Action, type AutomationRule, type Condition, type DayOfWeek, type Expr, type RunOutcome, type Trigger, type Side } from '../types' @@ -22,12 +24,14 @@ interface RunDetail { clamped?: boolean sent?: boolean dryRun?: boolean + error?: string temp?: number }> } interface Harness { engine: AutomationEngine + control: TemperatureController setNow: (ms: number) => void advance: (ms: number) => void setSignal: (key: string, value: number | undefined) => void @@ -40,7 +44,7 @@ interface Harness { disabled: number[] } -function makeHarness(rules: AutomationRule[]): Harness { +function makeHarness(rules: AutomationRule[], lock: AutomationEngineDeps['withSideLock'] = async (_side, fn) => fn()): Harness { // Base clock is a realistic epoch so a `tick` trigger's first evaluation is // due (production `now` is always >> any everyMin window from epoch 0). let nowMs = 1_700_000_000_000 @@ -64,20 +68,21 @@ function makeHarness(rules: AutomationRule[]): Harness { setTemperature: async (side, temp, duration) => { hwCalls.push({ op: 'temp', side, temp, duration }) }, setPower: async (side, on, temp) => { hwCalls.push({ op: 'power', side, on, temp }) }, }), - withSideLock: async (_side, fn) => fn(), + withSideLock: lock, pumpStallShouldBlock: side => stallBlocked[side], broadcast: () => {}, markMutated: () => {}, - loadRules: async () => rules, + loadRules: async () => structuredClone(rules), recordRun: async (id, outcome, detail) => { runs.push({ id, outcome, detail: detail as RunDetail }) }, disableRule: async (id) => { disabled.push(id) }, hasActiveRunOnceSession: async () => runOnce, notify: (id, message) => notifies.push({ id, message }), } - const engine = new AutomationEngine(deps) + const { engine, control } = engineFixture(deps) return { engine, + control, setNow: (ms) => { nowMs = ms }, advance: (ms) => { nowMs += ms }, setSignal: (key, value) => { snapshot[key] = value }, @@ -250,6 +255,17 @@ describe('AutomationEngine β€” anti-thrash', () => { }) describe('AutomationEngine β€” runaway guard', () => { + it('counts coalesced shutdowns against each originating rule budget', async () => { + const h = makeHarness([1, 2].map(id => rule({ id, actions: [{ kind: 'setPower', on: false }] }))) + await h.engine.reload() + for (let i = 0; i < 20; i++) { + await h.engine.tick() + h.advance(60_000) + } + expect(h.hwCalls.filter(call => call.on === false)).toHaveLength(12) + expect(h.disabled).toEqual([1, 2]) + }) + it('auto-disables a rule that exceeds the hourly action budget', async () => { const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: sig('target') }] })]) await h.engine.reload() @@ -271,11 +287,11 @@ describe('AutomationEngine β€” precedence gates', () => { it('skips hardware while a manual-override hold is active on the side', async () => { const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: lit(72) }] })]) await h.engine.reload() - h.engine.registerManualOverride('left') + await h.control.setManual('left', 76) await h.engine.tick() - expect(h.hwCalls).toHaveLength(0) + expect(h.hwCalls).toHaveLength(1) expect(h.runs[0].outcome).toBe('skipped') - expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('manual-override') + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('manual-hold') }) it('skips hardware while a run-once session is active on the side', async () => { @@ -283,7 +299,8 @@ describe('AutomationEngine β€” precedence gates', () => { h.setRunOnce(true) await h.engine.reload() await h.engine.tick() - expect(h.hwCalls).toHaveLength(0) + expect(h.hwCalls).toHaveLength(2) + expect(h.hwCalls.every(call => call.temp === 74)).toBe(true) expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('run-once') }) }) @@ -414,9 +431,9 @@ describe('AutomationEngine β€” lifecycle', () => { it('start() installs the tick timer and stop() clears it', async () => { const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) await h.engine.start() // reloads + installs interval - h.engine.stop() // clears the interval + await h.engine.stop() // clears the interval // A second stop() is a safe no-op. - h.engine.stop() + await h.engine.stop() }) }) @@ -426,12 +443,12 @@ describe('AutomationEngine β€” global kill-switch', () => { await h.engine.reload() expect(h.engine.isGloballyEnabled()).toBe(true) - h.engine.setGlobalEnabled(false) + await h.engine.setGlobalEnabled(false) expect(h.engine.isGloballyEnabled()).toBe(false) await h.engine.tick() expect(h.runs).toHaveLength(0) - h.engine.setGlobalEnabled(true) + await h.engine.setGlobalEnabled(true) await h.engine.tick() expect(h.runs).toHaveLength(1) }) @@ -482,7 +499,7 @@ describe('AutomationEngine β€” setPower', () => { const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: true, temp: lit(72) }] })]) await h.engine.reload() await h.engine.tick() - expect(h.hwCalls[0]).toMatchObject({ op: 'power', side: 'left', on: true, temp: 72 }) + expect(h.hwCalls[0]).toMatchObject({ op: 'temp', side: 'left', temp: 72 }) expect(h.runs[0].outcome).toBe('fired') }) @@ -561,11 +578,11 @@ describe('AutomationEngine β€” action error', () => { hasActiveRunOnceSession: async () => false, notify: () => {}, } - const engine = new AutomationEngine(deps) + const { engine } = engineFixture(deps) await engine.reload() await engine.tick() expect(runs[0].outcome).toBe('error') - expect(runs[0].detail.reason).toBe('eval-threw') + expect(runs[0].detail.actions?.[0]?.error).toContain('hardware offline') }) it('stringifies a non-Error thrown during evaluation', async () => { @@ -586,11 +603,11 @@ describe('AutomationEngine β€” action error', () => { hasActiveRunOnceSession: async () => { throw 'gate exploded' }, notify: () => {}, } - const engine = new AutomationEngine(deps) + const { engine } = engineFixture(deps) await engine.reload() await engine.tick() expect(runs[0].outcome).toBe('error') - expect(runs[0].detail.message).toBe('gate exploded') + expect(runs[0].detail.actions?.[0]?.error).toBe('gate exploded') }) }) @@ -599,7 +616,7 @@ describe('AutomationEngine β€” branch coverage corners', () => { const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) await h.engine.start() await h.engine.start() // timer already set β†’ no-op on the interval - h.engine.stop() + await h.engine.stop() }) it('the interval callback drives ticks', async () => { @@ -609,7 +626,7 @@ describe('AutomationEngine β€” branch coverage corners', () => { await h.engine.start() await vi.advanceTimersByTimeAsync(AUTOMATION_TICK_MS) expect(h.runs.length).toBeGreaterThanOrEqual(1) - h.engine.stop() + await h.engine.stop() } finally { vi.useRealTimers() @@ -660,11 +677,11 @@ describe('AutomationEngine β€” branch coverage corners', () => { notify: () => {}, log: msg => logs.push(msg), } - const engine = new AutomationEngine(deps) + const { engine } = engineFixture(deps) await engine.start() - engine.setGlobalEnabled(false) - engine.setGlobalEnabled(true) - engine.stop() + await engine.setGlobalEnabled(false) + await engine.setGlobalEnabled(true) + await engine.stop() expect(logs.some(m => m.includes('started'))).toBe(true) expect(logs.some(m => m.includes('OFF'))).toBe(true) expect(logs.some(m => m.includes('ON'))).toBe(true) @@ -717,8 +734,7 @@ describe('AutomationEngine β€” branch coverage corners', () => { const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: true }] })]) // no temp await h.engine.reload() await h.engine.tick() - expect(h.hwCalls[0]).toMatchObject({ op: 'power', side: 'left', on: true }) - expect(h.hwCalls[0].temp).toBeUndefined() + expect(h.hwCalls[0]).toMatchObject({ op: 'temp', side: 'left', temp: 75 }) expect(h.runs[0].outcome).toBe('fired') }) @@ -730,17 +746,17 @@ describe('AutomationEngine β€” branch coverage corners', () => { // The user dials the side directly: the override suspends autopilot AND // invalidates the anti-thrash baseline β€” hardware is no longer at 72. - h.engine.registerManualOverride('left') + await h.control.setManual('left', 76) h.advance(60_000) await h.engine.tick() - expect(h.hwCalls).toHaveLength(1) // suspended inside the override window + expect(h.hwCalls).toHaveLength(2) // includes the manual write; automation remains suspended inside the override window h.advance(2 * 60 * 60_000) // override expired await h.engine.tick() // With a stale baseline this would be anti-thrash-skipped (|72βˆ’72| < 0.5) // and the user's manual setpoint would silently stick forever. - expect(h.hwCalls).toHaveLength(2) - expect(h.hwCalls[1]).toMatchObject({ op: 'temp', side: 'left', temp: 72 }) + expect(h.hwCalls).toHaveLength(3) + expect(h.hwCalls[2]).toMatchObject({ op: 'temp', side: 'left', temp: 72 }) }) it('reports clamped when an anti-thrash re-assertion is still out of band', async () => { @@ -767,8 +783,7 @@ describe('AutomationEngine β€” pump stall guard gate', () => { await h.engine.tick() expect(h.hwCalls).toHaveLength(0) expect(h.runs[0].outcome).toBe('skipped') - expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('pump-stall') - expect(warn).toHaveBeenCalledWith('[automation] skipped setTemperature: pump stall guard blocks left') + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('safety') } finally { warn.mockRestore() @@ -783,8 +798,7 @@ describe('AutomationEngine β€” pump stall guard gate', () => { await h.engine.reload() await h.engine.tick() expect(h.hwCalls).toHaveLength(0) - expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('pump-stall') - expect(warn).toHaveBeenCalledWith('[automation] skipped setPower: pump stall guard blocks left') + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('safety') } finally { warn.mockRestore() @@ -800,6 +814,30 @@ describe('AutomationEngine β€” pump stall guard gate', () => { expect(h.runs[0].outcome).toBe('fired') }) + it('cancels an automation shutdown disabled while waiting for the side lock', async () => { + let release!: () => void + let entered!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const queued = new Promise((resolve) => { + entered = resolve + }) + const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: false }] })], async (_side, fn) => { + entered() + await gate + return fn() + }) + await h.engine.reload() + const tick = h.engine.tick() + await queued + const disabled = h.engine.setGlobalEnabled(false) + release() + await Promise.all([tick, disabled]) + expect(h.hwCalls).toEqual([]) + expect(h.runs.filter(run => run.outcome === 'fired')).toEqual([]) + }) + it('blocks a write whose trip lands while it is queued on the side lock', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -835,7 +873,7 @@ describe('AutomationEngine β€” pump stall guard gate', () => { hasActiveRunOnceSession: async () => false, notify: () => {}, } - const engine = new AutomationEngine(deps) + const { engine } = engineFixture(deps) await engine.reload() const tick = engine.tick() // Let the tick reach withSideLock and park on the gate while healthy. @@ -846,7 +884,7 @@ describe('AutomationEngine β€” pump stall guard gate', () => { release() await tick expect(hwCalls).toHaveLength(0) - expect(runs[0].detail.actions?.[0]?.skipped).toBe('pump-stall') + expect(runs[0].detail.actions?.[0]?.skipped).toBe('safety') } finally { warn.mockRestore() @@ -862,10 +900,179 @@ describe('AutomationEngine β€” pump stall guard gate', () => { await h.engine.tick() expect(h.hwCalls).toHaveLength(1) expect(h.hwCalls[0]).toMatchObject({ op: 'temp', side: 'right', temp: 72 }) - expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('pump-stall') + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('safety') } finally { warn.mockRestore() } }) }) + +describe('AutomationEngine β€” shared control requests', () => { + it('publishes competing rules as one batch without flashing the lower-priority target', async () => { + const h = makeHarness([ + rule({ id: 1, priority: 1, actions: [{ kind: 'setTemperature', temp: lit(70) }] }), + rule({ id: 2, priority: 9, actions: [{ kind: 'setTemperature', temp: lit(80) }] }), + ]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls).toHaveLength(1) + expect(h.hwCalls[0].temp).toBe(80) + expect(h.control.status('left').requestId).toBe('rule:2:0') + expect(h.runs.find(r => r.id === 1)?.outcome).toBe('skipped') + }) + + it('withdraws a policy as soon as conditions become false, even between trigger times', async () => { + const h = makeHarness([rule({ + trigger: tickEvery(10), + conditions: { kind: 'compare', op: '>', left: sig('room'), right: lit(70) }, + actions: [{ kind: 'setTemperature', temp: lit(68) }], + })]) + h.setSignal('room', 75) + await h.engine.reload() + await h.engine.tick() + expect(h.control.status('left').source).toBe('autopilot') + h.setSignal('room', 65) + h.advance(60_000) + await h.engine.tick() + expect(h.control.status('left').source).toBeNull() + }) + + it('revokes disabled and edited rules immediately on reload', async () => { + const rules = [rule({ actions: [{ kind: 'setTemperature', temp: lit(70) }] })] + const h = makeHarness(rules) + await h.engine.reload() + await h.engine.tick() + rules[0] = { ...rules[0], enabled: false } + await h.engine.reload() + expect(h.control.status('left').source).toBeNull() + }) + + it('reactivates edited and re-enabled signal policies without replaying sibling side effects', async () => { + const rules = [rule({ + trigger: { kind: 'signalChange', signal: 'room' }, + actions: [ + { kind: 'setTemperature', mode: 'policy', temp: lit(68) }, + { kind: 'notify', message: 'real change' }, + { kind: 'setTemperature', mode: 'one-shot', side: 'right', temp: lit(79) }, + ], + })] + const h = makeHarness(rules) + h.setSignal('room', 70) + await h.engine.reload() + await h.engine.tick() + expect(h.control.status('left').targetTemperature).toBe(68) + expect(h.control.status('right').source).toBeNull() + expect(h.notifies).toHaveLength(0) + rules[0] = { ...rules[0], name: 'renamed' } + await h.engine.reload() + expect(h.control.status('left').source).toBeNull() + await h.engine.tick() + expect(h.control.status('left').targetTemperature).toBe(68) + await h.engine.setGlobalEnabled(false) + await h.engine.setGlobalEnabled(true) + await h.engine.tick() + expect(h.control.status('left').targetTemperature).toBe(68) + rules[0] = { ...rules[0], enabled: false } + await h.engine.reload() + rules[0] = { ...rules[0], enabled: true } + await h.engine.reload() + await h.engine.tick() + expect(h.control.status('left').targetTemperature).toBe(68) + expect(h.notifies).toHaveLength(0) + expect(h.control.status('right').source).toBeNull() + h.setSignal('room', 71) + await h.engine.tick() + expect(h.notifies).toHaveLength(1) + expect(h.control.status('right').targetTemperature).toBe(79) + }) + + it('reacquires a signal policy when conditions recover without a trigger-signal edge', async () => { + const h = makeHarness([rule({ + trigger: { kind: 'signalChange', signal: 'room' }, + conditions: { kind: 'compare', op: '>', left: sig('occupancy'), right: lit(0) }, + actions: [{ kind: 'setTemperature', mode: 'policy', temp: lit(68) }], + })]) + h.setSignal('room', 70) + h.setSignal('occupancy', 1) + await h.engine.reload() + await h.engine.tick() + h.setSignal('occupancy', 0) + await h.engine.tick() + expect(h.control.status('left').source).toBeNull() + h.setSignal('occupancy', 1) + await h.engine.tick() + expect(h.control.status('left').targetTemperature).toBe(68) + }) + + it('uses baseline history for action windows while condition windows still observe live targets', async () => { + const relative: Expr = { + kind: 'binary', op: '+', + left: { kind: 'window', fn: 'max', signal: 'left.targetTemperature', lastMin: 1 }, right: lit(1), + } + const h = makeHarness([ + rule({ actions: [{ kind: 'setTemperature', mode: 'policy', temp: relative }] }), + rule({ id: 2, conditions: { kind: 'compare', op: '>', left: { kind: 'window', fn: 'max', signal: 'left.targetTemperature', lastMin: 1 }, right: lit(76) }, actions: [{ kind: 'notify', message: 'live target above 76' }] }), + ]) + await h.engine.reload() + for (let i = 0; i < 4; i++) { + h.setSignal('left.targetTemperature', 75 + i) + await h.engine.tick() + h.advance(60_000) + } + expect(h.hwCalls.map(call => call.temp)).toEqual([76]) + expect(h.notifies).toHaveLength(2) + }) + + it('freezes a relative one-shot target and never uses its own output as the next baseline', async () => { + const h = makeHarness([rule({ actions: [{ + kind: 'setTemperature', + temp: { kind: 'binary', op: '-', left: sig('left.targetTemperature'), right: lit(2) }, + }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0].temp).toBe(73) // documented 75Β°F baseline when no schedule exists + for (let i = 0; i < 35; i++) { + h.advance(60_000) + h.setSignal('left.targetTemperature', 73) + await h.engine.tick() + } + expect(h.hwCalls.every(call => call.temp === 73)).toBe(true) + expect(h.hwCalls).toHaveLength(1) + }) + + it('allows a one-shot to expire while a manual hold still masks it', async () => { + const h = makeHarness([rule({ + trigger: { kind: 'timeOfDay', at: '00:00' }, + actions: [{ kind: 'setTemperature', temp: lit(70), holdMinutes: 1 }], + })]) + await h.engine.reload() + await h.control.setManual('left', 76) + await h.engine.tick() + h.advance(2 * 60_000) + await h.engine.tick() + await h.control.resume('left') + expect(h.control.status('left').source).toBeNull() + expect(h.hwCalls.map(call => call.temp)).toEqual([76]) + }) + + it('does not repeatedly power on after a scheduled shutdown while a policy stays active', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: true, temp: lit(72) }] })]) + await h.engine.reload() + await h.engine.tick() + await h.control.powerOff('left') + h.advance(60_000) + await h.engine.tick() + expect(h.control.status('left').blocked).toBe('off') + expect(h.hwCalls.map(call => call.op)).toEqual(['temp', 'power']) + }) + + it('expires continuous ownership if the evaluator stops refreshing it', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: lit(72) }] })]) + await h.engine.reload() + await h.engine.tick() + h.advance(2 * AUTOMATION_TICK_MS) + await h.control.reconcile('left') + expect(h.control.status('left').source).toBeNull() + }) +}) diff --git a/src/automation/tests/instance.test.ts b/src/automation/tests/instance.test.ts index aeaecfc58..82f7b6036 100644 --- a/src/automation/tests/instance.test.ts +++ b/src/automation/tests/instance.test.ts @@ -14,6 +14,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const ctorMock = vi.fn() +const tickMock = vi.fn(async () => {}) +const controlMock = {} +vi.mock('@/src/temperature/instance', () => ({ getTemperatureController: () => controlMock })) + const startMock = vi.fn(async () => {}) const stopMock = vi.fn() const setGlobalEnabledMock = vi.fn() @@ -26,6 +30,7 @@ vi.mock('../engine', () => { ctorMock(deps) } + tick = tickMock start = startMock stop = stopMock setGlobalEnabled = setGlobalEnabledMock @@ -99,6 +104,8 @@ async function freshModule(): Promise { } beforeEach(() => { + delete (globalThis as Record).__sp_automationState + tickMock.mockClear() ctorMock.mockClear() startMock.mockClear() stopMock.mockClear() @@ -283,22 +290,12 @@ describe('automation/instance β€” injected dependency closures', () => { // disableRule resolves through the mocked update chain. await expect(capturedDeps.disableRule(9)).resolves.toBeUndefined() - // hasActiveRunOnceSession reflects whether a row was found. - selectImpl = async () => [{ id: 1 }] - expect(await capturedDeps.hasActiveRunOnceSession('left')).toBe(true) - selectImpl = async () => [] - expect(await capturedDeps.hasActiveRunOnceSession('right')).toBe(false) - - // Remaining wiring closures. + // Arbitration, hardware serialization and run-once precedence belong to + // the injected shared controller, not a second set of engine gates. + expect(capturedDeps.control).toBe(controlMock) expect(typeof capturedDeps.now()).toBe('number') expect(capturedDeps.clock()).toEqual({ nowMinutes: 123, dayOfWeek: 'monday' }) - expect(capturedDeps.getHardware()).toBe(hardwareClient) - expect(await capturedDeps.withSideLock('left', async () => 42)).toBe(42) - - capturedDeps.broadcast('left', { targetLevel: 0 }) - expect(broadcastMock).toHaveBeenCalledWith('left', { targetLevel: 0 }) - capturedDeps.markMutated('left') - expect(markSideMutatedMock).toHaveBeenCalledWith('left') + expect(tickMock).toHaveBeenCalledTimes(1) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) capturedDeps.notify(9, 'hello') diff --git a/src/automation/types.ts b/src/automation/types.ts index 87177a15d..b627b6935 100644 --- a/src/automation/types.ts +++ b/src/automation/types.ts @@ -68,8 +68,10 @@ export type Action temp: Expr clamp?: { min: number, max: number } durationSec?: number + mode?: 'policy' | 'one-shot' + holdMinutes?: number } - | { kind: 'setPower', side?: Side, on: boolean, temp?: Expr } + | { kind: 'setPower', side?: Side, on: boolean, temp?: Expr, mode?: 'policy' | 'one-shot', holdMinutes?: number } /** * A fully-resolved automation as the engine consumes it (JSON columns parsed). diff --git a/src/components/TempScreen/TempScreen.tsx b/src/components/TempScreen/TempScreen.tsx index fb71ecabd..dfba0b83a 100644 --- a/src/components/TempScreen/TempScreen.tsx +++ b/src/components/TempScreen/TempScreen.tsx @@ -1,12 +1,13 @@ 'use client' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { trpc } from '@/src/utils/trpc' import { useSide } from '@/src/providers/SideProvider' import { useDeviceStatus } from '@/src/hooks/useDeviceStatus' import { useOptimisticValue } from '@/src/hooks/useOptimisticValue' import { SideSelector } from '@/src/components/SideSelector/SideSelector' import { EnvironmentInfoPanel } from '@/src/components/EnvironmentInfo/EnvironmentInfoPanel' +import { TemperatureHoldControls } from './TemperatureHoldControls' import { TemperatureDial } from '@/src/components/TemperatureDial/TemperatureDial' import { AlarmBanner } from '@/src/components/TempScreen/AlarmBanner' import { PrimingIndicator } from '@/src/components/TempScreen/PrimingIndicator' @@ -56,6 +57,7 @@ export const TempScreen = () => { const { data: settings } = trpc.settings.getAll.useQuery({}) const unit: TempUnit = (settings?.device?.temperatureUnit as TempUnit) ?? 'F' + const [holdMinutes, setHoldMinutes] = useState(30) const setTempMutation = trpc.device.setTemperature.useMutation() const setPowerMutation = trpc.device.setPower.useMutation() @@ -93,14 +95,14 @@ export const TempScreen = () => { targetOpt.commit(tempF) for (const side of activeSides) { setTempMutation.mutate( - { side, temperature: tempF }, + { side, temperature: tempF, ...(holdMinutes === 30 ? {} : { holdMinutes }) }, { onSettled: () => refetch(), onError: () => targetOpt.discard(), }, ) } - }, [activeSides, setTempMutation, refetch, targetOpt]) + }, [activeSides, setTempMutation, refetch, targetOpt, holdMinutes]) const handleTempAdjust = (delta: number) => { const displayValue = setpointFToDisplay(targetTemp, unit) ?? targetTemp @@ -109,7 +111,7 @@ export const TempScreen = () => { targetOpt.commit(newTemp) for (const side of activeSides) { setTempMutation.mutate( - { side, temperature: newTemp }, + { side, temperature: newTemp, ...(holdMinutes === 30 ? {} : { holdMinutes }) }, { onSettled: () => refetch(), onError: () => targetOpt.discard(), @@ -195,6 +197,16 @@ export const TempScreen = () => { onTemperatureCommit={handleDialCommit} /> + {status?.temperatureControl && ( + { void refetch() }} + /> + )} + {/* Temperature controls: βˆ’/power/+ (tight gap to dial to avoid mobile scroll) */}
{/* Minus button */} diff --git a/src/components/TempScreen/TemperatureHoldControls.tsx b/src/components/TempScreen/TemperatureHoldControls.tsx new file mode 100644 index 000000000..6a371913c --- /dev/null +++ b/src/components/TempScreen/TemperatureHoldControls.tsx @@ -0,0 +1,73 @@ +'use client' + +import { trpc } from '@/src/utils/trpc' +import type { Side } from '@/src/hardware/types' +import type { TemperatureControlStatus } from '@/src/temperature/controller' + +const sourceLabels = { 'manual': 'Manual hold', 'run-once': 'Run-once session', 'autopilot': 'Autopilot', 'schedule': 'Schedule' } + +export function TemperatureHoldControls({ sides, status, holdMinutes, onDurationChange, onResumed }: { + sides: Side[] + status: Record + holdMinutes: number + onDurationChange: (minutes: number) => void + onResumed: () => void +}) { + const resume = trpc.device.resumeTemperature.useMutation({ onSuccess: onResumed }) + return ( +
+ {sides.map((side) => { + const control = status[side] + return ( +
+ + {side === 'left' ? 'Left' : 'Right'} + {': '} + {control.blocked === 'safety' ? 'Safety stop' : control.blocked === 'off' ? 'Off' : (control.source ? sourceLabels[control.source] : 'Manual temperature')} + {control.holdUntil != null && ( + + {' Β· hold until '} + {new Date(control.holdUntil).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} + + )} + + {control.holdUntil != null && ( + + )} +
+ ) + })} + + {resume.error && ( +

+ Could not resume: + {' '} + {resume.error.message} +

+ )} +
+ ) +} diff --git a/src/components/TempScreen/tests/TemperatureHoldControls.test.tsx b/src/components/TempScreen/tests/TemperatureHoldControls.test.tsx new file mode 100644 index 000000000..90f99fc57 --- /dev/null +++ b/src/components/TempScreen/tests/TemperatureHoldControls.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TemperatureControlStatus } from '@/src/temperature/controller' + +const mutation = vi.hoisted(() => ({ mutate: vi.fn(), isPending: false, error: null as Error | null, onSuccess: undefined as (() => void) | undefined })) +vi.mock('@/src/utils/trpc', () => ({ trpc: { device: { resumeTemperature: { useMutation: (options: { onSuccess: () => void }) => { + mutation.onSuccess = options.onSuccess + return mutation +} } } } })) +import { TemperatureHoldControls } from '../TemperatureHoldControls' + +const status: Record<'left' | 'right', TemperatureControlStatus> = { + left: { source: 'manual', requestId: 'manual', targetTemperature: 76, holdUntil: 1_800_000, blocked: null }, + right: { source: 'schedule', requestId: 'schedule:1', targetTemperature: 70, holdUntil: null, blocked: null }, +} +beforeEach(() => { + mutation.mutate.mockReset() + mutation.isPending = false + mutation.error = null +}) +afterEach(cleanup) + +describe('TemperatureHoldControls', () => { + it('shows independent ownership and resumes only the side with a hold', () => { + const onResumed = vi.fn() + const screen = render() + expect(screen.getByText('Right: Schedule')).toBeTruthy() + expect(screen.getByText(/Left: Manual hold/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Resume' })) + expect(mutation.mutate).toHaveBeenCalledExactlyOnceWith({ side: 'left' }) + mutation.onSuccess?.() + expect(onResumed).toHaveBeenCalledOnce() + }) + + it('changes the next-adjustment duration without sending a temperature or Resume', () => { + const onDurationChange = vi.fn() + const screen = render() + fireEvent.change(screen.getByRole('combobox', { name: 'Temperature hold duration' }), { target: { value: '60' } }) + expect(onDurationChange).toHaveBeenCalledWith(60) + expect(mutation.mutate).not.toHaveBeenCalled() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('shows safety status and a Resume error, disabling repeated requests while pending', () => { + mutation.isPending = true + mutation.error = new Error('connection unavailable') + const screen = render() + expect(screen.getByText(/Left: Safety stop/)).toBeTruthy() + expect((screen.getByRole('button') as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('alert').textContent).toContain('connection unavailable') + }) +}) diff --git a/src/db/migrations/0015_temperature_holds.sql b/src/db/migrations/0015_temperature_holds.sql new file mode 100644 index 000000000..34a3d6a97 --- /dev/null +++ b/src/db/migrations/0015_temperature_holds.sql @@ -0,0 +1,6 @@ +CREATE TABLE `temperature_holds` ( + `side` text PRIMARY KEY NOT NULL, + `temperature` real NOT NULL, + `started_at` integer NOT NULL, + `expires_at` integer NOT NULL +); diff --git a/src/db/migrations/0016_hardware_deadline.sql b/src/db/migrations/0016_hardware_deadline.sql new file mode 100644 index 000000000..b5b76f41a --- /dev/null +++ b/src/db/migrations/0016_hardware_deadline.sql @@ -0,0 +1 @@ +ALTER TABLE `device_state` ADD `hardware_deadline` integer; \ No newline at end of file diff --git a/src/db/migrations/meta/0015_snapshot.json b/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 000000000..59d4d2d5b --- /dev/null +++ b/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,1131 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5b8a25d8-0a59-45fb-8fad-ae5e1d2bdeca", + "prevId": "80f61be4-0aff-435e-8b04-6d86c964750a", + "tables": { + "alarm_schedules": { + "name": "alarm_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_intensity": { + "name": "vibration_intensity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_pattern": { + "name": "vibration_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rise'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alarm_temperature": { + "name": "alarm_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_alarm_schedules_side_day": { + "name": "idx_alarm_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_fired": { + "name": "idx_automation_runs_automation_fired", + "columns": [ + "automation_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cooldown_min": { + "name": "cooldown_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_automations_enabled": { + "name": "idx_automations_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_settings": { + "name": "device_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Los_Angeles'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'F'" + }, + "reboot_daily": { + "name": "reboot_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reboot_time": { + "name": "reboot_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'03:00'" + }, + "prime_pod_daily": { + "name": "prime_pod_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prime_pod_time": { + "name": "prime_pod_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'14:00'" + }, + "led_night_mode_enabled": { + "name": "led_night_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "led_day_brightness": { + "name": "led_day_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "led_night_brightness": { + "name": "led_night_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "led_night_start_time": { + "name": "led_night_start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'22:00'" + }, + "led_night_end_time": { + "name": "led_night_end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'07:00'" + }, + "global_max_on_hours": { + "name": "global_max_on_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_enabled": { + "name": "mqtt_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_url": { + "name": "mqtt_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_username": { + "name": "mqtt_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_password": { + "name": "mqtt_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_topic_prefix": { + "name": "mqtt_topic_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_ha_discovery": { + "name": "mqtt_ha_discovery", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_enabled": { + "name": "mqtt_tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_insecure": { + "name": "mqtt_tls_insecure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homekit_enabled": { + "name": "homekit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_protection_enabled": { + "name": "pump_stall_protection_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_rpm_threshold": { + "name": "pump_stall_rpm_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "pump_stall_dwell_samples": { + "name": "pump_stall_dwell_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "pump_stall_auto_recovery_enabled": { + "name": "pump_stall_auto_recovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_recovery_rpm": { + "name": "pump_stall_recovery_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1500 + }, + "pump_stall_recovery_samples": { + "name": "pump_stall_recovery_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_state": { + "name": "device_state", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_temperature": { + "name": "current_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_temperature": { + "name": "target_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_alarm_vibrating": { + "name": "is_alarm_vibrating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "water_level": { + "name": "water_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "powered_on_at": { + "name": "powered_on_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "power_schedules": { + "name": "power_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_time": { + "name": "on_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "off_time": { + "name": "off_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_temperature": { + "name": "on_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_power_schedules_side_day": { + "name": "idx_power_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "run_once_sessions": { + "name": "run_once_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_points": { + "name": "set_points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_time": { + "name": "wake_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_run_once_side_status": { + "name": "idx_run_once_side_status", + "columns": [ + "side", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_settings": { + "name": "side_settings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "away_mode": { + "name": "away_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "always_on": { + "name": "always_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_enabled": { + "name": "auto_off_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_minutes": { + "name": "auto_off_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "away_start": { + "name": "away_start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "away_return": { + "name": "away_return", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_health": { + "name": "system_health", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "system_health_component_unique": { + "name": "system_health_component_unique", + "columns": [ + "component" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tap_gestures": { + "name": "tap_gestures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tap_type": { + "name": "tap_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature_change": { + "name": "temperature_change", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature_amount": { + "name": "temperature_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_behavior": { + "name": "alarm_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_snooze_duration": { + "name": "alarm_snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_inactive_behavior": { + "name": "alarm_inactive_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uq_tap_side_type": { + "name": "uq_tap_side_type", + "columns": [ + "side", + "tap_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_holds": { + "name": "temperature_holds", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_schedules": { + "name": "temperature_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_temp_schedules_side_day_time": { + "name": "idx_temp_schedules_side_day_time", + "columns": [ + "side", + "day_of_week", + "time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0016_snapshot.json b/src/db/migrations/meta/0016_snapshot.json new file mode 100644 index 000000000..9e8de46af --- /dev/null +++ b/src/db/migrations/meta/0016_snapshot.json @@ -0,0 +1,1138 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "eb2a2128-9152-4404-853d-a222ce880650", + "prevId": "5b8a25d8-0a59-45fb-8fad-ae5e1d2bdeca", + "tables": { + "alarm_schedules": { + "name": "alarm_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_intensity": { + "name": "vibration_intensity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_pattern": { + "name": "vibration_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rise'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alarm_temperature": { + "name": "alarm_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_alarm_schedules_side_day": { + "name": "idx_alarm_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_fired": { + "name": "idx_automation_runs_automation_fired", + "columns": [ + "automation_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cooldown_min": { + "name": "cooldown_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_automations_enabled": { + "name": "idx_automations_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_settings": { + "name": "device_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Los_Angeles'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'F'" + }, + "reboot_daily": { + "name": "reboot_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reboot_time": { + "name": "reboot_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'03:00'" + }, + "prime_pod_daily": { + "name": "prime_pod_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prime_pod_time": { + "name": "prime_pod_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'14:00'" + }, + "led_night_mode_enabled": { + "name": "led_night_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "led_day_brightness": { + "name": "led_day_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "led_night_brightness": { + "name": "led_night_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "led_night_start_time": { + "name": "led_night_start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'22:00'" + }, + "led_night_end_time": { + "name": "led_night_end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'07:00'" + }, + "global_max_on_hours": { + "name": "global_max_on_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_enabled": { + "name": "mqtt_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_url": { + "name": "mqtt_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_username": { + "name": "mqtt_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_password": { + "name": "mqtt_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_topic_prefix": { + "name": "mqtt_topic_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_ha_discovery": { + "name": "mqtt_ha_discovery", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_enabled": { + "name": "mqtt_tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_insecure": { + "name": "mqtt_tls_insecure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homekit_enabled": { + "name": "homekit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_protection_enabled": { + "name": "pump_stall_protection_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_rpm_threshold": { + "name": "pump_stall_rpm_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "pump_stall_dwell_samples": { + "name": "pump_stall_dwell_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "pump_stall_auto_recovery_enabled": { + "name": "pump_stall_auto_recovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_recovery_rpm": { + "name": "pump_stall_recovery_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1500 + }, + "pump_stall_recovery_samples": { + "name": "pump_stall_recovery_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_state": { + "name": "device_state", + "columns": { + "hardware_deadline": { + "name": "hardware_deadline", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_temperature": { + "name": "current_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_temperature": { + "name": "target_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_alarm_vibrating": { + "name": "is_alarm_vibrating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "water_level": { + "name": "water_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "powered_on_at": { + "name": "powered_on_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "power_schedules": { + "name": "power_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_time": { + "name": "on_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "off_time": { + "name": "off_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_temperature": { + "name": "on_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_power_schedules_side_day": { + "name": "idx_power_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "run_once_sessions": { + "name": "run_once_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_points": { + "name": "set_points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_time": { + "name": "wake_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_run_once_side_status": { + "name": "idx_run_once_side_status", + "columns": [ + "side", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_settings": { + "name": "side_settings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "away_mode": { + "name": "away_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "always_on": { + "name": "always_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_enabled": { + "name": "auto_off_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_minutes": { + "name": "auto_off_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "away_start": { + "name": "away_start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "away_return": { + "name": "away_return", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_health": { + "name": "system_health", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "system_health_component_unique": { + "name": "system_health_component_unique", + "columns": [ + "component" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tap_gestures": { + "name": "tap_gestures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tap_type": { + "name": "tap_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature_change": { + "name": "temperature_change", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature_amount": { + "name": "temperature_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_behavior": { + "name": "alarm_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_snooze_duration": { + "name": "alarm_snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_inactive_behavior": { + "name": "alarm_inactive_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uq_tap_side_type": { + "name": "uq_tap_side_type", + "columns": [ + "side", + "tap_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_holds": { + "name": "temperature_holds", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_schedules": { + "name": "temperature_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_temp_schedules_side_day_time": { + "name": "idx_temp_schedules_side_day_time", + "columns": [ + "side", + "day_of_week", + "time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index e87c7787e..dd8036ace 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1780862594290, "tag": "0014_optimal_marvex", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1790574873141, + "tag": "0015_temperature_holds", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1790577666755, + "tag": "0016_hardware_deadline", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index d9b7a5c76..77c10d715 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -220,7 +220,17 @@ export const alarmSchedules = sqliteTable('alarm_schedules', { // Device State (Runtime) // ============================================================================ +export const temperatureHolds = sqliteTable('temperature_holds', { + side: text('side', { enum: ['left', 'right'] }).primaryKey(), + temperature: real('temperature').notNull(), + // Epoch milliseconds preserve the exact hold boundary across restarts. + startedAt: integer('started_at').notNull(), + expiresAt: integer('expires_at').notNull(), +}) + export const deviceState = sqliteTable('device_state', { + // Explicit manual hardware cutoff, independent of temperature ownership (epoch ms). + hardwareDeadline: integer('hardware_deadline'), side: text('side', { enum: ['left', 'right'] }).primaryKey(), currentTemperature: real('current_temperature'), // Current temp in Β°F targetTemperature: real('target_temperature'), // Target temp in Β°F diff --git a/src/db/tests/__snapshots__/schema.test.ts.snap b/src/db/tests/__snapshots__/schema.test.ts.snap index ae6ed0ca6..3492210e9 100644 --- a/src/db/tests/__snapshots__/schema.test.ts.snap +++ b/src/db/tests/__snapshots__/schema.test.ts.snap @@ -422,6 +422,7 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col { "checks": 0, "columns": [ + "hardware_deadline | sql:integer | logical:number/SQLiteInteger | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "side | sql:text | logical:string/SQLiteText | not-null | default:none | primary-key | not-unique | not-autoincrement | enum:left,right", "current_temperature | sql:real | logical:number/SQLiteReal | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "target_temperature | sql:real | logical:number/SQLiteReal | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", @@ -542,6 +543,21 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col "primaryKeys": [], "uniqueConstraints": [], }, + { + "checks": 0, + "columns": [ + "side | sql:text | logical:string/SQLiteText | not-null | default:none | primary-key | not-unique | not-autoincrement | enum:left,right", + "temperature | sql:real | logical:number/SQLiteReal | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "started_at | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "expires_at | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + ], + "exportName": "temperatureHolds", + "foreignKeys": [], + "indexes": [], + "name": "temperature_holds", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ diff --git a/src/db/tests/schema.test.ts b/src/db/tests/schema.test.ts index 544530e21..23c904561 100644 --- a/src/db/tests/schema.test.ts +++ b/src/db/tests/schema.test.ts @@ -160,6 +160,16 @@ function describeSchema(module: Record) { } const expectedTables: Record = { + temperatureHolds: { + name: 'temperature_holds', + columns: [ + { name: 'side', notNull: true, default: undefined }, + { name: 'temperature', notNull: true, default: undefined }, + { name: 'started_at', notNull: true, default: undefined }, + { name: 'expires_at', notNull: true, default: undefined }, + ], + indexes: [], + }, deviceSettings: { name: 'device_settings', columns: [ @@ -287,6 +297,7 @@ const expectedTables: Record = { deviceState: { name: 'device_state', columns: [ + { name: 'hardware_deadline', notNull: false, default: undefined }, { name: 'side', notNull: true, default: undefined }, { name: 'current_temperature', notNull: false, default: undefined }, { name: 'target_temperature', notNull: false, default: undefined }, diff --git a/src/hardware/dacMonitor.instance.ts b/src/hardware/dacMonitor.instance.ts index 17082740c..1d67d58d3 100644 --- a/src/hardware/dacMonitor.instance.ts +++ b/src/hardware/dacMonitor.instance.ts @@ -17,6 +17,7 @@ * monitors competing for the same socket. */ +import { getTemperatureControlStatus } from '@/src/temperature/instance' import { connectDac, disconnectDac } from './dacTransport' import { DacMonitor } from './dacMonitor' import { GestureActionHandler } from './gestureActionHandler' @@ -103,6 +104,7 @@ export const getDacMonitor = async (): Promise => { const stallNotices = getAllPumpStallNotices() broadcastFrame({ type: 'deviceStatus', + temperatureControl: getTemperatureControlStatus(), ts: Date.now(), // A poll can still report the pre-command target right after a // mutation; keep the mutation's target until the firmware agrees. diff --git a/src/hardware/gestureActionHandler.ts b/src/hardware/gestureActionHandler.ts index a1c18e410..c2d561fc7 100644 --- a/src/hardware/gestureActionHandler.ts +++ b/src/hardware/gestureActionHandler.ts @@ -1,7 +1,7 @@ import type { HardwareClient } from './client' -import { MAX_TEMP, MIN_TEMP, TEMP_NEUTRAL, type Side } from './types' +import { MAX_TEMP, MIN_TEMP, type Side } from './types' import type { GestureEvent } from './dacMonitor' -import { getAutomationEngineIfRunning } from '@/src/automation' +import { getTemperatureController } from '@/src/temperature/instance' import { shouldBlock as pumpStallShouldBlock } from './pumpStallGuard' import { withSideLock } from '@/src/hardware/sideLock' @@ -95,27 +95,18 @@ export class GestureActionHandler { event: GestureEvent, gesture: TapGestureRow ): Promise => { - const state = await this.deps.findDeviceState(event.side) - const currentTemp = state?.targetTemperature ?? 75 - const amount = gesture.temperatureAmount ?? 0 - if (!gesture.temperatureChange) return // misconfigured row β€” skip - const delta = gesture.temperatureChange === 'increment' ? amount : -amount - const newTemp = Math.min(MAX_TEMP, Math.max(MIN_TEMP, currentTemp + delta)) - await withSideLock(event.side, async () => { + const state = await this.deps.findDeviceState(event.side) + const currentTemp = state?.targetTemperature ?? 75 + const amount = gesture.temperatureAmount ?? 0 + if (!gesture.temperatureChange) return + const delta = gesture.temperatureChange === 'increment' ? amount : -amount + const newTemp = Math.min(MAX_TEMP, Math.max(MIN_TEMP, currentTemp + delta)) if (pumpStallShouldBlock(event.side)) { console.warn(`[gestureActionHandler] skipped setTemperature: pump stall guard blocks ${event.side}`) return } - const client = this.deps.newHardwareClient(this.socketPath) - try { - getAutomationEngineIfRunning()?.registerManualOverride(event.side) - await client.connect() - await client.setTemperature(event.side, newTemp) - } - finally { - client.disconnect() - } + await getTemperatureController().setManualLocked(event.side, newTemp) }) } @@ -166,22 +157,20 @@ export class GestureActionHandler { } else { if (gesture.alarmInactiveBehavior === 'power') { - const currentlyPowered = state?.isPowered ?? false - const nextPowered = !currentlyPowered - // Pass the polled target so a power-on preserves the user's setpoint - // across off-cycles instead of landing on the firmware-default - // fallback in DacHardwareClient.setPower. - const target = state?.targetTemperature ?? TEMP_NEUTRAL await withSideLock(event.side, async () => { + // Resolve the toggle after older queued commands have updated state. + const current = await this.deps.findDeviceState(event.side) + const nextPowered = !(current?.isPowered ?? false) + const target = current?.targetTemperature ?? 75 if (nextPowered && pumpStallShouldBlock(event.side)) { console.warn(`[gestureActionHandler] skipped power-on: pump stall guard blocks ${event.side}`) return } const client = this.deps.newHardwareClient(this.socketPath) try { - getAutomationEngineIfRunning()?.registerManualOverride(event.side) await client.connect() - await client.setPower(event.side, nextPowered, nextPowered ? target : undefined) + if (nextPowered) await getTemperatureController().setManualLocked(event.side, target) + else await getTemperatureController().powerOffLocked(event.side) } finally { client.disconnect() diff --git a/src/hardware/pumpStallGuard.ts b/src/hardware/pumpStallGuard.ts index 41c229d68..d5d35d19e 100644 --- a/src/hardware/pumpStallGuard.ts +++ b/src/hardware/pumpStallGuard.ts @@ -17,6 +17,7 @@ * shouldBlock is read from API route handlers. */ +import { getTemperatureControllerIfRunning } from '@/src/temperature/instance' import { and, desc, eq, isNull, lt, lte } from 'drizzle-orm' import { biometricsDb, db } from '@/src/db' import { pumpAlerts } from '@/src/db/biometrics-schema' @@ -617,7 +618,12 @@ export async function restoreAcknowledgedSession( throw new Error('pump-stall resolution was superseded before restore') } const client = getSharedHardwareClient() + const controller = getTemperatureControllerIfRunning() + restore.targetTemperature = controller?.recoveryTargetLocked(side, restore.targetTemperature) ?? restore.targetTemperature + restore.durationSeconds = controller?.recoveryDurationLocked(side, restore.durationSeconds) ?? restore.durationSeconds + if (restore.durationSeconds <= 0) throw new Error('Hardware heating deadline expired during recovery') await client.setTemperature(side, restore.targetTemperature, restore.durationSeconds) + controller?.recoveredLocked(side, restore.targetTemperature) wrote = true }) if (!wrote) throw new Error('pump-stall restore did not reach hardware') @@ -995,8 +1001,13 @@ async function autoRecover(side: Side, now: number): Promise { // setPower(on) delegates to setTemperature without a duration, emitting // a contradictory 28,800-second session before the real duration. One // duration-bearing write is sufficient to energize and restore. + const controller = getTemperatureControllerIfRunning() + restore.targetTemperature = controller?.recoveryTargetLocked(side, restore.targetTemperature) ?? restore.targetTemperature + restore.durationSeconds = controller?.recoveryDurationLocked(side, restore.durationSeconds) ?? restore.durationSeconds + if (restore.durationSeconds <= 0) throw new Error('Hardware heating deadline expired during recovery') await client.setTemperature(side, restore.targetTemperature, restore.durationSeconds) if (getState()[side] !== state || !state.blocked) return + controller?.recoveredLocked(side, restore.targetTemperature) try { db .update(deviceState) diff --git a/src/hardware/tests/deviceStateSync.stallSuppression.test.ts b/src/hardware/tests/deviceStateSync.stallSuppression.test.ts index 7f932197f..b19416d85 100644 --- a/src/hardware/tests/deviceStateSync.stallSuppression.test.ts +++ b/src/hardware/tests/deviceStateSync.stallSuppression.test.ts @@ -40,6 +40,7 @@ function resetSchema(): void { ;(sqlite as any).exec(` DROP TABLE IF EXISTS device_state; CREATE TABLE device_state ( + hardware_deadline INTEGER, side TEXT PRIMARY KEY, current_temperature REAL, target_temperature REAL, diff --git a/src/hardware/tests/deviceStateSync.test.ts b/src/hardware/tests/deviceStateSync.test.ts index 85bb63e3d..d5ff012e1 100644 --- a/src/hardware/tests/deviceStateSync.test.ts +++ b/src/hardware/tests/deviceStateSync.test.ts @@ -35,6 +35,7 @@ function resetSchema(): void { ;(sqlite as any).exec(` DROP TABLE IF EXISTS device_state; CREATE TABLE device_state ( + hardware_deadline INTEGER, side TEXT PRIMARY KEY, current_temperature REAL, target_temperature REAL, diff --git a/src/hardware/tests/gestureActionHandler.test.ts b/src/hardware/tests/gestureActionHandler.test.ts index 1ae4728e8..2a68af198 100644 --- a/src/hardware/tests/gestureActionHandler.test.ts +++ b/src/hardware/tests/gestureActionHandler.test.ts @@ -2,13 +2,16 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import type { GestureActionDeps } from '../gestureActionHandler' import type { GestureEvent } from '../dacMonitor' import type { HardwareClient } from '../client' -import { TEMP_NEUTRAL } from '../types' -const registerManualOverride = vi.fn() +let controllerClient: HardwareClient const pumpStallShouldBlock = vi.fn<(side: 'left' | 'right') => boolean>(() => false) -vi.mock('@/src/automation', () => ({ - getAutomationEngineIfRunning: () => ({ registerManualOverride }), +// Gesture unit tests assert delegation to the shared controller boundary. +vi.mock('@/src/temperature/instance', () => ({ + getTemperatureController: () => ({ + setManualLocked: (side: 'left' | 'right', temp: number) => controllerClient.setTemperature(side, temp), + powerOffLocked: (side: 'left' | 'right') => controllerClient.setPower(side, false), + }), })) vi.mock('../pumpStallGuard', () => ({ shouldBlock: (side: 'left' | 'right') => pumpStallShouldBlock(side), @@ -30,7 +33,7 @@ const makeEvent = ( type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] } -const makeMockClient = (overrides: DeepPartial = {}): HardwareClient => ({ +const makeMockClient = (overrides: DeepPartial = {}): HardwareClient => (controllerClient = { connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn(), setTemperature: vi.fn().mockResolvedValue(undefined), @@ -60,7 +63,6 @@ const makeDeps = ( describe('GestureActionHandler', () => { afterEach(() => { vi.clearAllTimers() - registerManualOverride.mockClear() pumpStallShouldBlock.mockReset().mockReturnValue(false) vi.useRealTimers() }) @@ -132,8 +134,7 @@ describe('GestureActionHandler', () => { await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('left', 'doubleTap')) expect(client.setTemperature).toHaveBeenCalledWith('left', 75) - expect(registerManualOverride).toHaveBeenCalledWith('left') - expect(client.disconnect).toHaveBeenCalledOnce() + expect(client.disconnect).not.toHaveBeenCalled() // shared controller owns the transport }) test('decrements temperature', async () => { @@ -297,7 +298,7 @@ describe('GestureActionHandler', () => { await holder await pending - expect(client.setPower).toHaveBeenCalledWith('left', true, 70) + expect(client.setTemperature).toHaveBeenCalledWith('left', 70) } finally { releaseLeft() @@ -312,18 +313,17 @@ describe('GestureActionHandler', () => { await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('left', 'doubleTap')) - expect(client.setPower).toHaveBeenCalledWith('left', true, 70) - expect(registerManualOverride).toHaveBeenCalledWith('left') + expect(client.setTemperature).toHaveBeenCalledWith('left', 70) }) - test('power-on falls back to TEMP_NEUTRAL when no targetTemperature is cached', async () => { + test('power-on falls back to 75Β°F when no targetTemperature is cached', async () => { const gesture = { actionType: 'alarm', alarmBehavior: 'dismiss', alarmInactiveBehavior: 'power' } const state = { isAlarmVibrating: false, isPowered: false, targetTemperature: null } const { deps, client } = makeDeps(gesture, state) await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('left', 'doubleTap')) - expect(client.setPower).toHaveBeenCalledWith('left', true, 82.5) + expect(client.setTemperature).toHaveBeenCalledWith('left', 75) }) test('treats a missing state row as inactive and powered off', async () => { @@ -333,7 +333,7 @@ describe('GestureActionHandler', () => { await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('right', 'quadTap')) expect(client.clearAlarm).not.toHaveBeenCalled() - expect(client.setPower).toHaveBeenCalledWith('right', true, TEMP_NEUTRAL) + expect(client.setTemperature).toHaveBeenCalledWith('right', 75) expect(client.disconnect).toHaveBeenCalledOnce() }) @@ -344,7 +344,7 @@ describe('GestureActionHandler', () => { await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('right', 'quadTap')) - expect(client.setPower).toHaveBeenCalledWith('right', false, undefined) + expect(client.setPower).toHaveBeenCalledWith('right', false) }) test('no-op when alarmInactiveBehavior=none', async () => { @@ -421,7 +421,7 @@ describe('GestureActionHandler', () => { 'GestureActionHandler: error executing action for left doubleTap:', 'hardware failure', ) - expect(client.disconnect).toHaveBeenCalledOnce() + expect(client.disconnect).not.toHaveBeenCalled() // shared controller owns the transport error.mockRestore() }) @@ -436,7 +436,6 @@ describe('GestureActionHandler', () => { expect(deps.newHardwareClient).not.toHaveBeenCalled() expect(client.setTemperature).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith('[gestureActionHandler] skipped setTemperature: pump stall guard blocks left') warn.mockRestore() }) @@ -464,7 +463,7 @@ describe('GestureActionHandler', () => { await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('right', 'quadTap')) - expect(client.setPower).toHaveBeenCalledWith('right', false, undefined) + expect(client.setPower).toHaveBeenCalledWith('right', false) }) test('blocks a temperature gesture whose trip lands while queued on the side lock', async () => { @@ -490,7 +489,6 @@ describe('GestureActionHandler', () => { await pending expect(client.setTemperature).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith('[gestureActionHandler] skipped setTemperature: pump stall guard blocks left') } finally { @@ -521,7 +519,6 @@ describe('GestureActionHandler', () => { await pending expect(client.setPower).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith('[gestureActionHandler] skipped power-on: pump stall guard blocks right') } finally { diff --git a/src/hardware/tests/pumpStallGuard.test.ts b/src/hardware/tests/pumpStallGuard.test.ts index 5e5449c49..74437b799 100644 --- a/src/hardware/tests/pumpStallGuard.test.ts +++ b/src/hardware/tests/pumpStallGuard.test.ts @@ -94,6 +94,7 @@ function resetSchema(): void { updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE device_state ( + hardware_deadline INTEGER, side TEXT PRIMARY KEY, current_temperature REAL, target_temperature REAL, diff --git a/src/homekit/accessories/sideController.ts b/src/homekit/accessories/sideController.ts index 4fb558e06..003f3697d 100644 --- a/src/homekit/accessories/sideController.ts +++ b/src/homekit/accessories/sideController.ts @@ -22,8 +22,8 @@ import type { HAPStatus } from 'hap-nodejs' import type { DacMonitor } from '@/src/hardware/dacMonitor' import type { DeviceStatus, Side } from '@/src/hardware/types' import { MAX_TEMP, MIN_TEMP } from '@/src/hardware/types' -import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' -import { getAutomationEngineIfRunning } from '@/src/automation' +import { getTemperatureController } from '@/src/temperature/instance' +import { TemperatureBlockedError } from '@/src/temperature/controller' import { shouldBlock as pumpStallShouldBlock } from '@/src/hardware/pumpStallGuard' import { withSideLock } from '@/src/hardware/sideLock' @@ -76,16 +76,13 @@ const POWER_ON_FALLBACK_F = 75 // the pre-toggle value and silently swallow the queued setTemperature. const intendedPower: Record = { left: null, right: null } -function registerManualOverride(side: Side): void { - getAutomationEngineIfRunning()?.registerManualOverride(side) -} - async function logged(label: string, fn: () => Promise): Promise { try { return await fn() } catch (e) { console.warn(`[homekit] ${label} failed:`, e instanceof Error ? e.message : e) + if (e instanceof TemperatureBlockedError) throw new GuardBlockedError() throw e } } @@ -199,10 +196,9 @@ export async function setTargetTemperature( // harmless, and the guard trip leaves the intent latch stuck ON // (firmware never confirms), so `powered` alone can't be trusted here. assertNotGuardBlocked(side, `setTemperature(${side}, ${f})`) - registerManualOverride(side) await logged( `setTemperature(${side}, ${f})`, - () => getSharedHardwareClient().setTemperature(side, f), + () => getTemperatureController().setManualLocked(side, f), ) }) } @@ -228,10 +224,9 @@ export async function setSidePowerOn(monitor: DacMonitor, side: Side): Promise getSharedHardwareClient().setPower(side, true, target), + () => getTemperatureController().setManualLocked(side, target), ) }) } @@ -253,8 +248,7 @@ export async function setSidePowerOff(monitor: DacMonitor, side: Side): Promise< await withSideLock(side, () => logged( `setPower(${side}, false)`, () => { - registerManualOverride(side) - return getSharedHardwareClient().setPower(side, false) + return getTemperatureController().powerOffLocked(side) }, )) } diff --git a/src/homekit/tests/sideController.test.ts b/src/homekit/tests/sideController.test.ts index 557aae259..591471056 100644 --- a/src/homekit/tests/sideController.test.ts +++ b/src/homekit/tests/sideController.test.ts @@ -2,14 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const setTemperature = vi.fn() const setPower = vi.fn() -const registerManualOverride = vi.fn() const shouldBlock = vi.fn<(side: 'left' | 'right') => boolean>() -vi.mock('@/src/hardware/dacMonitor.instance', () => ({ - getSharedHardwareClient: () => ({ setTemperature, setPower }), -})) -vi.mock('@/src/automation', () => ({ - getAutomationEngineIfRunning: () => ({ registerManualOverride }), +// Test HomeKit's staging, serialization, and error mapping at the shared +// controller boundary. Controller/hardware integration has its own real-DB tests. +vi.mock('@/src/temperature/instance', () => ({ + getTemperatureController: () => ({ + setManualLocked: setTemperature, + powerOffLocked: (side: 'left' | 'right') => setPower(side, false), + }), })) vi.mock('@/src/hardware/pumpStallGuard', () => ({ shouldBlock: (side: 'left' | 'right') => shouldBlock(side), @@ -54,7 +55,6 @@ describe('sideController', () => { __resetSideController() setTemperature.mockReset() setPower.mockReset() - registerManualOverride.mockReset() shouldBlock.mockReset() setTemperature.mockResolvedValue(undefined) setPower.mockResolvedValue(undefined) @@ -148,14 +148,12 @@ describe('sideController', () => { it('caches the requested target even when the side is off (no firmware write)', async () => { await setTargetTemperature(monitor(offStatus), 'left', 68) expect(setTemperature).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() expect(getStagedTargetF(monitor(offStatus), 'left')).toBe(68) }) it('caches AND writes when the side is on', async () => { await setTargetTemperature(monitor(onStatus), 'left', 72) expect(setTemperature).toHaveBeenCalledWith('left', 72) - expect(registerManualOverride).toHaveBeenCalledWith('left') expect(getStagedTargetF(monitor(onStatus), 'left')).toBe(72) }) }) @@ -165,18 +163,17 @@ describe('sideController', () => { const m = monitor(offStatus) await setTargetTemperature(m, 'left', 68) await setSidePowerOn(m, 'left') - expect(setPower).toHaveBeenCalledWith('left', true, 68) - expect(registerManualOverride).toHaveBeenCalledWith('left') + expect(setTemperature).toHaveBeenCalledWith('left', 68) }) it('falls back to firmware status target when no cache exists', async () => { await setSidePowerOn(monitor(offStatus), 'right') - expect(setPower).toHaveBeenCalledWith('right', true, 75) + expect(setTemperature).toHaveBeenCalledWith('right', 75) }) it('falls back to 75Β°F when no status and no cache', async () => { await setSidePowerOn(monitor(null), 'left') - expect(setPower).toHaveBeenCalledWith('left', true, 75) + expect(setTemperature).toHaveBeenCalledWith('left', 75) }) it('powers on with a non-zero level when firmware reports a null off target', async () => { @@ -187,8 +184,8 @@ describe('sideController', () => { await setSidePowerOn(offNullTarget, 'left') - const target = setPower.mock.calls[0]?.[2] - expect(setPower).toHaveBeenCalledWith('left', true, 75) + const target = setTemperature.mock.calls[0]?.[1] + expect(setTemperature).toHaveBeenCalledWith('left', 75) expect(fahrenheitToLevel(target)).not.toBe(0) }) }) @@ -197,7 +194,6 @@ describe('sideController', () => { it('routes to setPower(side, false)', async () => { await setSidePowerOff(monitor(onStatus), 'left') expect(setPower).toHaveBeenCalledWith('left', false) - expect(registerManualOverride).toHaveBeenCalledWith('left') }) }) @@ -235,7 +231,7 @@ describe('sideController', () => { let resolveTemp: () => void = () => {} setTemperature.mockImplementation(async (_s, f) => { order.push(`setTemp(${f})`) - if (f === 78) { + if (f === 78 && order.length === 1) { await new Promise((r) => { resolveTemp = r }) @@ -260,7 +256,7 @@ describe('sideController', () => { await Promise.all([p1, p2]) // p2 reads cache populated by p1 (78), so power-on uses the latest target. - expect(order).toEqual(['setTemp(78)', 'setPower(true,78)']) + expect(order).toEqual(['setTemp(78)', 'setTemp(78)']) }) it('does not block a different side', async () => { @@ -311,7 +307,7 @@ describe('sideController', () => { // depends on microtask interleaving (cache may already hold 78 by the // time setSidePowerOn's fn runs); what matters is setTemperature(78) // was NOT silently dropped. - expect(setPower).toHaveBeenCalledWith('left', true, expect.any(Number)) + expect(setTemperature).toHaveBeenCalledWith('left', expect.any(Number)) expect(setTemperature).toHaveBeenCalledWith('left', 78) }) }) @@ -339,9 +335,9 @@ describe('sideController', () => { warn.mockRestore() }) - it('logs the exact powered-on target when setPower rejects', async () => { + it('logs the exact powered-on target when the controller rejects', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - setPower.mockRejectedValueOnce(new Error('comms')) + setTemperature.mockRejectedValueOnce(new Error('comms')) await expect(setSidePowerOn(monitor(offStatus), 'left')).rejects.toThrow('comms') @@ -440,11 +436,12 @@ describe('sideController', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) // Side starts off (intendedPower=null, firmware=off). const m = monitor(offStatus) - setPower.mockRejectedValueOnce(new Error('hardware down')) + setTemperature.mockRejectedValueOnce(new Error('hardware down')) await expect(setSidePowerOn(m, 'left')).rejects.toThrow('hardware down') // intendedPower must have rolled back to null/false. Adjusting target // now should NOT push to firmware (side is genuinely off). + setTemperature.mockClear() await setTargetTemperature(m, 'left', 68) expect(setTemperature).not.toHaveBeenCalled() warn.mockRestore() @@ -454,7 +451,7 @@ describe('sideController', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) // Side is on (intendedPower=true after a successful power-on). await setSidePowerOn(monitor(onStatus), 'left') - expect(setPower).toHaveBeenCalledWith('left', true, expect.any(Number)) + expect(setTemperature).toHaveBeenCalledWith('left', expect.any(Number)) setPower.mockClear() setPower.mockRejectedValueOnce(new Error('hardware down')) @@ -485,7 +482,6 @@ describe('sideController', () => { hapStatus: -70412, }) expect(setPower).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() // The ingress assert (not the in-lock re-check) refused this call β€” // its log must name the exact write so field debugging can tell the // two gates' refusals apart from a silent drop. @@ -517,7 +513,6 @@ describe('sideController', () => { await expect(pending).rejects.toThrow('Pump stall protection active') expect(setPower).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith('[homekit] refused setPower(left, true) β€” pump stall protection active on left') // Intent rolled back β€” iOS Home reads OFF again instead of a phantom ON. expect(isEffectivelyPowered(monitor(offStatus), 'left')).toBe(false) @@ -550,7 +545,6 @@ describe('sideController', () => { await expect(pending).rejects.toThrow('Pump stall protection active') expect(setTemperature).not.toHaveBeenCalled() - expect(registerManualOverride).not.toHaveBeenCalled() } finally { release() @@ -603,7 +597,6 @@ describe('sideController', () => { expect(warn).toHaveBeenCalledWith('[homekit] refused setTemperature(left, 68) β€” pump stall protection active on left') // A rejected write must not suspend autopilot (same ordering contract // as the REST path: the override registers only after the gate passes). - expect(registerManualOverride).not.toHaveBeenCalled() expect(getStagedTargetF(monitor(onStatus), 'left')).toBe(68) warn.mockRestore() }) @@ -648,7 +641,7 @@ describe('sideController', () => { // tell which side's guard fired when both surfaces share one log. expect(warn).toHaveBeenCalledWith('[homekit] refused setPower(left, true) β€” pump stall protection active on left') await setSidePowerOn(monitor(offStatus), 'right') - expect(setPower).toHaveBeenCalledWith('right', true, expect.any(Number)) + expect(setTemperature).toHaveBeenCalledWith('right', expect.any(Number)) warn.mockRestore() }) }) diff --git a/src/homekit/tests/thermostat.test.ts b/src/homekit/tests/thermostat.test.ts index 2b99dd27c..392f33818 100644 --- a/src/homekit/tests/thermostat.test.ts +++ b/src/homekit/tests/thermostat.test.ts @@ -3,14 +3,15 @@ import { Characteristic } from 'hap-nodejs' const setTemperature = vi.fn().mockResolvedValue(undefined) const setPower = vi.fn().mockResolvedValue(undefined) -const registerManualOverride = vi.fn() const shouldBlock = vi.fn<() => boolean>().mockReturnValue(false) -vi.mock('@/src/hardware/dacMonitor.instance', () => ({ - getSharedHardwareClient: () => ({ setTemperature, setPower }), -})) -vi.mock('@/src/automation', () => ({ - getAutomationEngineIfRunning: () => ({ registerManualOverride }), +// Test HomeKit's staging, serialization, and error mapping at the shared +// controller boundary. Controller/hardware integration has its own real-DB tests. +vi.mock('@/src/temperature/instance', () => ({ + getTemperatureController: () => ({ + setManualLocked: setTemperature, + powerOffLocked: (side: 'left' | 'right') => setPower(side, false), + }), })) vi.mock('@/src/hardware/pumpStallGuard', () => ({ shouldBlock: () => shouldBlock(), @@ -43,7 +44,6 @@ describe('thermostat accessory', () => { __resetSideController() setTemperature.mockClear() setPower.mockClear() - registerManualOverride.mockClear() shouldBlock.mockReset() shouldBlock.mockReturnValue(false) }) @@ -134,7 +134,7 @@ describe('thermostat accessory', () => { await service.getCharacteristic(Characteristic.TargetHeatingCoolingState).handleSetRequest(3) // Right fixture targetTemperature is 80Β°F β€” must pass through so the // hardware client doesn't silently fall back to its 75Β°F default. - expect(setPower).toHaveBeenCalledWith('right', true, 80) + expect(setTemperature).toHaveBeenCalledWith('right', 80) setPower.mockClear() await service.getCharacteristic(Characteristic.TargetHeatingCoolingState).handleSetRequest(0) @@ -181,7 +181,7 @@ describe('thermostat accessory', () => { // the requested intent, not the 77Β°F the slider visibly reverted to. shouldBlock.mockReturnValue(false) await service.getCharacteristic(Characteristic.TargetHeatingCoolingState).handleSetRequest(3) - expect(setPower).toHaveBeenCalledWith('left', true, 95) + expect(setTemperature).toHaveBeenCalledWith('left', 95) warn.mockRestore() }) diff --git a/src/hooks/useDeviceStatus.ts b/src/hooks/useDeviceStatus.ts index 6d21c43a2..c9809cff5 100644 --- a/src/hooks/useDeviceStatus.ts +++ b/src/hooks/useDeviceStatus.ts @@ -72,6 +72,7 @@ export function useDeviceStatus() { } const wsToStatus = (frame: DeviceStatusFrame) => ({ + temperatureControl: frame.temperatureControl, leftSide: frame.leftSide, rightSide: frame.rightSide, waterLevel: frame.waterLevel, diff --git a/src/hooks/useSensorStream.ts b/src/hooks/useSensorStream.ts index c8deb3b43..413e4b0a3 100644 --- a/src/hooks/useSensorStream.ts +++ b/src/hooks/useSensorStream.ts @@ -1,5 +1,6 @@ 'use client' +import type { TemperatureControlStatus } from '@/src/temperature/controller' import { useEffect, useLayoutEffect, useRef, useState, useCallback, useSyncExternalStore } from 'react' import { normalizeFrame } from '@/src/streaming/normalizeFrame' import type { SideStatus } from '@/src/hardware/types' @@ -137,6 +138,7 @@ export interface GestureFrame { * broadcastMutationStatus. Sides carry the full SideStatus payload the * producers spread (nullable temps when a side is off/neutral). */ export interface DeviceStatusFrame { + temperatureControl?: { left: TemperatureControlStatus, right: TemperatureControlStatus } type: 'deviceStatus' ts: number leftSide: SideStatus & { isAlarmVibrating: boolean } diff --git a/src/scheduler/jobManager.ts b/src/scheduler/jobManager.ts index 04818fd8d..961503000 100644 --- a/src/scheduler/jobManager.ts +++ b/src/scheduler/jobManager.ts @@ -1,3 +1,4 @@ +import { getTemperatureController } from '@/src/temperature/instance' import { Scheduler } from './scheduler' import { JobType } from './types' import { db } from '@/src/db' @@ -13,7 +14,7 @@ import { import { and, eq, gt } from 'drizzle-orm' import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' import { encode as cborEncode } from 'cbor-x' -import { fahrenheitToLevel, HardwareCommand } from '@/src/hardware/types' +import { HardwareCommand } from '@/src/hardware/types' import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' import { cancelAutoOffTimer } from '@/src/services/autoOffWatcher' import { markSideMutated } from '@/src/hardware/deviceStateSync' @@ -325,28 +326,10 @@ export class JobManager { * the registered scheduler handler. */ async runTemperatureJob(sched: typeof temperatureSchedules.$inferSelect): Promise { - if (await this.hasActiveRunOnceSession(sched.side)) { - console.log(`Skipping recurring temp job temp-${sched.id} β€” run-once session active for ${sched.side}`) - return + const status = await getTemperatureController().reconcile(sched.side) + if (status.source !== 'schedule' || status.blocked) { + console.log(`[jobManager] temperature schedule ${sched.id} deferred: ${status.blocked ?? status.source ?? 'no target'}`) } - await withSideLock(sched.side, async () => { - if (!(await this.isSidePowered(sched.side))) { - console.log(`Skipping temp job temp-${sched.id} β€” ${sched.side} is not powered`) - return - } - if (pumpStallShouldBlock(sched.side)) { - console.warn(`[jobManager] skipped temp job temp-${sched.id}: pump stall guard blocks ${sched.side}`) - return - } - markSideMutated(sched.side) - const client = getSharedHardwareClient() - await client.connect() - await client.setTemperature(sched.side, sched.temperature) - broadcastMutationStatus(sched.side, { - targetTemperature: sched.temperature, - targetLevel: fahrenheitToLevel(sched.temperature), - }) - }) } /** @@ -378,13 +361,8 @@ export class JobManager { markSideMutated(sched.side) const client = getSharedHardwareClient() await client.connect() - await client.setPower(sched.side, true, sched.onTemperature) + await getTemperatureController().powerOnLocked(sched.side, sched.onTemperature ?? 75) cancelAutoOffTimer(sched.side) - const onTemp = sched.onTemperature ?? 75 - broadcastMutationStatus(sched.side, { - targetTemperature: onTemp, - targetLevel: fahrenheitToLevel(onTemp), - }) }) } @@ -416,7 +394,7 @@ export class JobManager { await this.markSideOff(sched.side) const client = getSharedHardwareClient() await client.connect() - await client.setPower(sched.side, false) + await getTemperatureController().powerOffLocked(sched.side) broadcastMutationStatus(sched.side, { targetLevel: 0 }) }) } @@ -451,7 +429,7 @@ export class JobManager { const client = getSharedHardwareClient() await client.connect() if (powered && !stallBlocked) { - await client.setTemperature(sched.side, sched.alarmTemperature) + await getTemperatureController().reconcileLocked(sched.side) } else if (powered) { console.warn(`[jobManager] skipped alarm temperature alarm-${sched.id}: pump stall guard blocks ${sched.side}; firing vibration only`) @@ -465,10 +443,6 @@ export class JobManager { duration: sched.duration, }) broadcastMutationStatus(sched.side, { - ...(powered && !stallBlocked && { - targetTemperature: sched.alarmTemperature, - targetLevel: fahrenheitToLevel(sched.alarmTemperature), - }), isAlarmVibrating: true, }) }) @@ -660,7 +634,7 @@ export class JobManager { try { const client = getSharedHardwareClient() await client.connect() - await client.setPower(side, false) + await getTemperatureController().powerOffLocked(side) broadcastMutationStatus(side, { targetLevel: 0 }) } catch (e) { @@ -699,7 +673,7 @@ export class JobManager { try { const client = getSharedHardwareClient() await client.connect() - await client.setPower(side, true) + await getTemperatureController().powerOnLocked(side) cancelAutoOffTimer(side) broadcastMutationStatus(side, {}) } @@ -1106,20 +1080,7 @@ export class JobManager { JobType.RUN_ONCE, fireDate, async () => { - await withSideLock(side, async () => { - if (pumpStallShouldBlock(side)) { - console.warn(`[jobManager] skipped run-once set point: pump stall guard blocks ${side}`) - return - } - markSideMutated(side) - const client = getSharedHardwareClient() - await client.connect() - await client.setTemperature(side, sp.temperature) - broadcastMutationStatus(side, { - targetTemperature: sp.temperature, - targetLevel: fahrenheitToLevel(sp.temperature), - }) - }) + await getTemperatureController().reconcile(side) }, { sessionId, side, index: i, targetTemperature: sp.temperature }, ) @@ -1131,7 +1092,7 @@ export class JobManager { `runonce-cleanup-${sessionId}`, JobType.RUN_ONCE, cleanupDate, - async () => { + async () => withSideLock(side, async () => { // Check if session is still active β€” if cancelled or replaced, bail out // to avoid powering off a side that a replacement session is using const [current] = await db @@ -1150,21 +1111,19 @@ export class JobManager { .set({ status: 'completed' }) .where(eq(runOnceSessions.id, sessionId)) - await withSideLock(side, async () => { - await this.markSideOff(side) - try { - const client = getSharedHardwareClient() - await client.connect() - await client.setPower(side, false) - broadcastMutationStatus(side, { targetLevel: 0 }) - } - catch (e) { - console.warn(`[runOnce] Failed to power off ${side} at wake:`, e) - } - }) + await this.markSideOff(side) + try { + const client = getSharedHardwareClient() + await client.connect() + await getTemperatureController().powerOffLocked(side) + broadcastMutationStatus(side, { targetLevel: 0 }) + } + catch (e) { + console.warn(`[runOnce] Failed to power off ${side} at wake:`, e) + } console.log(`Run-once session ${sessionId} completed β€” ${side} powered off`) - }, + }), { sessionId, side, cleanup: true }, ) } @@ -1172,9 +1131,10 @@ export class JobManager { /** * Cancel an active run-once session for a side. */ - cancelRunOnceSession(side: 'left' | 'right'): void { + cancelRunOnceSession(side: 'left' | 'right', sessionId?: number): void { for (const job of this.scheduler.getJobs()) { - if (job.type === JobType.RUN_ONCE && job.metadata?.side === side) { + if (job.type === JobType.RUN_ONCE && job.metadata?.side === side + && (sessionId === undefined || job.metadata.sessionId === sessionId)) { this.scheduler.cancelJob(job.id) } } @@ -1200,7 +1160,7 @@ export class JobManager { try { const client = getSharedHardwareClient() await client.connect() - await client.setPower(session.side, false) + await withSideLock(session.side, () => getTemperatureController().powerOffLocked(session.side)) broadcastMutationStatus(session.side, { targetLevel: 0 }) } catch { /* best effort */ } diff --git a/src/scheduler/tests/jobManager.test.ts b/src/scheduler/tests/jobManager.test.ts index dabf51cab..601c39698 100644 --- a/src/scheduler/tests/jobManager.test.ts +++ b/src/scheduler/tests/jobManager.test.ts @@ -3,12 +3,20 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' const hardwareClient = vi.hoisted(() => ({ connect: vi.fn(async () => {}), - setTemperature: vi.fn(async () => {}), - setPower: vi.fn(async () => {}), + setTemperature: vi.fn<(side: string, temperature: number) => Promise>(async () => {}), + setPower: vi.fn<(side: string, powered: boolean) => Promise>(async () => {}), setAlarm: vi.fn(async () => {}), startPriming: vi.fn(async () => {}), sendRaw: vi.fn<(command: string, arg?: string) => Promise>(), })) +const control = vi.hoisted(() => ({ + reconcileLocked: vi.fn<(side: string) => Promise>(async () => {}), + reconcile: vi.fn<(side: string) => Promise<{ source: string, blocked: null }>>(async () => ({ source: 'schedule', blocked: null })), + powerOnLocked: vi.fn(async (side: string, temperature = 75) => hardwareClient.setTemperature(side, temperature)), + powerOffLocked: vi.fn(async (side: string) => hardwareClient.setPower(side, false)), +})) +vi.mock('@/src/temperature/instance', () => ({ getTemperatureController: () => control })) + const pumpStallMock = vi.hoisted(() => ({ shouldBlock: vi.fn<(side: 'left' | 'right') => boolean>(() => false), })) @@ -97,7 +105,7 @@ vi.mock('child_process', () => ({ exec: execMock })) import { decode as cborDecode } from 'cbor-x' import { db } from '@/src/db' import { sendCommand } from '@/src/hardware/dacTransport' -import { fahrenheitToLevel, HardwareCommand } from '@/src/hardware/types' +import { HardwareCommand } from '@/src/hardware/types' import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' import { withSideLock } from '@/src/hardware/sideLock' import { JobManager } from '../jobManager' @@ -1474,6 +1482,7 @@ describe('JobManager residual mutation contracts', () => { heartbeatStaleMs: 90_000, }) for (const mock of Object.values(hardwareClient)) mock.mockClear() + for (const mock of Object.values(control)) mock.mockClear() hardwareClient.connect.mockResolvedValue(undefined) hardwareClient.setTemperature.mockResolvedValue(undefined) hardwareClient.setPower.mockResolvedValue(undefined) @@ -1494,7 +1503,6 @@ describe('JobManager residual mutation contracts', () => { it('logs every recurring-job skip and the alarm vibration-only branch exactly', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(manager, 'hasActiveRunOnceSession') - .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) .mockResolvedValue(false) @@ -1545,16 +1553,14 @@ describe('JobManager residual mutation contracts', () => { duration: 30, }) - expect(log).toHaveBeenCalledWith( - 'Skipping recurring temp job temp-11 β€” run-once session active for left', - ) + expect(control.reconcile).toHaveBeenCalledWith('left') expect(log).toHaveBeenCalledWith( 'Skipping recurring power-on job β€” run-once session active for right', ) expect(log).toHaveBeenCalledWith( 'Skipping recurring power-off job β€” run-once session active for left', ) - expect(log).toHaveBeenCalledWith('Skipping temp job temp-14 β€” right is not powered') + expect(control.reconcile).toHaveBeenCalledWith('right') expect(log).toHaveBeenCalledWith( 'Alarm job alarm-15 β€” right not powered; skipping temperature, firing vibration only', ) @@ -1587,7 +1593,7 @@ describe('JobManager residual mutation contracts', () => { expect(hardwareClient.setTemperature).not.toHaveBeenCalled() expect(hardwareClient.setPower).not.toHaveBeenCalled() expect(broadcastMutationStatus).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith('[jobManager] skipped temp job temp-41: pump stall guard blocks left') + expect(control.reconcile).toHaveBeenCalledWith('left') expect(warn).toHaveBeenCalledWith('[jobManager] skipped power-on power-on-42: pump stall guard blocks right') }) @@ -1754,7 +1760,6 @@ describe('JobManager residual mutation contracts', () => { }) it('blocks a temperature job whose trip lands while it is queued on the side lock', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(manager, 'hasActiveRunOnceSession').mockResolvedValue(false) vi.spyOn(manager as any, 'isSidePowered').mockResolvedValue(true) @@ -1780,14 +1785,13 @@ describe('JobManager residual mutation contracts', () => { await jobRun expect(hardwareClient.setTemperature).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith('[jobManager] skipped temp job temp-45: pump stall guard blocks left') + expect(control.reconcile).toHaveBeenCalledWith('left') }) it('skips a run-once set point while the guard blocks the side', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-07-20T12:00:00.000Z')) const captured = captureOneTimeJobs() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) pumpStallMock.shouldBlock.mockReturnValue(true) manager.scheduleRunOnceSession(78, 'left', [{ time: '12:10', temperature: 78 }], '13:00', 'UTC') @@ -1795,7 +1799,7 @@ describe('JobManager residual mutation contracts', () => { expect(hardwareClient.setTemperature).not.toHaveBeenCalled() expect(broadcastMutationStatus).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith('[jobManager] skipped run-once set point: pump stall guard blocks left') + expect(control.reconcile).toHaveBeenCalledWith('left') }) it('pins away-mode state writes, payloads, metadata, logs, and failure warnings', async () => { @@ -1829,7 +1833,7 @@ describe('JobManager residual mutation contracts', () => { await required(captured.get('away-return-left'), 'away-return-left').handler() expect(updates[1]).toMatchObject({ awayMode: false }) expect(log).toHaveBeenCalledWith('Away mode: deactivating for left') - expect(hardwareClient.setPower).toHaveBeenCalledWith('left', true) + expect(control.powerOnLocked).toHaveBeenCalledWith('left') const startFailure = new Error('off failed') hardwareClient.setPower.mockRejectedValueOnce(startFailure) @@ -1837,7 +1841,7 @@ describe('JobManager residual mutation contracts', () => { expect(warn).toHaveBeenCalledWith('[awayMode] Failed to power off left:', startFailure) const returnFailure = new Error('on failed') - hardwareClient.setPower.mockRejectedValueOnce(returnFailure) + hardwareClient.setTemperature.mockRejectedValueOnce(returnFailure) await required(captured.get('away-return-left'), 'away-return-left').handler() expect(warn).toHaveBeenCalledWith('[awayMode] Failed to power on left:', returnFailure) }) @@ -1918,11 +1922,7 @@ describe('JobManager residual mutation contracts', () => { cleanup: true, }) await required(captured.get('runonce-77-0'), 'runonce-77-0').handler() - expect(hardwareClient.setTemperature).toHaveBeenCalledWith('left', 78) - expect(broadcastMutationStatus).toHaveBeenCalledWith('left', { - targetTemperature: 78, - targetLevel: fahrenheitToLevel(78), - }) + expect(control.reconcile).toHaveBeenCalledWith('left') }) it('logs exact cancelled and missing cleanup statuses without powering off', async () => { diff --git a/src/scheduler/tests/jobOrdering.test.ts b/src/scheduler/tests/jobOrdering.test.ts index cee603475..363873d6a 100644 --- a/src/scheduler/tests/jobOrdering.test.ts +++ b/src/scheduler/tests/jobOrdering.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +import { resetControlDatabase } from '@/src/temperature/tests/databaseFixture' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type BetterSqlite3 from 'better-sqlite3' @@ -81,119 +82,12 @@ vi.mock('@/src/db', async () => { import * as dbModule from '@/src/db' import { JobManager } from '../jobManager' +import { withSideLock } from '@/src/hardware/sideLock' import { fahrenheitToLevel } from '@/src/hardware/types' const { sqlite } = dbModule as typeof dbModule & { sqlite: BetterSqlite3.Database } function resetSchema(): void { - ;(sqlite as any).exec(` - DROP TABLE IF EXISTS device_state; - DROP TABLE IF EXISTS run_once_sessions; - DROP TABLE IF EXISTS temperature_schedules; - DROP TABLE IF EXISTS power_schedules; - DROP TABLE IF EXISTS alarm_schedules; - DROP TABLE IF EXISTS device_settings; - DROP TABLE IF EXISTS side_settings; - - CREATE TABLE device_state ( - side TEXT PRIMARY KEY, - current_temperature REAL, - target_temperature REAL, - is_powered INTEGER NOT NULL DEFAULT 0, - is_alarm_vibrating INTEGER NOT NULL DEFAULT 0, - water_level TEXT DEFAULT 'unknown', - powered_on_at INTEGER, - last_updated INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE run_once_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - side TEXT NOT NULL, - set_points TEXT NOT NULL, - wake_time TEXT NOT NULL, - started_at INTEGER NOT NULL DEFAULT (unixepoch()), - expires_at INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE temperature_schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - side TEXT NOT NULL, - day_of_week TEXT NOT NULL, - time TEXT NOT NULL, - temperature REAL NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE power_schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - side TEXT NOT NULL, - day_of_week TEXT NOT NULL, - on_time TEXT NOT NULL, - off_time TEXT NOT NULL, - on_temperature REAL NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE alarm_schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - side TEXT NOT NULL, - day_of_week TEXT NOT NULL, - time TEXT NOT NULL, - vibration_intensity INTEGER NOT NULL, - vibration_pattern TEXT NOT NULL DEFAULT 'rise', - duration INTEGER NOT NULL, - alarm_temperature REAL NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE device_settings ( - id INTEGER PRIMARY KEY, - timezone TEXT NOT NULL DEFAULT 'America/Los_Angeles', - temperature_unit TEXT NOT NULL DEFAULT 'F', - reboot_daily INTEGER NOT NULL DEFAULT 0, - reboot_time TEXT DEFAULT '03:00', - prime_pod_daily INTEGER NOT NULL DEFAULT 0, - prime_pod_time TEXT DEFAULT '14:00', - led_night_mode_enabled INTEGER NOT NULL DEFAULT 0, - led_day_brightness INTEGER NOT NULL DEFAULT 100, - led_night_brightness INTEGER NOT NULL DEFAULT 0, - led_night_start_time TEXT DEFAULT '22:00', - led_night_end_time TEXT DEFAULT '07:00', - global_max_on_hours INTEGER, - mqtt_enabled INTEGER, - mqtt_url TEXT, - mqtt_username TEXT, - mqtt_password TEXT, - mqtt_topic_prefix TEXT, - mqtt_ha_discovery INTEGER, - mqtt_tls_enabled INTEGER, - mqtt_tls_insecure INTEGER, - homekit_enabled INTEGER NOT NULL DEFAULT 0, - pump_stall_protection_enabled INTEGER NOT NULL DEFAULT 1, - pump_stall_rpm_threshold INTEGER NOT NULL DEFAULT 500, - pump_stall_dwell_samples INTEGER NOT NULL DEFAULT 2, - pump_stall_auto_recovery_enabled INTEGER NOT NULL DEFAULT 0, - pump_stall_recovery_rpm INTEGER NOT NULL DEFAULT 1500, - pump_stall_recovery_samples INTEGER NOT NULL DEFAULT 3, - autopilot_enabled INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE side_settings ( - side TEXT PRIMARY KEY, - name TEXT NOT NULL, - away_mode INTEGER NOT NULL DEFAULT 0, - always_on INTEGER NOT NULL DEFAULT 0, - auto_off_enabled INTEGER NOT NULL DEFAULT 0, - auto_off_minutes INTEGER NOT NULL DEFAULT 30, - away_start TEXT, - away_return TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - `) + resetControlDatabase(sqlite) } function insertTempSchedule(opts: { @@ -337,7 +231,7 @@ function readSide(side: 'left' | 'right') { } const tempSched = (side: 'left' | 'right', temperature: number) => ({ - id: 1, + id: insertTempSchedule({ side, dayOfWeek: 'saturday', time: '10:00', temperature }), side, dayOfWeek: 'saturday' as const, time: '10:00', @@ -348,7 +242,7 @@ const tempSched = (side: 'left' | 'right', temperature: number) => ({ }) const alarmSched = (side: 'left' | 'right') => ({ - id: 1, + id: insertAlarmSchedule({ side, dayOfWeek: 'saturday', time: '09:00', alarmTemperature: 88 }), side, dayOfWeek: 'saturday' as const, time: '09:00', @@ -463,7 +357,6 @@ describe('JobManager β€” job ordering and gating', () => { await manager.runAlarmJob(alarmSched('right')) - expect(broadcastMutationStatus).toHaveBeenCalledOnce() expect(broadcastMutationStatus).toHaveBeenCalledWith('right', { isAlarmVibrating: true, }) @@ -513,6 +406,8 @@ describe('JobManager β€” job ordering and gating', () => { }) const power = manager.runPowerOffJob(powerSched('left')) + // Wait until cutoff owns the lock; recurring power-off first checks session state. + await vi.waitFor(() => expect(setPower).toHaveBeenCalled()) const temp = manager.runTemperatureJob(tempSched('left', 80)) await Promise.all([power, temp]) @@ -571,7 +466,8 @@ describe('JobManager β€” job ordering and gating', () => { expect(powerEnd).toBeGreaterThan(powerStart) // temp-start must NOT appear between power-off-start and power-off-end. if (tempStart !== -1) { - expect(tempStart).toBeGreaterThan(powerEnd) + const tempEnd = order.indexOf('temp-end') + expect(tempStart > powerEnd || tempEnd < powerStart).toBe(true) } }) @@ -580,24 +476,28 @@ describe('JobManager β€” job ordering and gating', () => { seedSidePowered('right', true) const order: string[] = [] + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) setTemperature.mockImplementation(async (side: string) => { order.push(`${side}-start`) - await new Promise(r => setTimeout(r, 25)) + await gate order.push(`${side}-end`) }) - - const start = Date.now() - await Promise.all([ + const jobs = Promise.all([ manager.runTemperatureJob(tempSched('left', 80)), manager.runTemperatureJob(tempSched('right', 80)), ]) - const elapsed = Date.now() - start - - // Both ran in parallel β€” total time should be ~25ms not ~50ms. - expect(elapsed).toBeLessThan(45) - // Interleaved starts confirm parallelism. - expect(order[0]).toMatch(/-start$/) - expect(order[1]).toMatch(/-start$/) + try { + // Both must enter hardware while neither side has completed. + await vi.waitFor(() => expect(order).toEqual(expect.arrayContaining(['left-start', 'right-start']))) + expect(order).toHaveLength(2) + } + finally { + release() + await jobs + } }) }) }) @@ -928,7 +828,7 @@ describe('JobManager run-once sessions', () => { updatedAt: new Date(), }) - expect(setPower).toHaveBeenCalledWith('left', true, 82) + expect(setTemperature).toHaveBeenCalledWith('left', 82) }) it('runPowerOnJob falls back to 75Β°F broadcast when onTemperature is null', async () => { @@ -944,7 +844,7 @@ describe('JobManager run-once sessions', () => { updatedAt: new Date(), }) - expect(setPower).toHaveBeenCalledWith('right', true, null) + expect(setTemperature).toHaveBeenCalledWith('right', 75) }) it('runPowerOnJob skips when a run-once session is active', async () => { @@ -1507,7 +1407,7 @@ describe('JobManager handler closures', () => { const handler = captured.get('power-on-1')! await handler() - expect(setPower).toHaveBeenCalledWith('right', true, 80) + expect(setTemperature).toHaveBeenCalledWith('right', 80) }) it('power-off cron handler closure delegates to runPowerOffJob and clears DB', async () => { @@ -1559,7 +1459,7 @@ describe('JobManager handler closures', () => { const handler = captured.get('away-return-right')! await handler() - expect(setPower).toHaveBeenCalledWith('right', true) + expect(setTemperature).toHaveBeenCalledWith('right', 75) }) it('away-mode start handler tolerates hardware failures', async () => { @@ -1590,14 +1490,35 @@ describe('JobManager handler closures', () => { const setpointTime = fmt(new Date(now.getTime() + 30 * 60_000)) const wakeTime = fmt(new Date(now.getTime() + 8 * 60 * 60_000)) - manager.scheduleRunOnceSession(7, 'left', [{ time: setpointTime, temperature: 78 }], wakeTime, 'UTC') + seedSidePowered('left', true) + const sessionId = insertRunOnceSession({ side: 'left', setPoints: JSON.stringify([{ time: setpointTime, temperature: 78 }]), wakeTime, startedAt: now, expiresAt: new Date(now.getTime() + 8 * 60 * 60_000) }) + manager.scheduleRunOnceSession(sessionId, 'left', [{ time: setpointTime, temperature: 78 }], wakeTime, 'UTC') - const handler = captured.get('runonce-7-0')! + const handler = captured.get(`runonce-${sessionId}-0`)! await handler() expect(setTemperature).toHaveBeenCalledWith('left', 78) }) + it('rechecks session status after queued cleanup acquires the side lock', async () => { + seedSidePowered('left', true) + const now = new Date() + const sessionId = insertRunOnceSession({ side: 'left', setPoints: '[]', wakeTime: '07:00', startedAt: now, expiresAt: new Date(now.getTime() + 3_600_000) }) + manager.scheduleRunOnceSession(sessionId, 'left', [], '07:00', 'UTC') + let release = () => {} + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve + })) + await Promise.resolve() + const cleanup = captured.get(`runonce-cleanup-${sessionId}`)!() + await Promise.resolve() + sqlite.prepare('UPDATE run_once_sessions SET status=\'cancelled\' WHERE id=?').run(sessionId) + release() + await Promise.all([holder, cleanup]) + expect(setPower).not.toHaveBeenCalled() + expect(readSide('left')?.is_powered).toBe(1) + }) + it('run-once cleanup handler powers the side off when status is still active', async () => { const now = new Date() const fmt = (d: Date) => @@ -1724,7 +1645,7 @@ describe('JobManager handler closures', () => { errSpy.mockRestore() }) - it('runPowerOffJob tolerates DB failure inside markSideOff', async () => { + it('runPowerOffJob still shuts hardware off when state persistence fails', async () => { seedSidePowered('left', true) // Drop the table so the update inside markSideOff throws β€” runPowerOffJob // must still issue setPower(false) and not surface the DB error. @@ -1743,7 +1664,7 @@ describe('JobManager handler closures', () => { createdAt: new Date(), updatedAt: new Date(), }), - ).resolves.toBeUndefined() + ).rejects.toThrow('no such table') expect(setPower).toHaveBeenCalledWith('left', false) expect(warnSpy).toHaveBeenCalledWith( @@ -1804,6 +1725,7 @@ describe('JobManager β€” streaming + downstream side-effects', () => { it('runTemperatureJob broadcasts targetTemperature + targetLevel after setTemperature', async () => { seedSidePowered('left', true) + insertTempSchedule({ side: 'left', dayOfWeek: 'monday', time: '08:00', temperature: 78 }) await manager.runTemperatureJob({ id: 1, @@ -1816,14 +1738,13 @@ describe('JobManager β€” streaming + downstream side-effects', () => { updatedAt: new Date(), }) - expect(broadcastMutationStatus).toHaveBeenCalledOnce() expect(broadcastMutationStatus).toHaveBeenCalledWith('left', { targetTemperature: 78, targetLevel: fahrenheitToLevel(78), }) }) - it('runTemperatureJob does NOT broadcast when the side is unpowered', async () => { + it('runTemperatureJob does not publish an energizing overlay when the side is unpowered', async () => { seedSidePowered('left', false) await manager.runTemperatureJob({ @@ -1837,7 +1758,7 @@ describe('JobManager β€” streaming + downstream side-effects', () => { updatedAt: new Date(), }) - expect(broadcastMutationStatus).not.toHaveBeenCalled() + expect(broadcastMutationStatus.mock.calls.every(([, patch]) => patch?.targetTemperature === undefined)).toBe(true) }) it('runPowerOnJob broadcasts targetTemperature matching onTemperature when set', async () => { @@ -1853,7 +1774,6 @@ describe('JobManager β€” streaming + downstream side-effects', () => { updatedAt: new Date(), }) - expect(broadcastMutationStatus).toHaveBeenCalledOnce() expect(broadcastMutationStatus).toHaveBeenCalledWith('right', { targetTemperature: 84, targetLevel: fahrenheitToLevel(84), @@ -1877,7 +1797,6 @@ describe('JobManager β€” streaming + downstream side-effects', () => { updatedAt: new Date(), }) - expect(broadcastMutationStatus).toHaveBeenCalledOnce() expect(broadcastMutationStatus).toHaveBeenCalledWith('left', { targetTemperature: 75, targetLevel: fahrenheitToLevel(75), @@ -1899,12 +1818,12 @@ describe('JobManager β€” streaming + downstream side-effects', () => { updatedAt: new Date(), }) - expect(broadcastMutationStatus).toHaveBeenCalledOnce() expect(broadcastMutationStatus).toHaveBeenCalledWith('left', { targetLevel: 0 }) }) it('runAlarmJob broadcasts alarm temperature + isAlarmVibrating:true', async () => { seedSidePowered('right', true) + insertAlarmSchedule({ side: 'right', dayOfWeek: 'thursday', time: '06:30', alarmTemperature: 88 }) await manager.runAlarmJob({ id: 1, @@ -1920,12 +1839,11 @@ describe('JobManager β€” streaming + downstream side-effects', () => { updatedAt: new Date(), }) - expect(broadcastMutationStatus).toHaveBeenCalledOnce() expect(broadcastMutationStatus).toHaveBeenCalledWith('right', { targetTemperature: 88, targetLevel: fahrenheitToLevel(88), - isAlarmVibrating: true, }) + expect(broadcastMutationStatus).toHaveBeenCalledWith('right', { isAlarmVibrating: true }) // setAlarm forwarded the exact vibration parameters from the schedule expect(setAlarm).toHaveBeenCalledWith('right', { vibrationIntensity: 90, diff --git a/src/server/helpers.ts b/src/server/helpers.ts index d4c636e19..7fda939b0 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers.ts @@ -1,6 +1,7 @@ /** * Shared helper functions for tRPC routers */ +import { TemperatureBlockedError } from '@/src/temperature/controller' import { TRPCError } from '@trpc/server' import type { HardwareClient } from '@/src/hardware/client' import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' @@ -44,6 +45,9 @@ export async function withHardwareClient( return await callback(client) } catch (error) { + if (error instanceof TemperatureBlockedError) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: error.message, cause: error }) + } if (error instanceof TRPCError) { throw error } @@ -61,6 +65,9 @@ export async function withHardwareClient( // A TRPCError from the retried callback (e.g. the pump-stall guard's // PRECONDITION_FAILED) is an intentional rejection, not a hardware // failure β€” surface it instead of masking it as a 500. + if (retryError instanceof TemperatureBlockedError) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: retryError.message, cause: retryError }) + } if (retryError instanceof TRPCError) { throw retryError } diff --git a/src/server/routers/automations.ts b/src/server/routers/automations.ts index 2a04eb976..622d99414 100644 --- a/src/server/routers/automations.ts +++ b/src/server/routers/automations.ts @@ -182,12 +182,12 @@ export const automationsRouter = router({ .meta({ openapi: { method: 'POST', path: '/automations/kill-switch', protect: false, tags: ['Autopilot'] } }) .input(z.object({ enabled: z.boolean() }).strict()) .output(z.object({ enabled: z.boolean() })) - .mutation(({ input }) => { + .mutation(async ({ input }) => { db.insert(deviceSettings) .values({ id: 1, autopilotEnabled: input.enabled }) .onConflictDoUpdate({ target: deviceSettings.id, set: { autopilotEnabled: input.enabled, updatedAt: new Date() } }) .run() - getAutomationEngineIfRunning()?.setGlobalEnabled(input.enabled) + await getAutomationEngineIfRunning()?.setGlobalEnabled(input.enabled) return { enabled: input.enabled } }), diff --git a/src/server/routers/device.ts b/src/server/routers/device.ts index 2b854ac0b..f25ee6ded 100644 --- a/src/server/routers/device.ts +++ b/src/server/routers/device.ts @@ -9,12 +9,13 @@ import { getPrimeCompletedAt, dismissPrimeNotification } from '@/src/hardware/pr import { getAllPumpStallNotices } from '@/src/hardware/pumpStallNotification' import { snoozeAlarm, cancelSnooze, getSnoozeStatus } from '@/src/hardware/snoozeManager' import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' -import { HardwareCommand, fahrenheitToLevel } from '@/src/hardware/types' +import { HardwareCommand } from '@/src/hardware/types' import type { Side } from '@/src/hardware/types' import { getSharedHardwareClient } from '@/src/hardware/sharedClient' import { markSideMutated } from '@/src/hardware/deviceStateSync' import { withSideLock } from '@/src/hardware/sideLock' -import { getAutomationEngineIfRunning } from '@/src/automation' +import { getTemperatureController, getTemperatureControlStatus } from '@/src/temperature/instance' +import { holdMinutesSchema, temperatureControlStatusSchema } from '@/src/temperature/schema' import { sideSchema, temperatureSchema, @@ -96,10 +97,6 @@ interface PendingTemp { const pendingTemps = new Map() -function registerManualOverride(side: 'left' | 'right'): void { - getAutomationEngineIfRunning()?.registerManualOverride(side) -} - /** * Device control router - direct hardware control for immediate operations. * @@ -127,6 +124,7 @@ export const deviceRouter = router({ .meta({ openapi: { method: 'GET', path: '/device/status', protect: false, tags: ['Device'] } }) .input(z.object({ unit: z.enum(['F', 'C']).default('F') }).strict()) .output(z.object({ + temperatureControl: z.object({ left: temperatureControlStatusSchema, right: temperatureControlStatusSchema }).optional(), leftSide: z.object({ currentTemperature: z.number().nullable(), targetTemperature: z.number().nullable(), @@ -262,6 +260,7 @@ export const deviceRouter = router({ return { ...status, + temperatureControl: getTemperatureControlStatus(), leftSide: { ...status.leftSide, currentTemperature: convertTemp(status.leftSide.currentTemperature), @@ -282,6 +281,26 @@ export const deviceRouter = router({ } }), + getTemperatureControl: publicProcedure + .meta({ openapi: { method: 'GET', path: '/device/temperature/control', protect: false, tags: ['Device'] } }) + .input(z.object({ side: sideSchema }).strict()) + .output(temperatureControlStatusSchema) + .query(({ input }) => getTemperatureController().status(input.side)), + + resumeTemperature: publicProcedure + .meta({ openapi: { method: 'POST', path: '/device/temperature/resume', protect: false, tags: ['Device'] } }) + .input(z.object({ side: sideSchema }).strict()) + .output(temperatureControlStatusSchema) + .mutation(async ({ input }) => { + const pending = pendingTemps.get(input.side) + if (pending) { + clearTimeout(pending.timer) + pendingTemps.delete(input.side) + pending.reject(new TRPCError({ code: 'CONFLICT', message: 'Temperature adjustment superseded by Resume' })) + } + return getTemperatureController().resume(input.side) + }), + /** * Set target temperature for a pod side. * @@ -301,9 +320,8 @@ export const deviceRouter = router({ * - Hardware handles timing automatically (no background jobs needed) * * Database State: - * - Updates target temperature immediately (optimistic) - * - Race condition: getStatus() called immediately after may show old current temp - * but new target temp while hardware is still heating/cooling + * - The controller persists the hold before commanding hardware. + * - Device state and status are published after the command succeeds. * * Concurrent Operations: * - Commands are queued and executed sequentially at hardware level @@ -322,12 +340,13 @@ export const deviceRouter = router({ side: sideSchema, temperature: temperatureSchema, duration: z.number().int().min(0).optional(), + holdMinutes: holdMinutesSchema.optional(), }) .strict() ) .output(z.object({ success: z.boolean() })) .mutation(async ({ input }) => { - assertPumpStallNotBlocked(input.side) + if (input.duration !== 0) assertPumpStallNotBlocked(input.side) // Server-side debounce: collapse rapid dial-drag calls into one hardware command. // Cancel any pending hardware call for this side BEFORE the first await @@ -338,78 +357,27 @@ export const deviceRouter = router({ existing.resolve({ success: true }) // resolve the earlier promise immediately } - markSideMutated(input.side) - - // The DB is updated optimistically on every call for responsive UI. - try { - const now = new Date() - // Only stamp poweredOnAt on OFFβ†’ON transitions (preserve the existing - // timestamp if this is a temperature change while already on). - const [prev] = await db - .select({ isPowered: deviceState.isPowered, poweredOnAt: deviceState.poweredOnAt }) - .from(deviceState) - .where(eq(deviceState.side, input.side)) - .limit(1) - const poweredOnAt = prev?.isPowered ? prev.poweredOnAt : now - await db - .update(deviceState) - .set({ - targetTemperature: input.temperature, - isPowered: true, - poweredOnAt, - lastUpdated: now, - }) - .where(eq(deviceState.side, input.side)) - } - catch (dbError) { - console.error('Failed to sync temperature state to DB:', dbError) - } - return new Promise<{ success: boolean }>((resolve, reject) => { // Register the pending entry synchronously so later calls can cancel it const timer = setTimeout(async () => { pendingTemps.delete(input.side) try { - await withSideLock(input.side, () => withHardwareClient(async (client) => { + await withSideLock(input.side, () => withHardwareClient(async () => { // Re-check inside the lock: the guard can trip during the // debounce window or while queued behind the side lock, and a // stale queued command must not re-energize a parked side. - // The manual override registers only after the check passes β€” - // a rejected command must not suspend autopilot. - assertPumpStallNotBlocked(input.side) - registerManualOverride(input.side) - await client.setTemperature(input.side, input.temperature, input.duration) + // The controller persists a hold only after the check passes. + if (input.duration !== 0) assertPumpStallNotBlocked(input.side) + await getTemperatureController().setManualLocked( + input.side, input.temperature, + input.holdMinutes === undefined ? undefined : input.holdMinutes * 60_000, + input.duration, + ) return { success: true } }, 'Failed to set temperature')) - broadcastMutationStatus(input.side, { - targetTemperature: input.temperature, - targetLevel: fahrenheitToLevel(input.temperature), - }) resolve({ success: true }) } catch (error) { - // A late guard rejection means the optimistic isPowered=true - // write above contradicts the parked hardware β€” restore the - // off-state mirror trip() wrote so the UI doesn't keep showing - // an energized target until the next status poll. - if (error instanceof TRPCError && error.code === 'PRECONDITION_FAILED') { - try { - markSideMutated(input.side) - await db - .update(deviceState) - .set({ - isPowered: false, - poweredOnAt: null, - targetTemperature: null, - lastUpdated: new Date(), - }) - .where(eq(deviceState.side, input.side)) - } - catch (dbError) { - console.error('Failed to restore parked state after guard rejection:', dbError) - } - broadcastMutationStatus(input.side, { targetLevel: 0 }) - } reject(error) } }, TEMP_DEBOUNCE_MS) @@ -428,14 +396,15 @@ export const deviceRouter = router({ * Control power state for a pod side. * * Hardware Behavior: - * - ON (powered=true): Sets temperature (default 75Β°F) and activates heating/cooling - * 75Β°F chosen as comfortable neutral temperature for most users + * - ON (powered=true): Applies the current owner or the supplied temperature + * and activates heating/cooling; 75Β°F is the fallback when no owner exists * - OFF (powered=false): Sets temperature level to 0 (neutral/82.5Β°F), stops regulation * Note: Hardware has no true "off" state - level 0 achieves same effect * * Temperature Parameter: * - Only used when powering ON - * - If omitted when powering on, defaults to 75Β°F + * - If omitted, preserves the current owner without acquiring a manual hold + * - An explicit temperature acquires the default manual hold * - Ignored when powering OFF * * Relationship to Schedules: @@ -444,7 +413,7 @@ export const deviceRouter = router({ * * @param side - Which side to control * @param powered - true to power on, false to set to neutral - * @param temperature - Target temp when powering on (default: 75Β°F, range: 55-110Β°F) + * @param temperature - Optional manual target when powering on (range: 55-110Β°F) * @throws {TRPCError} INTERNAL_SERVER_ERROR if hardware connection fails */ setPower: publicProcedure @@ -466,7 +435,14 @@ export const deviceRouter = router({ assertPumpStallNotBlocked(input.side) } - return withSideLock(input.side, () => withHardwareClient(async (client) => { + const pending = pendingTemps.get(input.side) + if (pending) { + clearTimeout(pending.timer) + pendingTemps.delete(input.side) + pending.reject(new TRPCError({ code: 'CONFLICT', message: 'Temperature adjustment superseded by power command' })) + } + + return withSideLock(input.side, () => withHardwareClient(async () => { // Re-check inside the lock (see setTemperature): a trip while this // command queued must not let it re-energize the side. The manual // override registers only after the check passes β€” a rejected @@ -474,50 +450,14 @@ export const deviceRouter = router({ if (input.powered) { assertPumpStallNotBlocked(input.side) } - registerManualOverride(input.side) - await client.setPower(input.side, input.powered, input.temperature) - - // Best-effort DB sync β€” next getStatus() call will re-sync if this fails - try { - const now = new Date() - const [prev] = await db - .select({ isPowered: deviceState.isPowered, poweredOnAt: deviceState.poweredOnAt }) - .from(deviceState) - .where(eq(deviceState.side, input.side)) - .limit(1) - // OFFβ†’ON stamps poweredOnAt; ONβ†’OFF clears it; same-state preserves. - const poweredOnAt = input.powered - ? (prev?.isPowered ? prev.poweredOnAt : now) - : null - // Always write the effective target (default 75Β°F when powering on - // without an explicit temperature; null when powering off). Without - // this, markSideMutated's freshness preservation could leave a stale - // setpoint visible past the mutation. - const targetTemperature = input.powered - ? (input.temperature ?? 75) - : null - // Stamp freshness immediately before the DB write so the 5s guard - // covers this mutation. Stamping before the hardware roundtrip - // risks the window expiring while connect/setPower run. - markSideMutated(input.side) - await db - .update(deviceState) - .set({ - isPowered: input.powered, - poweredOnAt, - targetTemperature, - lastUpdated: now, - }) - .where(eq(deviceState.side, input.side)) + if (input.powered) { + if (input.temperature === undefined) await getTemperatureController().powerOnLocked(input.side) + else await getTemperatureController().setManualLocked(input.side, input.temperature) } - catch (dbError) { - console.error('Failed to sync power state to DB:', dbError) + else { + await getTemperatureController().powerOffLocked(input.side) } - broadcastMutationStatus(input.side, input.powered - ? { targetTemperature: input.temperature ?? 75, targetLevel: fahrenheitToLevel(input.temperature ?? 75) } - : { targetLevel: 0 }, - ) return { success: true } }, 'Failed to set power')) }), diff --git a/src/server/routers/runOnce.ts b/src/server/routers/runOnce.ts index a79a0fe1e..a6a428420 100644 --- a/src/server/routers/runOnce.ts +++ b/src/server/routers/runOnce.ts @@ -5,9 +5,10 @@ import { db } from '@/src/db' import { runOnceSessions, deviceSettings } from '@/src/db/schema' import { eq, and, gt } from 'drizzle-orm' import { getJobManager } from '@/src/scheduler' -import { assertPumpStallNotBlocked, withHardwareClient } from '@/src/server/helpers' +import { assertPumpStallNotBlocked } from '@/src/server/helpers' import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' -import { fahrenheitToLevel } from '@/src/hardware/types' +import { withSideLock } from '@/src/hardware/sideLock' +import { getTemperatureController } from '@/src/temperature/instance' import { sideSchema, temperatureSchema, timeStringSchema } from '@/src/server/validation-schemas' import { timeToDate } from '@/src/scheduler/timeUtils' @@ -16,24 +17,6 @@ const setPointSchema = z.object({ temperature: temperatureSchema, }) -/** - * Best-effort power-off after a start that already energized the side fails. - * Powering off is the safe direction and is never guard-blocked; failures - * here only log β€” the caller surfaces the original error. - */ -async function powerOffAfterFailedStart(side: 'left' | 'right'): Promise { - try { - await withHardwareClient(async (client) => { - await client.setPower(side, false) - return { success: true } - }, 'Failed to power off after run-once start failure') - broadcastMutationStatus(side, { targetLevel: 0 }) - } - catch (err) { - console.error(`Failed to power off ${side} after run-once start failure:`, err) - } -} - export const runOnceRouter = router({ /** * Start a run-once session β€” applies a curve from now until wake time. @@ -75,90 +58,66 @@ export const runOnceRouter = router({ }) } - // Cancel any existing active session for this side - // (synchronous transaction β€” better-sqlite3 is sync) - db.transaction((tx) => { - const existing = tx - .select({ id: runOnceSessions.id }) - .from(runOnceSessions) - .where(and( - eq(runOnceSessions.side, input.side), - eq(runOnceSessions.status, 'active'), - )) - .all() - - for (const row of existing) { - tx.update(runOnceSessions) - .set({ status: 'cancelled' }) - .where(eq(runOnceSessions.id, row.id)) - .run() - } - }) - jobManager.cancelRunOnceSession(input.side) - - // Power on + fire first set point immediately (before inserting session, - // so a hardware failure doesn't leave an orphaned active session) - const firstTemp = input.setPoints[0].temperature - await withHardwareClient(async (client) => { - // Re-check at the write: the guard can trip during the awaited session - // cancellation and settings lookup above, and a stale start must not - // re-energize a parked side. + return withSideLock(input.side, async () => { assertPumpStallNotBlocked(input.side) - await client.setPower(input.side, true, firstTemp) - return { success: true } - }, 'Failed to start run-once session') - - broadcastMutationStatus(input.side, { - targetTemperature: firstTemp, - targetLevel: fahrenheitToLevel(firstTemp), - }) - - // Create session in DB (after hardware success). If the insert fails, - // power the side back off β€” otherwise it stays energized with no - // session to run the curve or expire it. - let session: { id: number } | undefined - try { - [session] = await db - .insert(runOnceSessions) - .values({ + const previous = db.select().from(runOnceSessions).where(and( + eq(runOnceSessions.side, input.side), eq(runOnceSessions.status, 'active'), + )).all() + const controller = getTemperatureController() + const wasPowered = controller.status(input.side).blocked === null + // Commit the session before resolving its target. Cancellation and + // insertion are atomic; a failed insert leaves the old session intact. + const session = db.transaction((tx) => { + tx.update(runOnceSessions).set({ status: 'cancelled' }).where(and( + eq(runOnceSessions.side, input.side), eq(runOnceSessions.status, 'active'), + )).run() + const row = tx.insert(runOnceSessions).values({ side: input.side, setPoints: JSON.stringify(input.setPoints), wakeTime: input.wakeTime, + startedAt: now, expiresAt, status: 'active', - }) - .returning({ id: runOnceSessions.id }) - } - catch (insertError) { - console.error('Run-once session insert failed, powering side back off:', insertError) - await powerOffAfterFailedStart(input.side) - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create run-once session', - cause: insertError, + }).returning({ id: runOnceSessions.id }).get() + if (!row) throw new Error('Failed to create run-once session') + return row }) - } - - if (!session) { - await powerOffAfterFailedStart(input.side) - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create run-once session' }) - } - - // Schedule remaining set points (skip the first β€” already applied) + cleanup - jobManager.scheduleRunOnceSession( - session.id, - input.side, - input.setPoints.slice(1), - input.wakeTime, - timezone, - ) - - console.log(`Run-once session ${session.id} started for ${input.side} until ${input.wakeTime}`) - - return { - sessionId: session.id, - expiresAt: Math.floor(expiresAt.getTime() / 1000), - } + let attemptedPower = false + try { + // Register the new jobs before energizing. Old jobs remain registered + // until success so a failed replacement can retain its original clock. + jobManager.scheduleRunOnceSession( + session.id, input.side, input.setPoints.slice(1), input.wakeTime, timezone, + ) + attemptedPower = true + await controller.powerOnLocked(input.side, input.setPoints[0].temperature) + } + catch (error) { + jobManager.cancelRunOnceSession(input.side, session.id) + db.transaction((tx) => { + tx.update(runOnceSessions).set({ status: 'cancelled' }).where(eq(runOnceSessions.id, session.id)).run() + for (const old of previous) { + if (old.expiresAt.getTime() > Date.now()) { + tx.update(runOnceSessions).set({ status: 'active' }).where(eq(runOnceSessions.id, old.id)).run() + } + } + }) + // Never leave an untracked, partially energized start running. + try { + if (attemptedPower) { + controller.invalidate(input.side) + if (wasPowered) await controller.reconcileLocked(input.side, true) + else await controller.powerOffLocked(input.side) + } + } + catch (cleanupError) { + console.warn('[runOnce] Failed to restore hardware after rejected start:', cleanupError) + } + throw error + } + for (const old of previous) jobManager.cancelRunOnceSession(input.side, old.id) + return { sessionId: session.id, expiresAt: Math.floor(expiresAt.getTime() / 1000) } + }) }), /** @@ -221,15 +180,13 @@ export const runOnceRouter = router({ .mutation(async ({ input }) => { const jobManager = await getJobManager() - await db - .update(runOnceSessions) - .set({ status: 'cancelled' }) - .where(and( - eq(runOnceSessions.side, input.side), - eq(runOnceSessions.status, 'active'), + await withSideLock(input.side, async () => { + await db.update(runOnceSessions).set({ status: 'cancelled' }).where(and( + eq(runOnceSessions.side, input.side), eq(runOnceSessions.status, 'active'), )) - - jobManager.cancelRunOnceSession(input.side) + jobManager.cancelRunOnceSession(input.side) + await getTemperatureController().reconcileLocked(input.side) + }) broadcastMutationStatus(input.side) console.log(`Run-once session cancelled for ${input.side}`) return { success: true } diff --git a/src/server/routers/tests/device.test.ts b/src/server/routers/tests/device.test.ts index 0956f24b6..abf543e31 100644 --- a/src/server/routers/tests/device.test.ts +++ b/src/server/routers/tests/device.test.ts @@ -7,7 +7,6 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TRPCError } from '@trpc/server' const helpersMock = vi.hoisted(() => { const client = { @@ -74,9 +73,12 @@ const pumpStallNotificationMock = vi.hoisted(() => ({ getAllPumpStallNotices: vi.fn<() => { left: unknown, right: unknown }>(() => ({ left: null, right: null })), })) -const automationMock = vi.hoisted(() => ({ - registerManualOverride: vi.fn(), - getAutomationEngineIfRunning: vi.fn(() => ({ registerManualOverride: automationMock.registerManualOverride })), +const controllerMock = vi.hoisted(() => ({ + setManualLocked: vi.fn(), + powerOffLocked: vi.fn(), + powerOnLocked: vi.fn(), + resume: vi.fn(), + status: vi.fn(), })) const dbState = vi.hoisted(() => ({ @@ -159,7 +161,10 @@ vi.mock('@/src/hardware/sharedClient', () => ({ getSharedHardwareClient: sharedC vi.mock('@/src/hardware/deviceStateSync', () => stateSyncMock) vi.mock('@/src/hardware/pumpStallGuard', () => pumpStallMock) vi.mock('@/src/hardware/pumpStallNotification', () => pumpStallNotificationMock) -vi.mock('@/src/automation', () => ({ getAutomationEngineIfRunning: automationMock.getAutomationEngineIfRunning })) +vi.mock('@/src/temperature/instance', () => ({ + getTemperatureController: () => controllerMock, + getTemperatureControlStatus: () => undefined, +})) vi.mock('@/src/db', () => ({ db: dbMock, biometricsDb: biometricsDbMock, @@ -210,8 +215,12 @@ beforeEach(() => { stateSyncMock.markSideMutated.mockReset() pumpStallMock.shouldBlock.mockReset().mockReturnValue(false) pumpStallNotificationMock.getAllPumpStallNotices.mockReset().mockReturnValue({ left: null, right: null }) - automationMock.registerManualOverride.mockReset() - automationMock.getAutomationEngineIfRunning.mockClear() + Object.values(controllerMock).forEach(fn => fn.mockReset()) + controllerMock.setManualLocked.mockResolvedValue(undefined) + controllerMock.powerOffLocked.mockResolvedValue(undefined) + const controlStatus = { source: 'schedule', requestId: 'schedule:1', targetTemperature: 70, holdUntil: null, blocked: null } + controllerMock.status.mockReturnValue(controlStatus) + controllerMock.resume.mockResolvedValue(controlStatus) dbState.rowsQueue.length = 0 dbMock.select.mockClear() dbMock.update.mockClear() @@ -476,475 +485,179 @@ describe('device.getStatus', () => { }) }) -describe('device.setTemperature', () => { - it('waits behind an existing shared side lock before writing hardware', async () => { +describe('temperature controller API', () => { + it('debounces legacy dial payloads and delegates a default hold without optimistic state writes', async () => { vi.useFakeTimers() - let releaseLock: () => void = () => {} - const holder = withSideLock('left', async () => new Promise((resolve) => { - releaseLock = resolve - })) - await Promise.resolve() - try { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - const promise = caller.setTemperature({ side: 'left', temperature: 70 }) - await vi.advanceTimersByTimeAsync(250) - await Promise.resolve() - - expect(helpersMock.client.setTemperature).not.toHaveBeenCalled() - - releaseLock() - await holder - await promise - - expect(helpersMock.client.setTemperature).toHaveBeenCalledWith('left', 70, undefined) - } - finally { - releaseLock() - await holder.catch(() => {}) - vi.useRealTimers() - } - }) - - it('debounces and resolves after timer fires', async () => { - vi.useFakeTimers() - try { - // Provide a prior deviceState row for the prev lookup - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - - const promise = caller.setTemperature({ side: 'left', temperature: 70 }) - await vi.advanceTimersByTimeAsync(250) - const result = await promise - - expect(result).toEqual({ success: true }) - expect(helpersMock.client.setTemperature).toHaveBeenCalledTimes(1) - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalled() - expect(stateSyncMock.markSideMutated).toHaveBeenCalledWith('left') - expect(automationMock.registerManualOverride).toHaveBeenCalledWith('left') - expect(dbChain('update').set).toHaveBeenCalledWith({ - targetTemperature: 70, - isPowered: true, - poweredOnAt: expect.any(Date), - lastUpdated: expect.any(Date), - }) - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('left', { - targetTemperature: 70, - targetLevel: expect.any(Number), - }) + const pending = caller.setTemperature({ side: 'left', temperature: 70 }) + await vi.advanceTimersByTimeAsync(100) + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() + expect(dbMock.update).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(await pending).toEqual({ success: true }) + expect(controllerMock.setManualLocked).toHaveBeenCalledExactlyOnceWith('left', 70, undefined, undefined) expect(helpersMock.withHardwareClient.mock.calls.at(-1)?.[1]).toBe('Failed to set temperature') } - finally { - vi.useRealTimers() - } + finally { vi.useRealTimers() } }) - it('preserves poweredOnAt when changing temperature on an already-powered side', async () => { + it('forwards hold minutes independently from hardware duration', async () => { vi.useFakeTimers() - const poweredOnAt = new Date('2026-07-19T20:00:00Z') try { - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt }]) - const pending = caller.setTemperature({ side: 'right', temperature: 69, duration: 600 }) + const pending = caller.setTemperature({ side: 'right', temperature: 69, duration: 600, holdMinutes: 60 }) await vi.advanceTimersByTimeAsync(250) await pending - expect(dbChain('update').set).toHaveBeenCalledWith(expect.objectContaining({ - targetTemperature: 69, - isPowered: true, - poweredOnAt, - })) - expect(helpersMock.client.setTemperature).toHaveBeenCalledWith('right', 69, 600) - } - finally { - vi.useRealTimers() + expect(controllerMock.setManualLocked).toHaveBeenCalledExactlyOnceWith('right', 69, 3_600_000, 600) } + finally { vi.useRealTimers() } }) - it('stamps poweredOnAt when changing temperature without a prior device-state row', async () => { - vi.useFakeTimers() - try { - const pending = caller.setTemperature({ side: 'left', temperature: 71 }) - await vi.advanceTimersByTimeAsync(250) - await pending - - expect(dbChain('update').set).toHaveBeenCalledWith({ - targetTemperature: 71, - isPowered: true, - poweredOnAt: expect.any(Date), - lastUpdated: expect.any(Date), - }) - } - finally { - vi.useRealTimers() - } - }) - - it('throws PRECONDITION_FAILED when pump stall guard blocks the side', async () => { - pumpStallMock.shouldBlock.mockReturnValueOnce(true) - await expect(caller.setTemperature({ side: 'left', temperature: 70 })).rejects.toThrow(/Pump stall protection active/) - expect(helpersMock.client.setTemperature).not.toHaveBeenCalled() - expect(automationMock.registerManualOverride).not.toHaveBeenCalled() + it.each([0, 1441, 1.5])('rejects invalid hold minutes %s', async (holdMinutes) => { + await expect(caller.setTemperature({ side: 'left', temperature: 70, holdMinutes })).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() }) - it('re-checks the guard inside the side lock β€” a trip during the debounce window blocks the queued command', async () => { + it('coalesces adjustments into one hold with the last target and duration', async () => { vi.useFakeTimers() try { - // Early check passes, then the guard trips while the command is debounced. - pumpStallMock.shouldBlock.mockReturnValueOnce(false).mockReturnValue(true) - const pending = caller.setTemperature({ side: 'left', temperature: 70 }) - const assertion = expect(pending).rejects.toThrow(/Pump stall protection active/) + const first = caller.setTemperature({ side: 'left', temperature: 70, holdMinutes: 15 }) + await vi.advanceTimersByTimeAsync(0) + const last = caller.setTemperature({ side: 'left', temperature: 75, holdMinutes: 60 }) await vi.advanceTimersByTimeAsync(250) - await assertion - expect(helpersMock.client.setTemperature).not.toHaveBeenCalled() - // A rejected command must not suspend autopilot. - expect(automationMock.registerManualOverride).not.toHaveBeenCalled() - } - finally { - vi.useRealTimers() + expect(await Promise.all([first, last])).toEqual([{ success: true }, { success: true }]) + expect(controllerMock.setManualLocked).toHaveBeenCalledExactlyOnceWith('left', 75, 3_600_000, undefined) } + finally { vi.useRealTimers() } + }) + + it('rejects immediately when the guard blocks the side', async () => { + pumpStallMock.shouldBlock.mockReturnValue(true) + await expect(caller.setTemperature({ side: 'left', temperature: 70 })).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() }) - it('preserves a guard rejection when restoring the parked mirror also fails', async () => { + it.each([true, false])('permits duration-zero shutdown when protection was initially %s', async (initiallyBlocked) => { vi.useFakeTimers() - const failure = new Error('database locked') - const error = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - pumpStallMock.shouldBlock.mockReturnValueOnce(false).mockReturnValue(true) - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt: new Date() }]) - const pending = caller.setTemperature({ side: 'left', temperature: 70 }) - const assertion = expect(pending).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + pumpStallMock.shouldBlock.mockReturnValue(initiallyBlocked) + const pending = caller.setTemperature({ side: 'left', temperature: 70, duration: 0 }) await vi.advanceTimersByTimeAsync(0) - dbMock.update.mockImplementationOnce(() => { - throw failure - }) + pumpStallMock.shouldBlock.mockReturnValue(true) await vi.advanceTimersByTimeAsync(250) - await assertion - expect(error).toHaveBeenCalledWith('Failed to restore parked state after guard rejection:', failure) - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('left', { targetLevel: 0 }) - expect(helpersMock.client.setTemperature).not.toHaveBeenCalled() - } - finally { - error.mockRestore() - vi.useRealTimers() + expect(await pending).toEqual({ success: true }) + expect(controllerMock.setManualLocked).toHaveBeenCalledExactlyOnceWith('left', 70, undefined, 0) } + finally { vi.useRealTimers() } }) - it('restores the parked DB mirror and broadcasts off when the in-lock recheck rejects', async () => { + it('rechecks the guard after the debounce without overwriting controller state', async () => { vi.useFakeTimers() try { pumpStallMock.shouldBlock.mockReturnValueOnce(false).mockReturnValue(true) - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt: new Date() }]) - const pending = caller.setTemperature({ side: 'left', temperature: 70 }) - const assertion = expect(pending).rejects.toThrow(/Pump stall protection active/) + const assertion = expect(caller.setTemperature({ side: 'left', temperature: 70 })).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) await vi.advanceTimersByTimeAsync(250) await assertion - // update #0 is the optimistic energized write; update #1 must put the - // parked mirror back so the UI doesn't keep an energized target. - expect(dbChain('update', 1).set).toHaveBeenCalledWith({ - isPowered: false, - poweredOnAt: null, - targetTemperature: null, - lastUpdated: expect.any(Date), - }) - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('left', { targetLevel: 0 }) - } - finally { - vi.useRealTimers() + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() + expect(dbMock.update).not.toHaveBeenCalled() + expect(broadcastMock.broadcastMutationStatus).not.toHaveBeenCalled() } + finally { vi.useRealTimers() } }) - it('blocks a debounced command queued behind a held side lock when the trip lands while queued', async () => { + it('waits for the shared lock and refuses a trip that lands while queued', async () => { vi.useFakeTimers() + let release = () => {} + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve + })) + await Promise.resolve() try { - let tripped = false - pumpStallMock.shouldBlock.mockImplementation(() => tripped) - let release: () => void = () => {} - const holder = withSideLock('left', async () => new Promise((resolve) => { - release = resolve - })) - await vi.advanceTimersByTimeAsync(0) - - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) const pending = caller.setTemperature({ side: 'left', temperature: 70 }) - const assertion = expect(pending).rejects.toThrow(/Pump stall protection active/) - // Debounce fires and the write queues behind the held lock, all while - // the guard is still healthy β€” the flip below happens strictly after. + const assertion = expect(pending).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) await vi.advanceTimersByTimeAsync(250) - tripped = true + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() + pumpStallMock.shouldBlock.mockReturnValue(true) release() await holder await assertion - expect(helpersMock.client.setTemperature).not.toHaveBeenCalled() - expect(automationMock.registerManualOverride).not.toHaveBeenCalled() } finally { + release() + await holder vi.useRealTimers() } }) - it('swallows DB sync errors so the timer still fires', async () => { + it('propagates controller persistence or hardware failure without acknowledging the adjustment', async () => { vi.useFakeTimers() - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - dbMock.select.mockImplementationOnce(() => { - throw new Error('db down') - }) - - const promise = caller.setTemperature({ side: 'left', temperature: 70 }) + controllerMock.setManualLocked.mockRejectedValueOnce(new Error('storage unavailable')) + const assertion = expect(caller.setTemperature({ side: 'left', temperature: 70 })).rejects.toThrow('storage unavailable') await vi.advanceTimersByTimeAsync(250) - const result = await promise - - expect(result).toEqual({ success: true }) - expect(errSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to sync temperature state to DB'), - expect.any(Error), - ) - } - finally { - vi.useRealTimers() - errSpy.mockRestore() + await assertion + expect(dbMock.update).not.toHaveBeenCalled() } + finally { vi.useRealTimers() } }) - it('rejects the debounced promise when the deferred hardware call fails', async () => { - vi.useFakeTimers() - try { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - helpersMock.withHardwareClient.mockRejectedValueOnce(new Error('hw down')) - - const promise = caller.setTemperature({ side: 'left', temperature: 70 }) - // Swallow synchronously so vitest does not flag an unhandled rejection - // when the timer fires before the assertion below awaits the promise. - const caught = promise.catch((e: unknown) => e) - await vi.advanceTimersByTimeAsync(250) - const err = await caught - expect(err).toBeInstanceOf(Error) - expect((err as Error).message).toMatch(/hw down/) - // Only guard rejections compensate the optimistic write β€” a hardware - // failure leaves the single optimistic update in place. - expect(dbMock.update).toHaveBeenCalledTimes(1) - expect(broadcastMock.broadcastMutationStatus).not.toHaveBeenCalledWith('left', { targetLevel: 0 }) - } - finally { - vi.useRealTimers() - } + it('reads ownership for the requested side', async () => { + expect(await caller.getTemperatureControl({ side: 'right' })).toMatchObject({ source: 'schedule', holdUntil: null }) + expect(controllerMock.status).toHaveBeenCalledWith('right') }) - it('leaves the optimistic write in place when the failure is not a guard rejection', async () => { + it.each(['resume', 'off'] as const)('%s cancels a pending dial adjustment', async (action) => { vi.useFakeTimers() try { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - // A wrapped hardware failure is a TRPCError too β€” only the guard's - // PRECONDITION_FAILED code may trigger the parked-mirror restore. - helpersMock.withHardwareClient.mockRejectedValueOnce( - new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to set temperature' }), - ) - - const promise = caller.setTemperature({ side: 'left', temperature: 70 }) - const caught = promise.catch((e: unknown) => e) + const pending = caller.setTemperature({ side: 'left', temperature: 70 }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'CONFLICT' }) + await vi.advanceTimersByTimeAsync(0) + if (action === 'resume') await caller.resumeTemperature({ side: 'left' }) + else await caller.setPower({ side: 'left', powered: false }) + await assertion await vi.advanceTimersByTimeAsync(250) - const err = await caught - - expect(err).toBeInstanceOf(TRPCError) - expect((err as TRPCError).code).toBe('INTERNAL_SERVER_ERROR') - // Single optimistic update, no compensating off-write, no off broadcast. - expect(dbMock.update).toHaveBeenCalledTimes(1) - expect(broadcastMock.broadcastMutationStatus).not.toHaveBeenCalledWith('left', { targetLevel: 0 }) - } - finally { - vi.useRealTimers() + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() + expect(action === 'resume' ? controllerMock.resume : controllerMock.powerOffLocked).toHaveBeenCalledWith('left') } + finally { vi.useRealTimers() } }) - it('coalesces a second call into one hardware command (last value wins)', async () => { - vi.useFakeTimers() - try { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt: new Date() }]) + it('creates a hold only for an explicit power-on temperature', async () => { + expect(await caller.setPower({ side: 'left', powered: true, temperature: 72 })).toEqual({ success: true }) + await caller.setPower({ side: 'right', powered: true }) + expect(controllerMock.setManualLocked.mock.calls).toEqual([['left', 72]]) + expect(controllerMock.powerOnLocked).toHaveBeenCalledExactlyOnceWith('right') + expect(dbMock.update).not.toHaveBeenCalled() + }) - // Start call 1 and let it register its pending entry (await microtasks - // so the async DB lookup before pendingTemps.set has a chance to settle). - const p1 = caller.setTemperature({ side: 'left', temperature: 70 }) - await vi.advanceTimersByTimeAsync(0) - // Call 2 now sees the pending entry and cancels the first timer. - const p2 = caller.setTemperature({ side: 'left', temperature: 75 }) - await vi.advanceTimersByTimeAsync(250) - const [r1, r2] = await Promise.all([p1, p2]) - expect(r1).toEqual({ success: true }) - expect(r2).toEqual({ success: true }) - // Only the second call's value reaches hardware - expect(helpersMock.client.setTemperature).toHaveBeenCalledTimes(1) - expect(helpersMock.client.setTemperature).toHaveBeenCalledWith('left', 75, undefined) - // One executed hardware write β†’ one override registration; the - // coalesced first call must not register a second one. - expect(automationMock.registerManualOverride).toHaveBeenCalledTimes(1) - expect(automationMock.registerManualOverride).toHaveBeenLastCalledWith('left') - } - finally { - vi.useRealTimers() - } + it('never gates shutdown behind pump protection', async () => { + pumpStallMock.shouldBlock.mockReturnValue(true) + await caller.setPower({ side: 'left', powered: false }) + expect(controllerMock.powerOffLocked).toHaveBeenCalledExactlyOnceWith('left') + expect(controllerMock.setManualLocked).not.toHaveBeenCalled() }) -}) -describe('device.setPower', () => { - it('waits behind an existing shared side lock without blocking the opposite side', async () => { - let releaseLeft: () => void = () => {} - const holder = withSideLock('left', async () => new Promise((resolve) => { - releaseLeft = resolve + it('serializes power by side and rechecks protection inside the lock', async () => { + let release = () => {} + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve })) await Promise.resolve() - + const pending = caller.setPower({ side: 'left', powered: true, temperature: 72 }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) try { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - const left = caller.setPower({ side: 'left', powered: true, temperature: 72 }) - await Promise.resolve() - expect(helpersMock.client.setPower).not.toHaveBeenCalled() - - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) await caller.setPower({ side: 'right', powered: true, temperature: 73 }) - expect(helpersMock.client.setPower).toHaveBeenCalledWith('right', true, 73) - expect(helpersMock.client.setPower).not.toHaveBeenCalledWith('left', true, 72) - - releaseLeft() + expect(controllerMock.setManualLocked).toHaveBeenCalledExactlyOnceWith('right', 73) + pumpStallMock.shouldBlock.mockImplementation(side => side === 'left') + release() await holder - await left - - expect(helpersMock.client.setPower).toHaveBeenCalledWith('left', true, 72) + await assertion + expect(controllerMock.setManualLocked).toHaveBeenCalledTimes(1) } finally { - releaseLeft() - await holder.catch(() => {}) + release() + await holder } }) - - it('powers on with the provided temperature, broadcasts, syncs DB', async () => { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - - const result = await caller.setPower({ side: 'left', powered: true, temperature: 72 }) - expect(result).toEqual({ success: true }) - expect(helpersMock.client.setPower).toHaveBeenCalledWith('left', true, 72) - expect(automationMock.registerManualOverride).toHaveBeenCalledWith('left') - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('left', expect.objectContaining({ - targetTemperature: 72, - })) - expect(dbChain('update').set).toHaveBeenCalledWith({ - isPowered: true, - poweredOnAt: expect.any(Date), - targetTemperature: 72, - lastUpdated: expect.any(Date), - }) - expect(stateSyncMock.markSideMutated).toHaveBeenCalledWith('left') - expect(helpersMock.withHardwareClient.mock.calls[0]?.[1]).toBe('Failed to set power') - }) - - it('persists an OFF-to-ON transition without a prior device-state row', async () => { - await caller.setPower({ side: 'right', powered: true, temperature: 72 }) - - expect(dbChain('update').set).toHaveBeenCalledWith({ - isPowered: true, - poweredOnAt: expect.any(Date), - targetTemperature: 72, - lastUpdated: expect.any(Date), - }) - expect(stateSyncMock.markSideMutated).toHaveBeenCalledWith('right') - }) - - it('powers off and broadcasts targetLevel: 0', async () => { - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt: new Date() }]) - - await caller.setPower({ side: 'left', powered: false }) - expect(helpersMock.client.setPower).toHaveBeenCalledWith('left', false, undefined) - expect(automationMock.registerManualOverride).toHaveBeenCalledWith('left') - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('left', { targetLevel: 0 }) - expect(dbChain('update').set).toHaveBeenCalledWith({ - isPowered: false, - poweredOnAt: null, - targetTemperature: null, - lastUpdated: expect.any(Date), - }) - }) - - it('defaults an omitted on-temperature to 75 in both DB state and broadcast', async () => { - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - await caller.setPower({ side: 'right', powered: true }) - expect(dbChain('update').set).toHaveBeenCalledWith(expect.objectContaining({ targetTemperature: 75 })) - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('right', { - targetTemperature: 75, - targetLevel: expect.any(Number), - }) - }) - - it('preserves the original poweredOnAt on an ONβ†’ON mutation', async () => { - const poweredOnAt = new Date('2026-07-18T12:00:00Z') - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt }]) - await caller.setPower({ side: 'left', powered: true, temperature: 68 }) - expect(dbChain('update').set).toHaveBeenCalledWith(expect.objectContaining({ poweredOnAt, targetTemperature: 68 })) - }) - - it('throws PRECONDITION_FAILED when powering on while pump stall guard is active', async () => { - pumpStallMock.shouldBlock.mockReturnValueOnce(true) - await expect(caller.setPower({ side: 'left', powered: true, temperature: 72 })).rejects.toThrow(/Pump stall protection active/) - expect(helpersMock.client.setPower).not.toHaveBeenCalled() - expect(automationMock.registerManualOverride).not.toHaveBeenCalled() - }) - - it('re-checks the guard inside the side lock before sending power-on', async () => { - // Early check passes; the trip lands while the command queues on the lock. - pumpStallMock.shouldBlock.mockReturnValueOnce(false).mockReturnValue(true) - await expect(caller.setPower({ side: 'left', powered: true, temperature: 72 })).rejects.toThrow(/Pump stall protection active/) - expect(helpersMock.client.setPower).not.toHaveBeenCalled() - // A rejected command must not suspend autopilot. - expect(automationMock.registerManualOverride).not.toHaveBeenCalled() - }) - - it('blocks a power-on queued behind a held side lock when the trip lands while queued', async () => { - let tripped = false - pumpStallMock.shouldBlock.mockImplementation(() => tripped) - let release: () => void = () => {} - const holder = withSideLock('left', async () => new Promise((resolve) => { - release = resolve - })) - await Promise.resolve() - - dbState.rowsQueue.push([{ isPowered: false, poweredOnAt: null }]) - const pending = caller.setPower({ side: 'left', powered: true, temperature: 72 }) - const assertion = expect(pending).rejects.toThrow(/Pump stall protection active/) - // Let the mutation pass its entry check and queue on the held lock - // while the guard is still healthy β€” the flip below happens strictly - // after, so only an in-lock recheck can observe it. - await new Promise((resolve) => { - setTimeout(resolve, 0) - }) - expect(pumpStallMock.shouldBlock).toHaveBeenCalled() - tripped = true - release() - await holder - await assertion - expect(helpersMock.client.setPower).not.toHaveBeenCalled() - expect(automationMock.registerManualOverride).not.toHaveBeenCalled() - }) - - it('allows powering off even when pump stall guard is active', async () => { - pumpStallMock.shouldBlock.mockReturnValueOnce(true) - dbState.rowsQueue.push([{ isPowered: true, poweredOnAt: new Date() }]) - await caller.setPower({ side: 'left', powered: false }) - expect(helpersMock.client.setPower).toHaveBeenCalledWith('left', false, undefined) - }) - - it('swallows DB sync errors but still completes the hardware call', async () => { - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - // First select() throws β€” DB sync block fails, but withHardwareClient already - // ran setPower before that point so the result still succeeds. - dbMock.select.mockImplementationOnce(() => { - throw new Error('db down') - }) - const result = await caller.setPower({ side: 'left', powered: true, temperature: 72 }) - expect(result).toEqual({ success: true }) - expect(helpersMock.client.setPower).toHaveBeenCalledWith('left', true, 72) - expect(errSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to sync power state to DB'), - expect.any(Error), - ) - errSpy.mockRestore() - }) }) describe('device.setAlarm / clearAlarm / snoozeAlarm', () => { diff --git a/src/server/routers/tests/runOnce.test.ts b/src/server/routers/tests/runOnce.test.ts index ebe40fa26..0e20656a1 100644 --- a/src/server/routers/tests/runOnce.test.ts +++ b/src/server/routers/tests/runOnce.test.ts @@ -1,464 +1,201 @@ -/** - * Tests for the runOnce router β€” start (validates duration cap, primes hardware - * BEFORE inserting session, schedules remaining set points), getActive (parses - * setPoints JSON or returns empty on malformed), cancel. - * - * Db (better-sqlite3 chain), scheduler, hardware client, broadcaster, and - * timezone resolver all mocked. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const jobManagerMock = vi.hoisted(() => ({ - cancelRunOnceSession: vi.fn(), - scheduleRunOnceSession: vi.fn(), -})) - -const schedulerMock = vi.hoisted(() => ({ - getJobManager: vi.fn(async () => jobManagerMock), -})) - -const helpersMock = vi.hoisted(() => ({ - client: { setPower: vi.fn(async () => undefined) }, - withHardwareClient: vi.fn(), -})) - -const broadcastMock = vi.hoisted(() => ({ - broadcastMutationStatus: vi.fn(), -})) - -const pumpStallMock = vi.hoisted(() => ({ - shouldBlock: vi.fn<(side: 'left' | 'right') => boolean>(() => false), -})) - -const timeUtilsMock = vi.hoisted(() => ({ - // Default: wake time 1 hour from now - timeToDate: vi.fn((_t: string, _tz: string, now: Date) => new Date(now.getTime() + 60 * 60 * 1000)), +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eq } from 'drizzle-orm' +import { resetControlDatabase } from '@/src/temperature/tests/databaseFixture' + +const hardware = vi.hoisted(() => ({ + connect: vi.fn(async () => {}), + setTemperature: vi.fn<(side: string, temperature: number, duration?: number) => Promise>(async () => {}), + setPower: vi.fn<(side: string, on: boolean) => Promise>(async () => {}), })) - -// DB mock with branchable behaviours per test. The router uses three patterns: -// 1. db.transaction(tx => …) where tx exposes select/update with .all()/.run() -// 2. db.select().from().limit() (await-thenable returning a deviceSettings row) -// 3. db.insert().values().returning() (await-thenable returning [{ id }]) -// 4. db.update().set().where() (await-thenable, used by cancel) -const dbMock = vi.hoisted(() => { - // settings select chain β€” used to fetch timezone - const settingsRow: { timezone: string | null } = { timezone: 'America/Los_Angeles' } - const limit = vi.fn(async () => [settingsRow]) - const from = vi.fn(() => ({ limit })) - const select = vi.fn(() => ({ from })) - - // insert chain β€” returns [{ id }] from .returning() - let nextInsertedId = 42 - const returning = vi.fn(async () => [{ id: nextInsertedId }]) - const values = vi.fn(() => ({ returning })) - const insert = vi.fn(() => ({ values })) - - // update chain β€” cancel calls db.update(...).set(...).where(...) - const updateWhere = vi.fn(async () => undefined) - const updateSet = vi.fn(() => ({ where: updateWhere })) - const update = vi.fn(() => ({ set: updateSet })) - - // transaction β€” synchronous, runs callback with a tx that exposes - // select/update with the chain ending in .all()/.run(). - const txExisting: { id: number }[] = [] - const txSelectAll = vi.fn(() => txExisting) - const txSelectWhere = vi.fn(() => ({ all: txSelectAll })) - const txSelectFrom = vi.fn(() => ({ where: txSelectWhere })) - const txSelect = vi.fn(() => ({ from: txSelectFrom })) - const txUpdateRun = vi.fn(() => undefined) - const txUpdateWhere = vi.fn(() => ({ run: txUpdateRun })) - const txUpdateSet = vi.fn(() => ({ where: txUpdateWhere })) - const txUpdate = vi.fn(() => ({ set: txUpdateSet })) - const transaction = vi.fn((cb: (tx: unknown) => unknown) => cb({ - select: txSelect, - update: txUpdate, - })) - - return { - select, insert, update, transaction, - setNextInsertId: (id: number | null) => { - nextInsertedId = id as number - returning.mockResolvedValue(id === null ? [] : [{ id }]) - }, - settingsRow, txExisting, - values, updateSet, updateWhere, txUpdateSet, txUpdateWhere, txUpdateRun, transactionFn: transaction, - } +const scheduler = vi.hoisted(() => ({ cancelRunOnceSession: vi.fn(), scheduleRunOnceSession: vi.fn() })) +const guard = vi.hoisted(() => ({ shouldBlock: vi.fn(() => false) })) +vi.mock('@/src/scheduler', () => ({ getJobManager: async () => scheduler })) +vi.mock('@/src/hardware/pumpStallGuard', () => guard) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getSharedHardwareClient: () => hardware })) +vi.mock('@/src/streaming/broadcastMutationStatus', () => ({ broadcastMutationStatus: vi.fn() })) +vi.mock('@/src/hardware/deviceStateSync', () => ({ markSideMutated: vi.fn() })) +vi.mock('@/src/db', async () => { + const Database = (await import('better-sqlite3')).default + const { drizzle } = await import('drizzle-orm/better-sqlite3') + const schema = await import('@/src/db/schema') + const sqlite = new Database(':memory:') + return { sqlite, db: drizzle(sqlite, { schema }) } }) -vi.mock('@/src/scheduler', () => schedulerMock) -vi.mock('@/src/hardware/pumpStallGuard', () => pumpStallMock) -vi.mock('@/src/server/helpers', async () => { - const { TRPCError } = await import('@trpc/server') - return { - ...helpersMock, - // Mirrors the real helper but consults this file's pumpStallMock so - // tests keep driving the guard through shouldBlock. - assertPumpStallNotBlocked: (side: 'left' | 'right') => { - if (pumpStallMock.shouldBlock(side)) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'Pump stall protection active β€” re-enable the side first', - }) - } - }, - } -}) -vi.mock('@/src/streaming/broadcastMutationStatus', () => broadcastMock) -vi.mock('@/src/scheduler/timeUtils', () => timeUtilsMock) -vi.mock('@/src/db', () => ({ - db: { - select: dbMock.select, - insert: dbMock.insert, - update: dbMock.update, - transaction: dbMock.transaction, - }, - biometricsDb: {}, -})) +import { db, sqlite } from '@/src/db' +import { deviceSettings, deviceState, runOnceSessions, temperatureSchedules } from '@/src/db/schema' +import { runOnceRouter } from '../runOnce' +import { getTemperatureController } from '@/src/temperature/instance' +import { withSideLock } from '@/src/hardware/sideLock' -const { runOnceRouter } = await import('@/src/server/routers/runOnce') const caller = runOnceRouter.createCaller({}) +const input = { side: 'left' as const, setPoints: [{ time: '22:00', temperature: 74 }, { time: '23:00', temperature: 68 }], wakeTime: '07:00' } beforeEach(() => { - jobManagerMock.cancelRunOnceSession.mockReset() - jobManagerMock.scheduleRunOnceSession.mockReset() - helpersMock.withHardwareClient.mockClear() - helpersMock.withHardwareClient.mockImplementation(async (cb: (client: unknown) => Promise) => cb(helpersMock.client)) - helpersMock.client.setPower.mockReset().mockResolvedValue(undefined) - broadcastMock.broadcastMutationStatus.mockReset() - pumpStallMock.shouldBlock.mockReset().mockReturnValue(false) - timeUtilsMock.timeToDate.mockReset().mockImplementation( - (_t, _tz, now) => new Date(now.getTime() + 60 * 60 * 1000), - ) - dbMock.select.mockClear() - dbMock.insert.mockClear() - dbMock.update.mockClear() - dbMock.transactionFn.mockClear() - dbMock.values.mockClear() - dbMock.updateSet.mockClear() - dbMock.updateWhere.mockClear() - dbMock.txUpdateSet.mockClear() - dbMock.txUpdateWhere.mockClear() - dbMock.txExisting.length = 0 - dbMock.setNextInsertId(42) - dbMock.settingsRow.timezone = 'America/Los_Angeles' + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-28T22:00:00Z')) + vi.resetAllMocks() + guard.shouldBlock.mockReturnValue(false) + resetControlDatabase(sqlite) + db.insert(deviceSettings).values({ id: 1, timezone: 'UTC' }).run() + db.insert(deviceState).values({ side: 'left', isPowered: false }).run() }) - -describe('runOnce.start', () => { - it('throws PRECONDITION_FAILED before touching sessions while pump stall guard blocks the side', async () => { - pumpStallMock.shouldBlock.mockReturnValue(true) - - await expect(caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toThrow(/Pump stall protection active/) - - // Fails fast: the existing session is not cancelled, no hardware write, - // no session row inserted. - expect(dbMock.transactionFn).not.toHaveBeenCalled() - expect(jobManagerMock.cancelRunOnceSession).not.toHaveBeenCalled() - expect(helpersMock.client.setPower).not.toHaveBeenCalled() - expect(dbMock.insert).not.toHaveBeenCalled() - }) - - it('re-checks the guard at the hardware write and blocks a trip that lands mid-flight', async () => { - // Entry check passes, then the guard trips during the awaited session - // cancellation / settings lookup β€” the write-time re-check must stop the - // power-on and the session insert. - pumpStallMock.shouldBlock.mockReturnValueOnce(false).mockReturnValue(true) - - await expect(caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toMatchObject({ - code: 'PRECONDITION_FAILED', - message: 'Pump stall protection active β€” re-enable the side first', - }) - - expect(dbMock.transactionFn).toHaveBeenCalled() - expect(helpersMock.client.setPower).not.toHaveBeenCalled() - expect(broadcastMock.broadcastMutationStatus).not.toHaveBeenCalled() - expect(dbMock.insert).not.toHaveBeenCalled() - expect(jobManagerMock.scheduleRunOnceSession).not.toHaveBeenCalled() - }) - - it('powers on the side with the first set-point temperature, persists session, and schedules the rest', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const expiresAt = new Date(Date.now() + 3_600_123) - timeUtilsMock.timeToDate.mockReturnValueOnce(expiresAt) - const result = await caller.start({ - side: 'left', - setPoints: [ - { time: '23:00', temperature: 70 }, - { time: '03:00', temperature: 65 }, - ], - wakeTime: '07:00', - }) - - // Hardware was invoked with the first set-point temp - expect(helpersMock.withHardwareClient).toHaveBeenCalledTimes(1) - expect(helpersMock.withHardwareClient.mock.calls[0]?.[1]).toBe('Failed to start run-once session') - expect(helpersMock.client.setPower).toHaveBeenCalledWith('left', true, 70) - - // Mutation broadcast for live UI - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('left', expect.objectContaining({ - targetTemperature: 70, - })) - - // Scheduler receives the *remaining* set points (slice 1) β€” the first - // already fired immediately. - expect(jobManagerMock.scheduleRunOnceSession).toHaveBeenCalledTimes(1) - const args = jobManagerMock.scheduleRunOnceSession.mock.calls[0] - expect(args[0]).toBe(42) // session id - expect(args[1]).toBe('left') - expect(args[2]).toEqual([{ time: '03:00', temperature: 65 }]) - expect(args[3]).toBe('07:00') - expect(args[4]).toBe('America/Los_Angeles') - - expect(dbMock.values).toHaveBeenCalledWith({ - side: 'left', - setPoints: JSON.stringify([ - { time: '23:00', temperature: 70 }, - { time: '03:00', temperature: 65 }, - ]), - wakeTime: '07:00', - expiresAt, - status: 'active', - }) - - expect(result.sessionId).toBe(42) - expect(result.expiresAt).toBe(Math.floor(expiresAt.getTime() / 1000)) - expect(log).toHaveBeenCalledWith('Run-once session 42 started for left until 07:00') - log.mockRestore() - }) - - it('falls back to the default timezone when settings row is missing', async () => { - // No settings row found - dbMock.settingsRow.timezone = null as unknown as string - // Also exercise the empty-array path: replace from()β†’limit() with empty - const limit = vi.fn(async () => []) - const from = vi.fn(() => ({ limit })) - dbMock.select.mockReturnValueOnce({ from } as never) - - await caller.start({ - side: 'right', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - }) - - const args = jobManagerMock.scheduleRunOnceSession.mock.calls[0] - expect(args[4]).toBe('America/Los_Angeles') - }) - - it('rejects sessions longer than 14 hours (catches wake-time-already-passed bug)', async () => { - timeUtilsMock.timeToDate.mockImplementation( - (_t, _tz, now) => new Date(now.getTime() + 20 * 60 * 60 * 1000), - ) - - await expect(caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toThrow(/Session too long/) - - // Hardware must NOT be touched if validation rejects - expect(helpersMock.withHardwareClient).not.toHaveBeenCalled() - expect(jobManagerMock.scheduleRunOnceSession).not.toHaveBeenCalled() - // And the existing active session must survive a rejected start β€” - // validation runs before any cancellation. - expect(dbMock.transactionFn).not.toHaveBeenCalled() - expect(jobManagerMock.cancelRunOnceSession).not.toHaveBeenCalled() - }) - - it('accepts a session exactly 14 hours long', async () => { - timeUtilsMock.timeToDate.mockImplementation( - (_t, _tz, now) => new Date(now.getTime() + 14 * 60 * 60 * 1000), - ) - await expect(caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).resolves.toMatchObject({ sessionId: 42 }) - expect(helpersMock.client.setPower).toHaveBeenCalledOnce() - }) - - it('reports the rounded over-limit duration in hours', async () => { - timeUtilsMock.timeToDate.mockImplementation( - (_t, _tz, now) => new Date(now.getTime() + 15.4 * 60 * 60 * 1000), - ) - await expect(caller.start({ - side: 'right', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toMatchObject({ - code: 'BAD_REQUEST', - message: 'Session too long (15h). Wake time may have already passed.', - }) - }) - - it('throws when DB insert returns no row and powers the side back off', async () => { - dbMock.setNextInsertId(null) - - await expect(caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toThrow(/Failed to create run-once session/) - - // The side was already energized β€” a failed insert must not leave it - // running with no session to drive or expire it. - expect(helpersMock.client.setPower).toHaveBeenNthCalledWith(1, 'left', true, 70) - expect(helpersMock.client.setPower).toHaveBeenNthCalledWith(2, 'left', false) - expect(broadcastMock.broadcastMutationStatus).toHaveBeenLastCalledWith('left', { targetLevel: 0 }) - }) - - it('powers the side back off when the session insert throws', async () => { - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - dbMock.insert.mockImplementationOnce(() => { - throw new Error('db down') - }) - - await expect(caller.start({ - side: 'right', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toMatchObject({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create run-once session', - }) - - expect(helpersMock.client.setPower).toHaveBeenNthCalledWith(2, 'right', false) - expect(jobManagerMock.scheduleRunOnceSession).not.toHaveBeenCalled() - errSpy.mockRestore() - }) - - it('surfaces the insert failure even when the compensating power-off also fails', async () => { - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - dbMock.setNextInsertId(null) - helpersMock.client.setPower - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('off failed')) - - await expect(caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - })).rejects.toThrow(/Failed to create run-once session/) - - expect(errSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to power off left after run-once start failure'), - expect.anything(), - ) - errSpy.mockRestore() - }) - - it('cancels any prior active session for the same side before starting', async () => { - // Pretend an existing active row exists for this side - dbMock.txExisting.push({ id: 7 }, { id: 9 }) - - await caller.start({ - side: 'left', - setPoints: [{ time: '23:00', temperature: 70 }], - wakeTime: '07:00', - }) - - // Both prior rows were updated to cancelled (one .run() per row) - expect(dbMock.txUpdateRun).toHaveBeenCalledTimes(2) - expect(dbMock.txUpdateSet).toHaveBeenNthCalledWith(1, { status: 'cancelled' }) - expect(dbMock.txUpdateSet).toHaveBeenNthCalledWith(2, { status: 'cancelled' }) - expect(jobManagerMock.cancelRunOnceSession).toHaveBeenCalledWith('left') - }) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() }) -describe('runOnce.getActive', () => { - it('returns null when no active session', async () => { - const limit = vi.fn(async () => []) - const where = vi.fn(() => ({ limit })) - const from = vi.fn(() => ({ where })) - dbMock.select.mockReturnValueOnce({ from } as never) - - const result = await caller.getActive({ side: 'left' }) - expect(result).toBeNull() - }) +function activeRows() { + return db.select().from(runOnceSessions).where(eq(runOnceSessions.status, 'active')).all() +} - it('parses persisted setPoints JSON', async () => { - const session = { - id: 99, - side: 'left' as const, - setPoints: JSON.stringify([{ time: '02:00', temperature: 67 }]), - wakeTime: '07:00', - startedAt: new Date(1700000000000), - expiresAt: new Date(1700100000000), - status: 'active' as const, - } - const limit = vi.fn(async () => [session]) - const where = vi.fn(() => ({ limit })) - const from = vi.fn(() => ({ where })) - dbMock.select.mockReturnValueOnce({ from } as never) - - const result = await caller.getActive({ side: 'left' }) - expect(result).not.toBeNull() - expect(result?.id).toBe(99) - expect(result?.setPoints).toEqual([{ time: '02:00', temperature: 67 }]) - expect(result?.startedAt).toBe(Math.floor(1700000000000 / 1000)) - expect(result?.expiresAt).toBe(Math.floor(1700100000000 / 1000)) - expect(result).toMatchObject({ - side: 'left', wakeTime: '07:00', status: 'active', +describe('run-once API with real controller and SQLite transactions', () => { + it('commits the session before resolving its first target, then schedules remaining points', async () => { + hardware.setTemperature.mockImplementationOnce(async () => { + expect(activeRows()).toHaveLength(1) }) - }) - - it('returns empty setPoints when persisted JSON is malformed', async () => { - const session = { - id: 100, - side: 'left' as const, - setPoints: '{not json', - wakeTime: '07:00', - startedAt: new Date(), - expiresAt: new Date(Date.now() + 1000), - status: 'active' as const, - } - const limit = vi.fn(async () => [session]) - const where = vi.fn(() => ({ limit })) - const from = vi.fn(() => ({ where })) - dbMock.select.mockReturnValueOnce({ from } as never) - - const result = await caller.getActive({ side: 'left' }) - expect(result?.setPoints).toEqual([]) - }) -}) - -describe('runOnce.cancel', () => { - it('marks active session cancelled, cancels scheduler jobs, broadcasts', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await caller.cancel({ side: 'right' }) - - expect(dbMock.update).toHaveBeenCalledTimes(1) - expect(dbMock.updateSet).toHaveBeenCalledWith({ status: 'cancelled' }) - expect(jobManagerMock.cancelRunOnceSession).toHaveBeenCalledWith('right') - expect(broadcastMock.broadcastMutationStatus).toHaveBeenCalledWith('right') - expect(result).toEqual({ success: true }) - expect(log).toHaveBeenCalledWith('Run-once session cancelled for right') - log.mockRestore() - }) -}) - -describe('runOnce insertion rollback mutation regressions', () => { - it.each(['left', 'right'] as const)('powers %s back off when the session insert rejects', async (side) => { - const failure = new Error('disk full') - const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - dbMock.insert.mockImplementationOnce(() => { - throw failure + const result = await caller.start(input) + expect(result.expiresAt).toBe(Date.parse('2026-09-29T07:00Z') / 1000) + expect(hardware.setTemperature).toHaveBeenCalledWith('left', 74) + expect(scheduler.scheduleRunOnceSession).toHaveBeenCalledWith(result.sessionId, 'left', [input.setPoints[1]], '07:00', 'UTC') + expect(activeRows()[0].setPoints).toBe(JSON.stringify(input.setPoints)) + }) + + it('keeps a manual hold in control while starting the run-once clock', async () => { + const control = getTemperatureController() + await control.setManual('left', 77) + const expiry = control.status('left').holdUntil + await caller.start(input) + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 77) + expect(control.status('left').holdUntil).toBe(expiry) + vi.setSystemTime(new Date('2026-09-28T23:15Z')) + await control.resume('left') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 68) + }) + + it('replaces only the same side and cancels its old jobs after the new command succeeds', async () => { + const old = await caller.start(input) + const right = await caller.start({ ...input, side: 'right' }) + scheduler.cancelRunOnceSession.mockClear() + const next = await caller.start({ ...input, setPoints: [{ time: '22:00', temperature: 70 }] }) + expect(activeRows().map(row => row.id)).toEqual([right.sessionId, next.sessionId]) + expect(db.select().from(runOnceSessions).where(eq(runOnceSessions.id, old.sessionId)).get()?.status).toBe('cancelled') + expect(scheduler.cancelRunOnceSession).toHaveBeenCalledExactlyOnceWith('left', old.sessionId) + }) + + it('uses America/Los_Angeles when settings are missing', async () => { + db.delete(deviceSettings).run() + const result = await caller.start({ ...input, wakeTime: '23:00' }) + expect(scheduler.scheduleRunOnceSession).toHaveBeenCalledWith(result.sessionId, 'left', [input.setPoints[1]], '23:00', 'America/Los_Angeles') + }) + + it('rejects overlong sessions before changing an existing session and accepts exactly 14 hours', async () => { + const previous = await caller.start(input) + await expect(caller.start({ ...input, wakeTime: '13:00' })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('15h') }) + expect(activeRows()[0].id).toBe(previous.sessionId) + const accepted = await caller.start({ ...input, wakeTime: '12:00' }) + expect(accepted.expiresAt * 1000 - Date.now()).toBe(14 * 3_600_000) + }) + + it('refuses a guard-blocked start before changing sessions', async () => { + guard.shouldBlock.mockReturnValue(true) + await expect(caller.start(input)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + expect(activeRows()).toHaveLength(0) + expect(hardware.setTemperature).not.toHaveBeenCalled() + expect(scheduler.cancelRunOnceSession).not.toHaveBeenCalled() + }) + + it('rechecks protection after a start waits for the side lock', async () => { + let release = () => {} + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve + })) + await Promise.resolve() + const pending = caller.start(input) + const assertion = expect(pending).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + await vi.advanceTimersByTimeAsync(0) + guard.shouldBlock.mockReturnValue(true) + release() + await holder + await assertion + expect(activeRows()).toHaveLength(0) + expect(hardware.setTemperature).not.toHaveBeenCalled() + }) + + it.each(['left', 'right'] as const)('rolls back an insert failure on %s without commanding hardware', async (side) => { + const old = await caller.start({ ...input, side }) + vi.clearAllMocks() + sqlite.exec('CREATE TRIGGER reject_run_once BEFORE INSERT ON run_once_sessions BEGIN SELECT RAISE(ABORT, \'insert failed\'); END') + await expect(caller.start({ ...input, side })).rejects.toThrow('insert failed') + expect(activeRows().map(row => row.id)).toEqual([old.sessionId]) + expect(hardware.setTemperature).not.toHaveBeenCalled() + expect(hardware.setPower).not.toHaveBeenCalled() + expect(scheduler.cancelRunOnceSession).not.toHaveBeenCalled() + }) + + it('reactivates the previous session after a failed hardware start and restores its current target', async () => { + const old = await caller.start(input) + scheduler.cancelRunOnceSession.mockClear() + hardware.setTemperature.mockRejectedValueOnce(new Error('write failed')) + await expect(caller.start({ ...input, setPoints: [{ time: '22:00', temperature: 80 }] })).rejects.toThrow('write failed') + expect(activeRows().map(row => row.id)).toEqual([old.sessionId]) + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 74) + expect(scheduler.cancelRunOnceSession).not.toHaveBeenCalledWith('left', old.sessionId) + }) + + it('rolls back a scheduler registration failure without losing the prior session jobs', async () => { + const old = await caller.start(input) + hardware.setTemperature.mockClear() + scheduler.cancelRunOnceSession.mockClear() + scheduler.scheduleRunOnceSession.mockImplementationOnce(() => { + throw new Error('scheduler unavailable') }) - try { - await expect(caller.start({ side, setPoints: [{ time: '23:00', temperature: 70 }], wakeTime: '07:00' })).rejects.toMatchObject({ - code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create run-once session', - }) - expect(helpersMock.client.setPower.mock.calls).toEqual([[side, true, 70], [side, false]]) - expect(jobManagerMock.scheduleRunOnceSession).not.toHaveBeenCalled() - expect(error).toHaveBeenCalledWith('Run-once session insert failed, powering side back off:', failure) - } - finally { error.mockRestore() } + await expect(caller.start(input)).rejects.toThrow('scheduler unavailable') + expect(activeRows().map(row => row.id)).toEqual([old.sessionId]) + expect(scheduler.cancelRunOnceSession).not.toHaveBeenCalledWith('left', old.sessionId) + expect(hardware.setTemperature).not.toHaveBeenCalled() + }) + + it('cuts off a partially failed first start and preserves the original error when cutoff also fails', async () => { + hardware.setTemperature.mockRejectedValueOnce(new Error('start failed')) + hardware.setPower.mockRejectedValueOnce(new Error('cutoff failed')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect(caller.start(input)).rejects.toThrow('start failed') + expect(activeRows()).toHaveLength(0) + expect(hardware.setPower).toHaveBeenCalledWith('left', false) + expect(warn).toHaveBeenCalledWith('[runOnce] Failed to restore hardware after rejected start:', expect.any(Error)) + }) + + it('does not discard an existing manual hold when a run-once power command fails', async () => { + const control = getTemperatureController() + await control.setManual('left', 77) + const expiry = control.status('left').holdUntil + hardware.setTemperature.mockRejectedValueOnce(new Error('start failed')) + await expect(caller.start(input)).rejects.toThrow('start failed') + expect(control.status('left')).toMatchObject({ source: 'manual', targetTemperature: 77, holdUntil: expiry }) + expect(hardware.setPower).not.toHaveBeenCalled() + }) + + it('returns parsed active sessions, and filters cancelled and expired sessions', async () => { + expect(await caller.getActive({ side: 'left' })).toBeNull() + const result = await caller.start(input) + expect(await caller.getActive({ side: 'left' })).toMatchObject({ id: result.sessionId, setPoints: input.setPoints, startedAt: Date.now() / 1000 }) + vi.setSystemTime(new Date('2026-09-29T07:00Z')) + expect(await caller.getActive({ side: 'left' })).toBeNull() + }) + + it.each(['invalid json', '{}', '[{"time":"bad","temperature":900}]'])('returns empty points for corrupted stored JSON %s', async (setPoints) => { + const result = await caller.start(input) + db.update(runOnceSessions).set({ setPoints }).where(eq(runOnceSessions.id, result.sessionId)).run() + expect(await caller.getActive({ side: 'left' })).toMatchObject({ setPoints: [] }) + }) + + it('cancels under the shared lock and immediately restores the applicable recurring point', async () => { + await caller.start(input) + db.insert(temperatureSchedules).values({ side: 'left', dayOfWeek: 'monday', time: '21:00', temperature: 71 }).run() + expect(await caller.cancel({ side: 'left' })).toEqual({ success: true }) + expect(activeRows()).toHaveLength(0) + expect(scheduler.cancelRunOnceSession).toHaveBeenLastCalledWith('left') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 71) }) }) diff --git a/src/server/validation-schemas.ts b/src/server/validation-schemas.ts index b2864fb74..77a3ca436 100644 --- a/src/server/validation-schemas.ts +++ b/src/server/validation-schemas.ts @@ -184,12 +184,16 @@ export const automationActionSchema = z.union([ temp: automationExprSchema, clamp: automationClampSchema.optional(), durationSec: z.number().int().min(0).max(86400).optional(), + mode: z.enum(['policy', 'one-shot']).optional(), + holdMinutes: z.number().int().min(1).max(1440).optional(), }).strict(), z.object({ kind: z.literal('setPower'), side: sideSchema.optional(), on: z.boolean(), temp: automationExprSchema.optional(), + mode: z.enum(['policy', 'one-shot']).optional(), + holdMinutes: z.number().int().min(1).max(1440).optional(), }).strict(), ]) diff --git a/src/services/autoOffWatcher.ts b/src/services/autoOffWatcher.ts index 4c8b17c6b..bb0a3c2b9 100644 --- a/src/services/autoOffWatcher.ts +++ b/src/services/autoOffWatcher.ts @@ -22,9 +22,7 @@ import { eq, and } from 'drizzle-orm' import { db } from '@/src/db' import { deviceSettings, sideSettings, deviceState, runOnceSessions } from '@/src/db/schema' -import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' -import { markSideMutated } from '@/src/hardware/deviceStateSync' -import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' +import { getTemperatureController } from '@/src/temperature/instance' import { getOccupancy } from '@/src/lib/occupancy' // --------------------------------------------------------------------------- @@ -186,34 +184,7 @@ function presenceState(side: Side): 'occupied' | 'empty' | 'unsensable' { /** Power off a side via the shared hardware client. */ async function powerOffSide(side: Side): Promise { try { - const client = getSharedHardwareClient() - await client.connect() - await client.setPower(side, false) - - // Best-effort DB sync β€” also clear poweredOnAt so the global cap doesn't - // see a stale "powered on X hours ago" after the side comes back on later - // via a path that doesn't stamp through deviceStateSync. - try { - // Stamp freshness immediately before the DB write so the 5s guard - // protects this mutation from concurrent DAC polls β€” placing it before - // the slow hardware roundtrip risks the window expiring before the DB - // update lands. - markSideMutated(side) - db.update(deviceState) - .set({ - isPowered: false, - poweredOnAt: null, - targetTemperature: null, - lastUpdated: new Date(), - }) - .where(eq(deviceState.side, side)) - .run() - } - catch { - // next status poll will re-sync - } - - broadcastMutationStatus(side, { targetLevel: 0 }) + await getTemperatureController().powerOff(side) console.log(`[auto-off] Powered off ${side} side (no presence detected)`) } catch (error) { diff --git a/src/services/temperatureKeepalive.ts b/src/services/temperatureKeepalive.ts index d486dc37c..2b42741e7 100644 --- a/src/services/temperatureKeepalive.ts +++ b/src/services/temperatureKeepalive.ts @@ -12,7 +12,7 @@ import { eq } from 'drizzle-orm' import { db } from '@/src/db' import { deviceState, sideSettings } from '@/src/db/schema' -import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' +import { getTemperatureController } from '@/src/temperature/instance' import { shouldBlock as pumpStallShouldBlock } from '@/src/hardware/pumpStallGuard' import { getPumpStallNotice } from '@/src/hardware/pumpStallNotification' import { withSideLock } from '@/src/hardware/sideLock' @@ -74,9 +74,7 @@ export function startKeepalive(side: Side): void { return } - const client = getSharedHardwareClient() - await client.connect() - await client.setTemperature(side, state.targetTemperature) + await getTemperatureController().reconcileLocked(side, true) console.log(`[keepalive] Re-sent temperature ${state.targetTemperature}Β°F for ${side}`) }) } diff --git a/src/services/tests/autoOffWatcher.test.ts b/src/services/tests/autoOffWatcher.test.ts index 5b2d41695..841efe2f3 100644 --- a/src/services/tests/autoOffWatcher.test.ts +++ b/src/services/tests/autoOffWatcher.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { resetControlDatabase } from '@/src/temperature/tests/databaseFixture' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type BetterSqlite3 from 'better-sqlite3' import type { OccupancyResult } from '@/src/lib/occupancy' @@ -74,67 +75,11 @@ const { sqlite } = dbModule as typeof dbModule & { sqlite: BetterSqlite3.Databas const POLL_MS = 30_000 function resetSchema(): void { - ;(sqlite as any).exec(` - DROP TABLE IF EXISTS device_settings; - DROP TABLE IF EXISTS side_settings; - DROP TABLE IF EXISTS device_state; - DROP TABLE IF EXISTS run_once_sessions; - - CREATE TABLE device_settings ( - id INTEGER PRIMARY KEY, - timezone TEXT NOT NULL DEFAULT 'UTC', - temperature_unit TEXT NOT NULL DEFAULT 'F', - reboot_daily INTEGER NOT NULL DEFAULT 0, - reboot_time TEXT, - prime_pod_daily INTEGER NOT NULL DEFAULT 0, - prime_pod_time TEXT, - led_night_mode_enabled INTEGER NOT NULL DEFAULT 0, - led_day_brightness INTEGER NOT NULL DEFAULT 100, - led_night_brightness INTEGER NOT NULL DEFAULT 0, - led_night_start_time TEXT, - led_night_end_time TEXT, - global_max_on_hours INTEGER, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE side_settings ( - side TEXT PRIMARY KEY, - name TEXT NOT NULL, - away_mode INTEGER NOT NULL DEFAULT 0, - always_on INTEGER NOT NULL DEFAULT 0, - auto_off_enabled INTEGER NOT NULL DEFAULT 0, - auto_off_minutes INTEGER NOT NULL DEFAULT 30, - away_start TEXT, - away_return TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE device_state ( - side TEXT PRIMARY KEY, - current_temperature REAL, - target_temperature REAL, - is_powered INTEGER NOT NULL DEFAULT 0, - is_alarm_vibrating INTEGER NOT NULL DEFAULT 0, - water_level TEXT DEFAULT 'unknown', - powered_on_at INTEGER, - last_updated INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE run_once_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - side TEXT NOT NULL, - set_points TEXT NOT NULL, - wake_time TEXT NOT NULL, - started_at INTEGER NOT NULL DEFAULT (unixepoch()), - expires_at INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - - INSERT INTO device_settings (id) VALUES (1); + resetControlDatabase(sqlite) + sqlite.exec(`INSERT INTO device_settings (id) VALUES (1); INSERT INTO side_settings (side, name, auto_off_enabled, auto_off_minutes) VALUES ('left', 'Left', 1, 30), ('right', 'Right', 1, 30); - INSERT INTO device_state (side, is_powered) VALUES ('left', 0), ('right', 0); - `) + INSERT INTO device_state (side, is_powered) VALUES ('left', 0), ('right', 0);`) } function setSideOn(side: 'left' | 'right', poweredOnAtMs: number): void { @@ -260,7 +205,7 @@ describe('autoOffWatcher β€” live presence', () => { expect(error).toHaveBeenCalledWith('[auto-off] Failed to power off left:', 'offline') expect(broadcastMutationStatus).not.toHaveBeenCalled() - expect(markSideMutated).not.toHaveBeenCalled() + expect(markSideMutated).toHaveBeenCalledWith('left') }) it('logs the countdown start with the configured timeout', () => { @@ -390,7 +335,7 @@ describe('autoOffWatcher β€” unreadable state falls back to standing down', () = setSideOn('left', Date.now() - 2 * 3600_000) startAutoOffWatcher() - await vi.runAllTicks() + await stopAutoOffWatcher() expect(setPower).toHaveBeenCalledWith('left', false) }) @@ -468,7 +413,7 @@ describe('autoOffWatcher β€” global wall-clock cap', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) startAutoOffWatcher() - await vi.runAllTicks() + await stopAutoOffWatcher() expect(setPower).toHaveBeenCalledWith('left', false) expect(log).toHaveBeenCalledWith( @@ -515,7 +460,7 @@ describe('autoOffWatcher β€” global wall-clock cap', () => { setSideOn('left', Date.now() - 7 * 86_400_000) startAutoOffWatcher() - await vi.runAllTicks() + await stopAutoOffWatcher() expect(setPower).toHaveBeenCalledWith('left', false) }) diff --git a/src/services/tests/temperatureKeepalive.test.ts b/src/services/tests/temperatureKeepalive.test.ts index 68721c2dc..5c17d30c3 100644 --- a/src/services/tests/temperatureKeepalive.test.ts +++ b/src/services/tests/temperatureKeepalive.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { resetControlDatabase } from '@/src/temperature/tests/databaseFixture' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type BetterSqlite3 from 'better-sqlite3' @@ -51,36 +52,9 @@ const { sqlite } = dbModule as typeof dbModule & { const KEEPALIVE_INTERVAL_MS = 6 * 60 * 60 * 1000 function resetSchema(): void { - ;(sqlite as any).exec(` - DROP TABLE IF EXISTS side_settings; - DROP TABLE IF EXISTS device_state; - - CREATE TABLE side_settings ( - side TEXT PRIMARY KEY, - name TEXT NOT NULL, - away_mode INTEGER NOT NULL DEFAULT 0, - always_on INTEGER NOT NULL DEFAULT 0, - auto_off_enabled INTEGER NOT NULL DEFAULT 0, - auto_off_minutes INTEGER NOT NULL DEFAULT 30, - away_start TEXT, - away_return TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE device_state ( - side TEXT PRIMARY KEY, - current_temperature REAL, - target_temperature REAL, - is_powered INTEGER NOT NULL DEFAULT 0, - is_alarm_vibrating INTEGER NOT NULL DEFAULT 0, - water_level TEXT DEFAULT 'unknown', - powered_on_at INTEGER, - last_updated INTEGER NOT NULL DEFAULT (unixepoch()) - ); - - INSERT INTO side_settings (side, name) VALUES ('left', 'Left'), ('right', 'Right'); - INSERT INTO device_state (side, is_powered) VALUES ('left', 0), ('right', 0); - `) + resetControlDatabase(sqlite) + sqlite.exec(`INSERT INTO side_settings (side, name) VALUES ('left', 'Left'), ('right', 'Right'); + INSERT INTO device_state (side, is_powered) VALUES ('left', 0), ('right', 0);`) } function setSideState( diff --git a/src/streaming/broadcastMutationStatus.ts b/src/streaming/broadcastMutationStatus.ts index f3fcc8efc..d1ef67bf8 100644 --- a/src/streaming/broadcastMutationStatus.ts +++ b/src/streaming/broadcastMutationStatus.ts @@ -11,13 +11,14 @@ * HomeKit and gesture writes rely on the poll to surface their changes. */ +import { getTemperatureControlStatus } from '@/src/temperature/instance' import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' import { broadcastFrame } from './piezoStream' import { getPrimeCompletedAt } from '@/src/hardware/primeNotification' import { getAllPumpStallNotices } from '@/src/hardware/pumpStallNotification' import { getAlarmState } from '@/src/hardware/deviceStateSync' import { getSnoozeStatus } from '@/src/hardware/snoozeManager' -import { recordMutationOverlay } from './mutationOverlay' +import { applyMutationOverlay, recordMutationOverlay } from './mutationOverlay' export function broadcastMutationStatus( side?: 'left' | 'right', @@ -33,8 +34,8 @@ export function broadcastMutationStatus( const primeCompletedAt = getPrimeCompletedAt() const alarmState = getAlarmState() const stallNotices = getAllPumpStallNotices() - const leftSide = { ...lastStatus.leftSide, isAlarmVibrating: alarmState.left } - const rightSide = { ...lastStatus.rightSide, isAlarmVibrating: alarmState.right } + const leftSide = { ...applyMutationOverlay('left', { ...lastStatus.leftSide }), isAlarmVibrating: alarmState.left } + const rightSide = { ...applyMutationOverlay('right', { ...lastStatus.rightSide }), isAlarmVibrating: alarmState.right } if (side && sideOverlay) { if (side === 'left') Object.assign(leftSide, sideOverlay) @@ -43,6 +44,7 @@ export function broadcastMutationStatus( broadcastFrame({ type: 'deviceStatus', + temperatureControl: getTemperatureControlStatus(), ts: Date.now(), leftSide, rightSide, diff --git a/src/streaming/tests/broadcastMutationStatus.test.ts b/src/streaming/tests/broadcastMutationStatus.test.ts index 93bab81f4..ad2be2563 100644 --- a/src/streaming/tests/broadcastMutationStatus.test.ts +++ b/src/streaming/tests/broadcastMutationStatus.test.ts @@ -7,6 +7,7 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { _resetMutationOverlays } from '../mutationOverlay' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const dacMock = vi.hoisted(() => { @@ -88,6 +89,7 @@ const baseStatus = { let warnSpy: ReturnType beforeEach(() => { + _resetMutationOverlays() dacMock.state.monitor = null primeMock.state.primeCompletedAt = null alarmMock.state.left = false @@ -288,3 +290,13 @@ describe('broadcastMutationStatus', () => { ) }) }) + +it('retains a just-written target in control-only publications and opposite-side mutations', () => { + setLastStatus({ ...baseStatus, leftSide: { targetTemperature: 70, targetLevel: -50 }, rightSide: { targetTemperature: 72, targetLevel: -40 } }) + broadcastMutationStatus('left', { targetTemperature: 75, targetLevel: -30 }) + broadcastMutationStatus() + broadcastMutationStatus('right', { targetTemperature: 74 }) + for (const [frame] of piezoMock.broadcastFrame.mock.calls) { + expect(frame.leftSide).toMatchObject({ targetTemperature: 75, targetLevel: -30 }) + } +}) diff --git a/src/temperature/baseline.ts b/src/temperature/baseline.ts new file mode 100644 index 000000000..bf6fc3385 --- /dev/null +++ b/src/temperature/baseline.ts @@ -0,0 +1,61 @@ +import { parseExpression } from 'cron-parser' +import { DAYS_OF_WEEK, type DayOfWeek } from '@/src/lib/scheduleTime' +import { timeToDate } from '@/src/scheduler/timeUtils' +import type { TemperatureRequest } from './controller' + +export interface WeeklyTarget { + id: string + dayOfWeek: DayOfWeek + time: string + temperature: number +} + +/** Use node-schedule's cron parser and timezone semantics for both engines. */ +export function recurringTarget(rows: WeeklyTarget[], timezone: string, now: number): TemperatureRequest | null { + let latest: TemperatureRequest | null = null + for (const row of rows) { + const [hour, minute] = row.time.split(':').map(Number) + const cron = `${minute} ${hour} * * ${DAYS_OF_WEEK.indexOf(row.dayOfWeek)}` + // Forward iteration matches node-schedule through missing/repeated DST + // hours. cron-parser.prev() is not the inverse of next() in a spring gap. + const occurrences = parseExpression(cron, { currentDate: new Date(now - 15 * 86_400_000), tz: timezone }) + let startsAt = occurrences.next().getTime() + let expiresAt = occurrences.next().getTime() + while (expiresAt <= now) { + startsAt = expiresAt + expiresAt = occurrences.next().getTime() + } + const candidate: TemperatureRequest = { + id: row.id, source: 'schedule', temperature: row.temperature, + startsAt, expiresAt, createdAt: startsAt, priority: 0, + } + if (!latest || startsAt > latest.startsAt || (startsAt === latest.startsAt && row.id < latest.id)) latest = candidate + } + return latest +} + +export interface SessionTarget { + id: number + startedAt: Date + expiresAt: Date + setPoints: Array<{ time: string, temperature: number }> +} + +/** The first point applies immediately; subsequent points keep the session's original clock. */ +export function sessionTarget(session: SessionTarget, timezone: string, now: number): TemperatureRequest | null { + if (session.startedAt.getTime() > now || session.expiresAt.getTime() <= now || !session.setPoints.length) return null + let temperature = session.setPoints[0].temperature + let lastPointAt = session.startedAt.getTime() + for (const point of session.setPoints.slice(1)) { + const at = timeToDate(point.time, timezone, session.startedAt).getTime() + if (at <= now && at >= lastPointAt) { + temperature = point.temperature + lastPointAt = at + } + } + return { + id: `run-once:${session.id}`, source: 'run-once', temperature, + startsAt: session.startedAt.getTime(), expiresAt: session.expiresAt.getTime(), + createdAt: session.startedAt.getTime(), priority: 0, + } +} diff --git a/src/temperature/controller.ts b/src/temperature/controller.ts new file mode 100644 index 000000000..796721b97 --- /dev/null +++ b/src/temperature/controller.ts @@ -0,0 +1,389 @@ +import { MAX_TEMP, MIN_TEMP, type Side } from '@/src/hardware/types' + +export class TemperatureBlockedError extends Error { + constructor() { + super('Pump stall protection active β€” re-enable the side first') + this.name = 'TemperatureBlockedError' + } +} + +export const DEFAULT_HOLD_MS = 30 * 60_000 +export const MAX_HOLD_MS = 24 * 60 * 60_000 + +export type TemperatureSource = 'manual' | 'run-once' | 'autopilot' | 'schedule' + +/** A resolved target, never a deferred relative operation. Times are epoch ms. */ +export interface TemperatureRequest { + id: string + source: TemperatureSource + temperature: number + startsAt: number + expiresAt: number + priority: number + /** Stable for a request's lifetime, including continuous-policy refreshes. */ + createdAt: number + durationSec?: number +} + +export interface TemperatureControlStatus { + source: TemperatureSource | null + requestId: string | null + targetTemperature: number | null + holdUntil: number | null + blocked: 'safety' | 'off' | null +} + +export interface TemperatureControllerDeps { + now: () => number + withSideLock: (side: Side, fn: () => Promise) => Promise + /** Synchronous DB access inside the side lock. Throws on persistence failure. */ + readHold: (side: Side) => TemperatureRequest | null + writeHold: (side: Side, hold: TemperatureRequest | null) => void + /** Compute current schedule/session requests from their original clocks. */ + readBaseline: (side: Side, now: number) => TemperatureRequest[] + isPowered: (side: Side) => boolean + readCurrentTarget: (side: Side) => number | null + readHardwareDeadline: (side: Side) => number | null + writeHardwareDeadline: (side: Side, deadline: number | null) => void + isBlocked: (side: Side) => boolean + /** Must connect before returning; selection and safety are rechecked afterward. */ + connect: () => Promise + /** Hardware write + state mirror; the caller already owns the side lock. */ + apply: (side: Side, temperature: number, durationSec?: number) => Promise + powerOff: (side: Side) => Promise + publish: (side: Side, status: TemperatureControlStatus) => void +} + +const rank: Record = { + 'manual': 4, 'run-once': 3, 'autopilot': 2, 'schedule': 1, +} + +export function isLiveRequest(request: TemperatureRequest, now: number): boolean { + return Number.isFinite(request.temperature) + && request.temperature >= MIN_TEMP && request.temperature <= MAX_TEMP + && Number.isFinite(request.startsAt) && Number.isFinite(request.expiresAt) + && request.startsAt <= now && now < request.expiresAt +} + +/** Deterministic priority: source, rule priority, activation time, then stable ID. */ +export function selectTemperatureRequest(requests: TemperatureRequest[], now: number): TemperatureRequest | null { + return requests.filter(r => isLiveRequest(r, now)).sort((a, b) => + rank[b.source] - rank[a.source] + || b.priority - a.priority + || b.createdAt - a.createdAt + || a.id.localeCompare(b.id), + )[0] ?? null +} + +/** + * Temperature authority shared by all producers. Methods suffixed Locked are + * for callers already holding withSideLock; never recursively acquire it. + * Passive reconciliation never powers an off side on. Only an explicit manual + * or scheduled power-on operation may authorize energizing an off side. + */ +export class TemperatureController { + private requests: Record> = { + left: new Map(), right: new Map(), + } + + private poweredOff: Record = { left: false, right: false } + + private applied: Record = { left: null, right: null } + + private appliedDeadline: Record = { left: null, right: null } + + private published: Partial> = {} + + private publishStatus(side: Side, status: TemperatureControlStatus): void { + const key = JSON.stringify(status) + if (this.published[side] === key) return + this.deps.publish(side, status) + this.published[side] = key + } + + constructor(private deps: TemperatureControllerDeps) {} + + private isPowered(side: Side): boolean { + const deadline = this.deps.readHardwareDeadline(side) + return !this.poweredOff[side] && this.deps.isPowered(side) + && (deadline === null || deadline > this.deps.now()) + } + + status(side: Side): TemperatureControlStatus { + const now = this.deps.now() + const hold = this.deps.readHold(side) + const selected = this.select(side, now, hold) + return { + source: selected?.source ?? null, + requestId: selected?.id ?? null, + targetTemperature: selected?.temperature ?? null, + holdUntil: hold && isLiveRequest(hold, now) ? hold.expiresAt : null, + blocked: this.deps.isBlocked(side) ? 'safety' : this.isPowered(side) ? null : 'off', + } + } + + private select(side: Side, now: number, hold = this.deps.readHold(side)): TemperatureRequest | null { + return selectTemperatureRequest([ + ...(hold ? [hold] : []), + ...this.deps.readBaseline(side, now), + ...this.requests[side].values(), + ], now) + } + + async setManual(side: Side, temperature: number, holdMs = DEFAULT_HOLD_MS, durationSec?: number): Promise { + return this.deps.withSideLock(side, () => this.setManualLocked(side, temperature, holdMs, durationSec)) + } + + async setManualLocked(side: Side, temperature: number, holdMs = DEFAULT_HOLD_MS, durationSec?: number): Promise { + if (!Number.isFinite(temperature) || temperature < MIN_TEMP || temperature > MAX_TEMP) { + throw new Error('Temperature outside hardware bounds') + } + if (!Number.isInteger(holdMs) || holdMs <= 0 || holdMs > MAX_HOLD_MS) { + throw new Error('Hold must be between 1 millisecond and 24 hours') + } + if (durationSec !== undefined && (!Number.isSafeInteger(durationSec) || durationSec < 0)) { + throw new Error('Hardware duration must be a nonnegative integer') + } + if (durationSec === 0) { + await this.powerOffLocked(side) + return this.status(side) + } + await this.deps.connect() + if (this.deps.isBlocked(side)) throw new TemperatureBlockedError() + const now = this.deps.now() + const previous = this.deps.readHold(side) + const previousDeadline = this.deps.readHardwareDeadline(side) + // Save first: a persistence failure must not acknowledge a non-durable hold. + this.deps.writeHold(side, { + id: 'manual', source: 'manual', temperature, startsAt: now, + expiresAt: now + holdMs, createdAt: now, priority: 0, + }) + try { + this.deps.writeHardwareDeadline(side, durationSec === undefined ? null : now + durationSec * 1_000) + await this.deps.apply(side, temperature, durationSec) + this.applied[side] = temperature + this.appliedDeadline[side] = this.deps.readHardwareDeadline(side) + this.poweredOff[side] = false + } + catch (error) { + this.applied[side] = null // an ambiguous hardware error needs reconciliation + this.deps.writeHold(side, previous) + this.deps.writeHardwareDeadline(side, previousDeadline) + throw error + } + const status = this.status(side) + this.publishStatus(side, status) + return status + } + + async resume(side: Side): Promise { + return this.deps.withSideLock(side, async () => { + this.deps.writeHold(side, null) + return this.reconcileLocked(side) + }) + } + + /** Explicit shutdown ends the hold; it must never reconcile an energizing target. */ + clearHoldLocked(side: Side): void { + this.deps.writeHold(side, null) + this.invalidate(side) + } + + /** An explicit power event may energize the selected target, but creates no manual hold. */ + async powerOnLocked(side: Side, fallback = 75, isCurrent: () => boolean = () => true): Promise { + await this.deps.connect() + if (this.deps.isBlocked(side)) throw new TemperatureBlockedError() + if (!isCurrent()) return this.status(side) + const selected = this.select(side, this.deps.now()) + const temperature = selected?.temperature ?? fallback + if (!Number.isFinite(temperature) || temperature < MIN_TEMP || temperature > MAX_TEMP) { + throw new Error('Temperature outside hardware bounds') + } + // A scheduled ON during a manual hold must not extend its bounded session. + // A new explicit ON with no hold starts a new hardware session. + const previousDeadline = this.deps.readHardwareDeadline(side) + if (selected?.source !== 'manual') this.deps.writeHardwareDeadline(side, null) + const duration = this.remainingDuration(side, selected) + if (duration !== undefined && duration <= 0) { + await this.powerOffLocked(side) + return this.status(side) + } + try { + if (duration === undefined) await this.deps.apply(side, temperature) + else await this.deps.apply(side, temperature, duration) + } + catch (error) { + this.invalidate(side) + this.deps.writeHardwareDeadline(side, previousDeadline) + throw error + } + this.applied[side] = temperature + this.appliedDeadline[side] = this.hardwareDeadline(side, selected) + this.poweredOff[side] = false + const status = this.status(side) + this.publishStatus(side, status) + return status + } + + async powerOffLocked(side: Side): Promise { + this.poweredOff[side] = true + this.invalidate(side) + await this.deps.powerOff(side) + this.deps.writeHold(side, null) + this.deps.writeHardwareDeadline(side, null) + this.publishStatus(side, this.status(side)) + } + + /** Replacing a lease does not apply it when a higher-priority source owns the side. */ + async submit(side: Side, request: TemperatureRequest): Promise { + return this.deps.withSideLock(side, async () => { + if (request.source !== 'autopilot') throw new Error('Only Autopilot leases may be submitted') + if (!isLiveRequest(request, this.deps.now())) throw new Error('Invalid or expired temperature request') + this.requests[side].set(request.id, { ...request }) + return this.reconcileLocked(side) + }) + } + + async withdraw(side: Side, id: string): Promise { + return this.deps.withSideLock(side, async () => { + this.requests[side].delete(id) + return this.reconcileLocked(side) + }) + } + + /** Publish one evaluation atomically so lower-priority rules never flash a target first. */ + async replaceAutopilot( + side: Side, + requests: TemperatureRequest[], + powerOnIds: string[] = [], + isCurrent: () => boolean = () => true, + ): Promise { + return this.deps.withSideLock(side, async () => { + if (!isCurrent()) return this.status(side) + const live = requests.filter(r => r.source === 'autopilot' && isLiveRequest(r, this.deps.now())) + this.requests[side] = new Map(live.map(r => [r.id, { ...r }])) + const selected = this.select(side, this.deps.now()) + if (selected && powerOnIds.includes(selected.id) && !this.deps.isBlocked(side)) { + return this.powerOnLocked(side, 75, isCurrent) + } + return this.reconcileLocked(side, false, isCurrent) + }) + } + + /** Relative rules use the scheduled/session baseline, never their own output or a manual hold. */ + automationBaseline(side: Side): number | undefined { + return selectTemperatureRequest(this.deps.readBaseline(side, this.deps.now()), this.deps.now())?.temperature + ?? 75 + } + + async powerOff(side: Side, isCurrent: () => boolean = () => true): Promise { + return this.deps.withSideLock(side, async () => { + if (isCurrent()) await this.powerOffLocked(side) + }) + } + + async reconcile(side: Side, force = false, isCurrent: () => boolean = () => true): Promise { + return this.deps.withSideLock(side, () => this.reconcileLocked(side, force, isCurrent)) + } + + /** Call after power-off, safety cutoff, or an independently observed target change. */ + invalidate(side: Side): void { + this.applied[side] = null + this.appliedDeadline[side] = null + } + + /** Guard recovery owns the bounded hardware duration; it resolves only the target here. */ + recoveryTargetLocked(side: Side, fallback: number): number { + return this.select(side, this.deps.now())?.temperature ?? fallback + } + + /** Called only after a successful, guard-authorized restore under the side lock. */ + recoveredLocked(side: Side, temperature: number): void { + this.poweredOff[side] = false + this.applied[side] = temperature + this.appliedDeadline[side] = this.hardwareDeadline(side, this.select(side, this.deps.now())) + } + + /** An explicit hardware deadline survives hold expiry/Resume and producer changes. */ + private hardwareDeadline(side: Side, request: TemperatureRequest | null): number | null { + const manualDeadline = this.deps.readHardwareDeadline(side) + const requestDeadline = request?.durationSec === undefined ? null : request.createdAt + request.durationSec * 1_000 + const deadlines = [manualDeadline, requestDeadline].filter((d): d is number => d !== null) + return deadlines.length ? Math.min(...deadlines) : null + } + + private remainingDuration(side: Side, request: TemperatureRequest | null): number | undefined { + const deadline = this.hardwareDeadline(side, request) + return deadline === null ? undefined : Math.floor((deadline - this.deps.now()) / 1_000) + } + + recoveryDurationLocked(side: Side, duration: number): number { + return Math.max(0, Math.min(duration, this.remainingDuration(side, null) ?? duration)) + } + + async reconcileLocked(side: Side, force = false, isCurrent: () => boolean = () => true): Promise { + if (!isCurrent()) return this.status(side) + const now = this.deps.now() + const deadline = this.deps.readHardwareDeadline(side) + if (deadline !== null && deadline <= now) { + await this.powerOffLocked(side) + return this.status(side) + } + const hold = this.deps.readHold(side) + if (hold && !isLiveRequest(hold, now)) this.deps.writeHold(side, null) + for (const [id, request] of this.requests[side]) { + if (request.expiresAt <= now) this.requests[side].delete(id) + } + let selected = this.select(side, now) + if (selected && (this.remainingDuration(side, selected) ?? 1) <= 0 && this.deps.isPowered(side)) { + await this.powerOffLocked(side) + return this.status(side) + } + // With no configured owner, releasing a hold leaves the current target in + // place. Always-on keepalive may refresh that target without inventing an + // owner or extending a hold. The off/safety gates still apply. + if (!selected && force && this.isPowered(side) && !this.deps.isBlocked(side)) { + await this.deps.connect() + selected = this.select(side, this.deps.now()) + const target = selected?.temperature ?? this.deps.readCurrentTarget(side) + if (isCurrent() && target !== null && Number.isFinite(target) && target >= MIN_TEMP && target <= MAX_TEMP + && this.isPowered(side) && !this.deps.isBlocked(side)) { + const duration = this.remainingDuration(side, selected) + if (duration !== undefined && duration <= 0) { + await this.powerOffLocked(side) + return this.status(side) + } + if (duration === undefined) await this.deps.apply(side, target) + else await this.deps.apply(side, target, duration) + this.applied[side] = target + this.appliedDeadline[side] = this.hardwareDeadline(side, selected) + } + const status = this.status(side) + this.publishStatus(side, status) + return status + } + if (selected && !this.deps.isBlocked(side) && this.isPowered(side) + && (force || selected.temperature !== this.applied[side] + || this.hardwareDeadline(side, selected) !== this.appliedDeadline[side])) { + await this.deps.connect() + // Connecting can wait for transport recovery. Do not issue an expired + // request or undo a guard trip that happened while connecting. + selected = this.select(side, this.deps.now()) + if (isCurrent() && selected && !this.deps.isBlocked(side) && this.isPowered(side)) { + const duration = this.remainingDuration(side, selected) + if (duration !== undefined && duration <= 0) { + await this.powerOffLocked(side) + return this.status(side) + } + if (duration === undefined) await this.deps.apply(side, selected.temperature) + else await this.deps.apply(side, selected.temperature, duration) + this.applied[side] = selected.temperature + this.appliedDeadline[side] = this.hardwareDeadline(side, selected) + } + } + if (this.deps.isBlocked(side) || !this.isPowered(side)) this.applied[side] = null + const status = this.status(side) + this.publishStatus(side, status) + return status + } +} diff --git a/src/temperature/instance.ts b/src/temperature/instance.ts new file mode 100644 index 000000000..6d3615e2f --- /dev/null +++ b/src/temperature/instance.ts @@ -0,0 +1,193 @@ +import { and, eq } from 'drizzle-orm' +import { db } from '@/src/db' +import { alarmSchedules, deviceSettings, deviceState, powerSchedules, runOnceSessions, sideSettings, temperatureHolds, temperatureSchedules } from '@/src/db/schema' +import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' +import { markSideMutated } from '@/src/hardware/deviceStateSync' +import { shouldBlock } from '@/src/hardware/pumpStallGuard' +import { withSideLock } from '@/src/hardware/sideLock' +import { fahrenheitToLevel, MAX_TEMP, MIN_TEMP, type Side } from '@/src/hardware/types' +import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' +import { recurringTarget, sessionTarget } from './baseline' +import { TemperatureController, type TemperatureRequest } from './controller' + +const invalidSessions: Record> = { left: new Map(), right: new Map() } + +const recurringCache: Partial> = {} + +function readBaseline(side: Side, now: number): TemperatureRequest[] { + const settings = db.select({ timezone: deviceSettings.timezone }).from(deviceSettings).get() + const timezone = settings?.timezone || 'America/Los_Angeles' + const away = db.select({ awayMode: sideSettings.awayMode }).from(sideSettings).where(eq(sideSettings.side, side)).get()?.awayMode + const requests: TemperatureRequest[] = [] + if (!away) { + const temps = db.select().from(temperatureSchedules) + .where(and(eq(temperatureSchedules.side, side), eq(temperatureSchedules.enabled, true))).all() + const powers = db.select().from(powerSchedules) + .where(and(eq(powerSchedules.side, side), eq(powerSchedules.enabled, true))).all() + const alarms = db.select().from(alarmSchedules) + .where(and(eq(alarmSchedules.side, side), eq(alarmSchedules.enabled, true))).all() + const rows = [ + ...alarms.map(r => ({ id: `alarm:${r.id}`, dayOfWeek: r.dayOfWeek, time: r.time, temperature: r.alarmTemperature })), + ...temps.map(r => ({ id: `temperature:${r.id}`, dayOfWeek: r.dayOfWeek, time: r.time, temperature: r.temperature })), + ...powers.map(r => ({ id: `power:${r.id}`, dayOfWeek: r.dayOfWeek, time: r.onTime, temperature: r.onTemperature })), + ] + const key = JSON.stringify([timezone, Math.floor(now / 60_000), rows]) + const cached = recurringCache[side] + const baseline = cached?.key === key ? cached.target : recurringTarget(rows, timezone, now) + recurringCache[side] = { key, target: baseline } + if (baseline) requests.push(baseline) + } + const sessions = db.select().from(runOnceSessions) + .where(and(eq(runOnceSessions.side, side), eq(runOnceSessions.status, 'active'))).all() + const activeIds = new Set(sessions.map(session => session.id)) + for (const id of invalidSessions[side].keys()) { + if (!activeIds.has(id)) invalidSessions[side].delete(id) + } + for (const session of sessions) { + try { + const setPoints: unknown = JSON.parse(session.setPoints) + if (!Array.isArray(setPoints) || setPoints.some(p => typeof p?.time !== 'string' + || !/^([01]\d|2[0-3]):[0-5]\d$/.test(p.time) || !Number.isFinite(p?.temperature) + || p.temperature < MIN_TEMP || p.temperature > MAX_TEMP)) { + throw new Error('Invalid set points') + } + const target = sessionTarget({ ...session, setPoints }, timezone, now) + if (target) requests.push(target) + invalidSessions[side].delete(session.id) + } + catch (error) { + // Corrupt legacy rows must not break manual commands or status for either + // side. Warn once per bad payload; a corrected row is retried immediately. + if (invalidSessions[side].get(session.id) !== session.setPoints) { + console.warn(`[temperature] ignoring invalid run-once session ${session.id}:`, error) + invalidSessions[side].set(session.id, session.setPoints) + } + } + } + return requests +} + +const globalState = globalThis as typeof globalThis & { + __sp_temperatureController?: TemperatureController + __sp_temperatureService?: { + running: boolean + timer?: ReturnType + pending: Set> + } +} + +export function getTemperatureController(): TemperatureController { + if (!globalState.__sp_temperatureController) { + globalState.__sp_temperatureController = new TemperatureController({ + now: Date.now, + withSideLock, + readHold: (side) => { + const row = db.select().from(temperatureHolds).where(eq(temperatureHolds.side, side)).get() + return row + ? { + id: 'manual', source: 'manual', temperature: row.temperature, + startsAt: row.startedAt, expiresAt: row.expiresAt, createdAt: row.startedAt, priority: 0, + } + : null + }, + writeHold: (side, hold) => { + if (!hold) { + db.delete(temperatureHolds).where(eq(temperatureHolds.side, side)).run() + return + } + const values = { side, temperature: hold.temperature, startedAt: hold.startsAt, expiresAt: hold.expiresAt } + db.insert(temperatureHolds).values(values).onConflictDoUpdate({ target: temperatureHolds.side, set: values }).run() + }, + readBaseline, + isPowered: side => db.select().from(deviceState).where(eq(deviceState.side, side)).get()?.isPowered ?? false, + readCurrentTarget: side => db.select().from(deviceState).where(eq(deviceState.side, side)).get()?.targetTemperature ?? null, + readHardwareDeadline: side => db.select({ deadline: deviceState.hardwareDeadline }).from(deviceState).where(eq(deviceState.side, side)).get()?.deadline ?? null, + writeHardwareDeadline: (side, hardwareDeadline) => { + db.insert(deviceState).values({ side, hardwareDeadline }).onConflictDoUpdate({ target: deviceState.side, set: { hardwareDeadline } }).run() + }, + isBlocked: shouldBlock, + connect: () => getSharedHardwareClient().connect(), + apply: async (side, temperature, durationSec) => { + markSideMutated(side) + if (durationSec === undefined) await getSharedHardwareClient().setTemperature(side, temperature) + else await getSharedHardwareClient().setTemperature(side, temperature, durationSec) + const previous = db.select().from(deviceState).where(eq(deviceState.side, side)).get() + markSideMutated(side) + const values = { + side, targetTemperature: temperature, isPowered: true, + poweredOnAt: previous?.isPowered ? previous.poweredOnAt : new Date(), + lastUpdated: new Date(), + } + db.insert(deviceState).values(values).onConflictDoUpdate({ target: deviceState.side, set: values }).run() + broadcastMutationStatus(side, { targetTemperature: temperature, targetLevel: fahrenheitToLevel(temperature) }) + }, + powerOff: async (side) => { + // Hardware shutdown is always attempted, including when the DB is unavailable. + markSideMutated(side) + try { + db.update(deviceState).set({ + isPowered: false, targetTemperature: null, poweredOnAt: null, lastUpdated: new Date(), + }).where(eq(deviceState.side, side)).run() + } + finally { + await getSharedHardwareClient().connect() + await getSharedHardwareClient().setPower(side, false) + broadcastMutationStatus(side, { targetLevel: 0 }) + } + }, + publish: () => broadcastMutationStatus(), + }) + } + return globalState.__sp_temperatureController +} + +export function getTemperatureControlStatus() { + const controller = globalState.__sp_temperatureController + return controller ? { left: controller.status('left'), right: controller.status('right') } : undefined +} + +export function getTemperatureControllerIfRunning(): TemperatureController | undefined { + return globalState.__sp_temperatureController +} + +/** Start after migrations/state restoration. The non-overlapping loop also retries failed writes. */ +export async function startTemperatureController(): Promise { + if (globalState.__sp_temperatureService?.running) return + if (globalState.__sp_temperatureService) { + await stopTemperatureController() + return startTemperatureController() + } + const service = { running: true, pending: new Set>(), timer: undefined as ReturnType | undefined } + globalState.__sp_temperatureService = service + const ticking = new Set() + const tick = () => { + for (const side of ['left', 'right'] as const) { + if (!service.running || ticking.has(side)) continue + ticking.add(side) + const work = getTemperatureController().reconcile(side, false, () => service.running) + .then(() => {}, (error) => { + console.warn(`[temperature] ${side} reconciliation failed:`, error) + }).finally(() => { + ticking.delete(side) + service.pending.delete(work) + }) + service.pending.add(work) + } + return Promise.all([...service.pending]) + } + service.timer = setInterval(() => { + void tick() + }, 1_000) + service.timer.unref() + await tick() +} + +/** Cancel queued writes and drain admitted work before hardware/database teardown. */ +export async function stopTemperatureController(): Promise { + const service = globalState.__sp_temperatureService + if (!service) return + service.running = false + clearInterval(service.timer) + await Promise.all([...service.pending]) + if (globalState.__sp_temperatureService === service) globalState.__sp_temperatureService = undefined +} diff --git a/src/temperature/schema.ts b/src/temperature/schema.ts new file mode 100644 index 000000000..f1647d1db --- /dev/null +++ b/src/temperature/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const temperatureControlStatusSchema = z.object({ + source: z.enum(['manual', 'run-once', 'autopilot', 'schedule']).nullable(), + requestId: z.string().nullable(), + targetTemperature: z.number().nullable(), + holdUntil: z.number().nullable(), + blocked: z.enum(['safety', 'off']).nullable(), +}) + +/** Separate from hardware duration: this controls temperature ownership only. */ +export const holdMinutesSchema = z.number().int().min(1).max(1440) diff --git a/src/temperature/tests/baseline.test.ts b/src/temperature/tests/baseline.test.ts new file mode 100644 index 000000000..5a13777e9 --- /dev/null +++ b/src/temperature/tests/baseline.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { recurringTarget, sessionTarget, type WeeklyTarget } from '../baseline' + +describe('current recurring target', () => { + const rows: WeeklyTarget[] = [ + { id: 'bedtime', dayOfWeek: 'monday', time: '22:00', temperature: 75 }, + { id: 'overnight', dayOfWeek: 'tuesday', time: '02:00', temperature: 68 }, + ] + + it('selects the current point across midnight and includes an exact firing boundary', () => { + expect(recurringTarget(rows, 'UTC', Date.parse('2026-09-29T01:00Z'))?.temperature).toBe(75) + expect(recurringTarget(rows, 'UTC', Date.parse('2026-09-29T02:00Z'))?.temperature).toBe(68) + expect(recurringTarget(rows, 'UTC', Date.parse('2026-09-29T03:00Z'))?.temperature).toBe(68) + }) + + it('uses the configured zone rather than the server zone', () => { + const now = Date.parse('2026-09-29T05:30Z') // Monday 22:30 PDT + expect(recurringTarget(rows, 'America/Los_Angeles', now)?.temperature).toBe(75) + }) + + it('handles weekly rollover and has no target when schedules are absent', () => { + expect(recurringTarget(rows, 'UTC', Date.parse('2026-10-04T23:00Z'))?.temperature).toBe(68) + expect(recurringTarget([], 'UTC', Date.now())).toBeNull() + }) + + it('matches forward cron execution for a schedule inside the missing spring hour', () => { + const gap: WeeklyTarget[] = [ + { id: 'before', dayOfWeek: 'sunday', time: '01:00', temperature: 75 }, + { id: 'gap', dayOfWeek: 'sunday', time: '02:30', temperature: 68 }, + ] + expect(recurringTarget(gap, 'America/New_York', Date.parse('2026-03-08T07:29Z'))?.temperature).toBe(75) + const shifted = recurringTarget(gap, 'America/New_York', Date.parse('2026-03-08T07:30Z')) + expect(shifted?.temperature).toBe(68) + expect(shifted?.startsAt).toBe(Date.parse('2026-03-08T07:30Z')) + }) + + it('does not replay the repeated fall hour as a second occurrence', () => { + const fall: WeeklyTarget[] = [{ id: 'fall', dayOfWeek: 'sunday', time: '01:30', temperature: 68 }] + for (const time of ['2026-11-01T05:30Z', '2026-11-01T06:30Z']) { + expect(recurringTarget(fall, 'America/New_York', Date.parse(time))?.startsAt).toBe(Date.parse('2026-11-01T05:30Z')) + } + }) + + it('resolves spring-forward using the scheduler cron semantics', () => { + const spring: WeeklyTarget[] = [{ id: 'spring', dayOfWeek: 'sunday', time: '03:30', temperature: 68 }] + const target = recurringTarget(spring, 'America/New_York', Date.parse('2026-03-08T07:30Z')) + expect(target?.startsAt).toBe(Date.parse('2026-03-08T07:30Z')) + }) +}) + +describe('current run-once target', () => { + const session = { + id: 1, + startedAt: new Date('2026-09-28T22:00Z'), + expiresAt: new Date('2026-09-29T07:00Z'), + setPoints: [ + { time: '22:15', temperature: 75 }, + { time: '23:00', temperature: 72 }, + { time: '02:00', temperature: 68 }, + ], + } + + it('uses the first point immediately, even when its label is later', () => { + expect(sessionTarget(session, 'UTC', Date.parse('2026-09-28T22:00Z'))?.temperature).toBe(75) + }) + + it('resumes at the current point rather than shifting or replaying the curve', () => { + expect(sessionTarget(session, 'UTC', Date.parse('2026-09-29T03:00Z'))?.temperature).toBe(68) + expect(sessionTarget(session, 'UTC', Date.parse('2026-09-28T23:00Z'))?.temperature).toBe(72) + }) + + it('does not activate future or expired sessions', () => { + expect(sessionTarget(session, 'UTC', Date.parse('2026-09-28T21:00Z'))).toBeNull() + expect(sessionTarget(session, 'UTC', Date.parse('2026-09-29T07:00Z'))).toBeNull() + }) +}) diff --git a/src/temperature/tests/controller.test.ts b/src/temperature/tests/controller.test.ts new file mode 100644 index 000000000..40a7ed2e2 --- /dev/null +++ b/src/temperature/tests/controller.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest' +import { withSideLock } from '@/src/hardware/sideLock' +import type { Side } from '@/src/hardware/types' +import { DEFAULT_HOLD_MS, TemperatureController, selectTemperatureRequest } from '../controller' +import type { TemperatureControllerDeps, TemperatureRequest, TemperatureSource } from '../controller' + +function request(source: TemperatureSource, temperature: number, overrides: Partial = {}): TemperatureRequest { + return { id: source, source, temperature, startsAt: 0, expiresAt: 10_000_000, createdAt: 0, priority: 0, ...overrides } +} + +function harness() { + let now = 1_000 + const deadlines: Record = { left: null, right: null } + const holds: Record = { left: null, right: null } + const baselines: Record = { left: [request('schedule', 72)], right: [request('schedule', 68)] } + const powered = { left: true, right: true } + const blocked = { left: false, right: false } + const apply = vi.fn(async (side: Side, temperature: number) => { + if (!Number.isFinite(temperature)) throw new Error('Invalid target') + powered[side] = true + }) + const deps: TemperatureControllerDeps = { + now: () => now, + withSideLock, + readHold: side => holds[side], + writeHold: (side, hold) => { holds[side] = hold }, + readBaseline: side => baselines[side], + isPowered: side => powered[side], + readCurrentTarget: () => 75, + readHardwareDeadline: side => deadlines[side], + writeHardwareDeadline: (side, deadline) => { deadlines[side] = deadline }, + isBlocked: side => blocked[side], + connect: vi.fn(async () => {}), + apply, + powerOff: async (side) => { + powered[side] = false + }, + publish: vi.fn(), + } + return { + controller: new TemperatureController(deps), deps, holds, baselines, powered, blocked, apply, + advance: (ms: number) => { now += ms }, + } +} + +describe('temperature arbitration', () => { + it('selects source priority independently of producer order', () => { + const requests = [request('manual', 75), request('schedule', 72), request('run-once', 71), request('autopilot', 70)] + expect(selectTemperatureRequest(requests, 100)?.temperature).toBe(75) + expect(selectTemperatureRequest(requests.slice(1), 100)?.temperature).toBe(71) + expect(selectTemperatureRequest(requests.filter(r => r.source === 'autopilot' || r.source === 'schedule'), 100)?.temperature).toBe(70) + }) + + it('uses rule priority before activation time and a stable tie break', () => { + const a = request('autopilot', 70, { id: 'a', priority: 2 }) + const b = request('autopilot', 80, { id: 'b', priority: 1, createdAt: 90 }) + expect(selectTemperatureRequest([b, a], 100)).toEqual(a) + expect(selectTemperatureRequest([{ ...a, id: 'c' }, a], 100)).toEqual(a) + }) + + it('rejects future, expired, and invalid targets including NaN', () => { + const baseline = request('schedule', 72) + for (const bad of [{ startsAt: 101 }, { expiresAt: 100 }, { temperature: NaN }, { temperature: 111 }, { temperature: 54 }]) { + expect(selectTemperatureRequest([request('manual', 75, bad), baseline], 100)).toEqual(baseline) + } + }) + + it('publishes ownership changes without repeating unchanged periodic status', async () => { + const h = harness() + await h.controller.reconcile('left') + await h.controller.reconcile('right') + expect(h.deps.publish).toHaveBeenCalledTimes(2) + h.advance(1_000) + await h.controller.reconcile('left') + await h.controller.reconcile('right') + expect(h.deps.publish).toHaveBeenCalledTimes(2) + await h.controller.setManual('left', 75) + expect(h.deps.publish).toHaveBeenCalledTimes(3) + h.advance(1_000) + await h.controller.setManual('left', 75) + expect(h.deps.publish).toHaveBeenCalledTimes(4) // hold expiry changed + h.blocked.left = true + await h.controller.reconcile('left') + expect(h.deps.publish).toHaveBeenCalledTimes(5) + await h.controller.resume('left') + expect(h.deps.publish).toHaveBeenLastCalledWith('left', expect.objectContaining({ source: 'schedule', blocked: 'safety' })) + expect(h.deps.publish).toHaveBeenCalledTimes(6) + }) + + it('power-on without a requested temperature honors the current owner without a hold', async () => { + const h = harness() + h.powered.left = false + h.baselines.left.push(request('run-once', 69)) + await withSideLock('left', () => h.controller.powerOnLocked('left')) + expect(h.apply).toHaveBeenLastCalledWith('left', 69) + expect(h.holds.left).toBeNull() + h.baselines.left = [request('schedule', 66)] + await h.controller.reconcile('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 66) + }) + + it('retains evolving lower-priority targets throughout a manual hold', async () => { + const h = harness() + await h.controller.setManual('left', 75) + await h.controller.submit('left', request('autopilot', 70)) + h.baselines.left = [request('schedule', 66), request('run-once', 69)] + await h.controller.reconcile('left') + expect(h.apply.mock.calls).toEqual([['left', 75, undefined]]) + h.advance(DEFAULT_HOLD_MS) + await h.controller.reconcile('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 69) + expect(h.holds.left).toBeNull() + }) + + it('refreshes the hold on another adjustment without affecting the other side', async () => { + const h = harness() + await h.controller.setManual('left', 75) + h.advance(60_000) + await h.controller.setManual('left', 76) + expect(h.holds.left?.expiresAt).toBe(61_000 + DEFAULT_HOLD_MS) + expect(h.controller.status('right').holdUntil).toBeNull() + await h.controller.reconcile('right') + expect(h.apply).toHaveBeenLastCalledWith('right', 68) + }) + + it('restores a persisted hold in a new controller without extending it', async () => { + const h = harness() + await h.controller.setManual('left', 75) + const expiry = h.holds.left?.expiresAt + h.advance(10_000) + const restarted = new TemperatureController(h.deps) + await restarted.reconcile('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 75) + expect(restarted.status('left').holdUntil).toBe(expiry) + }) + + it('preserves a custom hardware cutoff across restart, Resume, and forced keepalive', async () => { + const h = harness() + await h.controller.setManual('left', 75, DEFAULT_HOLD_MS, 600) + h.advance(300_000) + const restarted = new TemperatureController(h.deps) + await restarted.reconcile('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 75, 300) + await restarted.resume('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 72, 300) + h.advance(60_000) + await restarted.reconcile('left', true) + expect(h.apply).toHaveBeenLastCalledWith('left', 72, 240) + expect(restarted.recoveryDurationLocked('left', 28_800)).toBe(240) + h.advance(240_000) + h.apply.mockClear() + await restarted.reconcile('left') + expect(h.powered.left).toBe(false) + expect(h.apply).not.toHaveBeenCalled() + expect(restarted.status('left').blocked).toBe('off') + }) + + it('does not extend a live bounded hold for a scheduled power-on', async () => { + const h = harness() + await h.controller.setManual('left', 75, DEFAULT_HOLD_MS, 600) + h.advance(300_000) + await withSideLock('left', () => h.controller.powerOnLocked('left')) + expect(h.apply).toHaveBeenLastCalledWith('left', 75, 300) + }) + + it('persists a cutoff beyond hold expiry and permits a new explicit manual session', async () => { + const h = harness() + await h.controller.setManual('left', 75, 60_000, 600) + h.advance(60_000) + await h.controller.reconcile('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 72, 540) + await h.controller.setManual('left', 75) + expect(h.deps.readHardwareDeadline('left')).toBeNull() + }) + + it('treats duration zero as shutdown, never a hold that can restart heating', async () => { + const h = harness() + await h.controller.setManual('left', 75, DEFAULT_HOLD_MS, 0) + await h.controller.reconcile('left') + expect(h.apply).not.toHaveBeenCalled() + expect(h.holds.left).toBeNull() + expect(h.powered.left).toBe(false) + }) + + it('applies a changed request deadline even when the new temperature is identical', async () => { + const h = harness() + await h.controller.submit('left', request('autopilot', 72, { createdAt: 1_000, durationSec: 600 })) + expect(h.apply).toHaveBeenLastCalledWith('left', 72, 600) + h.advance(60_000) + await h.controller.submit('left', request('autopilot', 72, { createdAt: 61_000, durationSec: 120 })) + expect(h.apply).toHaveBeenLastCalledWith('left', 72, 120) + h.advance(120_000) + await h.controller.reconcile('left') + expect(h.powered.left).toBe(false) + }) + + it('resumes the current schedule without replaying missed events', async () => { + const h = harness() + await h.controller.setManual('left', 75) + h.baselines.left = [request('schedule', 65)] + const status = await h.controller.resume('left') + expect(status.source).toBe('schedule') + expect(h.apply.mock.calls.map(c => c[1])).toEqual([75, 65]) + }) + + it('withdraws deactivated rules and expires one-shot requests', async () => { + const h = harness() + await h.controller.submit('left', request('autopilot', 70)) + await h.controller.withdraw('left', 'autopilot') + expect(h.apply).toHaveBeenLastCalledWith('left', 72) + await h.controller.submit('left', request('autopilot', 71, { expiresAt: 2_000 })) + h.advance(1_000) + await h.controller.reconcile('left') + expect(h.apply).toHaveBeenLastCalledWith('left', 72) + }) + + it('does not let reconciliation, expiry, or Resume undo shutdown', async () => { + const h = harness() + await h.controller.setManual('left', 75) + h.powered.left = false + h.apply.mockClear() + await h.controller.reconcile('left', true) + await h.controller.resume('left') + expect(h.apply).not.toHaveBeenCalled() + expect(h.controller.status('left').blocked).toBe('off') + }) + + it('rechecks safety after waiting for a connection and refuses manual holds', async () => { + const h = harness() + h.deps.connect = async () => { + h.blocked.left = true + } + await h.controller.reconcile('left') + expect(h.apply).not.toHaveBeenCalled() + await expect(h.controller.setManual('left', 75)).rejects.toThrow('Pump stall') + expect(h.holds.left).toBeNull() + }) + + it('reselects an expired request after transport recovery', async () => { + const h = harness() + h.deps.connect = async () => { + h.advance(1_000) + } + await h.controller.submit('left', request('autopilot', 70, { expiresAt: 2_000 })) + expect(h.apply).toHaveBeenCalledExactlyOnceWith('left', 72) + }) + + it('discards an obsolete queued automation shutdown while allowing operator shutdown', async () => { + const h = harness() + let release!: () => void + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve + })) + await Promise.resolve() + let current = true + const pending = h.controller.powerOff('left', () => current) + current = false + release() + await Promise.all([holder, pending]) + expect(h.powered.left).toBe(true) + await h.controller.powerOff('left') + expect(h.powered.left).toBe(false) + }) + + it('serializes a queued automation behind a newer manual adjustment', async () => { + const h = harness() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let entered!: () => void + const waiting = new Promise((resolve) => { + entered = resolve + }) + const owner = withSideLock('left', async () => { + entered() + await gate + }) + await waiting + const manual = h.controller.setManual('left', 75) + const automated = h.controller.submit('left', request('autopilot', 70)) + release() + await Promise.all([owner, manual, automated]) + expect(h.apply).toHaveBeenCalledExactlyOnceWith('left', 75, undefined) + }) + + it('does not apply a command when its hold cannot be persisted', async () => { + const h = harness() + h.deps.writeHold = () => { + throw new Error('disk full') + } + await expect(h.controller.setManual('left', 75)).rejects.toThrow('disk full') + expect(h.apply).not.toHaveBeenCalled() + }) + + it('restores the previous hold after a failed hardware command', async () => { + const h = harness() + await h.controller.setManual('left', 75) + const previous = h.holds.left + h.apply.mockRejectedValueOnce(new Error('disconnected')) + await expect(h.controller.setManual('left', 76)).rejects.toThrow('disconnected') + expect(h.holds.left).toEqual(previous) + }) + + it('refreshes always-on without inventing a hold when there is no schedule', async () => { + const h = harness() + h.baselines.left = [] + await h.controller.reconcile('left', true) + expect(h.apply).toHaveBeenCalledExactlyOnceWith('left', 75) + expect(h.controller.status('left').source).toBeNull() + expect(h.holds.left).toBeNull() + }) + + it('does not write twice when a target appears during keepalive connection', async () => { + const h = harness() + h.baselines.left = [] + h.deps.connect = async () => { + h.baselines.left = [request('schedule', 72)] + } + await h.controller.reconcile('left', true) + expect(h.apply).toHaveBeenCalledExactlyOnceWith('left', 72) + }) + + it('keeps a failed shutdown latched off until an explicit power-on succeeds', async () => { + const h = harness() + h.deps.powerOff = async () => { + throw new Error('state persistence failed') + } + await expect(withSideLock('left', () => h.controller.powerOffLocked('left'))).rejects.toThrow('persistence') + await h.controller.reconcile('left') + expect(h.apply).not.toHaveBeenCalled() + await withSideLock('left', () => h.controller.powerOnLocked('left')) + expect(h.apply).toHaveBeenCalledExactlyOnceWith('left', 72) + expect(h.controller.status('left').blocked).toBeNull() + }) +}) diff --git a/src/temperature/tests/databaseFixture.ts b/src/temperature/tests/databaseFixture.ts new file mode 100644 index 000000000..767f89980 --- /dev/null +++ b/src/temperature/tests/databaseFixture.ts @@ -0,0 +1,15 @@ +import path from 'node:path' +import type Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' + +/** Integration fixtures use the shipped migrations, including persisted control state. */ +export function resetControlDatabase(sqlite: Database.Database): void { + const globals = globalThis as Record + delete globals.__sp_temperatureController + sqlite.pragma('foreign_keys = OFF') + const tables = sqlite.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\'').all() as Array<{ name: string }> + for (const { name } of tables) sqlite.exec(`DROP TABLE "${name.replaceAll('"', '""')}"`) + migrate(drizzle(sqlite), { migrationsFolder: path.resolve('src/db/migrations') }) + sqlite.pragma('foreign_keys = ON') +} diff --git a/src/temperature/tests/instance.test.ts b/src/temperature/tests/instance.test.ts new file mode 100644 index 000000000..c32b4564f --- /dev/null +++ b/src/temperature/tests/instance.test.ts @@ -0,0 +1,271 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eq } from 'drizzle-orm' +import { resetControlDatabase } from './databaseFixture' + +const hardware = vi.hoisted(() => ({ + connect: vi.fn(async () => {}), + setTemperature: vi.fn<(side: string, temperature: number, duration?: number) => Promise>(async () => {}), + setPower: vi.fn<(side: string, on: boolean) => Promise>(async () => {}), +})) +const safety = vi.hoisted(() => ({ blocked: false })) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getSharedHardwareClient: () => hardware })) +vi.mock('@/src/hardware/pumpStallGuard', () => ({ shouldBlock: () => safety.blocked })) +vi.mock('@/src/streaming/broadcastMutationStatus', () => ({ broadcastMutationStatus: vi.fn() })) +vi.mock('@/src/hardware/deviceStateSync', () => ({ markSideMutated: vi.fn() })) +vi.mock('@/src/db', async () => { + const Database = (await import('better-sqlite3')).default + const { drizzle } = await import('drizzle-orm/better-sqlite3') + const schema = await import('@/src/db/schema') + const fs = await import('node:fs') + const os = await import('node:os') + const path = await import('node:path') + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'sleepypod-control-')) + const filename = path.join(directory, 'control.db') + let sqlite = new Database(filename) + let currentDb = drizzle(sqlite, { schema }) + return { + get sqlite() { return sqlite }, + get db() { return currentDb }, + reopenForTest() { + sqlite.close() + sqlite = new Database(filename) + currentDb = drizzle(sqlite, { schema }) + }, + cleanupForTest() { + sqlite.close() + fs.rmSync(directory, { recursive: true, force: true }) + }, + } +}) + +import { db, sqlite } from '@/src/db' +import * as databaseModule from '@/src/db' +import { deviceSettings, deviceState, runOnceSessions, sideSettings, temperatureHolds, temperatureSchedules } from '@/src/db/schema' +import { getTemperatureController, getTemperatureControlStatus, startTemperatureController, stopTemperatureController } from '../instance' +import { withSideLock } from '@/src/hardware/sideLock' + +const fileDatabase = databaseModule as typeof databaseModule & { reopenForTest: () => void, cleanupForTest: () => void } +afterAll(() => fileDatabase.cleanupForTest()) + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-28T22:00:00Z')) + safety.blocked = false + vi.clearAllMocks() + resetControlDatabase(sqlite) + db.insert(deviceSettings).values({ id: 1, timezone: 'UTC' }).run() + db.insert(sideSettings).values([{ side: 'left', name: 'Left' }, { side: 'right', name: 'Right' }]).run() + db.insert(deviceState).values([ + { side: 'left', isPowered: true, targetTemperature: 75 }, + { side: 'right', isPowered: true, targetTemperature: 75 }, + ]).run() + db.insert(temperatureSchedules).values([ + { side: 'left', dayOfWeek: 'monday', time: '21:00', temperature: 72 }, + { side: 'left', dayOfWeek: 'monday', time: '22:15', temperature: 68 }, + ]).run() +}) + +afterEach(async () => { + await stopTemperatureController() + vi.useRealTimers() +}) + +describe('production controller with migrated SQLite', () => { + it('keeps a dial target across a schedule step, then resolves the current point on expiry', async () => { + const controller = getTemperatureController() + await controller.setManual('left', 76) + vi.setSystemTime(new Date('2026-09-28T22:20:00Z')) + await controller.reconcile('left') + expect(hardware.setTemperature).toHaveBeenCalledTimes(1) + vi.setSystemTime(new Date('2026-09-28T22:30:00Z')) + await controller.reconcile('left') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 68) + expect(db.select().from(temperatureHolds).all()).toHaveLength(0) + }) + + it('restores the saved hold after singleton recreation without extending its deadline', async () => { + await getTemperatureController().setManual('left', 76) + const persisted = db.select().from(temperatureHolds).get() + delete (globalThis as Record).__sp_temperatureController + vi.setSystemTime(new Date('2026-09-28T22:10:00Z')) + const restarted = getTemperatureController() + await restarted.reconcile('left') + expect(restarted.status('left').holdUntil).toBe(persisted?.expiresAt) + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 76) + expect(db.select().from(temperatureHolds).get()).toEqual(persisted) + }) + + it('reopens the actual SQLite file and restores only the remaining hardware duration', async () => { + await getTemperatureController().setManual('left', 76, 30 * 60_000, 600) + const expiry = getTemperatureController().status('left').holdUntil + const cutoff = Date.now() + 600_000 + fileDatabase.reopenForTest() + delete (globalThis as Record).__sp_temperatureController + vi.setSystemTime(new Date('2026-09-28T22:05:00Z')) + await getTemperatureController().reconcile('left') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 76, 300) + expect(getTemperatureController().status('left').holdUntil).toBe(expiry) + expect(db.select().from(deviceState).where(eq(deviceState.side, 'left')).get()?.hardwareDeadline).toBe(cutoff) + fileDatabase.reopenForTest() + delete (globalThis as Record).__sp_temperatureController + vi.setSystemTime(new Date('2026-09-28T22:11:00Z')) + hardware.setTemperature.mockClear() + await getTemperatureController().reconcile('left') + expect(hardware.setTemperature).not.toHaveBeenCalled() + expect(hardware.setPower).toHaveBeenLastCalledWith('left', false) + expect(db.select().from(temperatureHolds).all()).toHaveLength(0) + expect(db.select().from(deviceState).where(eq(deviceState.side, 'left')).get()?.isPowered).toBe(false) + }) + + it('resolves an advancing run-once session ahead of Autopilot after Resume', async () => { + const controller = getTemperatureController() + db.insert(runOnceSessions).values({ + side: 'left', startedAt: new Date(), expiresAt: new Date(Date.now() + 3_600_000), wakeTime: '23:00', + setPoints: JSON.stringify([{ time: '22:00', temperature: 74 }, { time: '22:10', temperature: 69 }]), + }).run() + await controller.setManual('left', 76) + await controller.submit('left', { + id: 'rule:1:0', source: 'autopilot', temperature: 70, priority: 1, + startsAt: Date.now(), expiresAt: Date.now() + 3_600_000, createdAt: Date.now(), + }) + vi.setSystemTime(new Date('2026-09-28T22:20:00Z')) + const status = await controller.resume('left') + expect(status.source).toBe('run-once') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 69) + }) + + it.each([ + '{broken', + '{}', + '[{"time":"22:00","temperature":74},{"time":"invalid","temperature":70}]', + '[{"time":"22:00","temperature":999}]', + ])('isolates a corrupt session and immediately uses its repaired payload: %s', async (setPoints) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const session = db.insert(runOnceSessions).values({ + side: 'left', startedAt: new Date(), expiresAt: new Date(Date.now() + 3_600_000), wakeTime: '23:00', setPoints, + }).returning().get() + expect(getTemperatureController().status('left').source).toBe('schedule') + expect(getTemperatureControlStatus()?.right).toBeDefined() + await expect(getTemperatureController().setManual('left', 77)).resolves.toMatchObject({ source: 'manual' }) + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 77) + expect(warn).toHaveBeenCalledTimes(1) + db.update(runOnceSessions).set({ setPoints: '[{"time":"22:00","temperature":69}]' }).where(eq(runOnceSessions.id, session.id)).run() + await expect(getTemperatureController().resume('left')).resolves.toMatchObject({ source: 'run-once', targetTemperature: 69 }) + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 69) + } + finally { warn.mockRestore() } + }) + + it('keeps an explicit shutdown off through expiry and restart', async () => { + await getTemperatureController().setManual('left', 76) + await getTemperatureController().powerOff('left') + hardware.setTemperature.mockClear() + delete (globalThis as Record).__sp_temperatureController + vi.setSystemTime(new Date('2026-09-28T22:45:00Z')) + await getTemperatureController().reconcile('left') + expect(hardware.setTemperature).not.toHaveBeenCalled() + expect(db.select().from(deviceState).where(eq(deviceState.side, 'left')).get()?.isPowered).toBe(false) + }) + + it('resolves schedule edits made in the same minute and handles missing device-state rows', async () => { + const controller = getTemperatureController() + await controller.reconcile('left') + db.update(temperatureSchedules).set({ temperature: 71 }).where(eq(temperatureSchedules.time, '21:00')).run() + await controller.reconcile('left') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 71) + db.delete(deviceState).where(eq(deviceState.side, 'right')).run() + await controller.setManual('right', 73) + expect(db.select().from(deviceState).where(eq(deviceState.side, 'right')).get()?.targetTemperature).toBe(73) + }) + + it('expires holds automatically through the service loop', async () => { + await getTemperatureController().setManual('left', 76, 60_000) + await startTemperatureController() + await vi.advanceTimersByTimeAsync(60_000) + expect(getTemperatureController().status('left').source).toBe('schedule') + expect(hardware.setTemperature).toHaveBeenLastCalledWith('left', 72) + }) + + it('expires the right hold while the left side lock is occupied', async () => { + await getTemperatureController().setManual('right', 76, 1_000) + let release = () => {} + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve + })) + await Promise.resolve() + const starting = startTemperatureController() + try { + await vi.advanceTimersByTimeAsync(2_000) + expect(db.select().from(temperatureHolds).where(eq(temperatureHolds.side, 'right')).get()).toBeUndefined() + } + finally { + release() + await holder + await starting + } + }) + + it('cancels queued reconciliation and drains it before stopping', async () => { + let release!: () => void + const holder = withSideLock('left', () => new Promise((resolve) => { + release = resolve + })) + await Promise.resolve() + const starting = startTemperatureController() + let stopped = false + const stopping = stopTemperatureController().then(() => { + stopped = true + }) + await Promise.resolve() + expect(stopped).toBe(false) + release() + await Promise.all([holder, starting, stopping]) + expect(hardware.setTemperature.mock.calls.filter(([side]) => side === 'left')).toEqual([]) + await vi.advanceTimersByTimeAsync(2_000) + expect(hardware.setTemperature.mock.calls.filter(([side]) => side === 'left')).toEqual([]) + await startTemperatureController() + expect(hardware.setTemperature.mock.calls.some(([side]) => side === 'left')).toBe(true) + }) + + it('cancels a reconciliation waiting for connection before it writes', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + hardware.connect.mockImplementationOnce(() => gate) + const starting = startTemperatureController() + await vi.waitFor(() => expect(hardware.connect).toHaveBeenCalled()) + const stopping = stopTemperatureController() + release() + await Promise.all([starting, stopping]) + expect(hardware.setTemperature.mock.calls.filter(([side]) => side === 'left')).toEqual([]) + }) + + it('waits for an admitted hardware write before completing shutdown', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + hardware.setTemperature.mockImplementationOnce(() => gate) + const starting = startTemperatureController() + await vi.waitFor(() => expect(hardware.setTemperature).toHaveBeenCalled()) + let stopped = false + const stopping = stopTemperatureController().then(() => { + stopped = true + }) + await Promise.resolve() + expect(stopped).toBe(false) + release() + await Promise.all([starting, stopping]) + expect(stopped).toBe(true) + }) + + it('blocks both manual and scheduled energizing commands during a safety cutoff', async () => { + safety.blocked = true + await expect(getTemperatureController().setManual('left', 76)).rejects.toThrow('Pump stall') + await withSideLock('left', () => getTemperatureController().reconcileLocked('left')) + expect(hardware.setTemperature).not.toHaveBeenCalled() + expect(db.select().from(temperatureHolds).all()).toHaveLength(0) + }) +}) diff --git a/tests/instrumentation.initialize.test.ts b/tests/instrumentation.initialize.test.ts index 1a6213fe2..dcbb78129 100644 --- a/tests/instrumentation.initialize.test.ts +++ b/tests/instrumentation.initialize.test.ts @@ -42,6 +42,8 @@ const mocks = vi.hoisted(() => ({ startPerformanceMonitoring: vi.fn(), stopPerformanceMonitoring: vi.fn(), recordStartupPhase: vi.fn(), + startTemperatureController: vi.fn(async () => undefined), + stopTemperatureController: vi.fn(), getAutomationEngine: vi.fn(async () => ({})), shutdownAutomationEngine: vi.fn(async () => undefined), })) @@ -55,6 +57,11 @@ vi.mock('@/src/lib/serverPerformance', () => ({ stopPerformanceMonitoring: mocks.stopPerformanceMonitoring, recordStartupPhase: mocks.recordStartupPhase, })) +vi.mock('@/src/temperature/instance', () => ({ + startTemperatureController: mocks.startTemperatureController, + stopTemperatureController: mocks.stopTemperatureController, +})) + vi.mock('@/src/automation', () => ({ getAutomationEngine: mocks.getAutomationEngine, shutdownAutomationEngine: mocks.shutdownAutomationEngine, @@ -336,6 +343,16 @@ describe('initializeScheduler β€” error swallowing', () => { }) describe('register()', () => { + it('starts the shared controller and keepalives even when Autopilot fails', async () => { + mocks.getAutomationEngine.mockRejectedValueOnce(new Error('automation table unavailable')) + const { register } = await import('../instrumentation') + await register() + await vi.waitFor(() => { + expect(mocks.startTemperatureController).toHaveBeenCalledTimes(1) + expect(mocks.initializeKeepalives).toHaveBeenCalledTimes(1) + }) + }) + it('waits for migrations before publishing hardware readiness', async () => { let release!: () => void mocks.runMigrations.mockImplementationOnce(() => new Promise((resolve) => { From 3a5d9bec62ccdb2fc5118124c5085281bf1e2872 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Mon, 28 Sep 2026 07:41:36 +0000 Subject: [PATCH 028/119] perf: cache weekly temperature schedule occurrences (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After deploying #727 to a Pod with hundreds of schedule rows, synchronous forward cron resolution saturated the Node event loop and caused API timeouts. A read-only on-Pod benchmark took 5.7 seconds for just 20 rows; the controller repeated the full calculation every minute. Cache each row's weekly occurrence interval per side and reuse it while `startsAt <= now < expiresAt`. Crossing the next occurrence, moving the clock backward, or changing the timezone/day/time recomputes that interval. Temperature edits apply immediately; removed entries are pruned. Forward cron iteration and its existing DST behavior remain unchanged. Initial cold-cache resolution is still expensive, but ordinary minute ticks no longer repeat it. Validation: 201 focused tests pass across controller, SQLite integration, Autopilot and scheduler suites. New regressions cover minute reuse, exact boundaries, clock rollback, timezone/time/temperature edits, pruning and DST gap/fold behavior. Independent focused adversarial review approved the repair; a 400-row probe made zero parser calls across 60 warm minute resolutions. Pre-push typecheck/lint/schema/related-test gates and live Pod verification accompany this follow-up. Live validation on .88 with the repaired build: after the initial cache fill, left/right control endpoints returned HTTP200 in70ms/55ms, version in119ms, and full device status included both ownership objects. Before this fix, repeated requests timed out at15–20seconds. This is a temporary repair deployment; the final requested deployment will use merged dev. Initial cache fill remains a startup latency limitation.
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update fix/temperature-schedule-cache ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/fix/temperature-schedule-cache/scripts/install \ | sudo bash -s -- --branch fix/temperature-schedule-cache ``` 🎯 **Pin to this exact build** ([run 36392765184](https://github.com/sleepypod/core/actions/runs/36392765184) Β· `1175f2d`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/1175f2dcdbf9f93386a4aeb000e74ea93d1f4a91/scripts/install \ | sudo bash -s -- \ --branch fix/temperature-schedule-cache \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36392765184/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36392765184/artifacts/10956862456) Β· self-destructs in 30 days πŸ’₯
--- src/temperature/baseline.ts | 38 ++++++++++++------ src/temperature/instance.ts | 6 ++- src/temperature/tests/baseline.test.ts | 55 +++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/temperature/baseline.ts b/src/temperature/baseline.ts index bf6fc3385..7a629c3d7 100644 --- a/src/temperature/baseline.ts +++ b/src/temperature/baseline.ts @@ -10,27 +10,43 @@ export interface WeeklyTarget { temperature: number } +export type RecurringOccurrenceCache = Map + /** Use node-schedule's cron parser and timezone semantics for both engines. */ -export function recurringTarget(rows: WeeklyTarget[], timezone: string, now: number): TemperatureRequest | null { +export function recurringTarget(rows: WeeklyTarget[], timezone: string, now: number, cache: RecurringOccurrenceCache = new Map()): TemperatureRequest | null { + const activeKeys = new Set() let latest: TemperatureRequest | null = null for (const row of rows) { - const [hour, minute] = row.time.split(':').map(Number) - const cron = `${minute} ${hour} * * ${DAYS_OF_WEEK.indexOf(row.dayOfWeek)}` - // Forward iteration matches node-schedule through missing/repeated DST - // hours. cron-parser.prev() is not the inverse of next() in a spring gap. - const occurrences = parseExpression(cron, { currentDate: new Date(now - 15 * 86_400_000), tz: timezone }) - let startsAt = occurrences.next().getTime() - let expiresAt = occurrences.next().getTime() - while (expiresAt <= now) { - startsAt = expiresAt - expiresAt = occurrences.next().getTime() + const key = JSON.stringify([timezone, row.dayOfWeek, row.time]) + activeKeys.add(key) + let occurrence = cache.get(key) + if (!occurrence || now < occurrence.startsAt || now >= occurrence.expiresAt) { + const [hour, minute] = row.time.split(':').map(Number) + const cron = `${minute} ${hour} * * ${DAYS_OF_WEEK.indexOf(row.dayOfWeek)}` + // Forward iteration matches node-schedule through missing/repeated DST + // hours. cron-parser.prev() is not the inverse of next() in a spring gap. + // Cache each weekly interval, not just the winning target for one minute: + // recomputing hundreds of unchanged cron rows can saturate a Pod CPU. + const occurrences = parseExpression(cron, { currentDate: new Date(now - 15 * 86_400_000), tz: timezone }) + let startsAt = occurrences.next().getTime() + let expiresAt = occurrences.next().getTime() + while (expiresAt <= now) { + startsAt = expiresAt + expiresAt = occurrences.next().getTime() + } + occurrence = { startsAt, expiresAt } + cache.set(key, occurrence) } + const { startsAt, expiresAt } = occurrence const candidate: TemperatureRequest = { id: row.id, source: 'schedule', temperature: row.temperature, startsAt, expiresAt, createdAt: startsAt, priority: 0, } if (!latest || startsAt > latest.startsAt || (startsAt === latest.startsAt && row.id < latest.id)) latest = candidate } + for (const key of cache.keys()) { + if (!activeKeys.has(key)) cache.delete(key) + } return latest } diff --git a/src/temperature/instance.ts b/src/temperature/instance.ts index 6d3615e2f..efd4bf5e8 100644 --- a/src/temperature/instance.ts +++ b/src/temperature/instance.ts @@ -7,11 +7,13 @@ import { shouldBlock } from '@/src/hardware/pumpStallGuard' import { withSideLock } from '@/src/hardware/sideLock' import { fahrenheitToLevel, MAX_TEMP, MIN_TEMP, type Side } from '@/src/hardware/types' import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' -import { recurringTarget, sessionTarget } from './baseline' +import { recurringTarget, sessionTarget, type RecurringOccurrenceCache } from './baseline' import { TemperatureController, type TemperatureRequest } from './controller' const invalidSessions: Record> = { left: new Map(), right: new Map() } +const occurrenceCaches: Record = { left: new Map(), right: new Map() } + const recurringCache: Partial> = {} function readBaseline(side: Side, now: number): TemperatureRequest[] { @@ -33,7 +35,7 @@ function readBaseline(side: Side, now: number): TemperatureRequest[] { ] const key = JSON.stringify([timezone, Math.floor(now / 60_000), rows]) const cached = recurringCache[side] - const baseline = cached?.key === key ? cached.target : recurringTarget(rows, timezone, now) + const baseline = cached?.key === key ? cached.target : recurringTarget(rows, timezone, now, occurrenceCaches[side]) recurringCache[side] = { key, target: baseline } if (baseline) requests.push(baseline) } diff --git a/src/temperature/tests/baseline.test.ts b/src/temperature/tests/baseline.test.ts index 5a13777e9..2f207995e 100644 --- a/src/temperature/tests/baseline.test.ts +++ b/src/temperature/tests/baseline.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { recurringTarget, sessionTarget, type WeeklyTarget } from '../baseline' +import { describe, expect, it, vi } from 'vitest' +import * as cronParser from 'cron-parser' +import { recurringTarget, sessionTarget, type WeeklyTarget, type RecurringOccurrenceCache } from '../baseline' + +vi.mock('cron-parser', async (importOriginal) => { + const original = await importOriginal() + return { ...original, parseExpression: vi.fn(original.parseExpression) } +}) describe('current recurring target', () => { const rows: WeeklyTarget[] = [ @@ -23,6 +29,51 @@ describe('current recurring target', () => { expect(recurringTarget([], 'UTC', Date.now())).toBeNull() }) + it('reuses unchanged weekly occurrences across minutes and recomputes only a due row', () => { + const parse = vi.mocked(cronParser.parseExpression) + parse.mockClear() + const cache: RecurringOccurrenceCache = new Map() + try { + const start = Date.parse('2026-09-29T01:00Z') + expect(recurringTarget(rows, 'UTC', start, cache)?.temperature).toBe(75) + expect(parse).toHaveBeenCalledTimes(2) + expect(recurringTarget(rows, 'UTC', start + 60_000, cache)?.temperature).toBe(75) + expect(parse).toHaveBeenCalledTimes(2) + expect(recurringTarget(rows, 'UTC', Date.parse('2026-09-29T02:00Z'), cache)?.temperature).toBe(68) + expect(parse).toHaveBeenCalledTimes(3) + // A setpoint edit changes the target without recalculating its clock. + expect(recurringTarget([{ ...rows[1], temperature: 70 }], 'UTC', Date.parse('2026-09-29T02:01Z'), cache)?.temperature).toBe(70) + expect(parse).toHaveBeenCalledTimes(3) + expect(cache.size).toBe(1) // deleted/disabled schedules do not accumulate + } + finally { parse.mockClear() } + }) + + it('invalidates cached occurrences on clock rollback, timezone and time edits', () => { + const cache: RecurringOccurrenceCache = new Map() + const now = Date.parse('2026-09-29T03:00Z') + recurringTarget(rows, 'UTC', now, cache) + expect(recurringTarget(rows, 'UTC', now - 2 * 86_400_000, cache)).toEqual(recurringTarget(rows, 'UTC', now - 2 * 86_400_000)) + expect(recurringTarget(rows, 'America/Los_Angeles', now, cache)).toEqual(recurringTarget(rows, 'America/Los_Angeles', now)) + const edited = [{ ...rows[0], time: '23:30' }, rows[1]] + expect(recurringTarget(edited, 'America/Los_Angeles', now, cache)).toEqual(recurringTarget(edited, 'America/Los_Angeles', now)) + expect(cache.size).toBe(2) + }) + + it('keeps cached spring-gap and fall-fold boundaries identical to fresh resolution', () => { + for (const [time, moments] of [ + ['02:30', ['2026-03-08T07:29Z', '2026-03-08T07:30Z', '2026-03-15T07:00Z']], + ['01:30', ['2026-11-01T05:29Z', '2026-11-01T05:30Z', '2026-11-01T06:30Z']], + ] as const) { + const cache: RecurringOccurrenceCache = new Map() + const schedules: WeeklyTarget[] = [{ id: 'dst', dayOfWeek: 'sunday', time, temperature: 68 }] + for (const moment of moments) { + const now = Date.parse(moment) + expect(recurringTarget(schedules, 'America/New_York', now, cache)).toEqual(recurringTarget(schedules, 'America/New_York', now)) + } + } + }) + it('matches forward cron execution for a schedule inside the missing spring hour', () => { const gap: WeeklyTarget[] = [ { id: 'before', dayOfWeek: 'sunday', time: '01:00', temperature: 75 }, From 399d9bd34100838c5ff446983008371226bea6ab Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Tue, 29 Sep 2026 14:32:28 +0000 Subject: [PATCH 029/119] feat: redesign the web UI around what needs attention on each screen, with Scheduler, Databases and Hardware pages (#733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The web UI redesign: every screen rebuilt on one design system and a new navigation, each page organized around what someone needs to see or do. 70 commits, including merged PRs #731 and #732. Every page and sub-page now has an HTML title ("Databases Β· System Β· sleepypod"). **Navigation and shell** - Sidebar with nested Settings/System sections, Autopilot promoted to a top-level tab, Biometrics moved under a Sleep group, Diagnostics flattened into System with a Dashboard landing page - Sidebar footer that says what needs attention, links the System dashboard, and shows the release version (or a DEV chip with the branch) - Links to the user docs at sleepypod.github.io; developer mode removed so Pipeline, Logs and raw data are always reachable **Temperature and schedule** - Temp screen: per-side cards, dial/slider control, All off, Link sides, and last night + tonight on one timeline - Per-device "Show as: Degrees / Offset Β±" (offset from 80Β°F, like the old dial and Eight Sleep) - Schedule: curve cards, in-page editing, eased holds, and a Both view that stacks each side **Autopilot / Automations** - Rebuilt around who controls the bed tonight, rule backtests, an activity log that explains every skip, a page per rule, and per-minute diagnostics **Sleep / Biometrics** - Per-metric vitals rows on a session-only axis, a sessions table, and a stalled-pipeline banner - When a side reads occupied with no vitals or movement for 2h+, it asks "Is anyone there?" (recalibrate the empty bed vs restart the piezo processor) instead of blaming the pipeline **System** - Dashboard around what needs attention; Thermal records bed/target/power history and charts every signal on one axis; Sensors draw each live signal against its calibration band; storage reclaims leftover releases - **Health rebuilt around whether data reaches each stage**, not only whether processes run: a sensors β†’ hardware β†’ services β†’ core β†’ outputs map with a freshness check per stage, a one-line verdict naming where the chain breaks with its fix (restart a module, open logs, recalibrate), 24 hours of per-check history in a new `health_runs` table (migration 0016) with incidents in words, - **Hardware**: the vibration test (with a pattern preview) moved out of Health onto its own page - **Scheduler as tonight's timeline**: one lane per side drawn as the Schedule page's eased curve against 80Β°F, a pod lane for LED/reboot/prime, a now line and a ring on the next job; a night picker (nights with no jobs dimmed); next five jobs with plain labels and muted job IDs; "All jobs" grouped by kind with filters. Built from every cron firing via new `health.schedulerTimeline`, replacing the week strip that was built from the next 10 jobs. Simple crons are expanded without cron-parser, whose timezone math blocked the pod's event loop ~10 s per night - **Databases**: sleepypod.db and biometrics.db side by side β€” storage outlook projected against free disk, integrity, WAL, free pages, migrations (flags ones applied by another build), a 24-hour write strip per table (hatched where the pod was occupied but nothing arrived), a read-only row browser, and a backup download of both files (`/api/db-backup`, SQLite online backup). New `databases` router; the scan runs in a worker and is cached 5 min - **DB fixes the Databases page surfaced**: the hourly integrity check now covers biometrics.db (it only ever opened sleepypod.db; `getDatabaseIntegrity()` reports the combined status, naming the bad file), and `automation_runs` is pruned after 90 days (`AUTOMATION_RUNS_RETENTION_DAYS`), in 5k-row batches β€” it was growing without bound (135k rows on a test pod; first pass removed 26.7k) - New endpoints: `health.dataPath`, `health.history`, `health.restartService` (whitelisted to the three biometrics module units; `scripts/install` adds matching NOPASSWD sudoers lines, older pods get the manual command back) **Settings** - Status, Appearance and per-area sections; HomeKit split from MQTT & Home Assistant ## Review notes - The Health/offset/occupancy commits (`0062e63`..`0c0ac46`) were reviewed by CodeRabbit via proxy (ng/core#5); its 3 findings are fixed. - `health_runs` is pruned by the existing biometrics retention loop. - `health.thermal` logic moved unchanged into `src/lib/thermalTruth.ts` so the data path can reuse it. - `cap_sense_frames` is already pruned to 48 h by its writer; the Databases page shows that rather than "not pruned". - Not included: restoring a backup from the UI (it needs services stopped); the page says how to restore by hand. ## Test plan - [x] `vitest` for all touched areas passes, including new tests for the data-path evaluator, health history, Health panel, occupancy check, haptics replay and Temp control display modes - [x] `tsc --noEmit`, `eslint`, `drizzle-kit check` (both configs) clean - [x] Deployed to a Pod 5 (J55) with `sp-update feat/ui-redesign`: Health correctly flagged a phantom-occupied side (capacitance baseline drift, no vitals for 8h) and asked "Is anyone there?" instead of blaming the piezo processor - [x] Deployed to the Pod 5 at .88 with `scripts/deploy`: Scheduler, Databases and Hardware checked at 1440px and 390px in headless Chromium; titles verified on every page; `health.schedulerTimeline` answers in 0.18 s; hourly integrity OK for both files; retention pruned 26,709 `automation_runs` rows - [ ] Walk every page on a phone and desktop - [ ] Download a backup from System β†’ Databases and open both .db files - [ ] On a fresh install: Health's "Restart piezo processor" works via the new sudoers rules - [ ] Temp screen in Offset mode on a pod, dial and slider πŸ€– Generated with [Claude Code](https://claude.com/claude-code)
πŸ›οΈ Beam this PR onto a sleepypod ✨ Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** β€” sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. πŸ”„ **Already on a sleepypod** (most common β€” review a PR on a running pod): ```bash sudo sp-update feat/ui-redesign ``` πŸš€ **Fresh pod / first install** β€” bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/feat/ui-redesign/scripts/install \ | sudo bash -s -- --branch feat/ui-redesign ``` 🎯 **Pin to this exact build** ([run 36581574469](https://github.com/sleepypod/core/actions/runs/36581574469) Β· `14aa62c`) β€” fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/14aa62c5e3dc55c4c2065c21015ee185d1d25b0b/scripts/install \ | sudo bash -s -- \ --branch feat/ui-redesign \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/36581574469/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/36581574469/artifacts/11039114981) Β· self-destructs in 30 days πŸ’₯
--------- Co-authored-by: Claude Opus 5.5 (1M context) --- .gitattributes | 3 + README.md | 21 +- app/[lang]/autopilot/[id]/page.tsx | 13 + app/[lang]/autopilot/page.tsx | 10 +- app/[lang]/data/page.tsx | 237 +-- app/[lang]/debug/page.tsx | 9 +- app/[lang]/health/HealthcheckDemo.tsx | 24 + app/[lang]/health/page.tsx | 24 +- app/[lang]/layout.tsx | 58 +- app/[lang]/page.tsx | 4 + app/[lang]/schedule/page.tsx | 3 + app/[lang]/sensors/error.tsx | 31 - app/[lang]/sensors/page.tsx | 7 +- app/[lang]/settings/page.tsx | 3 + app/[lang]/sleep/page.tsx | 13 + app/[lang]/status/page.tsx | 7 +- app/[lang]/system/error.tsx | 24 + app/[lang]/system/page.tsx | 13 + app/[lang]/tests/redesignRoutes.test.tsx | 70 + app/api/db-backup/route.ts | 90 ++ app/globals.css | 326 +++-- docs/images/README.md | 13 + docs/images/core-temperature.png | 3 + docs/images/dial-LICENSE | 661 +++++++++ docs/images/dial-cooling.png | 3 + docs/images/ios-temperature.png | 3 + instrumentation.ts | 13 +- public/apple-touch-icon.png | Bin 0 -> 39666 bytes public/icon.png | Bin 0 -> 6973 bytes public/logo.png | Bin 0 -> 24605 bytes scripts/README.md | 1 + scripts/bin/sp-storage-cleanup | 188 +++ scripts/bin/sp-update | 31 + scripts/install | 24 +- scripts/lib/storage-helpers | 17 + scripts/tests/storageCleanup.test.ts | 163 +++ src/automation/activity.ts | 209 +++ src/automation/tests/activity.test.ts | 98 ++ src/components/AppShell/AppShell.tsx | 29 + src/components/AppShell/Sidebar.tsx | 207 +++ src/components/AppShell/TabBar.tsx | 48 + src/components/AppShell/footerStatus.ts | 40 + src/components/AppShell/navItems.ts | 47 + .../AppShell/tests/footerStatus.test.ts | 43 + .../AppShell/tests/navigation.test.tsx | 88 ++ src/components/Autopilot/ActivityCard.tsx | 142 ++ src/components/Autopilot/AutomationsList.tsx | 207 ++- src/components/Autopilot/AutopilotConsole.tsx | 316 ++-- .../Autopilot/AutopilotStatusChip.tsx | 33 + src/components/Autopilot/BacktestPanel.tsx | 102 +- src/components/Autopilot/CapZoneViz.tsx | 40 +- src/components/Autopilot/RuleEditor.tsx | 220 +-- src/components/Autopilot/RulePage.tsx | 64 + src/components/Autopilot/StatusPanel.tsx | 552 +++++-- src/components/Autopilot/TonightCard.tsx | 234 +++ src/components/Autopilot/automationsLogic.ts | 268 ++++ src/components/Autopilot/autopilotViews.ts | 11 + src/components/Autopilot/builderModel.ts | 88 ++ src/components/Autopilot/evaluationStrip.ts | 195 +++ src/components/Autopilot/primitives.tsx | 151 +- .../Autopilot/tests/AutopilotConsole.test.tsx | 104 ++ .../tests/AutopilotStatusChip.test.tsx | 43 + .../Autopilot/tests/BacktestPanel.test.tsx | 31 + .../Autopilot/tests/CapZoneViz.test.tsx | 36 + .../Autopilot/tests/RuleEditor.test.tsx | 72 + .../Autopilot/tests/RulePage.test.tsx | 103 ++ .../Autopilot/tests/StatusPanel.test.tsx | 110 ++ .../Autopilot/tests/automationCards.test.tsx | 136 ++ .../Autopilot/tests/automationsLogic.test.ts | 164 +++ .../Autopilot/tests/autopilotViews.test.ts | 14 + .../Autopilot/tests/builderFixtures.ts | 8 + .../Autopilot/tests/evaluationStrip.test.ts | 121 ++ .../Autopilot/tests/primitives.test.tsx | 8 + src/components/BottomNav/BottomNav.tsx | 88 -- .../DualSideChart/DualSideChart.tsx | 498 ------- src/components/Environment/BedTempChart.tsx | 72 +- src/components/Environment/HumidityChart.tsx | 128 +- .../Environment/TimeRangeSelector.tsx | 8 +- .../EnvironmentInfo/EnvironmentInfoPanel.tsx | 81 +- src/components/Header/Header.module.css | 13 - src/components/Header/Header.tsx | 73 - .../MovementChart/MovementChart.tsx | 359 +---- .../MovementChart/tests/charts.test.tsx | 76 + .../PowerButton/PowerButton.module.css | 35 - src/components/PowerButton/PowerButton.tsx | 81 -- .../PowerButton/tests/PowerButton.test.tsx | 137 -- .../PullToRefresh/PullToRefresh.tsx | 4 +- src/components/Schedule/AICurveWizard.tsx | 552 +++---- src/components/Schedule/AlarmCard.tsx | 149 +- src/components/Schedule/AlarmEditor.tsx | 422 +++--- src/components/Schedule/AlarmSection.tsx | 120 +- src/components/Schedule/BothNightView.tsx | 163 +++ src/components/Schedule/ConfirmDialog.tsx | 52 +- src/components/Schedule/CurveCard.tsx | 388 +++-- src/components/Schedule/CurveChart.tsx | 639 ++++++--- src/components/Schedule/CurveEditor.tsx | 710 ++++----- src/components/Schedule/DaySelector.tsx | 118 +- src/components/Schedule/PhaseLegend.tsx | 22 - src/components/Schedule/SchedulePage.tsx | 362 +++-- src/components/Schedule/ScheduleToggle.tsx | 65 +- .../Schedule/SchedulerConfirmation.tsx | 18 +- src/components/Schedule/SetPointCard.tsx | 186 +-- src/components/Schedule/SetPointEditor.tsx | 265 +--- src/components/Schedule/TimeInput.tsx | 12 +- src/components/Schedule/bothNight.ts | 66 + src/components/Schedule/scheduleFormat.ts | 79 + .../Schedule/tests/AICurveWizard.test.tsx | 67 + .../Schedule/tests/AlarmEditor.test.tsx | 164 +++ .../Schedule/tests/AlarmSection.test.tsx | 126 ++ .../Schedule/tests/BothNightView.test.tsx | 16 + .../Schedule/tests/CurveCard.test.tsx | 121 ++ .../Schedule/tests/CurveChart.test.tsx | 204 +++ .../Schedule/tests/CurveEditor.test.tsx | 209 +++ .../Schedule/tests/SchedulePage.test.tsx | 212 +++ .../Schedule/tests/SetPointEditor.test.tsx | 22 + .../Schedule/tests/bothNight.test.ts | 43 + .../Schedule/tests/scheduleFormat.test.ts | 56 + src/components/Sensors/BedTempMatrix.tsx | 398 ++--- .../Sensors/ConnectionStatusBar.tsx | 166 +-- src/components/Sensors/DataFlowDag.tsx | 173 --- src/components/Sensors/DataPipeline.tsx | 386 ++--- src/components/Sensors/EventTimeline.tsx | 205 +-- src/components/Sensors/FirmwareLogConsole.tsx | 155 +- src/components/Sensors/FlowrateChart.tsx | 240 +--- src/components/Sensors/FreezerHealthCard.tsx | 275 ++-- src/components/Sensors/PiezoWaveform.tsx | 322 ++--- src/components/Sensors/PipelineTab.tsx | 19 + src/components/Sensors/PresenceCard.tsx | 231 +-- src/components/Sensors/RawFrameDrawer.tsx | 202 +-- src/components/Sensors/SensorAge.tsx | 19 + src/components/Sensors/SensorsScreen.tsx | 299 ++-- src/components/Sensors/StreamsCard.tsx | 79 + src/components/Sensors/chartTheme.ts | 20 + src/components/Sensors/streamGroups.ts | 42 + .../Sensors/tests/PiezoWaveform.test.tsx | 165 +++ .../Sensors/tests/SensorAge.test.tsx | 36 + .../Sensors/tests/SensorsScreen.test.tsx | 59 + .../Sensors/tests/pipelineViews.test.tsx | 102 ++ .../Sensors/tests/sensorCards.test.tsx | 167 +++ .../Sensors/tests/sensorLogic.test.ts | 86 ++ .../Settings/AppearanceSettings.tsx | 160 +++ .../Settings/ArchivePushSettingsForm.tsx | 291 ++-- .../Settings/DeviceSettingsForm.tsx | 578 +++----- src/components/Settings/HapticsTestCard.tsx | 192 --- src/components/Settings/HomeKitConfig.tsx | 126 +- src/components/Settings/MqttSettingsForm.tsx | 409 ++---- src/components/Settings/SettingsLayout.tsx | 104 ++ src/components/Settings/SettingsScreen.tsx | 388 ++--- src/components/Settings/SideSettingsForm.tsx | 206 ++- src/components/Settings/TapGestureConfig.tsx | 519 +++---- src/components/Settings/Toggle.tsx | 36 - src/components/Settings/sections.ts | 29 + .../tests/AppearanceSettings.test.tsx | 54 + .../Settings/tests/SettingsScreen.test.tsx | 90 ++ .../Settings/tests/SideSettingsForm.test.tsx | 98 ++ .../Settings/tests/TapGestureConfig.test.tsx | 115 ++ .../tests/integrationSettings.test.tsx | 168 +++ .../Settings/tests/sections.test.ts | 32 + src/components/SideSelector/SideSelector.tsx | 203 +-- .../SideSelector/tests/SideSelector.test.tsx | 70 + src/components/Sleep/MonthView.tsx | 172 +++ src/components/Sleep/NightView.tsx | 335 +++++ src/components/Sleep/SleepScreen.tsx | 138 ++ src/components/Sleep/SleepSections.tsx | 50 + src/components/Sleep/WeekView.tsx | 141 ++ src/components/Sleep/parts.tsx | 73 + src/components/Sleep/sleepData.ts | 353 +++++ src/components/Sleep/sleepViews.ts | 12 + .../Sleep/tests/SleepScreen.test.tsx | 187 +++ src/components/Sleep/tests/sleepData.test.ts | 188 +++ src/components/Sleep/tests/sleepViews.test.ts | 14 + src/components/Sleep/useSleepData.ts | 42 + src/components/SleepStages/Hypnogram.tsx | 276 ---- src/components/SleepStages/QualityScore.tsx | 81 -- .../SleepStages/SleepStagesCard.tsx | 492 ------- .../SleepStages/SleepTimelineCard.tsx | 69 - .../SleepStages/StageDistributionBar.tsx | 66 - .../SleepStages/StageDurationLegend.tsx | 52 - .../SleepStages/TimeRangeSelector.tsx | 41 - .../SleepStages/WeeklySleepChart.tsx | 115 -- .../SwipeContainer/SwipeContainer.tsx | 22 - src/components/System/DatabasesTab.tsx | 515 +++++++ src/components/System/StorageTab.tsx | 169 +++ src/components/System/SystemScreen.tsx | 132 ++ src/components/System/databasesLogic.ts | 211 +++ src/components/System/systemTabs.ts | 29 + .../System/tests/ConnectionStatusBar.test.tsx | 73 + .../System/tests/DatabasesTab.test.tsx | 125 ++ .../System/tests/StorageTab.test.tsx | 90 ++ .../System/tests/SystemScreen.test.tsx | 121 ++ .../System/tests/databasesLogic.test.ts | 95 ++ src/components/TempControl/TempControl.tsx | 284 ++++ .../TempControl/tests/TempControl.test.tsx | 93 ++ src/components/TempScreen/AlarmBanner.tsx | 59 +- src/components/TempScreen/AlarmCard.tsx | 80 ++ .../TempScreen/AmbientLightChip.tsx | 23 +- src/components/TempScreen/LastNightCard.tsx | 78 + src/components/TempScreen/LatestSleepChip.tsx | 40 - .../TempScreen/PrimeCompleteNotification.tsx | 12 +- .../TempScreen/PrimingIndicator.tsx | 19 +- .../TempScreen/PumpStallNotification.tsx | 27 +- .../TempScreen/ScheduleTimeline.tsx | 279 ++++ src/components/TempScreen/SideCard.tsx | 147 ++ src/components/TempScreen/TempScreen.tsx | 416 +++--- .../TempScreen/TemperatureHoldControls.tsx | 133 +- src/components/TempScreen/TonightCard.tsx | 72 + src/components/TempScreen/tempScreenUtils.ts | 161 +++ .../tests/ScheduleTimeline.test.tsx | 83 ++ .../TempScreen/tests/TempScreen.test.tsx | 198 +++ .../tests/TemperatureHoldControls.test.tsx | 60 +- .../TempScreen/tests/contextCards.test.tsx | 97 ++ .../TempScreen/tests/tempScreenUtils.test.ts | 161 +++ .../TempScreen/tests/timelineLogic.test.ts | 110 ++ src/components/TempScreen/timelineLogic.ts | 149 ++ .../TempScreen/useSideTemperature.ts | 87 ++ .../TemperatureDial/TemperatureDial.tsx | 383 ----- .../tests/TemperatureDial.test.tsx | 113 -- src/components/UserSelector/UserSelector.tsx | 79 - src/components/VitalsChart/VitalsChart.tsx | 541 ------- src/components/VitalsPanel/VitalsPanel.tsx | 1236 ---------------- .../WeekNavigator/WeekNavigator.tsx | 53 - .../WifiSignalStrength/WifiSignalStrength.tsx | 51 - src/components/biometrics/BiometricsPage.tsx | 428 ++++++ src/components/biometrics/DataSideFilter.tsx | 59 - src/components/biometrics/LoadingSkeleton.tsx | 44 - src/components/biometrics/RawDataButton.tsx | 371 ++--- .../biometrics/SleepRecordActions.tsx | 192 +-- .../biometrics/SleepSummaryCard.tsx | 114 -- src/components/biometrics/VitalsChart.tsx | 255 ++++ src/components/biometrics/VitalsGrid.tsx | 64 - src/components/biometrics/biometricsLogic.ts | 299 ++++ .../biometrics/tests/BiometricsPage.test.tsx | 140 ++ .../biometrics/tests/DataPage.test.tsx | 91 +- .../tests/SleepRecordActions.test.tsx | 91 ++ .../biometrics/tests/biometricsLogic.test.ts | 105 ++ src/components/biometrics/types.ts | 37 - .../diagnostics/BiometricsTrendChart.tsx | 83 -- .../diagnostics/CalibrationPanel.tsx | 443 ++++++ src/components/diagnostics/DashboardPanel.tsx | 431 ++++++ src/components/diagnostics/DiagTable.tsx | 148 ++ .../diagnostics/DiagnosticsConsole.tsx | 1028 +++---------- .../diagnostics/HapticsTestCard.tsx | 146 ++ src/components/diagnostics/HealthPanel.tsx | 402 ++++++ src/components/diagnostics/OccupancyCheck.tsx | 70 + src/components/diagnostics/SchedulerPanel.tsx | 498 +++++++ .../diagnostics/ThermalHistoryChart.tsx | 186 +++ .../diagnostics/ThermalTrendChart.tsx | 87 -- .../diagnostics/calibrationSignals.ts | 183 +++ src/components/diagnostics/dashboardLogic.ts | 111 ++ .../diagnostics/diagnosticsLogic.ts | 111 +- src/components/diagnostics/healthLogic.ts | 143 ++ src/components/diagnostics/parts.tsx | 20 + src/components/diagnostics/schedulerLogic.ts | 254 ++++ .../tests/BiometricsTrendChart.test.tsx | 29 - .../tests/CalibrationPanel.test.tsx | 113 ++ .../diagnostics/tests/DiagTable.test.tsx | 46 + .../tests/DiagnosticsConsole.test.tsx | 297 ++++ .../tests/HapticsTestCard.test.tsx | 42 + .../diagnostics/tests/HealthPanel.test.tsx | 162 +++ .../diagnostics/tests/SchedulerPanel.test.tsx | 113 ++ .../tests/ThermalTrendChart.test.tsx | 40 - .../tests/calibrationSignals.test.ts | 122 ++ .../diagnostics/tests/dashboardLogic.test.ts | 83 ++ .../tests/diagnosticsLogic.test.ts | 124 +- .../diagnostics/tests/schedulerLogic.test.ts | 100 ++ .../tests/thermalHistoryLogic.test.ts | 70 + .../diagnostics/tests/trendChartData.test.tsx | 105 -- .../diagnostics/thermalHistoryLogic.ts | 208 +++ src/components/ds/charts.tsx | 268 ++++ src/components/ds/core.tsx | 264 ++++ src/components/ds/feedback.tsx | 114 ++ src/components/ds/forms.tsx | 296 ++++ src/components/ds/index.ts | 5 + src/components/ds/layout.tsx | 124 ++ src/components/status/CalibrationModal.tsx | 226 --- src/components/status/HealthCircle.tsx | 292 +--- src/components/status/HealthStatusCard.tsx | 166 --- src/components/status/InternetToggleCard.tsx | 109 +- src/components/status/PumpAlertsCard.tsx | 76 +- src/components/status/StatusScreen.tsx | 483 ++----- src/components/status/SystemInfoCard.tsx | 264 +--- src/components/status/SystemLogViewer.tsx | 340 +++-- src/components/status/UpdateCard.tsx | 306 ++-- src/components/status/WaterLevelCard.tsx | 326 +---- src/components/status/WaterModal.tsx | 339 ++--- .../status/tests/SystemLogViewer.test.ts | 62 + .../status/tests/SystemLogViewer.ui.test.tsx | 86 ++ .../status/tests/waterHistory.test.ts | 72 + src/components/status/waterHistory.ts | 71 + src/db/automationRunsRetention.ts | 122 ++ .../0014_thermal_state.sql | 10 + .../0015_prime_events.sql | 6 + .../0016_health_runs.sql | 11 + .../meta/0014_snapshot.json | 1175 +++++++++++++++ .../meta/0015_snapshot.json | 1207 ++++++++++++++++ .../meta/0016_snapshot.json | 1275 +++++++++++++++++ .../biometrics-migrations/meta/_journal.json | 21 + src/db/biometrics-schema.ts | 43 + src/db/integrity.ts | 103 +- src/db/retention.ts | 16 +- .../tests/__snapshots__/schema.test.ts.snap | 54 + src/db/tests/automationRunsRetention.test.ts | 220 +++ src/db/tests/integrity.integration.test.ts | 41 +- src/db/tests/integrity.test.ts | 64 +- src/db/tests/retention.test.ts | 17 +- src/hardware/dacMonitor.ts | 3 + src/hardware/deviceStateSync.ts | 57 +- src/hardware/tests/deviceStateSync.test.ts | 68 + src/hooks/tests/useDocumentTitle.test.tsx | 26 + src/hooks/tests/useSensorStream.test.ts | 108 ++ src/hooks/tests/useSwipeNavigation.test.ts | 2 +- src/hooks/useDocumentTitle.ts | 24 + src/hooks/useSensorStream.ts | 71 +- src/hooks/useSwipeNavigation.ts | 2 +- src/lib/dataPath.ts | 553 +++++++ src/lib/dataPathCollect.ts | 140 ++ src/lib/dbInspect.ts | 323 +++++ src/lib/healthHistory.ts | 174 +++ src/lib/healthSampler.ts | 42 + src/lib/podStorage.ts | 196 +++ src/lib/tests/dataPath.test.ts | 206 +++ src/lib/tests/dataPathCollect.test.ts | 74 + src/lib/tests/dbInspect.test.ts | 97 ++ src/lib/tests/healthHistory.test.ts | 107 ++ src/lib/tests/healthSampler.test.ts | 44 + src/lib/tests/thermalHistory.test.ts | 95 ++ src/lib/thermalHistory.ts | 156 ++ src/lib/thermalTruth.ts | 122 ++ src/providers/PrefsProvider.tsx | 125 ++ src/providers/tests/PrefsProvider.test.tsx | 72 + src/scheduler/occurrences.ts | 136 ++ src/scheduler/tests/occurrences.test.ts | 69 + src/server/routers/app.ts | 2 + src/server/routers/automations.ts | 453 +++++- src/server/routers/calibration.ts | 3 + src/server/routers/databases.ts | 206 +++ src/server/routers/health.ts | 373 +++-- src/server/routers/system.ts | 66 +- src/server/routers/tests/app.test.ts | 1 + .../routers/tests/automations.crud.test.ts | 70 +- .../routers/tests/automations.page.test.ts | 149 ++ src/server/routers/tests/calibration.test.ts | 11 + src/server/routers/tests/health.test.ts | 13 + .../routers/tests/health.thermal.test.ts | 71 +- src/server/routers/tests/system.test.ts | 12 +- .../routers/tests/systemStorage.test.ts | 171 +++ src/streaming/capFramePersistence.ts | 4 +- src/streaming/piezoStream.ts | 74 +- src/streaming/recentSensorFrames.ts | 80 ++ .../tests/piezoStreamInitialization.test.ts | 63 +- src/streaming/tests/piezoStreamSource.test.ts | 38 + .../tests/recentSensorFrames.test.ts | 66 + 352 files changed, 36035 insertions(+), 17235 deletions(-) create mode 100644 app/[lang]/autopilot/[id]/page.tsx create mode 100644 app/[lang]/health/HealthcheckDemo.tsx delete mode 100644 app/[lang]/sensors/error.tsx create mode 100644 app/[lang]/sleep/page.tsx create mode 100644 app/[lang]/system/error.tsx create mode 100644 app/[lang]/system/page.tsx create mode 100644 app/[lang]/tests/redesignRoutes.test.tsx create mode 100644 app/api/db-backup/route.ts create mode 100644 docs/images/README.md create mode 100644 docs/images/core-temperature.png create mode 100644 docs/images/dial-LICENSE create mode 100644 docs/images/dial-cooling.png create mode 100644 docs/images/ios-temperature.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/icon.png create mode 100644 public/logo.png create mode 100755 scripts/bin/sp-storage-cleanup create mode 100644 scripts/lib/storage-helpers create mode 100644 scripts/tests/storageCleanup.test.ts create mode 100644 src/automation/activity.ts create mode 100644 src/automation/tests/activity.test.ts create mode 100644 src/components/AppShell/AppShell.tsx create mode 100644 src/components/AppShell/Sidebar.tsx create mode 100644 src/components/AppShell/TabBar.tsx create mode 100644 src/components/AppShell/footerStatus.ts create mode 100644 src/components/AppShell/navItems.ts create mode 100644 src/components/AppShell/tests/footerStatus.test.ts create mode 100644 src/components/AppShell/tests/navigation.test.tsx create mode 100644 src/components/Autopilot/ActivityCard.tsx create mode 100644 src/components/Autopilot/AutopilotStatusChip.tsx create mode 100644 src/components/Autopilot/RulePage.tsx create mode 100644 src/components/Autopilot/TonightCard.tsx create mode 100644 src/components/Autopilot/automationsLogic.ts create mode 100644 src/components/Autopilot/autopilotViews.ts create mode 100644 src/components/Autopilot/evaluationStrip.ts create mode 100644 src/components/Autopilot/tests/AutopilotConsole.test.tsx create mode 100644 src/components/Autopilot/tests/AutopilotStatusChip.test.tsx create mode 100644 src/components/Autopilot/tests/BacktestPanel.test.tsx create mode 100644 src/components/Autopilot/tests/CapZoneViz.test.tsx create mode 100644 src/components/Autopilot/tests/RuleEditor.test.tsx create mode 100644 src/components/Autopilot/tests/RulePage.test.tsx create mode 100644 src/components/Autopilot/tests/StatusPanel.test.tsx create mode 100644 src/components/Autopilot/tests/automationCards.test.tsx create mode 100644 src/components/Autopilot/tests/automationsLogic.test.ts create mode 100644 src/components/Autopilot/tests/autopilotViews.test.ts create mode 100644 src/components/Autopilot/tests/builderFixtures.ts create mode 100644 src/components/Autopilot/tests/evaluationStrip.test.ts create mode 100644 src/components/Autopilot/tests/primitives.test.tsx delete mode 100644 src/components/BottomNav/BottomNav.tsx delete mode 100644 src/components/DualSideChart/DualSideChart.tsx delete mode 100644 src/components/Header/Header.module.css delete mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/MovementChart/tests/charts.test.tsx delete mode 100644 src/components/PowerButton/PowerButton.module.css delete mode 100644 src/components/PowerButton/PowerButton.tsx delete mode 100644 src/components/PowerButton/tests/PowerButton.test.tsx create mode 100644 src/components/Schedule/BothNightView.tsx delete mode 100644 src/components/Schedule/PhaseLegend.tsx create mode 100644 src/components/Schedule/bothNight.ts create mode 100644 src/components/Schedule/scheduleFormat.ts create mode 100644 src/components/Schedule/tests/AICurveWizard.test.tsx create mode 100644 src/components/Schedule/tests/AlarmEditor.test.tsx create mode 100644 src/components/Schedule/tests/AlarmSection.test.tsx create mode 100644 src/components/Schedule/tests/BothNightView.test.tsx create mode 100644 src/components/Schedule/tests/CurveCard.test.tsx create mode 100644 src/components/Schedule/tests/CurveChart.test.tsx create mode 100644 src/components/Schedule/tests/CurveEditor.test.tsx create mode 100644 src/components/Schedule/tests/SchedulePage.test.tsx create mode 100644 src/components/Schedule/tests/SetPointEditor.test.tsx create mode 100644 src/components/Schedule/tests/bothNight.test.ts create mode 100644 src/components/Schedule/tests/scheduleFormat.test.ts delete mode 100644 src/components/Sensors/DataFlowDag.tsx create mode 100644 src/components/Sensors/PipelineTab.tsx create mode 100644 src/components/Sensors/SensorAge.tsx create mode 100644 src/components/Sensors/StreamsCard.tsx create mode 100644 src/components/Sensors/chartTheme.ts create mode 100644 src/components/Sensors/streamGroups.ts create mode 100644 src/components/Sensors/tests/PiezoWaveform.test.tsx create mode 100644 src/components/Sensors/tests/SensorAge.test.tsx create mode 100644 src/components/Sensors/tests/SensorsScreen.test.tsx create mode 100644 src/components/Sensors/tests/pipelineViews.test.tsx create mode 100644 src/components/Sensors/tests/sensorCards.test.tsx create mode 100644 src/components/Sensors/tests/sensorLogic.test.ts create mode 100644 src/components/Settings/AppearanceSettings.tsx delete mode 100644 src/components/Settings/HapticsTestCard.tsx create mode 100644 src/components/Settings/SettingsLayout.tsx delete mode 100644 src/components/Settings/Toggle.tsx create mode 100644 src/components/Settings/sections.ts create mode 100644 src/components/Settings/tests/AppearanceSettings.test.tsx create mode 100644 src/components/Settings/tests/SettingsScreen.test.tsx create mode 100644 src/components/Settings/tests/SideSettingsForm.test.tsx create mode 100644 src/components/Settings/tests/TapGestureConfig.test.tsx create mode 100644 src/components/Settings/tests/integrationSettings.test.tsx create mode 100644 src/components/Settings/tests/sections.test.ts create mode 100644 src/components/SideSelector/tests/SideSelector.test.tsx create mode 100644 src/components/Sleep/MonthView.tsx create mode 100644 src/components/Sleep/NightView.tsx create mode 100644 src/components/Sleep/SleepScreen.tsx create mode 100644 src/components/Sleep/SleepSections.tsx create mode 100644 src/components/Sleep/WeekView.tsx create mode 100644 src/components/Sleep/parts.tsx create mode 100644 src/components/Sleep/sleepData.ts create mode 100644 src/components/Sleep/sleepViews.ts create mode 100644 src/components/Sleep/tests/SleepScreen.test.tsx create mode 100644 src/components/Sleep/tests/sleepData.test.ts create mode 100644 src/components/Sleep/tests/sleepViews.test.ts create mode 100644 src/components/Sleep/useSleepData.ts delete mode 100644 src/components/SleepStages/Hypnogram.tsx delete mode 100644 src/components/SleepStages/QualityScore.tsx delete mode 100644 src/components/SleepStages/SleepStagesCard.tsx delete mode 100644 src/components/SleepStages/SleepTimelineCard.tsx delete mode 100644 src/components/SleepStages/StageDistributionBar.tsx delete mode 100644 src/components/SleepStages/StageDurationLegend.tsx delete mode 100644 src/components/SleepStages/TimeRangeSelector.tsx delete mode 100644 src/components/SleepStages/WeeklySleepChart.tsx delete mode 100644 src/components/SwipeContainer/SwipeContainer.tsx create mode 100644 src/components/System/DatabasesTab.tsx create mode 100644 src/components/System/StorageTab.tsx create mode 100644 src/components/System/SystemScreen.tsx create mode 100644 src/components/System/databasesLogic.ts create mode 100644 src/components/System/systemTabs.ts create mode 100644 src/components/System/tests/ConnectionStatusBar.test.tsx create mode 100644 src/components/System/tests/DatabasesTab.test.tsx create mode 100644 src/components/System/tests/StorageTab.test.tsx create mode 100644 src/components/System/tests/SystemScreen.test.tsx create mode 100644 src/components/System/tests/databasesLogic.test.ts create mode 100644 src/components/TempControl/TempControl.tsx create mode 100644 src/components/TempControl/tests/TempControl.test.tsx create mode 100644 src/components/TempScreen/AlarmCard.tsx create mode 100644 src/components/TempScreen/LastNightCard.tsx delete mode 100644 src/components/TempScreen/LatestSleepChip.tsx create mode 100644 src/components/TempScreen/ScheduleTimeline.tsx create mode 100644 src/components/TempScreen/SideCard.tsx create mode 100644 src/components/TempScreen/TonightCard.tsx create mode 100644 src/components/TempScreen/tempScreenUtils.ts create mode 100644 src/components/TempScreen/tests/ScheduleTimeline.test.tsx create mode 100644 src/components/TempScreen/tests/TempScreen.test.tsx create mode 100644 src/components/TempScreen/tests/contextCards.test.tsx create mode 100644 src/components/TempScreen/tests/tempScreenUtils.test.ts create mode 100644 src/components/TempScreen/tests/timelineLogic.test.ts create mode 100644 src/components/TempScreen/timelineLogic.ts create mode 100644 src/components/TempScreen/useSideTemperature.ts delete mode 100644 src/components/TemperatureDial/TemperatureDial.tsx delete mode 100644 src/components/TemperatureDial/tests/TemperatureDial.test.tsx delete mode 100644 src/components/UserSelector/UserSelector.tsx delete mode 100644 src/components/VitalsChart/VitalsChart.tsx delete mode 100644 src/components/VitalsPanel/VitalsPanel.tsx delete mode 100644 src/components/WeekNavigator/WeekNavigator.tsx delete mode 100644 src/components/WifiSignalStrength/WifiSignalStrength.tsx create mode 100644 src/components/biometrics/BiometricsPage.tsx delete mode 100644 src/components/biometrics/DataSideFilter.tsx delete mode 100644 src/components/biometrics/LoadingSkeleton.tsx delete mode 100644 src/components/biometrics/SleepSummaryCard.tsx create mode 100644 src/components/biometrics/VitalsChart.tsx delete mode 100644 src/components/biometrics/VitalsGrid.tsx create mode 100644 src/components/biometrics/biometricsLogic.ts create mode 100644 src/components/biometrics/tests/BiometricsPage.test.tsx create mode 100644 src/components/biometrics/tests/SleepRecordActions.test.tsx create mode 100644 src/components/biometrics/tests/biometricsLogic.test.ts delete mode 100644 src/components/biometrics/types.ts delete mode 100644 src/components/diagnostics/BiometricsTrendChart.tsx create mode 100644 src/components/diagnostics/CalibrationPanel.tsx create mode 100644 src/components/diagnostics/DashboardPanel.tsx create mode 100644 src/components/diagnostics/DiagTable.tsx create mode 100644 src/components/diagnostics/HapticsTestCard.tsx create mode 100644 src/components/diagnostics/HealthPanel.tsx create mode 100644 src/components/diagnostics/OccupancyCheck.tsx create mode 100644 src/components/diagnostics/SchedulerPanel.tsx create mode 100644 src/components/diagnostics/ThermalHistoryChart.tsx delete mode 100644 src/components/diagnostics/ThermalTrendChart.tsx create mode 100644 src/components/diagnostics/calibrationSignals.ts create mode 100644 src/components/diagnostics/dashboardLogic.ts create mode 100644 src/components/diagnostics/healthLogic.ts create mode 100644 src/components/diagnostics/parts.tsx create mode 100644 src/components/diagnostics/schedulerLogic.ts delete mode 100644 src/components/diagnostics/tests/BiometricsTrendChart.test.tsx create mode 100644 src/components/diagnostics/tests/CalibrationPanel.test.tsx create mode 100644 src/components/diagnostics/tests/DiagTable.test.tsx create mode 100644 src/components/diagnostics/tests/DiagnosticsConsole.test.tsx create mode 100644 src/components/diagnostics/tests/HapticsTestCard.test.tsx create mode 100644 src/components/diagnostics/tests/HealthPanel.test.tsx create mode 100644 src/components/diagnostics/tests/SchedulerPanel.test.tsx delete mode 100644 src/components/diagnostics/tests/ThermalTrendChart.test.tsx create mode 100644 src/components/diagnostics/tests/calibrationSignals.test.ts create mode 100644 src/components/diagnostics/tests/dashboardLogic.test.ts create mode 100644 src/components/diagnostics/tests/schedulerLogic.test.ts create mode 100644 src/components/diagnostics/tests/thermalHistoryLogic.test.ts delete mode 100644 src/components/diagnostics/tests/trendChartData.test.tsx create mode 100644 src/components/diagnostics/thermalHistoryLogic.ts create mode 100644 src/components/ds/charts.tsx create mode 100644 src/components/ds/core.tsx create mode 100644 src/components/ds/feedback.tsx create mode 100644 src/components/ds/forms.tsx create mode 100644 src/components/ds/index.ts create mode 100644 src/components/ds/layout.tsx delete mode 100644 src/components/status/CalibrationModal.tsx delete mode 100644 src/components/status/HealthStatusCard.tsx create mode 100644 src/components/status/tests/SystemLogViewer.test.ts create mode 100644 src/components/status/tests/SystemLogViewer.ui.test.tsx create mode 100644 src/components/status/tests/waterHistory.test.ts create mode 100644 src/components/status/waterHistory.ts create mode 100644 src/db/automationRunsRetention.ts create mode 100644 src/db/biometrics-migrations/0014_thermal_state.sql create mode 100644 src/db/biometrics-migrations/0015_prime_events.sql create mode 100644 src/db/biometrics-migrations/0016_health_runs.sql create mode 100644 src/db/biometrics-migrations/meta/0014_snapshot.json create mode 100644 src/db/biometrics-migrations/meta/0015_snapshot.json create mode 100644 src/db/biometrics-migrations/meta/0016_snapshot.json create mode 100644 src/db/tests/automationRunsRetention.test.ts create mode 100644 src/hooks/tests/useDocumentTitle.test.tsx create mode 100644 src/hooks/useDocumentTitle.ts create mode 100644 src/lib/dataPath.ts create mode 100644 src/lib/dataPathCollect.ts create mode 100644 src/lib/dbInspect.ts create mode 100644 src/lib/healthHistory.ts create mode 100644 src/lib/healthSampler.ts create mode 100644 src/lib/podStorage.ts create mode 100644 src/lib/tests/dataPath.test.ts create mode 100644 src/lib/tests/dataPathCollect.test.ts create mode 100644 src/lib/tests/dbInspect.test.ts create mode 100644 src/lib/tests/healthHistory.test.ts create mode 100644 src/lib/tests/healthSampler.test.ts create mode 100644 src/lib/tests/thermalHistory.test.ts create mode 100644 src/lib/thermalHistory.ts create mode 100644 src/lib/thermalTruth.ts create mode 100644 src/providers/PrefsProvider.tsx create mode 100644 src/providers/tests/PrefsProvider.test.tsx create mode 100644 src/scheduler/occurrences.ts create mode 100644 src/scheduler/tests/occurrences.test.ts create mode 100644 src/server/routers/databases.ts create mode 100644 src/server/routers/tests/automations.page.test.ts create mode 100644 src/server/routers/tests/systemStorage.test.ts create mode 100644 src/streaming/recentSensorFrames.ts create mode 100644 src/streaming/tests/recentSensorFrames.test.ts diff --git a/.gitattributes b/.gitattributes index 585e95230..f6460ff38 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,6 @@ *.mov filter=lfs diff=lfs merge=lfs -text docs/images/*.gif filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text + +# Web assets ship in source tarballs (sp-update, release), which never resolve LFS pointers. +public/** !filter !diff !merge binary diff --git a/README.md b/README.md index f2b9ed7af..f44c4063f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ sleepypod +

+ # sleepypod β€” local-first Pod mattress controller [![CI](https://github.com/sleepypod/core/actions/workflows/test.yml/badge.svg?branch=dev)](https://github.com/sleepypod/core/actions/workflows/test.yml) @@ -8,19 +12,24 @@ Self-hosted control app for Pod 3, 4, and 5. Runs on the Pod's stock embedded Linux β€” replaces the cloud-bound controller with a local web UI, scheduler, on-device biometrics, and native integrations for Home Assistant (MQTT) and Apple Home (HomeKit). +**User docs:** [sleepypod.github.io](https://sleepypod.github.io/) +

- Temperature control - Sleep schedule + sleepypod core web UI with per-side temperature controls, sidebar navigation, and the schedule and sleep timeline

+The redesigned web UI brings temperature, schedules, Autopilot, sleep, and system health into one place. Control the same Pod from the [iOS app](https://sleepypod.github.io/ios/) or a bedside [M5 rotary dial](https://sleepypod.github.io/dial/). +

- UX walkthrough 1 - UX walkthrough 2 - UX walkthrough 3 + sleepypod iOS app temperature control +    + sleepypod rotary dial showing a cooling target

+Real product captures from the [documentation site](https://github.com/sleepypod/sleepypod.github.io/blob/7eef5490ef26b1c2f3ac22d073a06f9f52303712/capture/manifest.json). The web UI capture uses disposable demo data. See [asset provenance](docs/images/README.md). +

- Issues Β· Install guide + Docs Β· Issues Β· Install guide

--- diff --git a/app/[lang]/autopilot/[id]/page.tsx b/app/[lang]/autopilot/[id]/page.tsx new file mode 100644 index 000000000..04684ef39 --- /dev/null +++ b/app/[lang]/autopilot/[id]/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import { RulePage } from '@/src/components/Autopilot/RulePage' + +export const metadata: Metadata = { title: 'Automation Β· Autopilot' } + +export default async function AutomationPage({ params, searchParams }: { + params: Promise<{ id: string }> + searchParams: Promise<{ template?: string | string[] }> +}) { + const { id } = await params + const { template } = await searchParams + return +} diff --git a/app/[lang]/autopilot/page.tsx b/app/[lang]/autopilot/page.tsx index ba343c09e..ef9e88b94 100644 --- a/app/[lang]/autopilot/page.tsx +++ b/app/[lang]/autopilot/page.tsx @@ -1,5 +1,13 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' import { AutopilotConsole } from '@/src/components/Autopilot/AutopilotConsole' +export const metadata: Metadata = { title: 'Autopilot' } + export default function AutopilotPage() { - return + return ( + + + + ) } diff --git a/app/[lang]/data/page.tsx b/app/[lang]/data/page.tsx index 1657c5ebd..146d5bad7 100644 --- a/app/[lang]/data/page.tsx +++ b/app/[lang]/data/page.tsx @@ -1,235 +1,6 @@ -'use client' +import { redirect } from 'next/navigation' -import { useRef, useCallback } from 'react' -import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react' -import { useBiometricsSide } from '@/src/hooks/useBiometricsSide' -import { useWeekNavigator } from '@/src/hooks/useWeekNavigator' -import { trpc } from '@/src/utils/trpc' - -// Sub-components -import { SleepStagesCard } from '@/src/components/SleepStages/SleepStagesCard' -import { VitalsPanel } from '@/src/components/VitalsPanel/VitalsPanel' -import { MovementChart } from '@/src/components/MovementChart/MovementChart' -import { SleepSummaryCard } from '@/src/components/biometrics/SleepSummaryCard' -import { VitalsGrid } from '@/src/components/biometrics/VitalsGrid' -import { RawDataButton } from '@/src/components/biometrics/RawDataButton' - -// Section IDs for scroll navigation -const SECTIONS = [ - { id: 'sleep', label: 'Sleep' }, - { id: 'stages', label: 'Stages' }, - { id: 'vitals', label: 'Vitals' }, - { id: 'timeline', label: 'Timeline' }, - { id: 'movement', label: 'Movement' }, -] as const - -/** - * Biometrics page β€” unified scrollable view matching iOS HealthScreen. - * - * Layout: date picker + L/R toggle β†’ section pills β†’ - * Sleep Summary β†’ Stages β†’ VitalsGrid (3-block) β†’ Vitals charts β†’ - * Sleep Sessions β†’ Movement β†’ Raw Data - */ -export default function DataPage() { - // Biometrics show one side when a single sleeper is set (one side away). - const { side: primarySide, sides: activeSides, showBothSides, toggleSide } = useBiometricsSide() - const week = useWeekNavigator() - const scrollContainerRef = useRef(null) - - // Fetch sleep records for each active side - const leftSleepQuery = trpc.biometrics.getSleepRecords.useQuery( - { - side: 'left', - startDate: week.weekStart, - endDate: week.weekEnd, - limit: 7, - }, - { enabled: activeSides.includes('left') }, - ) - - const rightSleepQuery = trpc.biometrics.getSleepRecords.useQuery( - { - side: 'right', - startDate: week.weekStart, - endDate: week.weekEnd, - limit: 7, - }, - { enabled: activeSides.includes('right') }, - ) - - const leftRecords = ((leftSleepQuery.data ?? []) as SleepRecordRow[]).map( - r => ({ ...r, side: 'left' as const }), - ) - const rightRecords = ((rightSleepQuery.data ?? []) as SleepRecordRow[]).map( - r => ({ ...r, side: 'right' as const }), - ) - - const allSleepRecords = [ - ...(activeSides.includes('left') ? leftRecords : []), - ...(activeSides.includes('right') ? rightRecords : []), - ].sort( - (a, b) => new Date(b.enteredBedAt).getTime() - new Date(a.enteredBedAt).getTime(), - ) - - const scrollToSection = useCallback((sectionId: string) => { - const el = document.getElementById(`section-${sectionId}`) - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - }, []) - - return ( -
- {/* ── Header: Date Picker + Side Toggle ── */} -
- - - - - - - {/* Side toggle β€” tap to swap L ↔ R. Hidden for a single sleeper. */} - {toggleSide && ( - - )} -
- - {/* Section Navigation Pills */} -
- {SECTIONS.map(section => ( - - ))} -
- - {/* ── Sleep Summary ── */} -
- {showBothSides - ? ( -
- - r.side === 'left')} /> - - r.side === 'right')} /> -
- ) - : ( - - )} -
- - {/* ── Nightly Sleep Analysis (quality + distribution β€” matches iOS) ── */} -
- {showBothSides - ? ( -
- - - - -
- ) - : ( - - )} -
- - {/* ── Vitals: 3-block grid + charts (HR, HRV, BR) ── */} -
- - -
- - {/* ── Weekly Sleep Timeline (bar chart β€” matches iOS) ── */} -
- {showBothSides - ? ( -
- - - - -
- ) - : ( - - )} -
- - {/* ── Movement ── */} -
- -
- - {/* ── Raw Data Export ── */} - -
- ) -} - -// ── Helper types & components ── - -interface SleepRecordRow { - id: number - side: 'left' | 'right' - enteredBedAt: Date - leftBedAt: Date - sleepDurationSeconds: number - timesExitedBed: number -} - -function SideLabel({ side }: { side: 'left' | 'right' }) { - const isLeft = side === 'left' - return ( -
-
- - {side} - {' '} - Side - -
- ) +export default async function Page({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params + redirect(`/${lang}/sleep`) } diff --git a/app/[lang]/debug/page.tsx b/app/[lang]/debug/page.tsx index ee772037d..91907a95e 100644 --- a/app/[lang]/debug/page.tsx +++ b/app/[lang]/debug/page.tsx @@ -1,7 +1,6 @@ -'use client' +import { redirect } from 'next/navigation' -import { DiagnosticsConsole } from '@/src/components/diagnostics/DiagnosticsConsole' - -export default function DebugPage() { - return +export default async function Page({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params + redirect(`/${lang}/system`) } diff --git a/app/[lang]/health/HealthcheckDemo.tsx b/app/[lang]/health/HealthcheckDemo.tsx new file mode 100644 index 000000000..5df96e2ac --- /dev/null +++ b/app/[lang]/health/HealthcheckDemo.tsx @@ -0,0 +1,24 @@ +'use client' + +import { trpc } from '@/src/utils/trpc' +import { Trans } from '@lingui/react/macro' + +export function HealthcheckDemo() { + const healthcheck = trpc.healthcheck.useQuery({}) + + return ( +
+
+

tRPC Demo

+

Hello, tRPC test page!

+

+ Healthcheck: + {' '} + + {healthcheck.data ?? 'Loading...'} + +

+
+
+ ) +} diff --git a/app/[lang]/health/page.tsx b/app/[lang]/health/page.tsx index 7b2d51dbf..e24ca4994 100644 --- a/app/[lang]/health/page.tsx +++ b/app/[lang]/health/page.tsx @@ -1,24 +1,8 @@ -'use client' +import type { Metadata } from 'next' +import { HealthcheckDemo } from './HealthcheckDemo' -import { trpc } from '@/src/utils/trpc' -import { Trans } from '@lingui/react/macro' +export const metadata: Metadata = { title: 'Healthcheck' } export default function Page() { - const healthcheck = trpc.healthcheck.useQuery({}) - - return ( -
-
-

tRPC Demo

-

Hello, tRPC test page!

-

- Healthcheck: - {' '} - - {healthcheck.data ?? 'Loading...'} - -

-
-
- ) + return } diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx index 6a27dbc9b..234dd4904 100644 --- a/app/[lang]/layout.tsx +++ b/app/[lang]/layout.tsx @@ -1,19 +1,40 @@ import '@/app/globals.css' -import { BottomNav } from '@/src/components/BottomNav/BottomNav' -import { Header } from '@/src/components/Header/Header' -import { SwipeContainer } from '@/src/components/SwipeContainer/SwipeContainer' +import type { Metadata } from 'next' +import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google' +import Script from 'next/script' +import { AppShell } from '@/src/components/AppShell/AppShell' import { allMessages, getI18nInstance } from '@/src/lib/i18n/appRouterI18n' import { LinguiClientProvider } from '@/src/providers/LinguiClientProvider' +import { PrefsProvider, THEME_INIT_SCRIPT } from '@/src/providers/PrefsProvider' import { SideProvider } from '@/src/providers/SideProvider' import { TRPCProvider } from '@/src/providers/TRPCProvider' import { WeekNavigatorProvider } from '@/src/providers/WeekNavigatorProvider' import { setI18n } from '@lingui/react/server' + +// Self-hosted at build time by next/font, so the pod serves them offline. +const plexSans = IBM_Plex_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600'], + variable: '--font-plex-sans', + display: 'swap', +}) +const plexMono = IBM_Plex_Mono({ + subsets: ['latin'], + weight: ['300', '400', '500'], + variable: '--font-plex-mono', + display: 'swap', +}) + // Locales inlined to avoid importing lingui.config.ts at SSG time. // Turbopack's module resolution fails for this import on cross-platform deploys. const LOCALES = ['en', 'es', 'pseudo'] export const dynamicParams = false +export const metadata: Metadata = { + title: { template: '%s Β· sleepypod', default: 'sleepypod' }, +} + export function generateStaticParams() { return LOCALES.map(lang => ({ lang })) } @@ -31,30 +52,25 @@ export default async function LangLayout({ setI18n(i18n) return ( - + - - + + + + + - - -
-
- -
- - {children} - - - -
-
-
-
+ + + + {children} + + +
diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index 65d30aea0..d964e7170 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -1,8 +1,12 @@ +import type { Metadata } from 'next' import { TempScreen } from '@/src/components/TempScreen/TempScreen' import { getI18nInstance } from '@/src/lib/i18n/appRouterI18n' import { initLingui } from '@/src/lib/i18n/initLingui' import { setI18n } from '@lingui/react/server' +// The layout's title template only applies to child segments, not this page. +export const metadata: Metadata = { title: { absolute: 'Temp Β· sleepypod' } } + export default async function Page({ params, }: { diff --git a/app/[lang]/schedule/page.tsx b/app/[lang]/schedule/page.tsx index 958629615..1053ac49f 100644 --- a/app/[lang]/schedule/page.tsx +++ b/app/[lang]/schedule/page.tsx @@ -1,5 +1,8 @@ +import type { Metadata } from 'next' import { SchedulePage } from '@/src/components/Schedule/SchedulePage' +export const metadata: Metadata = { title: 'Schedule' } + export default function Schedule() { return } diff --git a/app/[lang]/sensors/error.tsx b/app/[lang]/sensors/error.tsx deleted file mode 100644 index 95fcb4595..000000000 --- a/app/[lang]/sensors/error.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client' - -export default function SensorsError({ - error, - reset, -}: { - error: Error & { digest?: string } - reset: () => void -}) { - return ( -
-

Sensors crashed

-

- Something went wrong loading sensor data. - {error.digest && ( - - ( - {error.digest} - ) - - )} -

- -
- ) -} diff --git a/app/[lang]/sensors/page.tsx b/app/[lang]/sensors/page.tsx index 0ee83e7ed..91907a95e 100644 --- a/app/[lang]/sensors/page.tsx +++ b/app/[lang]/sensors/page.tsx @@ -1,5 +1,6 @@ -import { SensorsScreen } from '@/src/components/Sensors/SensorsScreen' +import { redirect } from 'next/navigation' -export default function SensorsPage() { - return +export default async function Page({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params + redirect(`/${lang}/system`) } diff --git a/app/[lang]/settings/page.tsx b/app/[lang]/settings/page.tsx index c94efd093..4a6473a9e 100644 --- a/app/[lang]/settings/page.tsx +++ b/app/[lang]/settings/page.tsx @@ -1,9 +1,12 @@ +import type { Metadata } from 'next' import { Suspense } from 'react' import { SettingsScreen } from '@/src/components/Settings/SettingsScreen' import { getI18nInstance } from '@/src/lib/i18n/appRouterI18n' import { initLingui } from '@/src/lib/i18n/initLingui' import { setI18n } from '@lingui/react/server' +export const metadata: Metadata = { title: 'Settings' } + export default async function Page({ params, }: { diff --git a/app/[lang]/sleep/page.tsx b/app/[lang]/sleep/page.tsx new file mode 100644 index 000000000..8887d6d18 --- /dev/null +++ b/app/[lang]/sleep/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' +import { SleepSections } from '@/src/components/Sleep/SleepSections' + +export const metadata: Metadata = { title: 'Sleep' } + +export default function SleepPage() { + return ( + + + + ) +} diff --git a/app/[lang]/status/page.tsx b/app/[lang]/status/page.tsx index 3ed556802..91907a95e 100644 --- a/app/[lang]/status/page.tsx +++ b/app/[lang]/status/page.tsx @@ -1,5 +1,6 @@ -import { StatusScreen } from '@/src/components/status/StatusScreen' +import { redirect } from 'next/navigation' -export default function StatusPage() { - return +export default async function Page({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params + redirect(`/${lang}/system`) } diff --git a/app/[lang]/system/error.tsx b/app/[lang]/system/error.tsx new file mode 100644 index 000000000..e5a963392 --- /dev/null +++ b/app/[lang]/system/error.tsx @@ -0,0 +1,24 @@ +'use client' + +import { Button, Card } from '@/src/components/ds' + +export default function SystemError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( +
+ +

System crashed

+

+ Something went wrong loading sensor data. + {error.digest && {`(${error.digest})`}} +

+ +
+
+ ) +} diff --git a/app/[lang]/system/page.tsx b/app/[lang]/system/page.tsx new file mode 100644 index 000000000..5d290561b --- /dev/null +++ b/app/[lang]/system/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' +import { SystemScreen } from '@/src/components/System/SystemScreen' + +export const metadata: Metadata = { title: 'System' } + +export default function SystemPage() { + return ( + + + + ) +} diff --git a/app/[lang]/tests/redesignRoutes.test.tsx b/app/[lang]/tests/redesignRoutes.test.tsx new file mode 100644 index 000000000..ea5663a52 --- /dev/null +++ b/app/[lang]/tests/redesignRoutes.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ redirect: vi.fn(), rule: vi.fn() })) +vi.mock('next/navigation', () => ({ redirect: mocks.redirect })) +vi.mock('next/font/google', () => ({ IBM_Plex_Mono: () => ({ variable: 'mono' }), IBM_Plex_Sans: () => ({ variable: 'sans' }) })) +vi.mock('next/script', () => ({ default: ({ children }: { children: ReactNode }) => })) +vi.mock('@/src/lib/i18n/appRouterI18n', () => ({ allMessages: { en: {} }, getI18nInstance: () => ({}) })) +vi.mock('@lingui/react/server', () => ({ setI18n: vi.fn() })) +vi.mock('@/src/providers/LinguiClientProvider', () => ({ LinguiClientProvider: ({ children }: { children: ReactNode }) => children })) +vi.mock('@/src/providers/SideProvider', () => ({ SideProvider: ({ children }: { children: ReactNode }) => children })) +vi.mock('@/src/providers/TRPCProvider', () => ({ TRPCProvider: ({ children }: { children: ReactNode }) => children })) +vi.mock('@/src/providers/WeekNavigatorProvider', () => ({ WeekNavigatorProvider: ({ children }: { children: ReactNode }) => children })) +vi.mock('@/src/components/AppShell/AppShell', () => ({ AppShell: ({ children }: { children: ReactNode }) =>
{children}
})) +vi.mock('@/src/components/Autopilot/AutopilotConsole', () => ({ AutopilotConsole: () =>
Automations page
})) +vi.mock('@/src/components/Autopilot/RulePage', () => ({ RulePage: (props: unknown) => { + mocks.rule(props) + return
Rule page
+} })) +vi.mock('@/src/components/Sleep/SleepSections', () => ({ SleepSections: () =>
Sleep page
})) +vi.mock('@/src/components/System/SystemScreen', () => ({ SystemScreen: () =>
System page
})) + +it('renders redesigned entry pages and passes rule route parameters', async () => { + const { default: Autopilot } = await import('../autopilot/page') + const { default: Sleep } = await import('../sleep/page') + const { default: System } = await import('../system/page') + const { default: Rule } = await import('../autopilot/[id]/page') + render( + <> + + + + {await Rule({ params: Promise.resolve({ id: 'new' }), searchParams: Promise.resolve({ template: 'restless' }) })} + + ) + expect(screen.getByText('Automations page')).toBeTruthy() + expect(screen.getByText('Sleep page')).toBeTruthy() + expect(screen.getByText('System page')).toBeTruthy() + expect(mocks.rule).toHaveBeenCalledWith(expect.objectContaining({ id: 'new', template: 'restless' })) +}) + +it.each([ + ['data', '/de/sleep'], ['debug', '/de/system'], + ['sensors', '/de/system'], ['status', '/de/system'], +])('redirects the legacy %s route in the selected language', async (route, url) => { + const modules = { data: () => import('../data/page'), debug: () => import('../debug/page'), sensors: () => import('../sensors/page'), status: () => import('../status/page') } + const { default: Page } = await modules[route as keyof typeof modules]() + await Page({ params: Promise.resolve({ lang: 'de' }) }) + expect(mocks.redirect).toHaveBeenLastCalledWith(url) +}) + +it('sets fonts, locale and pre-paint theme initialization in the app layout', async () => { + const { default: Layout, generateStaticParams } = await import('../layout') + expect(generateStaticParams()).toEqual([{ lang: 'en' }, { lang: 'es' }, { lang: 'pseudo' }]) + const html = renderToStaticMarkup(await Layout({ params: Promise.resolve({ lang: 'en' }), children:

Pod

})) + expect(html).toContain('class="dark sans mono"') + expect(html).toContain('sleepypod-pref-theme') + expect(html).toContain('

Pod

') +}) + +it('retries a failed System page and includes an optional error digest', async () => { + const { default: ErrorPage } = await import('../system/error') + const reset = vi.fn() + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(reset).toHaveBeenCalledOnce() + rerender() + expect(screen.getByText('(abc)')).toBeTruthy() +}) diff --git a/app/api/db-backup/route.ts b/app/api/db-backup/route.ts new file mode 100644 index 000000000..829f43c86 --- /dev/null +++ b/app/api/db-backup/route.ts @@ -0,0 +1,90 @@ +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { Readable } from 'node:stream' +import Database from 'better-sqlite3' +import { backupMarkerPath, DB_FILES, type DbKey } from '@/src/lib/dbInspect' + +// Staging on /persistent, not /tmp: /tmp is a RAM-backed tmpfs on the pod and +// the biometrics copy alone can be tens of MB. +const EXPORT_STAGING_DIR = process.env.EXPORT_STAGING_DIR ?? '/persistent/sleepypod-data/export-staging' +const WATCHDOG_MS = 10 * 60 * 1000 + +let inflight = false + +/** SQLite online backup: a consistent copy while the pod keeps writing. */ +async function backupSqlite(dbPath: string, outPath: string): Promise { + const src = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + await src.backup(outPath) + } + finally { + src.close() + } +} + +/** + * GET /api/db-backup β€” sleepypod.db and biometrics.db as one .tar.gz, taken + * with SQLite's online backup so services keep running. Restore is a file + * copy of each .db with sleepypod stopped. + */ +export async function GET(request: Request) { + if (inflight) { + return Response.json({ error: 'A backup is already in progress, retry shortly' }, { status: 429, headers: { 'Retry-After': '30' } }) + } + inflight = true + + let stagingDir: string | null = null + try { + await mkdir(EXPORT_STAGING_DIR, { recursive: true }) + stagingDir = await mkdtemp(path.join(EXPORT_STAGING_DIR, 'sp-db-backup-')) + for (const key of Object.keys(DB_FILES) as DbKey[]) { + await backupSqlite(DB_FILES[key].path, path.join(stagingDir, `${key}.db`)) + } + + const tarChild = spawn('tar', ['-czf', '-', '-C', stagingDir, 'sleepypod.db', 'biometrics.db']) + let tarStderr = '' + tarChild.stderr.on('data', d => (tarStderr += d.toString())) + + const cleanup = () => { + const dir = stagingDir + stagingDir = null + if (dir) rm(dir, { recursive: true, force: true }).catch(() => {}) + inflight = false + } + const abort = () => { + tarChild.kill('SIGKILL') + cleanup() + } + const watchdog = setTimeout(abort, WATCHDOG_MS) + request.signal.addEventListener('abort', abort, { once: true }) + const settle = () => { + clearTimeout(watchdog) + request.signal.removeEventListener('abort', abort) + } + + tarChild.on('close', (code) => { + settle() + if (code === 0) writeFile(backupMarkerPath(), new Date().toISOString()).catch(() => {}) + else if (tarStderr) console.error('[db-backup] tar:', tarStderr) + cleanup() + }) + tarChild.on('error', () => { + settle() + cleanup() + }) + + const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '') + return new Response(Readable.toWeb(tarChild.stdout) as ReadableStream, { + headers: { + 'Content-Type': 'application/gzip', + 'Content-Disposition': `attachment; filename="sleepypod-databases-${stamp}.tar.gz"`, + }, + }) + } + catch (error) { + if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {}) + inflight = false + return Response.json({ error: error instanceof Error ? error.message : 'Backup failed' }, { status: 500 }) + } +} diff --git a/app/globals.css b/app/globals.css index 00d61db62..6833444cd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -7,8 +7,52 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); + --font-sans: var(--font-plex-sans), 'IBM Plex Sans', system-ui, sans-serif; + --font-mono: var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace; + + /* ─── Sleepypod design tokens (2026 redesign) ─────────────────────── */ + --color-app: var(--surface-app); + --color-surface: var(--surface-card); + --color-active: var(--surface-active); + --color-field: var(--surface-field); + --color-code: var(--surface-code); + --color-line: var(--border-1); + --color-line-2: var(--border-2); + --color-grid: var(--border-grid); + --color-fg: var(--text-1); + --color-fg-2: var(--text-2); + --color-fg-3: var(--text-3); + --color-icon: var(--text-icon); + --color-inverse: var(--text-inverse); + --color-cool: var(--accent-cool); + --color-warm: var(--accent-warm); + --color-hold: var(--accent-neutral); + --color-track: var(--dial-track); + --color-ok: var(--status-ok); + --color-warn: var(--status-warn); + --color-danger: var(--status-danger); + --color-ok-line: var(--status-ok-border); + --color-warn-line: var(--status-warn-border); + --color-warn-bg: var(--status-warn-bg); + --color-danger-line: var(--status-danger-border); + --color-stage-awake: var(--stage-awake); + --color-stage-rem: var(--stage-rem); + --color-stage-light: var(--stage-light); + --color-stage-deep: var(--stage-deep); + --color-hr: var(--chart-hr); + --color-humidity: var(--chart-humidity); + --color-link: var(--accent-link); + --color-vital-hr: var(--chart-vital-hr); + --color-vital-hrv: var(--chart-vital-hrv); + --color-vital-br: var(--chart-vital-br); + --radius-tag: 3px; + --radius-thumb: 6px; + --radius-ctl: 8px; + --radius-seg: 9px; + --radius-card: 12px; + --radius-dialog: 16px; + --radius-sheet: 24px; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -47,81 +91,150 @@ --radius-4xl: calc(var(--radius) + 16px); } +/* ─── Sleepypod color tokens ──────────────────────────────────────────── + Dark is the default. Light applies via [data-theme="light"] on , + or via prefers-color-scheme when the theme preference is "auto". */ +:root { + --sp-ink-0: #0b0b0c; + --sp-ink-1: #0f0f11; + --sp-ink-2: #17171a; + --sp-ink-code: #08080a; + --sp-line-1: #1f1f22; + --sp-line-2: #26262a; + --sp-line-grid: #16161a; + --sp-fg-1: #ececec; + --sp-fg-2: #8b8b92; + --sp-fg-3: #5d5d63; + --sp-fg-icon: #b0b0b6; + --sp-cool: #7ab5e0; + --sp-warm: #e0976a; + --sp-neutral: #9ab3c9; + --sp-green: #50c878; + --sp-amber: #e0b45a; + --sp-red: #e06a6a; + --sp-violet: #a58be0; + --sp-indigo: #4f73c9; + --sp-humidity: #4a90d9; + + --surface-app: var(--sp-ink-0); + --surface-card: var(--sp-ink-1); + --surface-active: var(--sp-ink-2); + --surface-field: var(--sp-ink-0); + --surface-code: var(--sp-ink-code); + --border-1: var(--sp-line-1); + --border-2: var(--sp-line-2); + --border-grid: var(--sp-line-grid); + --text-1: var(--sp-fg-1); + --text-2: var(--sp-fg-2); + --text-3: var(--sp-fg-3); + --text-icon: var(--sp-fg-icon); + --text-inverse: var(--sp-ink-0); + + --accent-cool: var(--sp-cool); + --accent-warm: var(--sp-warm); + --accent-neutral: var(--sp-neutral); + --dial-track: #1f1f23; + + --status-ok: var(--sp-green); + --status-warn: var(--sp-amber); + --status-danger: var(--sp-red); + --status-ok-border: #1f3a2a; + --status-warn-border: #3a3222; + --status-warn-bg: #2a2414; + --status-danger-border: #3a2020; + + --stage-awake: var(--sp-warm); + --stage-rem: var(--sp-violet); + --stage-light: var(--sp-cool); + --stage-deep: var(--sp-indigo); + --chart-hr: var(--sp-red); + --chart-humidity: var(--sp-humidity); + --heat-rgb: 122, 181, 224; + --accent-link: #8fc3ec; + --chart-vital-hr: #e8877a; + --chart-vital-hrv: #b294e6; + --chart-vital-br: #6db3e3; + + --toggle-off-track: var(--sp-line-2); + --toggle-off-knob: #6a6a70; + --toggle-on-knob: var(--sp-ink-0); + --overlay: rgba(0, 0, 0, 0.62); + --shadow-dialog: 0 24px 60px rgba(0, 0, 0, 0.35); +} + +[data-theme="light"] { + --sp-ink-0: #f6f6f5; + --sp-ink-1: #ffffff; + --sp-ink-2: #ececea; + --sp-ink-code: #f1f1ee; + --sp-line-1: #e2e2df; + --sp-line-2: #d6d6d2; + --sp-line-grid: #efefec; + --sp-fg-1: #111113; + --sp-fg-2: #6b6b70; + --sp-fg-3: #9a9aa0; + --sp-fg-icon: #44444a; + --sp-cool: #2f6fb5; + --sp-warm: #c8643a; + --sp-neutral: #5f7f9c; + --sp-green: #2e9d5b; + --sp-amber: #b07d1a; + --sp-red: #c94444; + --sp-violet: #7a5cc4; + --sp-indigo: #34509e; + --sp-humidity: #2f6fb5; + --dial-track: #e8e8e5; + --status-ok-border: #bfe0cb; + --status-warn-border: #ead9b0; + --status-warn-bg: #fbf1dc; + --status-danger-border: #f0c8c8; + --heat-rgb: 47, 111, 181; + --accent-link: #2f6fb5; + --chart-vital-hr: #c94f40; + --chart-vital-hrv: #7a5cc4; + --chart-vital-br: #2f7fb8; + --toggle-off-track: #dcdcd8; + --toggle-off-knob: #ffffff; + --toggle-on-knob: #ffffff; + --overlay: rgba(20, 20, 24, 0.32); + color-scheme: light; +} + :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.58 0.22 27); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.809 0.105 251.813); - --chart-2: oklch(0.623 0.214 259.815); - --chart-3: oklch(0.546 0.245 262.881); - --chart-4: oklch(0.488 0.243 264.376); - --chart-5: oklch(0.424 0.199 265.638); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - /* Deep black background matching theme.background (#0a0a0a) */ - --background: oklch(0.07 0 0); - --foreground: oklch(0.985 0 0); - /* Card bg matching theme.card (#141414) / zinc-900 */ - --card: oklch(0.12 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.12 0 0); - --popover-foreground: oklch(0.985 0 0); - /* Primary: sky-400 accent for active elements */ - --primary: oklch(0.746 0.108 233); - --primary-foreground: oklch(0.985 0 0); - /* Secondary: zinc-800 level */ - --secondary: oklch(0.21 0 0); - --secondary-foreground: oklch(0.985 0 0); - /* Muted: zinc-900 level */ - --muted: oklch(0.16 0 0); - --muted-foreground: oklch(0.556 0 0); - /* Accent: zinc-800 */ - --accent: oklch(0.21 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - /* Border: zinc-800 (~#27272a) */ - --border: oklch(0.24 0 0); - /* Input: slightly lighter for contrast */ - --input: oklch(0.21 0 0); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.746 0.108 233); - --chart-2: oklch(0.623 0.214 259.815); - --chart-3: oklch(0.546 0.245 262.881); - --chart-4: oklch(0.488 0.243 264.376); - --chart-5: oklch(0.424 0.199 265.638); - --sidebar: oklch(0.12 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.746 0.108 233); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.21 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.24 0 0); - --sidebar-ring: oklch(0.556 0 0); + /* shadcn semantic vars mapped onto the sleepypod tokens so base-ui/shadcn + primitives follow the active theme. */ + --background: var(--surface-app); + --foreground: var(--text-1); + --card: var(--surface-card); + --card-foreground: var(--text-1); + --popover: var(--surface-card); + --popover-foreground: var(--text-1); + --primary: var(--text-1); + --primary-foreground: var(--text-inverse); + --secondary: var(--surface-active); + --secondary-foreground: var(--text-1); + --muted: var(--surface-active); + --muted-foreground: var(--text-2); + --accent: var(--surface-active); + --accent-foreground: var(--text-1); + --destructive: var(--status-danger); + --border: var(--border-1); + --input: var(--border-2); + --ring: var(--text-3); + --chart-1: var(--accent-cool); + --chart-2: var(--accent-warm); + --chart-3: var(--stage-rem); + --chart-4: var(--stage-deep); + --chart-5: var(--accent-neutral); + --radius: 0.75rem; + --sidebar: var(--surface-app); + --sidebar-foreground: var(--text-1); + --sidebar-primary: var(--text-1); + --sidebar-primary-foreground: var(--text-inverse); + --sidebar-accent: var(--surface-active); + --sidebar-accent-foreground: var(--text-1); + --sidebar-border: var(--border-1); + --sidebar-ring: var(--text-3); } @layer base { @@ -130,7 +243,8 @@ } body { - @apply bg-background text-foreground; + @apply bg-app text-fg font-sans antialiased; + font-size: 14px; } /* Prevent 300ms tap delay on touch devices */ @@ -179,15 +293,10 @@ body { /* ─── Dark theme form element styles ──────────────────────────────────── */ /* Ensure
-
{side}
+
{side}
v / scale)} vals={vals} peak={peak} />
-
+
Replaying recorded zone loads for the backtested night (~5s windows). Scrub or play to see where contact sat over the night.
@@ -203,11 +203,11 @@ export function CapZoneViz({ side, backtestSide, nightId }: { side: 'left' | 'ri const active = canReplay ? mode : 'live' return ( - -
+ +
- - {`Bed pressure Β· ${active === 'replay' ? 'night replay' : 'live zones'}`} + + {`Bed pressure Β· ${active === 'replay' ? 'night replay' : 'live zones'}`}
{canReplay && ( (or /autopilot/new), two + * columns on wide screens. Left: WHEN / IF / THEN structured form. Right: live + * "reads as" sentence + a backtest that re-runs against real history as you edit. Local state until explicit Save (no autosave). */ 'use client' import { useEffect, useMemo, useState } from 'react' import { trpc } from '@/src/utils/trpc' +import { PageHeader } from '@/src/components/ds' +import { cn } from '@/lib/utils' import { Icon, type IconName } from './icons' import { Button, Card, NumberField, SectionLabel, Segmented, Select, Toggle } from './primitives' import { BacktestPanel } from './BacktestPanel' @@ -38,14 +40,14 @@ const numSignalOpts = SIGNALS.map(s => ({ value: s.id, label: s.label, icon: s.i function SentencePreview({ rule }: { rule: BuilderRule }) { const chunks = buildSentence(rule) return ( - -
- - Reads as + +
+ + Reads as
-

+

{chunks.map((c, i) => ( - {c.text} + {c.text} ))}

@@ -58,7 +60,7 @@ const TimeField = ({ value, onChange }: { value: string, onChange: (v: string) = } // ---------- WHEN ---------- -function WhenEditor({ rule, set }: { rule: BuilderRule, set: (r: BuilderRule) => void }) { +function WhenEditor({ rule, set, range }: { rule: BuilderRule, set: (r: BuilderRule) => void, range: string | null }) { const w = rule.when const setW = (patch: Partial) => set({ ...rule, when: { ...w, ...patch } as WhenSpec }) const types = [ @@ -75,10 +77,10 @@ function WhenEditor({ rule, set }: { rule: BuilderRule, set: (r: BuilderRule) => } return ( - - + +
-
+
{w.type === 'agg' && ( <> setW({ signal: v })} /> v === 'expr' ? setA({ expr: 'ambient + 3', delta: undefined, revert: undefined }) : setA({ expr: undefined, delta: -2 })} /> {!isExpr && ( -
+
setA({ expr: e.target.value })} spellCheck={false} className="mono flex-1 rounded-lg border border-zinc-700/70 bg-zinc-950/70 px-3 py-2 text-[14px] text-emerald-300 focus:border-zinc-500 focus:outline-none" /> +
+ + setA({ expr: e.target.value })} spellCheck={false} aria-label="Temperature expression" className="min-w-0 flex-1 rounded-ctl border border-line-2 bg-field px-3 py-2 font-mono text-[14px] text-ok focus:border-fg-3 focus:outline-none" /> {exprEval != null && ( - + = - + {Math.round(exprEval)} Β°F @@ -239,25 +243,25 @@ function ThenEditor({ rule, set, liveAmbient }: { rule: BuilderRule, set: (r: Bu )}
-
+
Variables: - ambient + ambient , - target + target , - current + current . Evaluated every tick.
)} -
-
- - Safety clamp - never command outside these bounds +
+
+ + Safety clamp + never command outside these bounds
-
+
min setA({ clamp: [v, clamp[1]] })} width={84} /> max @@ -268,15 +272,15 @@ function ThenEditor({ rule, set, liveAmbient }: { rule: BuilderRule, set: (r: Bu )} {a.action === 'notify' && ( - setA({ message: e.target.value })} placeholder="Notification message…" className="w-full rounded-lg border border-zinc-700/70 bg-zinc-950/70 px-3 py-2 text-[13px] text-zinc-200 focus:border-zinc-500 focus:outline-none" /> + setA({ message: e.target.value })} placeholder="Notification message…" aria-label="Notification message" className="w-full rounded-ctl border border-line-2 bg-field px-3 py-2 text-[13px] text-fg placeholder:text-fg-3 focus:border-fg-3 focus:outline-none" /> )} {a.action === 'setPower' && ( setA({ on: v === 'on' })} /> )} -
+
@@ -285,7 +289,7 @@ function ThenEditor({ rule, set, liveAmbient }: { rule: BuilderRule, set: (r: Bu ) } -// ---------- modal ---------- +// ---------- page ---------- export function RuleEditor({ automation, onClose, onSave, saving }: { automation: BuilderRule, onClose: () => void, onSave: (r: BuilderRule) => void, saving?: boolean }) { const [rule, setRule] = useState(() => clone(automation)) const backtestSide = rule.side === 'right' ? 'right' : 'left' @@ -328,61 +332,91 @@ export function RuleEditor({ automation, onClose, onSave, saving }: { automation { enabled: nights.length > 0, placeholderData: prev => prev }, ) + // The compared value's recorded range, so the threshold isn't set out of reach. + const hasThreshold = debounced.when.type === 'agg' || debounced.when.type === 'cond' + const rangeQ = trpc.automations.backtestRange.useQuery( + { nights: 5, rule: { side: ast.side, cooldownMin: ast.cooldownMin, trigger: ast.trigger, conditions: ast.conditions, actions: ast.actions } }, + { enabled: hasThreshold && nights.length > 0, placeholderData: prev => prev }, + ) + const range = hasThreshold && rangeQ.data && rangeQ.data.nights > 0 && rangeQ.data.low != null && rangeQ.data.peak != null + ? `last ${rangeQ.data.nights} night${rangeQ.data.nights === 1 ? '' : 's'}: ${fmtRange(rangeQ.data.low)}–${fmtRange(rangeQ.data.peak)}` + : null + return ( -
-
- - setRule({ ...rule, name: e.target.value })} className="min-w-0 flex-1 max-w-sm bg-transparent text-[16px] font-semibold text-zinc-100 focus:outline-none" /> -
+ <> + Autopilot / Automations / + Automations} + onBack={onClose} + className="[&>button]:min-[900px]:hidden" + title={( + setRule({ ...rule, name: e.target.value })} + className="w-[min(70vw,420px)] min-w-0 rounded-thumb bg-transparent font-medium text-fg focus:bg-field focus:outline-none" + /> + )} + right={( + <> + Nothing is saved until you press Save. + + + + )} + /> +
+
+ Side setRule({ ...rule, side: v })} /> -
+
+
+ Mode setRule({ ...rule, mode: v, enabled: true })} /> - -
-
-
-
- -
- -
- -
+
+
+ +
+ +
+
-
-
- {rule.mode === 'dryrun' && ( -
- - Dry-run: Autopilot logs what it would do but never touches hardware. -
- )} - - {usesCapSignal && } - - [0]['result']) : null} - loading={backtestQ.isLoading || backtestQ.isFetching} - message={nights.length === 0 ? (nightsQ.isLoading ? undefined : 'No recorded nights for this side yet β€” backtest needs sleep history.') : (backtestQ.data && !backtestQ.data.ok ? backtestQ.data.message : undefined)} - nights={nights} - nightId={nightId} - onNight={setPicked} - /> - -
- - Backtest replays real recorded sensor history against your current settings. Nothing here changes your bed β€” it's a dry preview of how this rule would have behaved. +
+ {rule.mode === 'dryrun' && ( +
+ + Dry-run: Autopilot logs what it would do but never touches hardware.
+ )} + + {usesCapSignal && } + + [0]['result']) : null} + loading={backtestQ.isLoading || backtestQ.isFetching} + message={nights.length === 0 ? (nightsQ.isLoading ? undefined : 'No recorded nights for this side yet β€” backtest needs sleep history.') : (backtestQ.data && !backtestQ.data.ok ? backtestQ.data.message : undefined)} + nights={nights} + nightId={nightId} + onNight={setPicked} + /> + +
+ + Backtest replays real recorded sensor history against your current settings. Nothing here changes your bed β€” it's a dry preview of how this rule would have behaved.
-
+ ) } + +function fmtRange(v: number): string { + return Math.abs(v) >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10) +} diff --git a/src/components/Autopilot/RulePage.tsx b/src/components/Autopilot/RulePage.tsx new file mode 100644 index 000000000..44ed324b2 --- /dev/null +++ b/src/components/Autopilot/RulePage.tsx @@ -0,0 +1,64 @@ +'use client' + +import { usePathname, useRouter } from 'next/navigation' +import { trpc } from '@/src/utils/trpc' +import { Card, InlineError, Skeleton } from '@/src/components/ds' +import { RuleEditor } from './RuleEditor' +import { type BuilderRule, blankRule, fromAST, templateRule, toAST } from './builderModel' + +/** + * /autopilot/ and /autopilot/new: loads the rule (or a blank one), saves + * via create/update, and returns to the Automations list on Save or Cancel. + * `/autopilot/new?template=` opens the editor prefilled from a template. + */ +export function RulePage({ id, template }: { id: string, template?: string }) { + const router = useRouter() + const pathname = usePathname() + const lang = pathname?.split('/')[1] || 'en' + const utils = trpc.useUtils() + const isNew = id === 'new' + const numericId = Number(id) + const validId = !isNew && Number.isInteger(numericId) && numericId > 0 + + const ruleQ = trpc.automations.get.useQuery({ id: numericId }, { enabled: validId }) + + const back = () => router.push(`/${lang}/autopilot`) + const onSaved = () => { + void utils.automations.list.invalidate() + void utils.automations.status.invalidate() + back() + } + const createM = trpc.automations.create.useMutation({ onSuccess: onSaved }) + const updateM = trpc.automations.update.useMutation({ onSuccess: onSaved }) + + const save = (rule: BuilderRule) => { + const ast = toAST(rule) + if (rule.id != null) updateM.mutate({ id: rule.id, ...ast }) + else createM.mutate(ast) + } + + if (!isNew && !validId) { + return {`No automation "${id}".`} + } + if (validId && ruleQ.isLoading) return + const row = ruleQ.data + if (!isNew && !row) { + return {ruleQ.error?.message ?? `Automation ${id} not found`} + } + + const automation = row ? fromAST(row) : (templateRule(template) ?? blankRule()) + const saveError = createM.error ?? updateM.error + + return ( + <> + {saveError && {`Failed to save: ${saveError.message}`}} + + + ) +} diff --git a/src/components/Autopilot/StatusPanel.tsx b/src/components/Autopilot/StatusPanel.tsx index 4db755652..be9f50abb 100644 --- a/src/components/Autopilot/StatusPanel.tsx +++ b/src/components/Autopilot/StatusPanel.tsx @@ -1,214 +1,458 @@ /** * Diagnostics / status panel β€” live Autopilot state and the audit trail. Global - * kill-switch, a per-rule card (status, last fire, fires today, dry-run toggle), - * and the run log: every evaluation that mattered, which is the transparency - * Eight Sleep's black box lacks. + * kill-switch, then one card per rule: mode (Off / Dry-run / Live), the rule in + * plain English, four stats that keep "last evaluated" apart from "fired", a + * last-3-hours strip with one tick per minute, and today's run log with + * repeats collapsed β€” the transparency Eight Sleep's black box lacks. */ 'use client' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { useMemo, useState } from 'react' +import type { Action, Condition, Trigger } from '@/src/automation/types' +import { SegmentedControl } from '@/src/components/ds' +import { cn } from '@/lib/utils' import { Icon } from './icons' -import { Badge, Card, SideBadge, StatusBadge, Toggle } from './primitives' -import { formatSetpointF } from '@/src/lib/tempUtils' +import { Card, SideBadge, Toggle } from './primitives' +import { buildSentence, fromAST, SIGNALS } from './builderModel' +import { + buildStrip, cadenceText, filterCounts, filterOf, groupLog, lastVerdict, missingTicks, thresholds, toMs, + type EvalRun, type LogFilter, type LogGroup, type Strip, type TickKind, +} from './evaluationStrip' -export interface RuleStatus { +/** Hours of history the strip shows. */ +export const STRIP_HOURS = 3 + +export type RuleMode = 'off' | 'dryrun' | 'live' + +export interface DiagRule { id: number name: string enabled: boolean dryRun: boolean side: 'left' | 'right' | null + priority: number cooldownMin: number | null - lastOutcome: string | null - lastFiredAt: Date | string | null - firesToday: number + trigger: Trigger + conditions: Condition + actions: Action[] + runs: EvalRun[] + /** Live value of each signal the condition reads (null = unavailable). */ + signals: Record } -export interface RunRow { - id: number - automationId: number - ruleName: string | null - firedAt: Date | string - outcome: 'fired' | 'skipped' | 'clamped' | 'dry_run' | 'error' - detail: unknown +export interface Diagnostics { + now: Date | string + startOfDay: Date | string + globalEnabled: boolean + rules: DiagRule[] } -function toDate(d: Date | string): Date { - return d instanceof Date ? d : new Date(d) +function ago(ms: number | null, now: number): string { + if (ms == null) return 'never' + const d = now - ms + if (d < 60_000) return 'just now' + const m = Math.floor(d / 60_000) + if (m < 60) return `${m} min ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h} h ago` + return `${Math.floor(h / 24)} d ago` } -function ago(d: Date | string | null): string { - if (!d) return 'never' - const ms = Date.now() - toDate(d).getTime() - if (ms < 60_000) return 'just now' - const m = Math.floor(ms / 60_000) - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - return `${Math.floor(h / 24)}d ago` +function clock(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } -function hhmm(d: Date | string): string { - const x = toDate(d) +function hhmm(ms: number): string { + const x = new Date(ms) return `${String(x.getHours()).padStart(2, '0')}:${String(x.getMinutes()).padStart(2, '0')}` } -function statusMode(r: RuleStatus): 'active' | 'dryrun' | 'paused' { - if (!r.enabled) return 'paused' - return r.dryRun ? 'dryrun' : 'active' +function ruleMode(r: Pick): RuleMode { + if (!r.enabled) return 'off' + return r.dryRun ? 'dryrun' : 'live' } -function verdictTone(v: RunRow['outcome']): 'red' | 'zinc' | 'amber' { - if (v === 'fired' || v === 'clamped') return 'red' - if (v === 'dry_run') return 'amber' - return 'zinc' +function signalMeta(key: string): { label: string, unit: string } { + const templ = key.replace(/^(left|right)\./, '{side}.') + const def = SIGNALS.find(s => s.id === templ || s.id === key) + const side = /^(left|right)\./.exec(key)?.[1] + const label = (def?.label ?? key).toLowerCase() + return { label: side ? `${side} ${label}` : label, unit: def?.unit ?? '' } } -interface ActionDetail { kind?: string, side?: string, temp?: number, on?: boolean, sent?: boolean, dryRun?: boolean, clamped?: boolean, antiThrash?: boolean, skipped?: string, notified?: boolean } -function actionText(detail: unknown): string { - if (!detail || typeof detail !== 'object') return '' - const d = detail as { actions?: ActionDetail[], reason?: string } - if (d.reason) return d.reason.replace(/-/g, ' ') - const a = d.actions?.[0] - if (!a) return '' - if (a.kind === 'notify') return 'notify' - if (a.kind === 'setPower') return `power ${a.on ? 'on' : 'off'}` - if (a.kind === 'setTemperature') { - if (a.skipped) return a.skipped.replace(/-/g, ' ') - const verb = a.sent ? 'set' : a.dryRun ? 'would set' : a.antiThrash ? 'held' : 'set' - return a.temp != null ? `${verb} ${formatSetpointF(a.temp, 'F')}${a.clamped ? ' (clamped)' : ''}` : verb - } - return a.kind ?? '' +function fmtVal(v: number, unit: string): string { + const n = Math.abs(v) >= 100 ? Math.round(v) : Math.round(v * 10) / 10 + return `${n}${unit}` +} + +/** "ambient temp 72.3Β°F now, needs > 75Β°F" β€” live reading beside the threshold it's checked against. */ +function conditionDetail(r: DiagRule): string { + const [t] = thresholds(r.conditions) + if (!t) return 'no threshold' + const { label, unit } = signalMeta(t.signal) + const live = r.signals[t.signal] + const needs = `needs ${t.op} ${fmtVal(t.value, unit)}${t.window ? ` (${t.window.lastMin}-min ${t.window.fn})` : ''}` + return `${label} ${live == null ? 'unavailable' : `${fmtVal(live, unit)} now`}, ${needs}` +} + +// ── Strip ──────────────────────────────────────────────────────────────────── + +const TICK_CLASS: Record = { + none: '', + skipped: 'bg-fg-3/45', + cooldown: '', + would: 'bg-warn', + fired: 'bg-ok', + error: 'bg-danger', + missing: 'border border-danger', } -function RuleStatusCard({ a, onDry }: { a: RuleStatus, onDry: (id: number, dryRun: boolean) => void }) { +function EvaluationStrip({ strip, nowMs, cooldownMin }: { strip: Strip, nowMs: number, cooldownMin: number | null }) { + const span = strip.endMs - strip.startMs + const pct = (ms: number) => `${((ms - strip.startMs) / span) * 100}%` + const n = strip.ticks.length + + const hours: number[] = [] + const first = new Date(strip.startMs) + first.setMinutes(0, 0, 0) + for (let t = first.getTime() + 3_600_000; t < strip.endMs; t += 3_600_000) hours.push(t) + + // Label the most recent action the strip shows. + let marker: { i: number, kind: 'would' | 'fired' } | null = null + for (let i = n - 1; i >= 0; i--) { + const k = strip.ticks[i] + if (k === 'would' || k === 'fired') { + marker = { i, kind: k } + break + } + } + return ( - -
-
-
- {a.name} - -
-
-
- +
+
+ {marker && ( + + {marker.kind === 'fired' ? 'fired' : 'would fire'} + {' Β· '} + {clock(strip.startMs + marker.i * 60_000)} + + )} + now
- -
-
- Last outcome - {a.lastOutcome ?? 'β€”'} +
+
+ {strip.ticks.map((k, i) => ( +
+ {k !== 'none' && k !== 'cooldown' && ( +
+ )} +
+ ))}
-
{a.cooldownMin ? `cooldown ${a.cooldownMin}m` : 'no cooldown'}
+ {strip.cooldowns.map(([a, b]) => { + const mins = b - a + 1 + return ( +
+ {`cooldown ${cooldownMin ?? mins} min`} +
+ ) + })} +
+
+
+ {hours.map(t => ( + + {new Date(t).toLocaleTimeString([], { hour: 'numeric' })} + + ))}
+
+ skipped + would fire (dry-run) + fired + cooldown + no evaluation logged +
+
+ ) +} -
-
-
Last fired
-
{ago(a.lastFiredAt)}
+function Legend({ swatch, children }: { swatch: string, children: string }) { + return ( + + + {children} + + ) +} + +// ── Stats ──────────────────────────────────────────────────────────────────── + +function Stat({ label, value, sub, tone }: { label: string, value: string, sub: string, tone?: 'muted' | 'warn' | 'ok' | 'danger' }) { + return ( +
+ {label} + + {value} + + {sub} +
+ ) +} + +// ── Run log ────────────────────────────────────────────────────────────────── + +const LOG_BADGE: Record = { + skipped: { text: 'SKIPPED', cls: 'border-line-2 text-fg-2' }, + cooldown: { text: 'COOLDOWN', cls: 'border-line-2 text-fg-2' }, + would: { text: 'WOULD FIRE', cls: 'border-warn-line text-warn' }, + fired: { text: 'FIRED', cls: 'border-ok-line text-ok' }, + error: { text: 'ERROR', cls: 'border-danger-line text-danger' }, + missing: { text: 'MISSING', cls: 'border-danger-line text-danger' }, +} + +function groupTime(g: LogGroup): string { + if (g.count === 1) return hhmm(g.firstMs) + if (g.count === 2) return `${hhmm(g.firstMs)}, ${hhmm(g.lastMs)}` + return `${hhmm(g.firstMs)} – ${hhmm(g.lastMs)}` +} + +function groupText(g: LogGroup, cooldownMin: number | null): string { + switch (g.kind) { + case 'missing': return 'no evaluation logged' + case 'cooldown': return `condition true, held by the ${cooldownMin ?? '?'} min cooldown` + case 'would': return 'dry run Β· would have acted' + case 'fired': return g.sent ? `${g.sent} live action${g.sent === 1 ? '' : 's'} sent` : 'fired Β· no command needed' + case 'error': return (g.reason ?? 'error').replace(/-/g, ' ') + default: return (g.reason ?? 'actions gated out').replace(/-/g, ' ') + } +} + +const FILTERS: Array<{ id: LogFilter, label: string }> = [ + { id: 'all', label: 'All' }, + { id: 'would', label: 'Would fire' }, + { id: 'fired', label: 'Fired' }, + { id: 'skipped', label: 'Skipped' }, + { id: 'missing', label: 'Missing' }, +] + +function RunLog({ groups, cooldownMin }: { groups: LogGroup[], cooldownMin: number | null }) { + const [filter, setFilter] = useState('all') + const counts = filterCounts(groups) + const shown = filter === 'all' ? groups : groups.filter(g => filterOf(g.kind) === filter) + return ( +
+
+ Run log + today Β· repeats grouped +
+ {FILTERS.map(f => ( + + ))}
-
-
Today
-
- {a.firesToday} - {' '} - fire - {a.firesToday === 1 ? '' : 's'} +
+
+ {shown.length === 0 &&
Nothing logged today.
} + {shown.map(g => ( +
+ {groupTime(g)} + + {LOG_BADGE[g.kind].text} + {g.count > 1 && ( + + Γ— + {g.count} + + )} + + {groupText(g, cooldownMin)}
-
+ ))}
- +
) } -function RunLog({ runs }: { runs: RunRow[] }) { +// ── Rule card ──────────────────────────────────────────────────────────────── + +function RuleDiagCard({ r, nowMs, startOfDayMs, globalEnabled, onMode }: { + r: DiagRule + nowMs: number + startOfDayMs: number + globalEnabled: boolean + onMode: (id: number, mode: RuleMode) => void +}) { + const lang = usePathname()?.split('/')[1] || 'en' + const mode = ruleMode(r) + const expectMissing = r.enabled && globalEnabled + + const derived = useMemo(() => { + const strip = buildStrip(r.runs, r.trigger, nowMs, STRIP_HOURS, expectMissing) + const today = r.runs.filter(x => toMs(x.t) >= startOfDayMs) + const missing = expectMissing ? missingTicks(r.runs, r.trigger, startOfDayMs, nowMs) : [] + const groups = groupLog(today, missing) + const last = r.runs.at(-1) + const would = today.filter(x => x.outcome === 'dry_run') + const fired = today.filter(x => x.outcome === 'fired' || x.outcome === 'clamped') + return { + strip, + groups, + last, + wouldCount: would.length, + lastWould: would.at(-1), + firedCount: fired.length, + sentCount: today.filter(x => x.sent).length, + } + }, [r.runs, r.trigger, nowMs, startOfDayMs, expectMissing]) + + const sentence = useMemo(() => buildSentence(fromAST(r)), [r]) + const verdict = lastVerdict(derived.last) + const lastMs = derived.last ? toMs(derived.last.t) : null + return ( - -
-
- - Run log - every evaluation that mattered + +
+ + {r.name} + + +
+ Mode + onMode(r.id, m)} + options={[ + { value: 'off', label: 'Off' }, + { value: 'dryrun', label: Dry-run }, + { value: 'live', label: Live }, + ]} + />
- audit trail
-
- - - - - - - - - - - {runs.length === 0 && ( - - )} - {runs.map(r => ( - - - - - - - ))} - -
TimeRuleVerdictAction / reason
No evaluations recorded yet.
{hhmm(r.firedAt)}{r.ruleName ?? `#${r.automationId}`}{r.outcome.replace('_', '-')}{actionText(r.detail)}
+ +

+ {sentence.map((c, i) => ( + {c.text} + ))} +

+ +
+ + + + +
+ +
+
+ Evaluations + + {`last ${STRIP_HOURS} hours Β· one tick per minute`} + +
+
+ + ) } -export function StatusPanel({ globalEnabled, onKill, rules, runs, loading, onDry }: { - globalEnabled: boolean - onKill: (enabled: boolean) => void - rules: RuleStatus[] - runs: RunRow[] +// ── Panel ──────────────────────────────────────────────────────────────────── + +export function StatusPanel({ data, loading, onKill, onMode }: { + data: Diagnostics | undefined loading: boolean - onDry: (id: number, dryRun: boolean) => void + onKill: (enabled: boolean) => void + onMode: (id: number, mode: RuleMode) => void }) { + const globalEnabled = data?.globalEnabled ?? true const killed = !globalEnabled + const nowMs = data ? toMs(data.now) : 0 + const startOfDayMs = data ? toMs(data.startOfDay) : 0 + const dry = data?.rules.filter(r => r.enabled && r.dryRun).length ?? 0 + return ( -
-
-
-

Diagnostics

-

Live Autopilot state & audit trail

-
-
- -
-
{killed ? 'Autopilot halted' : 'Autopilot running'}
-
{killed ? 'all rules suspended' : 'global kill-switch'}
+
+ + +
+
{killed ? 'Autopilot halted' : 'Autopilot running'}
+
+ {killed ? 'all rules suspended' : `running Β· ${dry} rule${dry === 1 ? '' : 's'} in dry-run`}
- onKill(killed)} tone={killed ? 'red' : 'accent'} />
-
+ onKill(killed)} /> + -
-
- {killed && ( -
- - Kill-switch engaged β€” no rule will command hardware. Manual control only. -
- )} - {loading - ?
Loading status…
- : ( - <> -
- {rules.map(a => )} -
- - - )} + {killed && ( +
+ + Kill-switch engaged β€” no rule will command hardware. Manual control only.
-
+ )} + {loading || !data + ?
Loading status…
+ : data.rules.length === 0 + ?
No automations yet.
+ : data.rules.map(r => ( + + ))}
) } diff --git a/src/components/Autopilot/TonightCard.tsx b/src/components/Autopilot/TonightCard.tsx new file mode 100644 index 000000000..4b61acefb --- /dev/null +++ b/src/components/Autopilot/TonightCard.tsx @@ -0,0 +1,234 @@ +/** + * "Who controls the bed tonight" β€” one now card per side (the owner by + * TemperatureController precedence, with a sentence explaining it) above a + * 6 PM β†’ 9 AM timeline. Each side gets two lanes: SCHED (the schedule's set + * points as temperature-coloured blocks, with any manual hold overlaid) and + * AUTO (each rule's active window β€” hatched amber in dry-run, green outline + * when active β€” with its fire events as dots). + */ +'use client' + +import type { CSSProperties } from 'react' +import { cn } from '@/lib/utils' +import { Skeleton } from '@/src/components/ds' +import { useNowMinute } from '@/src/components/Schedule/CurveChart' +import { tempTone, TONE_VAR } from '@/src/components/Schedule/scheduleFormat' +import { tonightWindow } from '@/src/components/diagnostics/dashboardLogic' +import { nightCurve, type CurvePoint } from '@/src/components/TempScreen/timelineLogic' +import { useSideNames } from '@/src/hooks/useSideNames' +import { formatSetpointF, type TempUnit } from '@/src/lib/tempUtils' +import type { Condition } from '@/src/automation/types' +import { trpc } from '@/src/utils/trpc' +import { clock, ownerView, ruleWindow, scheduleBlocks, type Owner, type RuleMode, type SideTonight } from './automationsLogic' + +type Side = 'left' | 'right' +const SIDES: Side[] = ['left', 'right'] +const HOUR = 3_600_000 +const LANE_H = 16 + +export interface TimelineRule { + id: number + name: string + mode: RuleMode + side: Side | null + conditions: Condition + missing: string[] +} + +export interface FireMark { ruleId: number, at: number, outcome: string, sides: Side[] } + +export const OWNER_TONE: Record = { + 'manual': 'text-link', + 'run-once': 'text-cool', + 'autopilot': 'text-ok', + 'schedule': 'text-fg-icon', + 'off': 'text-fg-3', +} + +export const HATCH: CSSProperties = { + backgroundImage: 'repeating-linear-gradient(135deg, color-mix(in srgb, var(--status-warn) 45%, transparent) 0 1px, transparent 1px 7px)', +} + +export function TonightCard({ rules, tonight, fires, unit }: { + rules: TimelineRule[] + tonight: { now: number, sides: Record } | undefined + fires: FireMark[] + unit: TempUnit +}) { + const nowMinute = useNowMinute() + const { sideName } = useSideNames() + const left = trpc.schedules.getAll.useQuery({ side: 'left' }, { staleTime: 60_000 }) + const right = trpc.schedules.getAll.useQuery({ side: 'right' }, { staleTime: 60_000 }) + + if (nowMinute == null) return + const now = nowMinute * 60_000 + const win = tonightWindow(new Date(now)) + const start = new Date(win.midnight.getFullYear(), win.midnight.getMonth(), win.midnight.getDate(), 18).getTime() + const end = win.end + const pct = (t: number) => ((Math.min(Math.max(t, start), end) - start) / (end - start)) * 100 + const fmt = (f: number) => formatSetpointF(f, unit, { includeUnit: false }) + const curves: Record = { + left: nightCurve(left.data?.temperature, win.midnight), + right: nightCurve(right.data?.temperature, win.midnight), + } + const ticks: number[] = [] + for (let t = start; t <= end; t += 3 * HOUR) ticks.push(t) + const nowIn = now >= start && now <= end + + return ( +
+
+ Who controls the bed tonight + 6 PM β†’ 9 AM +
+ + + manual hold + + + + dry-run + +
+
+ +
+ {SIDES.map((side) => { + const o = ownerView(tonight?.sides[side], curves[side], now, fmt) + return ( +
+
+ + {sideName(side)} + Β· now + + + + {o.label} + +
+ {tonight ? o.detail : 'Loading…'} +
+ ) + })} +
+ +
+
+ {SIDES.map(side => ( + r.mode !== 'off' && (r.side == null || r.side === side))} + fires={fires.filter(f => f.sides.length === 0 || f.sides.includes(side))} + midnight={win.midnight} + start={start} + end={end} + pct={pct} + fmt={fmt} + /> + ))} + + + +
+ {ticks.map(t => ( + + {clock(t).replace(':00', '')} + + ))} + {nowIn && ( + now + )} +
+ + {/* Hour grid and now line across every lane. */} +
+ {ticks.map(t => )} + {nowIn && } +
+
+
+
+ ) +} + +function SideLanes({ side, name, blocks, hold, rules, fires, midnight, start, end, pct, fmt }: { + side: Side + name: string + blocks: ReturnType + hold: SideTonight['hold'] + rules: TimelineRule[] + fires: FireMark[] + midnight: Date + start: number + end: number + pct: (t: number) => number + fmt: (f: number) => string +}) { + const span = (a: number, b: number): CSSProperties => ({ left: `${pct(a)}%`, width: `${pct(b) - pct(a)}%` }) + const holdSpan = hold && hold.expiresAt > start && hold.startedAt < end ? hold : null + return ( + <> +
+ {name} + {side} +
+ SCHED +
+ {blocks.map(b => ( + + {fmt(b.temperature)} + + ))} + {holdSpan && ( + + {fmt(holdSpan.temperature)} + + )} +
+ + AUTO +
+ {rules.map((r) => { + const w = ruleWindow(r.conditions, midnight, start, end) + if (!w) return null + const state = r.missing.length ? `no ${r.missing[0]} signal` : 'armed' + const label = r.mode === 'dryrun' ? `${r.name} Β· dry-run Β· ${state}` : r.missing.length ? `${r.name} Β· ${state}` : r.name + return ( + + {label} + + ) + })} + {fires.filter(f => f.at >= start && f.at <= end).map(f => ( + + ))} +
+ + ) +} diff --git a/src/components/Autopilot/automationsLogic.ts b/src/components/Autopilot/automationsLogic.ts new file mode 100644 index 000000000..9154b47d3 --- /dev/null +++ b/src/components/Autopilot/automationsLogic.ts @@ -0,0 +1,268 @@ +/** + * View-model for the Automations page β€” who owns each side now, tonight's + * timeline lanes, rule row copy and activity-log copy. Pure (times are epoch + * ms, temperatures Β°F) so it can be tested without React or tRPC. + */ + +import type { CurvePoint } from '@/src/components/TempScreen/timelineLogic' +import type { Condition } from '@/src/automation/types' +import { conditionTimeWindow } from '@/src/automation/activity' + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +export type Owner = 'manual' | 'run-once' | 'autopilot' | 'schedule' | 'off' + +export interface ControlLike { + source: 'manual' | 'run-once' | 'autopilot' | 'schedule' | null + targetTemperature: number | null + blocked: 'safety' | 'off' | null +} + +export interface SideTonight { + control: ControlLike | null + hold: { temperature: number, startedAt: number, expiresAt: number } | null + runOnceUntil: number | null + lastAutopilot: { ruleName: string, temp: number, at: number } | null +} + +export interface OwnerView { + owner: Owner + label: string + detail: string +} + +export const OWNER_LABEL: Record = { + 'manual': 'Manual hold', + 'run-once': 'Run once', + 'autopilot': 'Autopilot', + 'schedule': 'Schedule', + 'off': 'Off', +} + +export const clock = (t: number) => new Date(t).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + +/** + * The side's owner, by TemperatureController precedence (manual hold β†’ + * run-once β†’ autopilot β†’ schedule β†’ off), with one sentence explaining it. + * `fmt` renders a Β°F setpoint in the user's unit ("81Β°"). + */ +export function ownerView(s: SideTonight | undefined, curve: CurvePoint[], now: number, fmt: (f: number) => string): OwnerView { + const next = curve.find(p => p.at > now) + // Without a running controller, infer the owner from what's persisted. + const inferred = s?.hold && s.hold.expiresAt > now + ? 'manual' + : curve.length > 1 && curve[0].at <= now && now < curve[curve.length - 1].at ? 'schedule' : null + const source = s?.control ? (s.control.blocked ? null : s.control.source) : inferred + const hold = s?.hold + if (source === 'manual' && hold) { + const resumes = curve.some(p => p.at >= hold.expiresAt) ? 'Schedule resumes' : 'Hold ends' + return { + owner: 'manual', + label: OWNER_LABEL.manual, + detail: `Dial set to ${fmt(hold.temperature)} at ${clock(hold.startedAt)}. ${resumes} at ${clock(hold.expiresAt)}.`, + } + } + if (source === 'run-once') { + const target = s?.control?.targetTemperature + return { + owner: 'run-once', + label: OWNER_LABEL['run-once'], + detail: `${target != null ? `${fmt(target)} from a run-once session` : 'Run-once session'}${s?.runOnceUntil ? ` until ${clock(s.runOnceUntil)}` : ''}.`, + } + } + if (source === 'autopilot') { + const last = s?.lastAutopilot + return { + owner: 'autopilot', + label: OWNER_LABEL.autopilot, + detail: last ? `${last.ruleName} set ${fmt(last.temp)} at ${clock(last.at)}.` : 'An automation holds this side.', + } + } + if (source === 'schedule') { + const target = s?.control?.targetTemperature + const current = target ?? [...curve].reverse().find(p => p.at <= now)?.temperature ?? null + const then = next && next !== curve[curve.length - 1] ? `, then ${fmt(next.temperature)}` : '' + const until = next ? ` until ${clock(next.at)}` : '' + return { + owner: 'schedule', + label: OWNER_LABEL.schedule, + detail: current != null ? `${fmt(current)}${until}${then}.` : 'Following the schedule.', + } + } + const start = curve.length > 1 && curve[0].at > now ? curve[0] : null + return { + owner: 'off', + label: OWNER_LABEL.off, + detail: s?.control?.blocked === 'safety' + ? 'Paused by pump stall protection.' + : start ? `Schedule starts at ${clock(start.at)}.` : 'No schedule tonight.', + } +} + +// ── Timeline ──────────────────────────────────────────────────────────────── + +export interface Block { start: number, end: number, temperature: number } + +/** Set points as blocks: each point holds until the next (the last is power off). */ +export function scheduleBlocks(curve: CurvePoint[], from: number, to: number): Block[] { + const out: Block[] = [] + for (let i = 0; i < curve.length - 1; i++) { + const start = Math.max(curve[i].at, from) + const end = Math.min(curve[i + 1].at, to) + if (end > start) out.push({ start, end, temperature: curve[i].temperature }) + } + return out +} + +function hhmmToMin(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number) + return h * 60 + m +} + +/** + * A rule's active window tonight, from its `timeBetween` condition (a start + * before noon belongs to the morning after `midnight`), or the whole range + * when it has none. Clipped to [from, to]. + */ +export function ruleWindow(conditions: Condition, midnight: Date, from: number, to: number): { start: number, end: number } | null { + const w = conditionTimeWindow(conditions) + if (!w) return { start: from, end: to } + const s = hhmmToMin(w.start) + let e = hhmmToMin(w.end) + const startAt = new Date(midnight.getFullYear(), midnight.getMonth(), midnight.getDate(), 0, s < 12 * 60 ? s + 24 * 60 : s).getTime() + if (e <= s) e += 24 * 60 + const endAt = startAt + (e - s) * MINUTE + const start = Math.max(startAt, from) + const end = Math.min(endAt, to) + return end > start ? { start, end } : null +} + +// ── Night grouping ───────────────────────────────────────────────────────── + +const NIGHT_START_H = 18 + +/** Start of the 6 PM–6 PM night containing `now`. */ +export function currentNightStart(now: number): number { + const d = new Date(now) + const today = new Date(d.getFullYear(), d.getMonth(), d.getDate(), NIGHT_START_H).getTime() + return now >= today ? today : new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1, NIGHT_START_H).getTime() +} + +/** The last `count` night starts, ascending (oldest first). */ +export function nightStarts(now: number, count: number): number[] { + const tonight = new Date(currentNightStart(now)) + return Array.from({ length: count }, (_, i) => + new Date(tonight.getFullYear(), tonight.getMonth(), tonight.getDate() - (count - 1 - i), NIGHT_START_H).getTime()) +} + +/** `TONIGHT Β· MON SEP 28`, `LAST NIGHT Β· SUN SEP 27`, then the date. */ +export function nightHeader(start: number, now: number): string { + const date = new Date(start).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '').toUpperCase() + const tonight = currentNightStart(now) + if (start === tonight) return `TONIGHT Β· ${date}` + if (Math.abs(start - (tonight - DAY)) < 2 * HOUR) return `LAST NIGHT Β· ${date}` + return date +} + +// ── Rule rows ────────────────────────────────────────────────────────────── + +export type RuleMode = 'off' | 'dryrun' | 'active' + +export function ruleMode(r: { enabled: boolean, dryRun: boolean }): RuleMode { + if (!r.enabled) return 'off' + return r.dryRun ? 'dryrun' : 'active' +} + +/** `{n} rules Β· {active} active Β· {dry} dry-run`, dropping zero parts. */ +export function statusLine(rules: Array<{ enabled: boolean, dryRun: boolean }>): string { + const active = rules.filter(r => ruleMode(r) === 'active').length + const dry = rules.filter(r => ruleMode(r) === 'dryrun').length + const parts = [`${rules.length} rule${rules.length === 1 ? '' : 's'}`] + if (active) parts.push(`${active} active`) + if (dry) parts.push(`${dry} dry-run`) + return rules.length ? parts.join(' Β· ') : 'no rules' +} + +export interface BacktestSummary { nights: number, wouldFire: number, peak: number | null, threshold: number | null } + +function fmtNum(v: number): string { + return Math.abs(v) >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10) +} + +/** `5-night backtest Β· 0 would fire Β· peak 64 of 200`. */ +export function backtestLine(s: BacktestSummary | undefined): string { + if (!s) return 'backtesting…' + if (s.nights === 0) return 'no recorded nights to backtest' + const parts = [`${s.nights}-night backtest`, `${s.wouldFire} would fire`] + if (s.threshold != null && s.peak != null) parts.push(`peak ${fmtNum(s.peak)} of ${fmtNum(s.threshold)}`) + return parts.join(' Β· ') +} + +// ── Activity ─────────────────────────────────────────────────────────────── + +export type ActivityFilter = 'all' | 'fired' | 'would' | 'skipped' + +export type ActivityTone = 'ok' | 'warn' | 'muted' | 'danger' | 'link' + +export const OUTCOME_VIEW: Record = { + fired: { label: 'Fired', tone: 'ok' }, + dry_run: { label: 'Would fire', tone: 'warn' }, + skipped: { label: 'Skipped', tone: 'muted' }, + clamped: { label: 'Clamped', tone: 'warn' }, + error: { label: 'Error', tone: 'danger' }, + paused: { label: 'Paused', tone: 'link' }, +} + +export function matchesFilter(outcome: string, f: ActivityFilter): boolean { + if (f === 'all') return true + if (f === 'fired') return outcome === 'fired' || outcome === 'clamped' + if (f === 'would') return outcome === 'dry_run' + return outcome === 'skipped' +} + +export interface EntryLike { + code: string + temp: number | null + sides: Array<'left' | 'right'> +} + +function fmtWindow(w: { start: string, end: string }): string { + const f = (hhmm: string) => { + const [h, m] = hhmm.split(':').map(Number) + const hh = h % 12 === 0 ? 12 : h % 12 + return `${hh}${m ? `:${String(m).padStart(2, '0')}` : ''} ${h < 12 ? 'AM' : 'PM'}` + } + return `${f(w.start)}–${f(w.end)}` +} + +/** The reason column: why the rule did (or didn't) change the bed. */ +export function reasonText(e: EntryLike, rule: { conditions: Condition } | undefined, missing: string[], fmt: (f: number) => string): string { + const w = rule ? conditionTimeWindow(rule.conditions) : null + switch (e.code) { + case 'signal-unavailable': return `${missing[0] ?? 'signal'} unavailable` + case 'outside-window': return w ? `outside ${fmtWindow(w)}` : 'outside its window' + case 'condition-false': return 'condition not met' + case 'cooldown': return 'cooling down' + case 'manual-hold': return 'manual hold' + case 'anti-thrash': return 'below anti-thrash' + case 'side-off': return 'side is off' + case 'superseded': return 'another source owns the side' + case 'runaway': return 'fired too often Β· rule disabled' + case 'eval-error': return 'evaluation failed' + case 'action-error': return 'command failed' + case 'set-temperature': return e.temp != null ? `set ${fmt(e.temp)}` : 'set temperature' + case 'notify': return 'sent a notification' + case 'power-on': return 'turned power on' + case 'power-off': return 'turned power off' + default: return 'no reason recorded' + } +} + +/** `6:31 PM`, or `11:00 PM β†’ 6 AM` for a collapsed range (`β†’ now` if still running). */ +export function entryTime(start: number, end: number, now: number, count: number): string { + if (count <= 1) return clock(start) + const endLabel = now - end < 3 * MINUTE ? 'now' : clock(end).replace(':00 ', ' ') + return `${clock(start)} β†’ ${endLabel}` +} diff --git a/src/components/Autopilot/autopilotViews.ts b/src/components/Autopilot/autopilotViews.ts new file mode 100644 index 000000000..35d1f74ad --- /dev/null +++ b/src/components/Autopilot/autopilotViews.ts @@ -0,0 +1,11 @@ +export type AutopilotView = 'automations' | 'diagnostics' + +export const AUTOPILOT_VIEWS: ReadonlyArray<{ id: AutopilotView, label: string }> = [ + { id: 'automations', label: 'Automations' }, + { id: 'diagnostics', label: 'Diagnostics' }, +] + +/** Resolve `?view=` β€” anything but Diagnostics lands on Automations. */ +export function resolveAutopilotView(raw: string | null): AutopilotView { + return raw === 'diagnostics' ? 'diagnostics' : 'automations' +} diff --git a/src/components/Autopilot/builderModel.ts b/src/components/Autopilot/builderModel.ts index f60c03c78..80af07de0 100644 --- a/src/components/Autopilot/builderModel.ts +++ b/src/components/Autopilot/builderModel.ts @@ -499,3 +499,91 @@ export function buildSentence(r: BuilderRule): SentenceChunk[] { out.push({ text: '.' }) return out } + +// --------------------------------------------------------------------------- +// Live signals + templates +// --------------------------------------------------------------------------- + +/** + * Signals the engine reads live today (ADR 0023): per-side temperature/level + * and water.low. Biometric, ambient and enum signals are backtest only, so a + * rule reading them can never fire on the bed yet. + */ +export const LIVE_SIGNALS: ReadonlySet = new Set([ + '{side}.currentTemperature', + '{side}.targetTemperature', + '{side}.currentLevel', + 'water.low', +]) + +/** Signal ids (templated) a rule's WHEN/IF read, in first-seen order. */ +export function ruleSignals(r: BuilderRule): string[] { + const out: string[] = [] + const add = (s: string) => { + if (!out.includes(s)) out.push(s) + } + if (r.when.type !== 'time') add(r.when.signal) + for (const c of r.ifs) if (c.type === 'cond') add(c.signal) + return out +} + +/** Lower-cased labels of the signals a rule reads that have no live source. */ +export function missingLiveSignals(r: BuilderRule): string[] { + return ruleSignals(r).filter(s => !LIVE_SIGNALS.has(s)).map(s => sigLabel(s).toLowerCase()) +} + +export type TemplateId = 'hold-room' | 'restless' | 'water-low' + +export interface RuleTemplate { + id: TemplateId + title: string + description: string + rule: () => BuilderRule +} + +export const TEMPLATES: readonly RuleTemplate[] = [ + { + id: 'hold-room', + title: 'Hold room +3Β°F', + description: 'Between 11 PM and 6 AM, keep the bed 3Β°F above room temperature.', + rule: () => ({ + ...blankRule(), + name: 'Hold room +3Β°F', + when: { type: 'time', between: ['23:00', '06:00'] }, + ifs: [], + then: [{ action: 'setTemperature', expr: 'ambient + 3', clamp: [60, 85] }], + cooldown: 0, + }), + }, + { + id: 'restless', + title: 'Cool when restless', + description: 'If movement stays high for 10 minutes, lower the bed 2Β°F for 20 minutes.', + rule: () => ({ + ...blankRule(), + name: 'Cool when restless', + when: { type: 'agg', agg: 'avg', signal: '{side}.movement', window: 10, op: '>', value: 200 }, + ifs: [], + then: [{ action: 'setTemperature', delta: -2, revert: 20, clamp: [...DEFAULT_CLAMP] }], + cooldown: 30, + }), + }, + { + id: 'water-low', + title: 'Tell me when water is low', + description: 'Send a notification when the water tank reads low.', + rule: () => ({ + ...blankRule(), + name: 'Tell me when water is low', + when: { type: 'change', signal: 'water.low' }, + ifs: [{ type: 'cond', signal: 'water.low', op: '==', value: 1 }], + then: [{ action: 'notify', message: 'The water tank is low.' }], + cooldown: 0, + }), + }, +] + +/** A template's prefilled rule, or null for an unknown id. */ +export function templateRule(id: string | null | undefined): BuilderRule | null { + return TEMPLATES.find(t => t.id === id)?.rule() ?? null +} diff --git a/src/components/Autopilot/evaluationStrip.ts b/src/components/Autopilot/evaluationStrip.ts new file mode 100644 index 000000000..501408772 --- /dev/null +++ b/src/components/Autopilot/evaluationStrip.ts @@ -0,0 +1,195 @@ +/** + * Evaluation strip + grouped run log for Autopilot Diagnostics β€” pure + * derivations over the compact run rows `automations.diagnostics` returns. + * + * The engine writes one row per triggered evaluation (skipped / dry_run / + * fired / clamped / error; a cooldown skip is `skipped` + reason "cooldown"). + * "Missing" is inferred, not logged: only a `tick` trigger has an expected + * cadence, so only it can have minutes where an evaluation should have landed + * and didn't. Rows record no condition values, so groups carry reasons, not + * value ranges. + */ + +import type { Condition, Expr, Trigger } from '@/src/automation/types' + +export type RunOutcome = 'fired' | 'skipped' | 'clamped' | 'dry_run' | 'error' + +export interface EvalRun { + t: Date | string + outcome: RunOutcome + reason: string | null + sent: boolean +} + +export type TickKind = 'skipped' | 'would' | 'fired' | 'cooldown' | 'error' | 'missing' | 'none' + +/** Paint priority when several evaluations share a minute. */ +const RANK: Record = { none: 0, missing: 1, skipped: 2, cooldown: 3, error: 4, would: 5, fired: 6 } + +const MIN = 60_000 +/** Slack before a late tick counts as missing (setInterval drift, slow ticks). */ +const GRACE_MS = 45_000 + +export function toMs(t: Date | string): number { + return (t instanceof Date ? t : new Date(t)).getTime() +} + +export function runKind(r: Pick): Exclude { + if (r.outcome === 'dry_run') return 'would' + if (r.outcome === 'fired' || r.outcome === 'clamped') return 'fired' + if (r.outcome === 'error') return 'error' + return r.reason === 'cooldown' ? 'cooldown' : 'skipped' +} + +/** + * Start times (ms) of expected-but-absent `tick` evaluations in [fromMs, nowMs]. + * Gaps are measured between consecutive recorded evaluations (and from the + * last one to now); nothing before the first recorded row is flagged, since + * the rule may not have existed or been enabled yet. + */ +export function missingTicks(runs: EvalRun[], trigger: Trigger, fromMs: number, nowMs: number): number[] { + if (trigger.kind !== 'tick' || runs.length === 0) return [] + const step = Math.max(1, trigger.everyMin) * MIN + const times = runs.map(r => toMs(r.t)).sort((a, b) => a - b) + times.push(nowMs) + const out: number[] = [] + for (let i = 0; i < times.length - 1; i++) { + const a = times[i] + const b = times[i + 1] + if (b - a <= step + GRACE_MS) continue + for (let t = a + step; t < b - GRACE_MS + 1; t += step) { + if (t >= fromMs && t <= nowMs) out.push(t) + } + } + return out +} + +export interface Strip { + startMs: number + endMs: number + ticks: TickKind[] + /** Contiguous cooldown spans as [firstIndex, lastIndex] (inclusive). */ + cooldowns: Array<[number, number]> +} + +/** One slot per minute across the `hours` before `nowMs`; `missing` only when `expectMissing`. */ +export function buildStrip(runs: EvalRun[], trigger: Trigger, nowMs: number, hours: number, expectMissing: boolean): Strip { + const slots = hours * 60 + const endMs = Math.floor(nowMs / MIN) * MIN + MIN + const startMs = endMs - slots * MIN + const ticks: TickKind[] = Array.from({ length: slots }, () => 'none') + const paint = (t: number, k: TickKind) => { + const i = Math.floor((t - startMs) / MIN) + if (i >= 0 && i < slots && RANK[k] > RANK[ticks[i]]) ticks[i] = k + } + for (const r of runs) paint(toMs(r.t), runKind(r)) + if (expectMissing) for (const t of missingTicks(runs, trigger, startMs, nowMs)) paint(t, 'missing') + + // A cooldown span bridges the idle minutes between its own evaluations. + const gap = trigger.kind === 'tick' ? Math.max(1, trigger.everyMin) : 1 + const cooldowns: Array<[number, number]> = [] + let open: [number, number] | null = null + for (let i = 0; i < slots; i++) { + if (ticks[i] === 'cooldown') { + if (open && i - open[1] <= gap) open[1] = i + else { + if (open) cooldowns.push(open) + open = [i, i] + } + } + else if (ticks[i] !== 'none' && open) { + cooldowns.push(open) + open = null + } + } + if (open) cooldowns.push(open) + return { startMs, endMs, ticks, cooldowns } +} + +export type LogKind = Exclude + +export interface LogGroup { + kind: LogKind + reason: string | null + count: number + /** Oldest and newest member times (ms). */ + firstMs: number + lastMs: number + /** Live actions sent across the group. */ + sent: number +} + +/** Today's evaluations plus inferred missing ticks, newest first, runs of identical results collapsed. */ +export function groupLog(runs: EvalRun[], missing: number[]): LogGroup[] { + const events = [ + ...runs.map(r => ({ t: toMs(r.t), kind: runKind(r) as LogKind, reason: r.reason, sent: r.sent })), + ...missing.map(t => ({ t, kind: 'missing' as LogKind, reason: null, sent: false })), + ].sort((a, b) => b.t - a.t) + const out: LogGroup[] = [] + for (const e of events) { + const g = out.at(-1) + if (g && g.kind === e.kind && g.reason === e.reason) { + g.count++ + g.firstMs = e.t + if (e.sent) g.sent++ + } + else { + out.push({ kind: e.kind, reason: e.reason, count: 1, firstMs: e.t, lastMs: e.t, sent: e.sent ? 1 : 0 }) + } + } + return out +} + +/** Filter chip buckets; cooldown and error count toward Skipped/All as the engine logs them. */ +export type LogFilter = 'all' | 'would' | 'fired' | 'skipped' | 'missing' + +export function filterOf(kind: LogKind): Exclude | null { + if (kind === 'would' || kind === 'fired' || kind === 'missing') return kind + if (kind === 'skipped' || kind === 'cooldown') return 'skipped' + return null +} + +/** Evaluation counts per filter chip (individual evaluations, not groups). */ +export function filterCounts(groups: LogGroup[]): Record { + const out: Record = { all: 0, would: 0, fired: 0, skipped: 0, missing: 0 } + for (const g of groups) { + out.all += g.count + const f = filterOf(g.kind) + if (f) out[f] += g.count + } + return out +} + +/** "every minute", "every 5 min", "when ambient.temperature changes", "daily at 06:30". */ +export function cadenceText(trigger: Trigger): string { + if (trigger.kind === 'tick') return trigger.everyMin <= 1 ? 'every minute' : `every ${trigger.everyMin} min` + if (trigger.kind === 'signalChange') return `on ${trigger.signal} change` + return `daily at ${trigger.at}` +} + +/** Condition verdict implied by the latest evaluation row. */ +export function lastVerdict(run: EvalRun | undefined): 'true' | 'false' | 'unknown' | 'error' | null { + if (!run) return null + if (run.outcome === 'error') return 'error' + if (run.outcome === 'skipped' && run.reason === 'condition-false') return 'false' + if (run.outcome === 'skipped' && run.reason === 'condition-unknown') return 'unknown' + return 'true' +} + +export interface Threshold { signal: string, op: string, value: number, window?: { fn: string, lastMin: number } } + +/** Compares of a signal/window against a literal β€” the thresholds a condition checks. */ +export function thresholds(c: Condition): Threshold[] { + const out: Threshold[] = [] + const walk = (x: Condition): void => { + if (x.kind === 'and' || x.kind === 'or') x.conditions.forEach(walk) + else if (x.kind === 'not') walk(x.condition) + else if (x.kind === 'compare' && x.right.kind === 'literal') { + const l: Expr = x.left + if (l.kind === 'signal') out.push({ signal: l.signal, op: x.op, value: x.right.value }) + else if (l.kind === 'window') out.push({ signal: l.signal, op: x.op, value: x.right.value, window: { fn: l.fn, lastMin: l.lastMin } }) + } + } + walk(c) + return out +} diff --git a/src/components/Autopilot/primitives.tsx b/src/components/Autopilot/primitives.tsx index 1528a3a40..90667f29e 100644 --- a/src/components/Autopilot/primitives.tsx +++ b/src/components/Autopilot/primitives.tsx @@ -1,20 +1,28 @@ /** - * Autopilot console primitives β€” a small, self-contained set ported from the - * design bundle. Kept local (not the app's src/ui/* primitives) because this is - * a desktop console surface with its own visual language: a live-swappable - * accent via the `--accent` CSS var, hairline borders, near-black panels. + * Autopilot console primitives β€” thin adapters over the design-system + * primitives (`@/src/components/ds`) that keep the console's existing call + * APIs. Everything is token-driven so light and dark themes both work. */ 'use client' import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' +import { Button as DsButton, SegmentedControl, Toggle as DsToggle } from '@/src/components/ds' import { Icon, type IconName } from './icons' export function Card({ className = '', children, style }: { className?: string, children: ReactNode, style?: CSSProperties }) { - return
{children}
+ return
{children}
} type ButtonVariant = 'default' | 'ghost' | 'outline' | 'accent' | 'danger' type ButtonSize = 'sm' | 'md' | 'lg' +const DS_VARIANT = { + default: 'secondary', + ghost: 'ghost', + outline: 'secondary', + accent: 'primary', + danger: 'danger', +} as const export function Button({ variant = 'default', size = 'md', className = '', children, onClick, disabled, }: { @@ -25,81 +33,57 @@ export function Button({ onClick?: () => void disabled?: boolean }) { - const sizes: Record = { - sm: 'h-7 px-2.5 text-[12px] gap-1', - md: 'h-9 px-3.5 text-[13px] gap-1.5', - lg: 'h-10 px-4 text-[14px] gap-2', - } - const variants: Record = { - default: 'bg-zinc-800/80 hover:bg-zinc-700/80 text-zinc-100 border border-zinc-700/60', - ghost: 'bg-transparent hover:bg-zinc-800/60 text-zinc-300', - outline: 'bg-transparent hover:bg-zinc-800/40 text-zinc-200 border border-zinc-700/70', - accent: 'text-white border border-transparent', - danger: 'bg-transparent hover:bg-red-500/10 text-red-400 border border-red-500/30', - } return ( - + ) } type BadgeTone = 'zinc' | 'green' | 'amber' | 'red' | 'accent' +const BADGE_TONES: Record = { + zinc: 'border-line-2 text-fg-2', + green: 'border-ok-line text-ok', + amber: 'border-warn-line text-warn', + red: 'border-danger-line text-danger', + accent: 'text-cool', +} export function Badge({ tone = 'zinc', className = '', children, dot = false }: { tone?: BadgeTone, className?: string, children: ReactNode, dot?: boolean }) { - const tones: Record = { - zinc: 'bg-zinc-800/70 text-zinc-300 border-zinc-700/60', - green: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/25', - amber: 'bg-amber-500/10 text-amber-400 border-amber-500/25', - red: 'bg-red-500/10 text-red-400 border-red-500/25', - accent: 'border-transparent', - } const style: CSSProperties | undefined = tone === 'accent' - ? { background: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', borderColor: 'color-mix(in srgb, var(--accent) 30%, transparent)' } + ? { borderColor: 'color-mix(in srgb, var(--accent-cool) 35%, transparent)' } : undefined return ( - - {dot && } + + {dot && } {children} ) } export function StatusBadge({ mode }: { mode: 'active' | 'dryrun' | 'paused' }) { - if (mode === 'active') return Active - if (mode === 'dryrun') return Dry-run - return Paused + if (mode === 'active') return ACTIVE + if (mode === 'dryrun') return DRY-RUN + return PAUSED } export function SideBadge({ side }: { side: 'left' | 'right' | 'both' | null }) { const map = { left: 'L', right: 'R', both: 'L+R' } as const return ( - - + + {map[side ?? 'both']} ) } -export function Toggle({ checked, onChange, size = 'md', tone = 'accent' }: { checked: boolean, onChange: (v: boolean) => void, size?: 'sm' | 'md', tone?: 'accent' | 'red' }) { - const dims = size === 'sm' ? { w: 32, h: 18, k: 12 } : { w: 40, h: 22, k: 16 } - const onBg = tone === 'red' ? '#ef4444' : 'var(--accent)' - return ( - - ) +export function Toggle({ checked, onChange, size = 'md', label }: { checked: boolean, onChange: (v: boolean) => void, size?: 'sm' | 'md', tone?: 'accent' | 'red', label?: string }) { + return } export interface Option { value: string, label: string, icon?: IconName, hint?: string } @@ -109,27 +93,7 @@ function norm(o: Opt): Option { } export function Segmented({ value, options, onChange, size = 'md' }: { value: T, options: readonly (T | { value: T, label: string })[], onChange: (v: T) => void, size?: 'sm' | 'md' }) { - const pad = size === 'sm' ? 'px-2.5 py-1 text-[12px]' : 'px-3 py-1.5 text-[13px]' - return ( -
- {options.map((o) => { - const val = (typeof o === 'string' ? o : o.value) as T - const lab = typeof o === 'string' ? o : o.label - const on = val === value - return ( - - ) - })} -
- ) + return } export function Select({ value, options, onChange, placeholder = 'Select…', className = '', chip = false }: { value: string, options: Opt[], onChange: (v: string) => void, placeholder?: string, className?: string, chip?: boolean }) { @@ -145,21 +109,20 @@ export function Select({ value, options, onChange, placeholder = 'Select…', cl const opts = options.map(norm) const cur = opts.find(o => o.value === value) const base = chip - ? 'inline-flex items-center gap-1 rounded-md border border-zinc-700/70 bg-zinc-800/60 px-2 py-1 text-[13px] text-zinc-100 hover:border-zinc-600' - : 'inline-flex w-full items-center justify-between gap-2 rounded-lg border border-zinc-700/70 bg-zinc-900/70 px-3 py-2 text-[13px] text-zinc-100 hover:border-zinc-600' + ? 'inline-flex items-center gap-1 rounded-ctl border border-line-2 bg-transparent px-2 py-1 text-[13px] text-cool hover:bg-active' + : 'inline-flex w-full items-center justify-between gap-2 rounded-ctl border border-line-2 bg-field px-3 py-2 text-[13px] text-fg hover:bg-active' return ( -
+
{open && ( -
+
{opts.map((o) => { const I = o.icon ? Icon[o.icon] : null return ( @@ -170,12 +133,12 @@ export function Select({ value, options, onChange, placeholder = 'Select…', cl onChange(o.value) setOpen(false) }} - className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13px] hover:bg-zinc-800 ${o.value === value ? 'text-white' : 'text-zinc-300'}`} + className={cn('flex w-full items-center gap-2 rounded-thumb px-2.5 py-1.5 text-left text-[13px] hover:bg-active', o.value === value ? 'text-fg' : 'text-fg-2')} > - {I ? : null} + {I ? : null} {o.label} - {o.hint && {o.hint}} - {o.value === value && } + {o.hint && {o.hint}} + {o.value === value && } ) })} @@ -205,8 +168,8 @@ export function NumberField({ value, onChange, step = 1, suffix = '', width = 84 return ( - - + + setDraft(String(value))} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }} - className="min-w-0 flex-1 bg-transparent px-1 text-center mono text-[13px] text-zinc-100 tabular-nums focus:outline-none" + className="min-w-0 flex-1 bg-transparent px-1 py-1 text-center font-mono text-[13px] tabular-nums text-fg focus:outline-none" /> - + - {suffix && {suffix}} + {suffix && {suffix}} ) } @@ -230,14 +193,14 @@ export function NumberField({ value, onChange, step = 1, suffix = '', width = 84 export function SectionLabel({ kicker, color, icon, desc, right }: { kicker: string, color: string, icon: IconName, desc?: string, right?: ReactNode }) { const I = Icon[icon] return ( -
-
- +
+
+ {I && } -
-
{kicker}
- {desc &&
{desc}
} +
+
{kicker}
+ {desc &&
{desc}
}
{right} diff --git a/src/components/Autopilot/tests/AutopilotConsole.test.tsx b/src/components/Autopilot/tests/AutopilotConsole.test.tsx new file mode 100644 index 000000000..9546dfced --- /dev/null +++ b/src/components/Autopilot/tests/AutopilotConsole.test.tsx @@ -0,0 +1,104 @@ +import { requiredTemplate } from './builderFixtures' +import type * as CurveChartModule from '@/src/components/Schedule/CurveChart' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { AutopilotConsole } from '../AutopilotConsole' +import { toAST } from '../builderModel' + +const mock = vi.hoisted(() => ({ + path: '/de/autopilot', query: '', push: vi.fn(), replace: vi.fn(), rows: [] as unknown[], + enabled: vi.fn(), dryRun: vi.fn(), kill: vi.fn(), invalidate: vi.fn(), + activity: vi.fn(), globalEnabled: true, diag: undefined as unknown, +})) +vi.mock('next/navigation', () => ({ usePathname: () => mock.path, useRouter: () => ({ push: mock.push, replace: mock.replace }), useSearchParams: () => new URLSearchParams(mock.query) })) +vi.mock('@/src/hooks/useTemperatureUnit', () => ({ useTemperatureUnit: () => ({ unit: 'F' }) })) +vi.mock('@/src/components/Schedule/CurveChart', async importOriginal => ({ ...await importOriginal(), useNowMinute: () => 29800000 })) +vi.mock('@/src/hooks/useSideNames', () => ({ useSideNames: () => ({ sideName: (s: string) => s }) })) +vi.mock('@/src/utils/trpc', () => { + const mutation = (fn: typeof mock.enabled) => ({ useMutation: ({ onSuccess }: { onSuccess: () => void }) => ({ + mutate: (input: unknown) => { + fn(input) + onSuccess() + }, + mutateAsync: async (input: unknown) => { + await fn(input) + onSuccess() + }, + }) }) + return { trpc: { + useUtils: () => ({ automations: Object.fromEntries(['list', 'status', 'diagnostics', 'tonight', 'getKillSwitch'].map(k => [k, { invalidate: mock.invalidate }])) }), + schedules: { getAll: { useQuery: () => ({}) } }, + automations: { + list: { useQuery: () => ({ data: mock.rows }) }, status: { useQuery: () => ({ data: { globalEnabled: mock.globalEnabled } }) }, + diagnostics: { useQuery: () => ({ data: mock.diag }) }, tonight: { useQuery: () => ({}) }, + backtestSummaries: { useQuery: () => ({ data: [{ id: 1, nights: 5, wouldFire: 0, peak: null, threshold: null }] }) }, + activity: { useQuery: (input: unknown, opts: { placeholderData: (p: unknown) => unknown }) => { + mock.activity(input) + const data = { now: 0, nights: [{ start: 0, entries: [{ ruleId: 1, ruleName: 'Water', outcome: 'fired', code: 'set-temperature', temp: 78, start: 0, end: 0, count: 1, sides: [] }] }], holds: [] } + return { data: opts.placeholderData(data) } + } }, + setEnabled: mutation(mock.enabled), setDryRun: mutation(mock.dryRun), setKillSwitch: mutation(mock.kill), + }, + } } +}) + +beforeEach(() => { + vi.clearAllMocks() + mock.query = '' + mock.globalEnabled = true + mock.rows = [{ ...toAST(requiredTemplate('water-low')), id: 1, name: 'Water', enabled: false, dryRun: true }] + mock.diag = undefined +}) + +it('opens rules and new templates, updates modes in order and invalidates data', async () => { + const { rerender } = render() + expect(screen.getByTestId('automations-status').textContent).toBe('1 rule') + fireEvent.click(screen.getByTestId('rule-1')) + expect(mock.push).toHaveBeenLastCalledWith('/de/autopilot/1') + for (const button of screen.getAllByRole('button', { name: 'New automation' })) fireEvent.click(button) + expect(mock.push).toHaveBeenLastCalledWith('/de/autopilot/new') + fireEvent.click(screen.getByRole('radio', { name: 'Active' })) + await waitFor(() => expect(mock.enabled).toHaveBeenCalledWith({ id: 1, enabled: true })) + expect(mock.dryRun).toHaveBeenCalledWith({ id: 1, dryRun: false }) + expect(mock.dryRun.mock.invocationCallOrder[0]).toBeLessThan(mock.enabled.mock.invocationCallOrder[0]) + expect(mock.invalidate).toHaveBeenCalled() + mock.rows = [{ ...toAST(requiredTemplate('water-low')), id: 1, name: 'Water', enabled: true, dryRun: false }] + rerender() + fireEvent.click(screen.getByRole('radio', { name: 'Off' })) + expect(mock.enabled).toHaveBeenLastCalledWith({ id: 1, enabled: false }) + fireEvent.click(screen.getByRole('radio', { name: 'Dry-run' })) + await waitFor(() => expect(mock.dryRun).toHaveBeenLastCalledWith({ id: 1, dryRun: true })) + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(mock.activity.mock.lastCall?.[0].nightStarts).toHaveLength(4) + mock.rows = [] + rerender() + fireEvent.click(screen.getByRole('button', { name: /Tell me when water is low/ })) + expect(mock.push).toHaveBeenLastCalledWith('/de/autopilot/new?template=water-low') +}) + +it('preserves unrelated query parameters when switching views and toggles the kill switch', () => { + mock.query = 'other=keep' + const { rerender } = render() + fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' })) + expect(mock.replace).toHaveBeenLastCalledWith('/de/autopilot?other=keep&view=diagnostics', { scroll: false }) + fireEvent.click(screen.getByRole('switch', { name: 'Autopilot enabled' })) + expect(mock.kill).toHaveBeenCalledWith({ enabled: false }) + mock.globalEnabled = false + mock.query = 'view=diagnostics' + rerender() + expect(screen.getByRole('status').textContent).toBe('Autopilot is off. No rules are evaluated.') + fireEvent.click(screen.getByRole('tab', { name: /Automations/ })) + expect(mock.replace).toHaveBeenLastCalledWith('/de/autopilot', { scroll: false }) +}) + +it('routes diagnostic mode changes and the diagnostic kill switch through mutations', async () => { + mock.query = 'view=diagnostics' + mock.diag = { now: new Date(), startOfDay: new Date(), globalEnabled: false, rules: [ + { ...mock.rows[0] as object, runs: [], signals: {} }, + ] } + render() + fireEvent.click(screen.getByRole('tab', { name: 'Live' })) + await waitFor(() => expect(mock.enabled).toHaveBeenCalledWith({ id: 1, enabled: true })) + fireEvent.click(screen.getAllByRole('switch', { name: 'Autopilot enabled' }).slice(-1)[0]) + expect(mock.kill).toHaveBeenCalledWith({ enabled: true }) +}) diff --git a/src/components/Autopilot/tests/AutopilotStatusChip.test.tsx b/src/components/Autopilot/tests/AutopilotStatusChip.test.tsx new file mode 100644 index 000000000..eff026767 --- /dev/null +++ b/src/components/Autopilot/tests/AutopilotStatusChip.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ status: undefined as unknown })) + +vi.mock('next/navigation', () => ({ usePathname: () => '/en' })) +vi.mock('@/src/utils/trpc', () => ({ + trpc: { automations: { status: { useQuery: () => ({ data: m.status }) } } }, +})) + +import { AutopilotStatusChip } from '../AutopilotStatusChip' + +const rule = (enabled: boolean, dryRun = false) => ({ enabled, dryRun }) + +describe('AutopilotStatusChip', () => { + afterEach(cleanup) + + it('hides until a rule is enabled', () => { + m.status = undefined + const { container } = render() + expect(container.innerHTML).toBe('') + m.status = { globalEnabled: true, rules: [rule(false)] } + render() + expect(screen.queryByTestId('autopilot-chip')).toBeNull() + }) + + it('counts live rules and links to the console', () => { + m.status = { globalEnabled: true, rules: [rule(true), rule(true), rule(true, true), rule(false)] } + render() + expect(screen.getByText('AUTOPILOT Β· 2 ACTIVE')).toBeTruthy() + expect(screen.getByTestId('autopilot-chip').getAttribute('href')).toBe('/en/autopilot') + }) + + it('shows dry run when no rule is live, and halted under the kill switch', () => { + m.status = { globalEnabled: true, rules: [rule(true, true)] } + render() + expect(screen.getByText('AUTOPILOT Β· DRY RUN')).toBeTruthy() + cleanup() + m.status = { globalEnabled: false, rules: [rule(true)] } + render() + expect(screen.getByText('AUTOPILOT Β· HALTED')).toBeTruthy() + }) +}) diff --git a/src/components/Autopilot/tests/BacktestPanel.test.tsx b/src/components/Autopilot/tests/BacktestPanel.test.tsx new file mode 100644 index 000000000..4a5f2ee63 --- /dev/null +++ b/src/components/Autopilot/tests/BacktestPanel.test.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import { BacktestPanel } from '../BacktestPanel' +import type { BacktestResult } from '@/src/automation/backtest' +const result = { + mode: 'edge', clockMin: [1320, 1380, 0, 60, 120, 420], primaryAxis: { min: 0, max: 300 }, tempAxis: { min: 60, max: 85 }, + primary: { label: 'Movement', values: [10, null, 200, 250, 100, 50] }, avg: { label: 'Average', values: [10, 20, 200, 250, 100, 50] }, + setpoint: [80, null, 78, 76, 76, 80], suppressed: [1, 2, 4], fires: [0, 3], threshold: 150, + timeWindow: { startMin: 1380, endMin: 120 }, clamp: { min: 65, max: 80 }, + summary: { wouldFire: 2, suppressed: 3, netEffect: '-4Β°F', clampHits: 1, setpointRange: [65, 80] }, +} as BacktestResult +it('selects recorded nights and renders loading, message and insufficient data states', () => { + const props = { nights: [{ sleepRecordId: 1, label: 'Last night', date: 'Sep 28' }], nightId: 1, onNight: vi.fn() } + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: /Last night/ })) + expect(props.onNight).toHaveBeenCalledWith(1) + expect(screen.getByText('Replaying…')).toBeTruthy() + rerender() + expect(screen.getByText('No nights available')).toBeTruthy() + rerender() + expect(screen.getByText('Not enough data in this window to replay.')).toBeTruthy() +}) +it.each(['edge', 'policy'] as const)('renders %s traces with gaps, windows and clamp summaries', (mode) => { + const r = { ...result, mode, setpointRaw: [80, null, 85, 90, 85, 75] } + const { container, rerender } = render() + expect(screen.getByText(mode === 'edge' ? '-4Β°F' : 'Continuous')).toBeTruthy() + expect(container.querySelectorAll('path').length).toBeGreaterThan(2) + for (const path of container.querySelectorAll('path')) expect(path.getAttribute('d')).not.toContain('NaN') + rerender() + expect(screen.getByText('β€”')).toBeTruthy() +}) diff --git a/src/components/Autopilot/tests/CapZoneViz.test.tsx b/src/components/Autopilot/tests/CapZoneViz.test.tsx new file mode 100644 index 000000000..187508318 --- /dev/null +++ b/src/components/Autopilot/tests/CapZoneViz.test.tsx @@ -0,0 +1,36 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { CapZoneViz } from '../CapZoneViz' +import type { SensorFrame } from '@/src/hooks/useSensorStream' +const mock = vi.hoisted(() => ({ frame: (_f: SensorFrame) => { + void _f +}, live: undefined as unknown, frames: [] as unknown[], loading: true })) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: () => mock.live, useOnSensorFrame: (cb: typeof mock.frame) => { + mock.frame = cb +} })) +vi.mock('@/src/utils/trpc', () => ({ trpc: { automations: { capZoneReplay: { useQuery: (_input: unknown, opts: { placeholderData: (p: unknown) => unknown }) => ({ data: opts.placeholderData({ frames: mock.frames }), isLoading: mock.loading }) } } } })) +afterEach(() => vi.useRealTimers()) +it('shows live zone variance and replays, scrubs and pauses recorded frames', () => { + vi.useFakeTimers() + const { rerender, unmount } = render() + expect(screen.getByText('Waiting for live capacitive data…')).toBeTruthy() + mock.live = { ts: 1700000000 } + rerender() + act(() => mock.frame({ type: 'capSense', left: 0, right: 0 } as SensorFrame)) + act(() => mock.frame({ type: 'capSense2', left: [2, 2, 1, 1, 0, 0], right: [0, 0, 0, 0, 0, 0] } as SensorFrame)) + expect(screen.getAllByText('1.00').length).toBeGreaterThan(0) + fireEvent.click(screen.getByRole('tab', { name: 'Replay' })) + expect(screen.getByText('Loading replay…')).toBeTruthy() + mock.loading = false + rerender() + expect(screen.getByText('No spatial presence history for this night.')).toBeTruthy() + mock.frames = [{ tMs: 1, zones: [1, 0, 0], peakZone: 0 }, { tMs: 1000, zones: [0, 2, 0], peakZone: null }] + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Play replay' })) + act(() => vi.advanceTimersByTime(350)) + expect((screen.getByRole('slider') as HTMLInputElement).value).toBe('1') + fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } }) + expect(screen.getByRole('button', { name: 'Play replay' })).toBeTruthy() + unmount() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/components/Autopilot/tests/RuleEditor.test.tsx b/src/components/Autopilot/tests/RuleEditor.test.tsx new file mode 100644 index 000000000..252676b00 --- /dev/null +++ b/src/components/Autopilot/tests/RuleEditor.test.tsx @@ -0,0 +1,72 @@ +import { requiredTemplate } from './builderFixtures' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { RuleEditor } from '../RuleEditor' +const mock = vi.hoisted(() => ({ query: vi.fn() })) +vi.mock('@/src/utils/trpc', () => ({ trpc: { + automations: { + nights: { useQuery: () => ({ data: [{ sleepRecordId: 1, label: 'Last night', date: 'Sep 28' }, { sleepRecordId: 2, label: 'Previous', date: 'Sep 27' }] }) }, + backtest: { useQuery: (input: unknown, opts: { placeholderData: (p: unknown) => unknown }) => { + mock.query(input) + return { data: opts.placeholderData({ ok: false, message: 'No samples' }) } + } }, + backtestRange: { useQuery: (_input: unknown, opts: { placeholderData: (p: unknown) => unknown }) => ({ data: opts.placeholderData({ nights: 1, low: 1.25, peak: 300.2 }) }) }, + }, environment: { getLatestBedTemp: { useQuery: () => ({ data: { ambientTemp: 75 } }) } }, +} })) +afterEach(() => vi.useRealTimers()) +function choose(button: string, option: string) { + fireEvent.click(screen.getAllByRole('button', { name: button })[0]) + fireEvent.click(screen.getAllByRole('button', { name: option }).slice(-1)[0]) +} +it('edits triggers, conditions, expression and metadata, then saves only on request', () => { + vi.useFakeTimers() + const onSave = vi.fn(), onClose = vi.fn() + render() + expect(screen.getByTestId('threshold-range').textContent).toBe('last 1 night: 1.3–300') + fireEvent.change(screen.getByLabelText('Automation name'), { target: { value: 'Evening rule' } }) + fireEvent.click(screen.getByRole('tab', { name: 'R' })) + fireEvent.click(screen.getByRole('tab', { name: 'Active' })) + fireEvent.click(screen.getByRole('button', { name: /Previous/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Time of day' })) + choose('11pm', '10pm') + choose('6am', '7am') + fireEvent.click(screen.getByRole('button', { name: 'Time window' })) + fireEvent.click(screen.getByRole('button', { name: 'Condition' })) + choose('Current temp (level)', 'Ambient humidity') + fireEvent.click(screen.getAllByRole('button', { name: 'Remove condition' })[0]) + fireEvent.click(screen.getByRole('tab', { name: 'Expression' })) + fireEvent.change(screen.getByLabelText('Temperature expression'), { target: { value: 'ambient - 2' } }) + expect(screen.getByText('73Β°F')).toBeTruthy() + act(() => vi.advanceTimersByTime(350)) + expect(mock.query).toHaveBeenLastCalledWith(expect.objectContaining({ side: 'right', sleepRecordId: 2 })) + expect(onSave).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Evening rule', side: 'right', mode: 'active', when: { type: 'time', between: ['22:00', '07:00'] }, then: [expect.objectContaining({ expr: 'ambient - 2' })] })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() +}, 15_000) + +it('switches action types and validates expression previews and numeric drafts', () => { + const onSave = vi.fn() + render() + fireEvent.click(screen.getByRole('tab', { name: 'Threshold' })) + fireEvent.click(screen.getByRole('tab', { name: 'On change' })) + choose('Water low', 'Ambient light') + fireEvent.click(screen.getByRole('tab', { name: 'Aggregate' })) + choose('avg', 'max') + fireEvent.click(screen.getByRole('tab', { name: 'Expression' })) + for (const expr of ['invalid', 'target + 2', 'ambient', 'ambient + 3']) fireEvent.change(screen.getByLabelText('Temperature expression'), { target: { value: expr } }) + fireEvent.click(screen.getByRole('tab', { name: 'By amount' })) + fireEvent.click(screen.getByRole('switch', { name: 'Revert after' })) + const input = screen.getAllByRole('textbox').filter(el => el.getAttribute('inputmode') === 'numeric')[0] + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + fireEvent.click(screen.getAllByRole('button', { name: 'Increase' })[0]) + fireEvent.click(screen.getAllByRole('button', { name: 'Decrease' })[0]) + choose('Set temperature', 'Set power') + choose('Set power', 'Notify') + const message = screen.getByLabelText('Notification message') + fireEvent.change(message, { target: { value: 'Bed ready' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ then: [expect.objectContaining({ action: 'notify', message: 'Bed ready' })] })) +}, 15_000) diff --git a/src/components/Autopilot/tests/RulePage.test.tsx b/src/components/Autopilot/tests/RulePage.test.tsx new file mode 100644 index 000000000..56b7734a9 --- /dev/null +++ b/src/components/Autopilot/tests/RulePage.test.tsx @@ -0,0 +1,103 @@ +import { requiredTemplate } from './builderFixtures' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RulePage } from '../RulePage' +import { toAST } from '../builderModel' + +const m = vi.hoisted(() => ({ + push: vi.fn(), + create: vi.fn(), + update: vi.fn(), + get: { data: undefined as unknown, isLoading: false, error: null as null | { message: string } }, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: m.push }), + usePathname: () => '/en/autopilot/7', +})) +vi.mock('@/src/utils/trpc', () => { + const mutation = (fn: typeof m.create) => ({ + useMutation: (opts: { onSuccess: () => void }) => ({ + mutate: (v: unknown) => { + fn(v) + opts.onSuccess() + }, + isPending: false, + error: null, + }), + }) + return { + trpc: { + useUtils: () => ({ automations: { list: { invalidate: vi.fn() }, status: { invalidate: vi.fn() } } }), + automations: { + get: { useQuery: () => m.get }, + create: mutation(m.create), + update: mutation(m.update), + }, + }, + } +}) +vi.mock('../RuleEditor', () => ({ + RuleEditor: ({ automation, onSave, onClose }: { automation: { id?: number, name: string }, onSave: (r: unknown) => void, onClose: () => void }) => ( +
+ {automation.name} + + +
+ ), +})) + +beforeEach(() => { + m.push.mockReset() + m.create.mockReset() + m.update.mockReset() + m.get = { data: undefined, isLoading: false, error: null } +}) + +describe('RulePage', () => { + it('creates a new rule and returns to the Automations list', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(m.create).toHaveBeenCalledOnce() + expect(m.update).not.toHaveBeenCalled() + expect(m.push).toHaveBeenCalledWith('/en/autopilot') + }) + + it('Cancel returns to the Automations list without saving', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(m.push).toHaveBeenCalledWith('/en/autopilot') + expect(m.create).not.toHaveBeenCalled() + }) + + it('shows an error for a non-numeric id', () => { + render() + expect(screen.getByText('No automation "abc".')).toBeTruthy() + }) + + it('shows the server error when the rule is missing', () => { + m.get = { data: undefined, isLoading: false, error: { message: 'Automation 7 not found' } } + render() + expect(screen.getByText('Automation 7 not found')).toBeTruthy() + }) +}) + +it('loads an existing rule, updates it and returns to the list', () => { + m.get.isLoading = true + const { rerender } = render() + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull() + m.get.isLoading = false + m.get.data = { ...toAST(requiredTemplate('water-low')), id: 7 } + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(m.update).toHaveBeenCalledWith(expect.objectContaining({ id: 7 })) + expect(m.create).not.toHaveBeenCalled() + expect(m.push).toHaveBeenCalledWith('/en/autopilot') +}) + +it('prefills a new rule from its template and handles absent server rows', () => { + const { rerender } = render() + expect(screen.getByTestId('rule-name').textContent).toBe(requiredTemplate('water-low').name) + rerender() + expect(screen.getByText('Automation 7 not found')).toBeTruthy() +}) diff --git a/src/components/Autopilot/tests/StatusPanel.test.tsx b/src/components/Autopilot/tests/StatusPanel.test.tsx new file mode 100644 index 000000000..70f173f99 --- /dev/null +++ b/src/components/Autopilot/tests/StatusPanel.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { StatusPanel, type DiagRule, type Diagnostics } from '../StatusPanel' + +vi.mock('next/navigation', () => ({ usePathname: () => '/en/autopilot' })) + +const now = new Date('2026-09-28T18:00:30') +const startOfDay = new Date('2026-09-28T00:00:00') +const at = (h: number, m: number) => new Date(2026, 8, 28, h, m, 5) + +const rule: DiagRule = { + id: 7, + name: 'Ambient demo', + enabled: true, + dryRun: true, + side: null, + priority: 0, + cooldownMin: 30, + trigger: { kind: 'tick', everyMin: 1 }, + conditions: { + kind: 'and', + conditions: [{ kind: 'compare', op: '>', left: { kind: 'signal', signal: 'ambient.temperature' }, right: { kind: 'literal', value: 75 } }], + }, + actions: [{ kind: 'notify', message: 'hot' }], + runs: [ + { t: at(16, 11), outcome: 'dry_run', reason: null, sent: false }, + { t: at(17, 58), outcome: 'skipped', reason: 'condition-false', sent: false }, + { t: at(17, 59), outcome: 'skipped', reason: 'condition-false', sent: false }, + { t: at(18, 0), outcome: 'skipped', reason: 'condition-false', sent: false }, + ], + signals: { 'ambient.temperature': 72.3 }, +} + +function data(r: DiagRule = rule): Diagnostics { + return { now, startOfDay, globalEnabled: true, rules: [r] } +} + +describe('StatusPanel diagnostics card', () => { + it('links to the rule page and switches mode through the segmented control', () => { + const onMode = vi.fn() + render() + expect(screen.getByRole('link', { name: 'Ambient demo' }).getAttribute('href')).toBe('/en/autopilot/7') + fireEvent.click(screen.getByRole('tab', { name: 'Live' })) + expect(onMode).toHaveBeenCalledWith(7, 'live') + }) + + it('separates last evaluated from fired and shows the live condition reading', () => { + render() + expect(screen.getByText('just now')).toBeTruthy() + expect(screen.getByText('false')).toBeTruthy() + expect(screen.getByText('ambient temp 72.3Β°F now, needs > 75Β°F')).toBeTruthy() + expect(screen.getByText(/Β· dry-run$/)).toBeTruthy() + expect(screen.getByText('0 live actions sent')).toBeTruthy() + }) + + it('groups repeats in the run log and filters by outcome', () => { + render() + expect(screen.getByText('17:58 – 18:00')).toBeTruthy() + expect(screen.getByText('Γ—3')).toBeTruthy() + // 16:12–17:57 has no rows while the rule ticks every minute β†’ missing. + expect(screen.getByText('MISSING')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /Would fire/ })) + expect(screen.queryByText('Γ—3')).toBeNull() + expect(screen.getByText('WOULD FIRE')).toBeTruthy() + }) + + it('does not flag missing minutes for a disabled rule', () => { + render() + expect(screen.queryByText('MISSING')).toBeNull() + }) +}) + +it('handles loading, empty and globally halted diagnostics', () => { + const onKill = vi.fn() + const { rerender } = render() + expect(screen.getByText('Loading status…')).toBeTruthy() + rerender() + expect(screen.getByText('No automations yet.')).toBeTruthy() + expect(screen.getByText('Autopilot halted')).toBeTruthy() + fireEvent.click(screen.getByRole('switch', { name: 'Autopilot enabled' })) + expect(onKill).toHaveBeenCalledWith(true) +}) + +it.each([5, 120, 2880])('shows older evaluations (%s minutes) and live, cooldown and error outcomes', (minutes) => { + const stamp = new Date(now.getTime() - minutes * 60000) + const r: DiagRule = { ...rule, dryRun: false, side: 'right', cooldownMin: null, conditions: { kind: 'and', conditions: [] }, runs: [ + { t: stamp, outcome: 'fired', reason: null, sent: true }, + { t: stamp, outcome: 'error', reason: 'action-error', sent: false }, + ] } + render() + expect(screen.getByText('no threshold')).toBeTruthy() + expect(screen.getAllByText(minutes === 5 ? '5 min ago' : minutes === 120 ? '2 h ago' : '2 d ago').length).toBeGreaterThan(0) +}) + +it('explains cooldown bands, grouped pairs and missing live signals', () => { + const r: DiagRule = { ...rule, dryRun: false, signals: {}, runs: [ + { t: at(17, 55), outcome: 'fired', reason: null, sent: false }, + { t: at(17, 56), outcome: 'skipped', reason: 'cooldown', sent: false }, + { t: at(17, 57), outcome: 'skipped', reason: 'cooldown', sent: false }, + { t: at(17, 58), outcome: 'error', reason: null, sent: false }, + { t: at(17, 59), outcome: 'fired', reason: null, sent: true }, + ] } + render() + expect(screen.getByText('17:56, 17:57')).toBeTruthy() + expect(screen.getByText('condition true, held by the 30 min cooldown')).toBeTruthy() + expect(screen.getByText('fired Β· no command needed')).toBeTruthy() + expect(screen.getByText(/ambient temp unavailable/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /Fired/ })) + expect(screen.queryByText('COOLDOWN')).toBeNull() +}) diff --git a/src/components/Autopilot/tests/automationCards.test.tsx b/src/components/Autopilot/tests/automationCards.test.tsx new file mode 100644 index 000000000..08e6d32d8 --- /dev/null +++ b/src/components/Autopilot/tests/automationCards.test.tsx @@ -0,0 +1,136 @@ +import { requiredTemplate } from './builderFixtures' +import type * as CurveChartModule from '@/src/components/Schedule/CurveChart' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { ActivityCard, type ActivityData } from '../ActivityCard' +import { AutomationsList, type ListItem } from '../AutomationsList' +import { TonightCard, type TimelineRule } from '../TonightCard' +import { TEMPLATES } from '../builderModel' +import type { SideTonight } from '../automationsLogic' + +const t = (day: number, hour: number) => new Date(2026, 8, day, hour).getTime() +const mock = vi.hoisted(() => ({ minute: null as number | null, schedule: undefined as unknown })) +vi.mock('@/src/components/Schedule/CurveChart', async importOriginal => ({ ...await importOriginal(), useNowMinute: () => mock.minute })) +vi.mock('@/src/hooks/useSideNames', () => ({ useSideNames: () => ({ sideName: (side: string) => side === 'left' ? 'Alex' : 'Sam' }) })) +vi.mock('@/src/utils/trpc', () => ({ trpc: { schedules: { getAll: { useQuery: () => ({ data: mock.schedule }) } } } })) +beforeEach(() => { + mock.minute = t(28, 23) / 60000 + mock.schedule = undefined +}) + +it('renders loading and empty automation lists and opens each starter template', () => { + const onTemplate = vi.fn() + const props = { items: [], loading: true, onMode: vi.fn(), onOpen: vi.fn(), onTemplate } + const { rerender } = render() + expect(screen.getByText('Loading automations…')).toBeTruthy() + rerender() + for (const template of TEMPLATES) { + fireEvent.click(screen.getByRole('button', { name: new RegExp(template.title.replace('+', '\\+')) })) + expect(onTemplate).toHaveBeenLastCalledWith(template.id) + } +}) + +it('opens rows by keyboard and changes modes without opening the editor', () => { + const builder = requiredTemplate('restless') + const item: ListItem = { id: 1, name: 'Restless', mode: 'dryrun', side: 'both', builder, backtest: { nights: 5, wouldFire: 2, peak: 250, threshold: 200 } } + const onOpen = vi.fn(), onMode = vi.fn() + render() + expect(screen.getByText('no live movement signal')).toBeTruthy() + expect(screen.getByTestId('backtest-1').textContent).toContain('2 would fire') + fireEvent.click(screen.getByRole('radio', { name: 'Dry-run' })) + expect(onMode).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('radio', { name: 'Active' })) + expect(onMode).toHaveBeenCalledWith(1, 'active') + fireEvent.keyDown(screen.getByRole('radio', { name: 'Off' }), { key: 'Enter' }) + expect(onOpen).not.toHaveBeenCalled() + const row = screen.getByTestId('rule-1') + fireEvent.keyDown(row, { key: 'Escape' }) + fireEvent.keyDown(row, { key: 'Enter' }) + fireEvent.keyDown(row, { key: ' ' }) + fireEvent.click(row) + expect(onOpen).toHaveBeenCalledTimes(3) + expect(onOpen).toHaveBeenLastCalledWith(item) +}) + +it('groups activity by night, inserts holds, filters outcomes and requests more nights', () => { + const onMore = vi.fn() + const props = { rules: new Map(), fmt: (f: number) => `${f}Β°`, onMore, loadingMore: false } + const { rerender } = render() + expect(screen.getByText('Loading activity…')).toBeTruthy() + const data: ActivityData = { + now: t(28, 23), + nights: [{ start: t(28, 18), entries: [ + { ruleId: 1, ruleName: 'Cool', outcome: 'fired', code: 'set-temperature', temp: 75, sides: ['left'], start: t(28, 21), end: t(28, 21), count: 1 }, + { ruleId: 2, ruleName: 'Quiet', outcome: 'skipped', code: 'cooldown', temp: null, sides: [], start: t(28, 20), end: t(28, 22), count: 3 }, + { ruleId: 3, ruleName: 'Future', outcome: 'future-outcome', code: 'unknown', temp: null, sides: [], start: t(28, 19), end: t(28, 19), count: 1 }, + ] }, { start: t(27, 18), entries: [] }], + holds: [ + { side: 'left', temperature: 80, startedAt: t(28, 22), expiresAt: t(29, 1) }, + { side: 'right', temperature: 78, startedAt: t(27, 22), expiresAt: t(28, 1) }, + { side: 'right', temperature: 70, startedAt: t(26, 22), expiresAt: t(27, 1) }, + ], + } + rerender() + expect(screen.getByText('cooling down Β· 3Γ—', { exact: false })).toBeTruthy() + expect(screen.getByText('Left side')).toBeTruthy() + expect(screen.getByText('Right side')).toBeTruthy() + expect(screen.getByText('future-outcome')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: 'Fired' })) + expect(screen.queryByText('Left side')).toBeNull() + expect(screen.queryByText('Quiet')).toBeNull() + expect(screen.getByText('Cool')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: 'Would fire' })) + expect(screen.getAllByText('Nothing logged.')).toHaveLength(2) + fireEvent.click(screen.getByRole('tab', { name: 'Skipped' })) + expect(screen.getByText('Quiet')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(onMore).toHaveBeenCalledOnce() + rerender() + expect(screen.getByRole('button', { name: 'Loading…' }).hasAttribute('disabled')).toBe(true) +}) + +it('renders side ownership, schedule blocks, holds, rule windows and filtered fire marks', () => { + mock.schedule = { temperature: [{ enabled: true, dayOfWeek: 'monday', time: '22:00', temperature: 78 }, { enabled: true, dayOfWeek: 'monday', time: '07:00', temperature: 80 }] } + const empty: SideTonight = { control: null, hold: null, runOnceUntil: null, lastAutopilot: null } + const rules: TimelineRule[] = [ + { id: 1, name: 'Dry rule', mode: 'dryrun', side: null, conditions: { kind: 'and', conditions: [] }, missing: [] }, + { id: 2, name: 'No movement', mode: 'active', side: 'left', conditions: { kind: 'and', conditions: [] }, missing: ['movement'] }, + { id: 3, name: 'Off rule', mode: 'off', side: null, conditions: { kind: 'and', conditions: [] }, missing: [] }, + { id: 4, name: 'Outside', mode: 'active', side: 'right', conditions: { kind: 'timeBetween', start: '12:00', end: '13:00' }, missing: [] }, + { id: 5, name: 'Live rule', mode: 'active', side: 'right', conditions: { kind: 'and', conditions: [] }, missing: [] }, + ] + render( + + ) + expect(screen.getByTestId('owner-left').textContent).toContain('Manual hold') + expect(screen.getByTestId('hold-left').textContent).toBe('81Β°') + expect(screen.queryByTestId('hold-right')).toBeNull() + expect(screen.getByTestId('lane-left-auto').textContent).toContain('no movement signal') + expect(screen.getByTestId('lane-right-auto').textContent).toContain('Live rule') + expect(screen.queryByText('Off rule')).toBeNull() + expect(screen.queryByText('Outside')).toBeNull() + expect(screen.getAllByTitle('1:00 AM Β· fired')).toHaveLength(2) + expect(screen.getByTitle('11:00 PM Β· would fire')).toBeTruthy() + expect(screen.getByTestId('tonight-now')).toBeTruthy() +}) + +it('waits for the client clock and shows owner loading placeholders', () => { + mock.minute = null + const { rerender } = render() + expect(screen.queryByTestId('tonight-card')).toBeNull() + mock.minute = t(28, 12) / 60000 + rerender() + expect(screen.getAllByText('Loading…')).toHaveLength(2) + expect(screen.queryByTestId('tonight-now')).toBeNull() +}) diff --git a/src/components/Autopilot/tests/automationsLogic.test.ts b/src/components/Autopilot/tests/automationsLogic.test.ts new file mode 100644 index 000000000..959f55325 --- /dev/null +++ b/src/components/Autopilot/tests/automationsLogic.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { + backtestLine, currentNightStart, entryTime, matchesFilter, nightHeader, nightStarts, ownerView, + reasonText, ruleMode, ruleWindow, scheduleBlocks, statusLine, type SideTonight, +} from '../automationsLogic' +import { missingLiveSignals, templateRule, TEMPLATES } from '../builderModel' + +const t = (d: number, h: number, m = 0) => new Date(2026, 8, d, h, m).getTime() +const fmt = (f: number) => `${f}Β°` +const curve = [ + { at: t(28, 22, 30), temperature: 80 }, + { at: t(28, 23, 15), temperature: 76 }, + { at: t(29, 7), temperature: 76 }, +] +const side = (over: Partial): SideTonight => ({ control: null, hold: null, runOnceUntil: null, lastAutopilot: null, ...over }) + +describe('ownerView', () => { + it('explains a manual hold with when the dial was set and when the schedule resumes', () => { + const v = ownerView(side({ + control: { source: 'manual', targetTemperature: 81, blocked: null }, + hold: { temperature: 81, startedAt: t(28, 18, 31), expiresAt: t(28, 19, 15) }, + }), curve, t(28, 19), fmt) + expect(v.owner).toBe('manual') + expect(v.label).toBe('Manual hold') + expect(v.detail).toBe('Dial set to 81Β° at 6:31 PM. Schedule resumes at 7:15 PM.') + }) + + it('names the rule and setpoint when autopilot owns the side', () => { + const v = ownerView(side({ control: { source: 'autopilot', targetTemperature: 78, blocked: null }, lastAutopilot: { ruleName: 'Cool', temp: 78, at: t(29, 2, 14) } }), curve, t(29, 3), fmt) + expect(v.detail).toBe('Cool set 78Β° at 2:14 AM.') + }) + + it('shows the current schedule setpoint and what comes next', () => { + const v = ownerView(side({ control: { source: 'schedule', targetTemperature: 80, blocked: null } }), curve, t(28, 22, 45), fmt) + expect(v.detail).toBe('80Β° until 11:15 PM, then 76Β°.') + }) + + it('infers the owner from the hold and schedule when the controller is not running', () => { + const hold = { temperature: 81, startedAt: t(28, 18, 31), expiresAt: t(28, 19, 15) } + expect(ownerView(side({ hold }), curve, t(28, 19), fmt).owner).toBe('manual') + expect(ownerView(side({}), curve, t(28, 23), fmt)).toMatchObject({ owner: 'schedule', detail: '80Β° until 11:15 PM, then 76Β°.' }) + }) + + it('falls back to Off with when the schedule starts', () => { + expect(ownerView(side({}), curve, t(28, 19), fmt)).toMatchObject({ owner: 'off', detail: 'Schedule starts at 10:30 PM.' }) + expect(ownerView(side({}), [], t(28, 19), fmt).detail).toBe('No schedule tonight.') + expect(ownerView(side({ control: { source: 'schedule', targetTemperature: 80, blocked: 'off' } }), curve, t(28, 19), fmt).owner).toBe('off') + }) +}) + +describe('timeline lanes', () => { + it('turns set points into blocks that hold until the next point', () => { + expect(scheduleBlocks(curve, t(28, 18), t(29, 9))).toEqual([ + { start: t(28, 22, 30), end: t(28, 23, 15), temperature: 80 }, + { start: t(28, 23, 15), end: t(29, 7), temperature: 76 }, + ]) + }) + + it('places a rule window tonight, rolling morning starts to the next day', () => { + const midnight = new Date(2026, 8, 28) + const cond = (start: string, end: string) => ({ kind: 'and' as const, conditions: [{ kind: 'timeBetween' as const, start, end }] }) + expect(ruleWindow(cond('23:00', '06:00'), midnight, t(28, 18), t(29, 9))).toEqual({ start: t(28, 23), end: t(29, 6) }) + expect(ruleWindow(cond('01:00', '05:00'), midnight, t(28, 18), t(29, 9))).toEqual({ start: t(29, 1), end: t(29, 5) }) + expect(ruleWindow({ kind: 'and', conditions: [] }, midnight, t(28, 18), t(29, 9))).toEqual({ start: t(28, 18), end: t(29, 9) }) + }) +}) + +describe('nights', () => { + it('uses 6 PM–6 PM nights', () => { + expect(currentNightStart(t(28, 19))).toBe(t(28, 18)) + expect(currentNightStart(t(28, 3))).toBe(t(27, 18)) + expect(nightStarts(t(28, 19), 3)).toEqual([t(26, 18), t(27, 18), t(28, 18)]) + }) + + it('labels tonight, last night, then dates', () => { + expect(nightHeader(t(28, 18), t(28, 19))).toBe('TONIGHT Β· MON SEP 28') + expect(nightHeader(t(27, 18), t(28, 19))).toBe('LAST NIGHT Β· SUN SEP 27') + expect(nightHeader(t(26, 18), t(28, 19))).toBe('SAT SEP 26') + }) +}) + +describe('rule rows', () => { + it('builds the header status line without zero parts', () => { + expect(statusLine([{ enabled: true, dryRun: true }])).toBe('1 rule Β· 1 dry-run') + expect(statusLine([{ enabled: true, dryRun: false }, { enabled: false, dryRun: true }])).toBe('2 rules Β· 1 active') + expect(statusLine([])).toBe('no rules') + }) + + it('maps enabled + dryRun to the three-state mode', () => { + expect(ruleMode({ enabled: false, dryRun: false })).toBe('off') + expect(ruleMode({ enabled: true, dryRun: true })).toBe('dryrun') + expect(ruleMode({ enabled: true, dryRun: false })).toBe('active') + }) + + it('summarises the 5-night backtest', () => { + expect(backtestLine({ nights: 5, wouldFire: 0, peak: 64, threshold: 200 })).toBe('5-night backtest Β· 0 would fire Β· peak 64 of 200') + expect(backtestLine({ nights: 5, wouldFire: 2, peak: null, threshold: null })).toBe('5-night backtest Β· 2 would fire') + expect(backtestLine({ nights: 0, wouldFire: 0, peak: null, threshold: null })).toBe('no recorded nights to backtest') + expect(backtestLine(undefined)).toBe('backtesting…') + }) + + it('flags signals with no live source', () => { + const missing = (id: string) => { + const rule = templateRule(id) + if (!rule) throw new Error(`no template ${id}`) + return missingLiveSignals(rule) + } + expect(missing('restless')).toEqual(['movement']) + expect(missing('water-low')).toEqual([]) + expect(missing('hold-room')).toEqual([]) + expect(templateRule('nope')).toBeNull() + expect(TEMPLATES.map(x => x.title)).toEqual(['Hold room +3Β°F', 'Cool when restless', 'Tell me when water is low']) + }) +}) + +describe('activity copy', () => { + const rule = { conditions: { kind: 'and' as const, conditions: [{ kind: 'timeBetween' as const, start: '23:00', end: '06:00' }] } } + it('formats reasons', () => { + expect(reasonText({ code: 'outside-window', temp: null, sides: [] }, rule, [], fmt)).toBe('outside 11 PM–6 AM') + expect(reasonText({ code: 'signal-unavailable', temp: null, sides: [] }, rule, ['movement'], fmt)).toBe('movement unavailable') + expect(reasonText({ code: 'set-temperature', temp: 78, sides: ['left'] }, rule, [], fmt)).toBe('set 78Β°') + expect(reasonText({ code: 'anti-thrash', temp: null, sides: [] }, rule, [], fmt)).toBe('below anti-thrash') + }) + + it('formats single times and collapsed ranges', () => { + expect(entryTime(t(28, 18, 31), t(28, 18, 31), t(28, 19), 1)).toBe('6:31 PM') + expect(entryTime(t(27, 23), t(28, 6), t(28, 19), 420)).toBe('11:00 PM β†’ 6 AM') + expect(entryTime(t(28, 6), t(28, 18, 59), t(28, 19), 781)).toBe('6:00 AM β†’ now') + }) + + it('filters by outcome', () => { + expect(matchesFilter('clamped', 'fired')).toBe(true) + expect(matchesFilter('dry_run', 'would')).toBe(true) + expect(matchesFilter('skipped', 'fired')).toBe(false) + expect(matchesFilter('error', 'all')).toBe(true) + }) +}) + +it('explains run-once, missing control details and safety blocks', () => { + expect(ownerView(side({ control: { source: 'run-once', targetTemperature: 78, blocked: null }, runOnceUntil: t(29, 7) }), [], t(28, 23), fmt).detail).toBe('78Β° from a run-once session until 7:00 AM.') + expect(ownerView(side({ control: { source: 'run-once', targetTemperature: null, blocked: null } }), [], t(28, 23), fmt).detail).toBe('Run-once session.') + expect(ownerView(side({ control: { source: 'autopilot', targetTemperature: null, blocked: null } }), [], t(28, 23), fmt).detail).toBe('An automation holds this side.') + expect(ownerView(side({ control: { source: 'schedule', targetTemperature: null, blocked: null } }), [], t(28, 23), fmt).detail).toBe('Following the schedule.') + expect(ownerView(side({ control: { source: null, targetTemperature: null, blocked: 'safety' } }), [], t(28, 23), fmt).detail).toBe('Paused by pump stall protection.') + expect(ownerView(side({ hold: { temperature: 80, startedAt: t(28, 22), expiresAt: t(29, 1) } }), [], t(28, 23), fmt).detail).toContain('Hold ends') + expect(scheduleBlocks(curve, t(29, 8), t(29, 9))).toEqual([]) + expect(ruleWindow({ kind: 'timeBetween', start: '12:00', end: '13:00' }, new Date(2026, 8, 28), t(28, 18), t(29, 9))).toBeNull() +}) + +it.each([ + ['condition-false', 'condition not met'], ['cooldown', 'cooling down'], ['manual-hold', 'manual hold'], + ['side-off', 'side is off'], ['superseded', 'another source owns the side'], ['runaway', 'fired too often Β· rule disabled'], + ['eval-error', 'evaluation failed'], ['action-error', 'command failed'], ['set-temperature', 'set temperature'], + ['notify', 'sent a notification'], ['power-on', 'turned power on'], ['power-off', 'turned power off'], + ['unknown', 'no reason recorded'], ['outside-window', 'outside its window'], ['signal-unavailable', 'signal unavailable'], +])('explains %s without a rule', (code, expected) => { + expect(reasonText({ code, temp: null, sides: [] }, undefined, [], fmt)).toBe(expected) +}) + +it('formats minute precision and noon/midnight window boundaries', () => { + expect(reasonText({ code: 'outside-window', temp: null, sides: [] }, { conditions: { kind: 'timeBetween', start: '00:05', end: '12:30' } }, [], fmt)).toBe('outside 12:05 AM–12:30 PM') + expect(matchesFilter('skipped', 'skipped')).toBe(true) + expect(matchesFilter('fired', 'skipped')).toBe(false) +}) diff --git a/src/components/Autopilot/tests/autopilotViews.test.ts b/src/components/Autopilot/tests/autopilotViews.test.ts new file mode 100644 index 000000000..9196e0d19 --- /dev/null +++ b/src/components/Autopilot/tests/autopilotViews.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { AUTOPILOT_VIEWS, resolveAutopilotView } from '../autopilotViews' + +describe('resolveAutopilotView', () => { + it('lands on Automations unless ?view=diagnostics', () => { + expect(resolveAutopilotView(null)).toBe('automations') + expect(resolveAutopilotView('bogus')).toBe('automations') + expect(resolveAutopilotView('diagnostics')).toBe('diagnostics') + }) + + it('lists the views A–Z', () => { + expect(AUTOPILOT_VIEWS.map(v => v.label)).toEqual(['Automations', 'Diagnostics']) + }) +}) diff --git a/src/components/Autopilot/tests/builderFixtures.ts b/src/components/Autopilot/tests/builderFixtures.ts new file mode 100644 index 000000000..ed75b65d0 --- /dev/null +++ b/src/components/Autopilot/tests/builderFixtures.ts @@ -0,0 +1,8 @@ +import { assert } from 'vitest' +import { templateRule } from '../builderModel' + +export function requiredTemplate(name: string) { + const rule = templateRule(name) + assert(rule, `Missing automation template: ${name}`) + return rule +} diff --git a/src/components/Autopilot/tests/evaluationStrip.test.ts b/src/components/Autopilot/tests/evaluationStrip.test.ts new file mode 100644 index 000000000..6c673dc90 --- /dev/null +++ b/src/components/Autopilot/tests/evaluationStrip.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import type { Trigger } from '@/src/automation/types' +import { + buildStrip, cadenceText, filterCounts, groupLog, lastVerdict, missingTicks, runKind, thresholds, type EvalRun, +} from '../evaluationStrip' + +const MIN = 60_000 +const base = Date.UTC(2026, 8, 28, 15, 0, 0) +const everyMinute: Trigger = { kind: 'tick', everyMin: 1 } +const run = (min: number, outcome: EvalRun['outcome'] = 'skipped', reason: string | null = 'condition-false', sent = false): EvalRun => + ({ t: new Date(base + min * MIN + 5000), outcome, reason, sent }) + +describe('runKind', () => { + it.each([ + [{ outcome: 'dry_run', reason: null }, 'would'], + [{ outcome: 'fired', reason: null }, 'fired'], + [{ outcome: 'clamped', reason: null }, 'fired'], + [{ outcome: 'error', reason: 'eval-threw' }, 'error'], + [{ outcome: 'skipped', reason: 'cooldown' }, 'cooldown'], + [{ outcome: 'skipped', reason: 'condition-false' }, 'skipped'], + ] as const)('%o β†’ %s', (r, kind) => { + expect(runKind(r)).toBe(kind) + }) +}) + +describe('missingTicks', () => { + it('flags every expected minute inside a gap and up to now', () => { + const runs = [run(0), run(1), run(5)] + const now = base + 8 * MIN + 10_000 + const miss = missingTicks(runs, everyMinute, base, now).map(t => Math.round((t - base - 5000) / MIN)) + expect(miss).toEqual([2, 3, 4, 6, 7]) + }) + + it('tolerates tick drift under the grace window', () => { + const runs = [run(0), { ...run(1), t: new Date(base + MIN + 40_000) }] + expect(missingTicks(runs, everyMinute, base, base + 2 * MIN + 20_000)).toEqual([]) + }) + + it('steps by the trigger cadence and ignores non-tick triggers', () => { + const runs = [run(0), run(20)] + expect(missingTicks(runs, { kind: 'tick', everyMin: 5 }, base, base + 20 * MIN + 5000)).toHaveLength(3) + expect(missingTicks(runs, { kind: 'signalChange', signal: 'x' }, base, base + 60 * MIN)).toEqual([]) + expect(missingTicks([], everyMinute, base, base + 60 * MIN)).toEqual([]) + }) +}) + +describe('buildStrip', () => { + it('paints one slot per minute, highest-priority outcome wins, cooldown spans merge', () => { + const runs = [ + run(170, 'skipped'), + run(171, 'dry_run', null), + { ...run(171, 'skipped'), t: new Date(base + 171 * MIN + 20_000) }, + run(172, 'skipped', 'cooldown'), + run(173, 'skipped', 'cooldown'), + run(174, 'skipped', 'cooldown'), + run(175, 'skipped'), + ] + const now = base + 179 * MIN + 30_000 + const s = buildStrip(runs, everyMinute, now, 3, true) + expect(s.ticks).toHaveLength(180) + expect(s.endMs - s.startMs).toBe(180 * MIN) + const idx = (m: number) => Math.floor((base + m * MIN - s.startMs) / MIN) + expect(s.ticks[idx(170)]).toBe('skipped') + expect(s.ticks[idx(171)]).toBe('would') + expect(s.cooldowns).toEqual([[idx(172), idx(174)]]) + expect(s.ticks[idx(177)]).toBe('missing') + expect(s.ticks[idx(100)]).toBe('none') // before the first recorded row + }) + + it('omits missing slots when not expected', () => { + const s = buildStrip([run(170)], everyMinute, base + 179 * MIN, 3, false) + expect(s.ticks.includes('missing')).toBe(false) + }) +}) + +describe('groupLog + filterCounts', () => { + it('collapses consecutive identical results newest-first and counts evaluations', () => { + const runs = [run(0), run(1), run(2, 'dry_run', null), run(3, 'fired', null, true), run(4), run(5)] + const groups = groupLog(runs, [base + 7 * MIN]) + expect(groups.map(g => [g.kind, g.count])).toEqual([ + ['missing', 1], ['skipped', 2], ['fired', 1], ['would', 1], ['skipped', 2], + ]) + expect(groups[1].firstMs).toBeLessThan(groups[1].lastMs) + expect(groups[2].sent).toBe(1) + expect(filterCounts(groups)).toEqual({ all: 7, would: 1, fired: 1, skipped: 4, missing: 1 }) + }) + + it('keeps different skip reasons apart', () => { + const groups = groupLog([run(0), run(1, 'skipped', 'cooldown')], []) + expect(groups).toHaveLength(2) + }) +}) + +describe('text helpers', () => { + it('cadenceText', () => { + expect(cadenceText(everyMinute)).toBe('every minute') + expect(cadenceText({ kind: 'tick', everyMin: 5 })).toBe('every 5 min') + expect(cadenceText({ kind: 'timeOfDay', at: '06:30' })).toBe('daily at 06:30') + expect(cadenceText({ kind: 'signalChange', signal: 'left.movement' })).toBe('on left.movement change') + }) + + it('lastVerdict', () => { + expect(lastVerdict(undefined)).toBeNull() + expect(lastVerdict(run(0))).toBe('false') + expect(lastVerdict(run(0, 'skipped', 'condition-unknown'))).toBe('unknown') + expect(lastVerdict(run(0, 'skipped', 'cooldown'))).toBe('true') + expect(lastVerdict(run(0, 'dry_run', null))).toBe('true') + expect(lastVerdict(run(0, 'error', 'eval-threw'))).toBe('error') + }) + + it('thresholds walks nested conditions for literal compares', () => { + expect(thresholds({ + kind: 'and', + conditions: [ + { kind: 'timeBetween', start: '22:00', end: '06:00' }, + { kind: 'not', condition: { kind: 'compare', op: '<', left: { kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, right: { kind: 'literal', value: 200 } } }, + { kind: 'compare', op: '>', left: { kind: 'signal', signal: 'ambient.temperature' }, right: { kind: 'signal', signal: 'x' } }, + ], + })).toEqual([{ signal: 'left.movement', op: '<', value: 200, window: { fn: 'avg', lastMin: 10 } }]) + }) +}) diff --git a/src/components/Autopilot/tests/primitives.test.tsx b/src/components/Autopilot/tests/primitives.test.tsx new file mode 100644 index 000000000..f58d1a06f --- /dev/null +++ b/src/components/Autopilot/tests/primitives.test.tsx @@ -0,0 +1,8 @@ +import { render, screen } from '@testing-library/react' +import { expect, it } from 'vitest' +import { StatusBadge } from '../primitives' + +it.each([['active', 'ACTIVE'], ['dryrun', 'DRY-RUN'], ['paused', 'PAUSED']] as const)('labels %s automation status', (mode, label) => { + render() + expect(screen.getByText(label)).toBeTruthy() +}) diff --git a/src/components/BottomNav/BottomNav.tsx b/src/components/BottomNav/BottomNav.tsx deleted file mode 100644 index f2ac9412e..000000000 --- a/src/components/BottomNav/BottomNav.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client' - -import { msg } from '@lingui/core/macro' -import { useLingui } from '@lingui/react' -import { Activity, BarChart3, Calendar, Gauge, Radio, Thermometer } from 'lucide-react' -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import clsx from 'clsx' -import { useScheduleActive } from '@/src/hooks/useScheduleActive' - -const tabs = [ - { id: 'temp', icon: Thermometer, label: msg`Temp`, href: '/' }, - { id: 'schedule', icon: Calendar, label: msg`Schedule`, href: '/schedule' }, - { id: 'data', icon: BarChart3, label: msg`Biometrics`, href: '/data' }, - { id: 'sensors', icon: Radio, label: msg`Sensors`, href: '/sensors' }, - { id: 'status', icon: Activity, label: msg`Status`, href: '/status' }, - // Autopilot console β€” hidden from nav until P2 wires biometric/ambient/enum signals - // (sleepypod-core-69). The page still lives at /autopilot (deep-link) and the /debug - // Autopilot panel surfaces live engine state for dogfooding. Re-add SlidersHorizontal - // import + this entry to re-expose: - // { id: 'autopilot', icon: SlidersHorizontal, label: msg`Autopilot`, href: '/autopilot', desktopOnly: true }, - // Diagnostics console β€” desktop/tablet only; phones reach it via the Status card. - { id: 'debug', icon: Gauge, label: msg`Diagnostics`, href: '/debug', desktopOnly: true }, -] - -/** - * Global bottom navigation component with routing. - * Highlights the active tab based on the current pathname. - */ -export const BottomNav = () => { - const { i18n } = useLingui() - const pathname = usePathname() - const { isActive: scheduleActive } = useScheduleActive() - - // Extract the path segment after /[lang]/ to determine active tab - const getIsActive = (href: string) => { - if (!pathname) return false - // Remove the language prefix (e.g., /en/schedule -> /schedule) - const segments = pathname.split('/') - const pathWithoutLang = '/' + segments.slice(2).join('/') - if (href === '/') return pathWithoutLang === '/' || pathWithoutLang === '' - return pathWithoutLang.startsWith(href) - } - - // Get the language prefix from the current pathname - const lang = pathname?.split('/')[1] ?? 'en' - - return ( - - ) -} diff --git a/src/components/DualSideChart/DualSideChart.tsx b/src/components/DualSideChart/DualSideChart.tsx deleted file mode 100644 index a5a3282c4..000000000 --- a/src/components/DualSideChart/DualSideChart.tsx +++ /dev/null @@ -1,498 +0,0 @@ -'use client' - -import { useCallback, useMemo, useRef, useState } from 'react' - -// ── Types ── - -export interface DataPoint { - timestamp: Date - value: number -} - -export interface Zone { - label: string - min: number - max: number - color: string -} - -export interface DualSideChartProps { - /** Left side data series */ - leftData: DataPoint[] - /** Right side data series */ - rightData: DataPoint[] - /** Unit label for tooltip display (e.g. "BPM", "ms", "Β°F") */ - unit: string - /** Unique ID prefix for SVG gradient definitions */ - gradientId: string - /** Optional zone backgrounds (e.g. normal/elevated ranges) */ - zones?: Zone[] - /** Optional average line value (uses primary side average) */ - average?: number | null - /** Chart height in pixels (default 200) */ - height?: number - /** Left side color (default sky-400: #38bdf8) */ - leftColor?: string - /** Right side color (default amber-400: #fbbf24) */ - rightColor?: string - /** Left side label (default "Left") */ - leftLabel?: string - /** Right side label (default "Right") */ - rightLabel?: string - /** Format function for x-axis time labels */ - formatTime?: (date: Date) => string - /** Format function for tooltip values (default: Math.round) */ - formatValue?: (value: number) => string -} - -// ── Constants ── - -const PADDING = { top: 8, right: 8, bottom: 24, left: 36 } -const LEFT_COLOR_DEFAULT = '#38bdf8' // sky-400 -const RIGHT_COLOR_DEFAULT = '#fbbf24' // amber-400 - -function defaultFormatTime(date: Date): string { - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) -} - -function defaultFormatValue(value: number): string { - return Math.round(value).toString() -} - -// ── Component ── - -/** - * Reusable dual-series SVG line chart for comparing left/right side data. - * - * Features: - * - Two data series with distinct colors and gradient fills - * - Color-coded legend with side labels - * - Tap/click to select nearest point with tooltip showing side + value - * - Zone backgrounds for range indicators - * - Optional average line - * - Responsive width via ResizeObserver - * - Touch-friendly interactions for mobile - * - * Follows the VitalsChart pattern β€” lightweight custom SVG, no charting library. - */ -export function DualSideChart({ - leftData, - rightData, - unit, - gradientId, - zones = [], - average, - height = 200, - leftColor = LEFT_COLOR_DEFAULT, - rightColor = RIGHT_COLOR_DEFAULT, - leftLabel = 'Left', - rightLabel = 'Right', - formatTime = defaultFormatTime, - formatValue = defaultFormatValue, -}: DualSideChartProps) { - const svgRef = useRef(null) - const [svgWidth, setSvgWidth] = useState(320) - const [selectedPoint, setSelectedPoint] = useState<{ - side: 'left' | 'right' - index: number - point: DataPoint - } | null>(null) - - const measureRef = useCallback((node: HTMLDivElement | null) => { - if (!node) return - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setSvgWidth(entry.contentRect.width) - } - }) - observer.observe(node) - setSvgWidth(node.clientWidth) - return () => observer.disconnect() - }, []) - - const chartWidth = svgWidth - PADDING.left - PADDING.right - const chartHeight = height - PADDING.top - PADDING.bottom - - // Sort both series by timestamp - const sortedLeft = useMemo(() => - [...leftData].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()), - [leftData]) - - const sortedRight = useMemo(() => - [...rightData].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()), - [rightData]) - - // Compute unified scale across both series - const { minVal, maxVal, minTime, maxTime } = useMemo(() => { - const allPoints = [...sortedLeft, ...sortedRight] - if (allPoints.length === 0) { - return { minVal: 0, maxVal: 100, minTime: 0, maxTime: 1 } - } - - const values = allPoints.map(d => d.value) - let lo = Math.min(...values) - let hi = Math.max(...values) - - // Include zone ranges in scale - for (const zone of zones) { - lo = Math.min(lo, zone.min) - hi = Math.max(hi, zone.max) - } - - // Add 5% padding - const range = hi - lo || 1 - lo = lo - range * 0.05 - hi = hi + range * 0.05 - - const timestamps = allPoints.map(d => d.timestamp.getTime()) - return { - minVal: lo, - maxVal: hi, - minTime: Math.min(...timestamps), - maxTime: Math.max(...timestamps), - } - }, [sortedLeft, sortedRight, zones]) - - const scaleX = useCallback((time: number) => { - const timeRange = maxTime - minTime || 1 - return PADDING.left + ((time - minTime) / timeRange) * chartWidth - }, [minTime, maxTime, chartWidth]) - - const scaleY = useCallback((val: number) => { - const valRange = maxVal - minVal || 1 - return PADDING.top + chartHeight - ((val - minVal) / valRange) * chartHeight - }, [minVal, maxVal, chartHeight]) - - // Build SVG paths with smooth quadratic bezier curves - const buildPaths = useCallback((data: DataPoint[]) => { - if (data.length < 2) return { linePath: '', areaPath: '' } - - const points = data.map(d => ({ - x: scaleX(d.timestamp.getTime()), - y: scaleY(d.value), - })) - - let line = `M ${points[0].x},${points[0].y}` - for (let i = 1; i < points.length; i++) { - const prev = points[i - 1] - const curr = points[i] - const cpx = (prev.x + curr.x) / 2 - line += ` Q ${cpx},${prev.y} ${curr.x},${curr.y}` - } - - const baseline = PADDING.top + chartHeight - const area = line + ` L ${points[points.length - 1].x},${baseline} L ${points[0].x},${baseline} Z` - - return { linePath: line, areaPath: area } - }, [scaleX, scaleY, chartHeight]) - - const leftPaths = useMemo(() => buildPaths(sortedLeft), [buildPaths, sortedLeft]) - const rightPaths = useMemo(() => buildPaths(sortedRight), [buildPaths, sortedRight]) - - // X-axis tick labels (4 ticks) - const xTicks = useMemo(() => { - const allPoints = [...sortedLeft, ...sortedRight] - if (allPoints.length < 2) return [] - const count = 4 - const ticks: { x: number, label: string }[] = [] - for (let i = 0; i < count; i++) { - const t = minTime + (i / (count - 1)) * (maxTime - minTime) - ticks.push({ x: scaleX(t), label: formatTime(new Date(t)) }) - } - return ticks - }, [sortedLeft, sortedRight, minTime, maxTime, scaleX, formatTime]) - - // Y-axis tick labels (3 ticks) - const yTicks = useMemo(() => { - const count = 3 - const ticks: { y: number, label: string }[] = [] - for (let i = 0; i < count; i++) { - const v = minVal + (i / (count - 1)) * (maxVal - minVal) - ticks.push({ y: scaleY(v), label: Math.round(v).toString() }) - } - return ticks - }, [minVal, maxVal, scaleY]) - - // Find nearest point from either series on click/touch - const findNearest = useCallback((clientX: number, rect: DOMRect) => { - const clickX = clientX - rect.left - - let bestSide: 'left' | 'right' = 'left' - let bestIndex = 0 - let bestDist = Infinity - - for (let i = 0; i < sortedLeft.length; i++) { - const px = scaleX(sortedLeft[i].timestamp.getTime()) - const dist = Math.abs(px - clickX) - if (dist < bestDist) { - bestDist = dist - bestIndex = i - bestSide = 'left' - } - } - - for (let i = 0; i < sortedRight.length; i++) { - const px = scaleX(sortedRight[i].timestamp.getTime()) - const dist = Math.abs(px - clickX) - if (dist < bestDist) { - bestDist = dist - bestIndex = i - bestSide = 'right' - } - } - - const point = bestSide === 'left' ? sortedLeft[bestIndex] : sortedRight[bestIndex] - return { side: bestSide, index: bestIndex, point } - }, [sortedLeft, sortedRight, scaleX]) - - const handleClick = useCallback((e: React.MouseEvent) => { - if (sortedLeft.length === 0 && sortedRight.length === 0) return - const rect = e.currentTarget.getBoundingClientRect() - const result = findNearest(e.clientX, rect) - - // Toggle off if tapping same point - setSelectedPoint(prev => - prev?.side === result.side && prev?.index === result.index ? null : result, - ) - }, [sortedLeft.length, sortedRight.length, findNearest]) - - const handleTouch = useCallback((e: React.TouchEvent) => { - if (sortedLeft.length === 0 && sortedRight.length === 0) return - const rect = e.currentTarget.getBoundingClientRect() - const result = findNearest(e.touches[0].clientX, rect) - setSelectedPoint(result) - }, [sortedLeft.length, sortedRight.length, findNearest]) - - const hasData = sortedLeft.length > 0 || sortedRight.length > 0 - - if (!hasData) { - return ( -
- No data available -
- ) - } - - const selectedColor = selectedPoint?.side === 'left' ? leftColor : rightColor - const selectedLabel = selectedPoint?.side === 'left' ? leftLabel : rightLabel - - return ( -
- {/* Legend + selected value tooltip */} -
- {/* Color-coded legend */} -
- {sortedLeft.length > 0 && ( -
- - {leftLabel} -
- )} - {sortedRight.length > 0 && ( -
- - {rightLabel} -
- )} -
- - {/* Selected point tooltip */} - {selectedPoint && ( -
- - - {formatValue(selectedPoint.point.value)} - {' '} - {unit} - - - {selectedLabel} - {' '} - Β· - {formatTime(selectedPoint.point.timestamp)} - -
- )} -
- - {/* SVG Chart */} -
- - - {/* Left side gradient */} - - - - - {/* Right side gradient */} - - - - - - - {/* Zone backgrounds */} - {zones.map((zone) => { - const y1 = scaleY(zone.max) - const y2 = scaleY(zone.min) - return ( - - ) - })} - - {/* Y-axis grid lines and labels */} - {yTicks.map(tick => ( - - - - {tick.label} - - - ))} - - {/* Average dashed line */} - {average != null && ( - - - - avg - - - )} - - {/* Left side area fill */} - {leftPaths.areaPath && ( - - )} - - {/* Right side area fill */} - {rightPaths.areaPath && ( - - )} - - {/* Left side data line */} - {leftPaths.linePath && ( - - )} - - {/* Right side data line */} - {rightPaths.linePath && ( - - )} - - {/* Selected point indicator */} - {selectedPoint && ( - - {/* Vertical guide line */} - - {/* Selected point circle with side color */} - - - - )} - - {/* X-axis labels */} - {xTicks.map((tick, i) => ( - - {tick.label} - - ))} - -
-
- ) -} diff --git a/src/components/Environment/BedTempChart.tsx b/src/components/Environment/BedTempChart.tsx index 5187cd45f..45174e758 100644 --- a/src/components/Environment/BedTempChart.tsx +++ b/src/components/Environment/BedTempChart.tsx @@ -9,8 +9,8 @@ import { YAxis, CartesianGrid, Tooltip, - Legend, } from 'recharts' +import { AXIS_TICK, TOOLTIP_LABEL_STYLE, TOOLTIP_STYLE } from '@/src/components/Sensors/chartTheme' interface BedTempDataPoint { timestamp: Date | string @@ -60,7 +60,7 @@ export function BedTempChart({ data, unit, showAmbient = false, highlightSide }: if (chartData.length === 0) { return ( -
+
No temperature data available
) @@ -86,80 +86,72 @@ export function BedTempChart({ data, unit, showAmbient = false, highlightSide }: : chartData return ( -
+
- - + + formatTime(new Date(v))} - tick={{ fill: '#71717a', fontSize: 10 }} - stroke="#333" - tickCount={4} + tick={{ ...AXIS_TICK, fontSize: 11 }} + axisLine={false} + tickLine={false} + tickCount={6} /> `${Math.round(v)}Β°`} /> formatTooltipTime(new Date(v as number))} formatter={(value, name) => [ `${Number(value).toFixed(1)}Β°${unit}`, String(name), ]} /> - + {showAmbient && ( + + )} - {showAmbient && ( - - )}
diff --git a/src/components/Environment/HumidityChart.tsx b/src/components/Environment/HumidityChart.tsx index 59f6b9beb..93c9d81b9 100644 --- a/src/components/Environment/HumidityChart.tsx +++ b/src/components/Environment/HumidityChart.tsx @@ -1,15 +1,16 @@ 'use client' -import { useId, useMemo } from 'react' +import { useMemo } from 'react' import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, - CartesianGrid, Tooltip, } from 'recharts' +import { SectionLabel } from '@/src/components/ds' +import { TOOLTIP_LABEL_STYLE, TOOLTIP_STYLE } from '@/src/components/Sensors/chartTheme' interface HumidityDataPoint { timestamp: Date | string @@ -25,8 +26,8 @@ function formatTime(timestamp: string | Date): string { return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } +/** Humidity card body: current value, flat area chart, min/avg/max footer. */ export function HumidityChart({ data }: HumidityChartProps) { - const gradientId = useId() const chartData = useMemo(() => { const sorted = [...data].reverse() const mapped = sorted.map(d => ({ @@ -42,63 +43,72 @@ export function HumidityChart({ data }: HumidityChartProps) { : mapped }, [data]) - if (chartData.length === 0) { - return ( -
- No humidity data available -
- ) - } + const stats = useMemo(() => { + const values = data.map(d => d.humidity).filter((v): v is number => v !== null) + if (values.length === 0) return null + return { + // API order is newest-first + latest: values[0], + min: Math.min(...values), + max: Math.max(...values), + avg: values.reduce((a, b) => a + b, 0) / values.length, + } + }, [data]) return ( -
- - - - - - - - - - formatTime(new Date(v))} - tick={{ fill: '#71717a', fontSize: 10 }} - stroke="#333" - tickCount={4} - /> - `${v}%`} - /> - formatTime(new Date(v as number))} - formatter={value => [`${Number(value).toFixed(1)}%`, 'Humidity']} - /> - - - -
+ <> + {`${Math.round(stats.latest)}%`}}> + Humidity + + + {chartData.length === 0 || !stats + ? ( +
+ No humidity data available +
+ ) + : ( +
+ + + + + formatTime(new Date(v as number))} + formatter={value => [`${Number(value).toFixed(1)}%`, 'Humidity']} + /> + + + +
+ )} + +
+ + {'min '} + {stats ? Math.round(stats.min) : '--'} + + + {'avg '} + {stats ? Math.round(stats.avg) : '--'} + + + {'max '} + {stats ? Math.round(stats.max) : '--'} + +
+ ) } diff --git a/src/components/Environment/TimeRangeSelector.tsx b/src/components/Environment/TimeRangeSelector.tsx index 82a9da1e9..29ab9922a 100644 --- a/src/components/Environment/TimeRangeSelector.tsx +++ b/src/components/Environment/TimeRangeSelector.tsx @@ -25,16 +25,16 @@ interface TimeRangeSelectorProps { export function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) { return ( -
+
{ranges.map(range => ( - ) -} diff --git a/src/components/PowerButton/tests/PowerButton.test.tsx b/src/components/PowerButton/tests/PowerButton.test.tsx deleted file mode 100644 index a3c638b36..000000000 --- a/src/components/PowerButton/tests/PowerButton.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PowerButton } from '../PowerButton' - -const state = vi.hoisted(() => ({ - activeSides: ['left'] as Array<'left' | 'right'>, - primarySide: 'left' as 'left' | 'right', - status: null as { leftSide: { targetLevel: number }, rightSide: { targetLevel: number } } | null, - pending: false, - mutate: vi.fn<(input: { side: string, powered: boolean, temperature?: number }) => Promise>(), - invalidate: vi.fn(), - onSuccess: () => {}, -})) -vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => state })) -vi.mock('@/src/hooks/useDeviceStatus', () => ({ useDeviceStatus: () => ({ status: state.status }) })) -vi.mock('@/src/utils/trpc', () => ({ - trpc: { - useUtils: () => ({ device: { getStatus: { invalidate: state.invalidate } } }), - device: { setPower: { useMutation: (options: { onSuccess: () => void }) => { - state.onSuccess = options.onSuccess - return { isPending: state.pending, mutateAsync: state.mutate } - } } }, - }, -})) - -function status() { - if (!state.status) throw new Error('Status fixture is absent') - return state.status -} - -beforeEach(() => { - vi.useFakeTimers() - state.activeSides = ['left'] - state.primarySide = 'left' - state.status = { leftSide: { targetLevel: 0 }, rightSide: { targetLevel: 0 } } - state.pending = false - state.invalidate.mockReset() - state.mutate.mockReset().mockImplementation(async () => { - state.onSuccess() - }) -}) -afterEach(() => { - cleanup() - vi.useRealTimers() -}) - -async function click(name: 'Turn on' | 'Turn off') { - await act(async () => { - fireEvent.click(screen.getByRole('button', { name })) - }) -} - -describe('PowerButton', () => { - it('turns on a selected side at the neutral setpoint and holds optimism across stale frames', async () => { - const { rerender } = render() - await click('Turn on') - expect(state.mutate).toHaveBeenCalledExactlyOnceWith({ side: 'left', powered: true, temperature: 75 }) - expect(state.invalidate).toHaveBeenCalledOnce() - expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() - rerender() - expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() - act(() => vi.advanceTimersByTime(5999)) - expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() - act(() => vi.advanceTimersByTime(1)) - expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() - }) - - it('follows subsequent server changes once power has been acknowledged', async () => { - const { rerender } = render() - await click('Turn on') - status().leftSide.targetLevel = 1 - rerender() - expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() - status().leftSide.targetLevel = 0 - rerender() - expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() - }) - - it('uses the right primary side and powers it off without sending a temperature', async () => { - state.primarySide = 'right' - state.activeSides = ['right'] - status().rightSide.targetLevel = -10 - render() - await click('Turn off') - expect(state.mutate.mock.calls).toEqual([[{ side: 'right', powered: false }]]) - expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() - }) - - it.each([true, false])('sends linked power=%s to both sides', async (powered) => { - state.activeSides = ['left', 'right'] - status().leftSide.targetLevel = powered ? 0 : 10 - render() - await click(powered ? 'Turn on' : 'Turn off') - expect(state.mutate.mock.calls).toEqual(powered - ? [[{ side: 'left', powered: true, temperature: 75 }], [{ side: 'right', powered: true, temperature: 75 }]] - : [[{ side: 'left', powered: false }], [{ side: 'right', powered: false }]]) - }) - - it('reverts optimism and refetches after one linked-side mutation fails', async () => { - state.activeSides = ['left', 'right'] - state.mutate.mockImplementation(async ({ side }) => { - if (side === 'right') throw new Error('right DAC rejected write') - state.onSuccess() - }) - render() - await click('Turn on') - expect(state.mutate).toHaveBeenCalledTimes(2) - expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() - expect(state.invalidate).toHaveBeenCalledTimes(2) // success + recovery refetch - expect(vi.getTimerCount()).toBe(0) - }) - - it('shows optimism while a mutation is unresolved and disables further clicks while pending', async () => { - let resolve!: () => void - state.mutate.mockImplementation(() => new Promise((done) => { - resolve = done - })) - const { rerender } = render() - await click('Turn on') - expect(screen.getByRole('button', { name: 'Turn off' })).toBeTruthy() - state.pending = true - rerender() - const button = screen.getByRole('button', { name: 'Turn off' }) as HTMLButtonElement - expect(button.disabled).toBe(true) - fireEvent.click(button) - expect(state.mutate).toHaveBeenCalledOnce() - await act(async () => { - resolve() - }) - }) - - it('starts off before the first status frame arrives', () => { - state.status = null - render() - expect(screen.getByRole('button', { name: 'Turn on' })).toBeTruthy() - }) -}) diff --git a/src/components/PullToRefresh/PullToRefresh.tsx b/src/components/PullToRefresh/PullToRefresh.tsx index cfa8de2ab..0bade2ff2 100644 --- a/src/components/PullToRefresh/PullToRefresh.tsx +++ b/src/components/PullToRefresh/PullToRefresh.tsx @@ -39,9 +39,9 @@ export function PullToRefresh({ onRefresh, enabled = true, children }: PullToRef > { - if (editablePoints.length === 0) return { min: 55, max: 110 } - const temps = editablePoints.map(p => p.tempF) - return { min: Math.min(...temps), max: Math.max(...temps) } - }, [editablePoints]) - - const chartData = useMemo(() => { - if (!curve || editablePoints.length < 2) return null - const btMin = timeStringToMinutes(curve.bedtime) - - const withRelative = editablePoints.map((p) => { - let tMin = timeStringToMinutes(p.time) - btMin - if (tMin < -120) tMin += 24 * 60 - return { ...p, minutesFromBedtime: tMin } - }).sort((a, b) => a.minutesFromBedtime - b.minutesFromBedtime) - - const total = withRelative.length - const points: CurvePoint[] = withRelative.map((p, i) => { - const frac = i / (total - 1) - const phase = frac < 0.1 - ? 'warmUp' as const - : frac < 0.25 - ? 'coolDown' as const - : frac < 0.55 - ? 'deepSleep' as const - : frac < 0.75 - ? 'maintain' as const - : frac < 0.9 - ? 'preWake' as const - : 'wake' as const - return { minutesFromBedtime: p.minutesFromBedtime, tempOffset: p.tempF - 80, phase } - }) - - return { points, bedtimeMinutes: btMin } - }, [curve, editablePoints]) - - if (!open) return null - return ( - <> -
- -
-
-
- - Custom AI Curve + + +
+ {step === 0 && ( + <> + + + + )} + {step > 0 && step < 3 && ( + + )} + {step === 3 && ( + <> + + + + )}
- -
- -
- {STEP_LABELS.map((label, i) => ( - - ))} -
- -
- {step === 0 && ( - - )} - {step === 1 && ( - - )} - {step === 2 && ( - - )} - {step === 3 && curve && ( - - )} -
- -
+ + )} + > +
+ {STEP_LABELS.map((label, i) => ( - - {step === 0 && ( -
- - -
- )} - - {step > 0 && step < 3 && ( - - )} -
+ ))}
- + + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && curve && ( + + )} + ) } +const FIELD = 'w-full rounded-ctl border border-line-2 bg-field px-3 py-[9px] text-fg outline-none placeholder:text-fg-3 focus:border-fg-3' + function StepDescribe({ preferences, onPreferencesChange, @@ -425,9 +361,9 @@ function StepDescribe({ onDeleteTemplate: (name: string) => void }) { return ( -
-
-

+

+
+

Describe your sleep preferences in natural language. An AI will design a personalized temperature curve.