diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 58bc7cc..2ddfdef 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -95,7 +95,7 @@ {"id":"spaxel-jxru","title":"Build fleet status page","description":"Full table view with bulk actions and camera fly-to functionality.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.725489218Z","created_by":"coding","updated_at":"2026-04-10T09:39:43.690167347Z","closed_at":"2026-04-10T09:39:43.690013432Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4","mitosis-child","mitosis-depth:1","parent-spaxel-17u"]} {"id":"spaxel-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-09T23:28:13.618819456Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:325"]} {"id":"spaxel-jza","title":"Dashboard: PIN change flow","description":"## Overview\nAllow authenticated users to change their dashboard PIN after first setup.\n\n## Backend\n- POST /api/auth/change-pin — requires valid session; body: {old_pin:'...', new_pin:'...'}\n- Verify old_pin against current bcrypt hash; return HTTP 403 if mismatch\n- Hash new_pin with bcrypt cost=12; update auth.pin_bcrypt\n- Existing sessions remain valid after PIN change (session tokens are independent of PIN)\n- Return {ok:true} on success\n\n## Dashboard\n- Settings panel: 'Security' section with 'Change PIN' button\n- Modal form: old PIN → new PIN → confirm new PIN → Submit\n- On 403: show 'Incorrect current PIN' error inline\n- On success: show 'PIN changed successfully' toast; close modal\n\n## Acceptance\n- Old PIN still works immediately after change attempt fails (403)\n- New PIN works on next login after successful change\n- Active session cookie remains valid after PIN change\n- Requires: spaxel-nk6 (PIN auth)","status":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-09T12:10:28.896292868Z","closed_at":"2026-04-09T12:10:28.896154010Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} -{"id":"spaxel-kgn4","title":"Implement feature discovery notifications","description":"Fire single non-blocking notification when features become available. Events: DiurnalBaselineActivated (7 days), FirstSleepSessionComplete, WeightUpdateApproved, AutomationFirstFired, PredictionModelReady (7 days per person). Each keyed by unique event ID in SQLite (feature_notifications table: event_id, fired_at, acknowledged_at). Never fires twice. Dismissed by tapping. Does not fire during quiet hours. Files: mothership/internal/help/notifier.go. Acceptance: each notification fires exactly once per feature; plain language messages; respects quiet hours; SQLite persistence prevents duplicates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T03:34:49.150476063Z","created_by":"coding","updated_at":"2026-04-11T04:36:04.223420906Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-tig"]} +{"id":"spaxel-kgn4","title":"Implement feature discovery notifications","description":"Fire single non-blocking notification when features become available. Events: DiurnalBaselineActivated (7 days), FirstSleepSessionComplete, WeightUpdateApproved, AutomationFirstFired, PredictionModelReady (7 days per person). Each keyed by unique event ID in SQLite (feature_notifications table: event_id, fired_at, acknowledged_at). Never fires twice. Dismissed by tapping. Does not fire during quiet hours. Files: mothership/internal/help/notifier.go. Acceptance: each notification fires exactly once per feature; plain language messages; respects quiet hours; SQLite persistence prevents duplicates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T03:34:49.150476063Z","created_by":"coding","updated_at":"2026-04-11T04:59:10.558085497Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5","mitosis-child","mitosis-depth:1","parent-spaxel-tig"]} {"id":"spaxel-klf","title":"Build self-improving localization","description":"Implement localization that learns from ground truth data.\n\nDeliverables:\n- BLE integration as ground truth source\n- Fresnel zone weight refinement algorithm\n- Continuous weight adjustment based on feedback\n\nAcceptance: Localization accuracy improves automatically as BLE ground truth data accumulates.","notes":"Implementation COMPLETE. Components:\n\n- BLEGroundTruthProvider: RSSI trilateration with Gauss-Newton iteration\n- WeightLearner: Gradient-based Fresnel zone weight refinement \n- SpatialWeightLearner: Per-zone spatial weights with SGD\n- WeightStore: SQLite persistence (link_weights table)\n- Engine fusion.go: Applies learned weights during localization\n- REST API: /api/localization/* endpoints for all features\n\nAcceptance met: Accuracy improves automatically as BLE data accumulates.\n\nPhase 7 (Learning & Analytics) marked COMPLETE in PROGRESS.md.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-03-29T19:25:03.995110604Z","created_by":"coding","updated_at":"2026-04-09T14:34:11.347506328Z","closed_at":"2026-04-09T14:34:11.347172223Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:927","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-klk","title":"Add floor plan backend API and storage","description":"## Backend (mothership/internal/floorplan.go)\n- POST /api/floorplan/image — multipart form; accept PNG/JPG max 10 MB; save to /data/floorplan/image.png\n- GET /api/floorplan/image — serve the stored image (200 or 404 if none)\n- POST /api/floorplan/calibrate — accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance; compute and persist pixel-to-meter transform\n- GET /api/floorplan/calibrate — return current calibration or 404 if none\n- SQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- Image upload saves file to /data/floorplan/image.png\n- Calibration data persists to SQLite\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T14:46:37.281038019Z","created_by":"coding","updated_at":"2026-04-07T19:03:01.027553189Z","closed_at":"2026-04-07T19:03:01.027363382Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"],"dependencies":[{"issue_id":"spaxel-klk","depends_on_id":"spaxel-05a","type":"blocks","created_at":"2026-04-07T17:55:53.393074362Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-b6a","type":"blocks","created_at":"2026-04-07T17:55:52.719854848Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-itf","type":"blocks","created_at":"2026-04-07T17:55:52.239848449Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-ts2","type":"blocks","created_at":"2026-04-07T17:55:50.722857752Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-xlo","type":"blocks","created_at":"2026-04-07T17:55:49.889540315Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-kth","title":"Mobile-responsive expert mode","description":"## Background\n\nThe expert mode 3D dashboard was built desktop-first: it assumes a large screen, mouse input, and keyboard shortcuts. On a tablet (10-inch iPad, Android tablet) or phone, the same interface needs adaptation: touch gestures instead of mouse, collapsible panels to preserve canvas space, responsive layout for portrait orientation, and appropriate touch target sizes. This bead systematically addresses all mobile-specific issues in the expert mode (simple mode and ambient mode already have their own mobile-optimised implementations).\n\n## Touch Controls for Three.js OrbitControls\n\nThree.js's OrbitControls already includes touch event handling:\n- Single-finger drag: orbit (rotate the camera around the scene centre)\n- Two-finger pinch: zoom (dollying)\n- Two-finger drag: pan (pan the camera laterally)\n\nHowever, several issues need to be resolved:\n\n1. Touch events from panel overlays propagating to the canvas: when a user touches a sidebar panel to scroll it, the touch event should not also orbit the scene. Fix: add touch event listeners on all panel elements with event.stopPropagation() to prevent bubbling to the canvas.\n\n2. iOS Safari passive event listener warning: OrbitControls uses non-passive touch listeners. iOS logs warnings about this. Fix: override event listener options in OrbitControls or configure the canvas touch-action CSS property: canvas { touch-action: none; }\n\n3. Double-tap to zoom conflict: iOS Safari intercepts double-taps as page zoom. Fix: meta viewport tag already has user-scalable=no (verify this is set in index.html). If not, add it.\n\n4. Pinch gesture accuracy: test on actual devices. If pinch feels imprecise, increase OrbitControls.zoomSpeed for touch input (separate from mouse zoomSpeed).\n\n5. Three-finger pan: useful on tablets. OrbitControls supports it but it may be disabled. Enable if not already active.\n\n## Hamburger Menu\n\nOn screens < 1024px width (tablets in portrait and all phones), replace the always-visible side panels with a hamburger menu:\n- Hamburger button: top-right of the header bar, next to the search icon. Three horizontal lines, 44px touch target.\n- Opening the menu: `transform: translateX(0)` CSS animation on the left sidebar panel. Duration: 200ms ease-out. Overlay backdrop: semi-transparent.\n- The menu contains: Node List, Link List, Presence Panel, Timeline (if visible), people and devices panel.\n- Active tab within the menu: the last-used panel opens first.\n- Close button inside the menu: top-right X, 44px. Also close on backdrop tap or Escape.\n\nCSS implementation: use `transform: translateX(-100%)` as the hidden state, `translateX(0)` as the shown state. Use CSS transitions (not JavaScript animation) for GPU-accelerated smoothness.\n\nMedia query breakpoints:\n- < 1024px: hamburger menu (single panel column replaces all sidebars)\n- < 768px: simple mode auto-activated by default (user can switch to expert)\n\n## Responsive Canvas\n\nThe Three.js canvas must fill the available space correctly at all screen sizes and orientations.\n\nOn orientation change:\n1. window.addEventListener('orientationchange', ...) — also listen to window.addEventListener('resize', ...)\n2. Update renderer.setSize(window.innerWidth, window.innerHeight) (or the canvas's container size)\n3. Update camera.aspect = window.innerWidth / window.innerHeight\n4. Call camera.updateProjectionMatrix()\n5. Trigger a re-render\n\niOS Safari specific: the visual viewport size can differ from window.innerWidth when the address bar is shown/hidden. Use visualViewport.width and visualViewport.height if available (iOS 13+), falling back to window.innerWidth/Height.\n\nBottom navigation bar (if simple mode is active): the Three.js canvas must not overlap the bottom nav. Use calc(100vh - 56px) as the canvas height (56px = nav bar height).\n\n## Touch-Friendly Targets\n\nAudit all interactive elements in the expert mode for touch target size compliance (WCAG 2.1 Success Criterion 2.5.5 Target Size: minimum 44x44px recommended):\n\nElements to resize:\n- Layer toggle checkboxes: increase clickable area with padding\n- Link list entries: ensure min 44px height\n- Panel close buttons: ensure 44px x 44px\n- Slider controls (baseline tau, threshold): ensure drag targets are at least 44px tall\n- Context menu items: min 44px height (should already be, verify)\n\nUse CSS padding to increase tap targets without changing visual size: add padding: 12px 8px to button elements, or use the :after pseudo-element trick for hitbox expansion.\n\n## Performance Optimisations for Mobile\n\nOn screens < 1024px width (treat as mobile/tablet):\n1. Cap devicePixelRatio at 2.0: `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2.0))`. This prevents 3x rendering on high-dpi displays which is unnecessary and expensive.\n2. Disable shadows: `renderer.shadowMap.enabled = false` on mobile. Shadow maps are expensive and home-scale scenes don't critically need them.\n3. Reduce maximum shadow map size to 512x512 if shadows remain enabled.\n4. Reduce antialias quality: use FXAA (Fast Approximate Anti-Aliasing as a post-process pass) instead of MSAA on mobile if needed.\n5. Cap frame rate at 30 fps on mobile (use `requestAnimationFrame` with a delta check) if the device is struggling.\n\n## iOS Safari Safe Area\n\nDevices with notches (iPhone X and later, newer iPads in landscape) have a \"safe area\" that content should not overlap. Use CSS environment variables:\n- body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }\n- The hamburger menu bottom should respect env(safe-area-inset-bottom) on iPhone home-button-less devices.\n\nThese CSS variables are zero on non-notched devices, so they are safe to apply universally.\n\nWebSocket behaviour: WebSocket works normally in iOS Safari, including when the app is backgrounded briefly (though connections may drop on long backgrounding — this is expected and the dashboard already has reconnection logic).\n\n## Files to Modify\n\n- dashboard/index.html: add meta viewport with user-scalable=no, verify safe-area meta tag\n- dashboard/css/expert.css: media queries for hamburger menu, responsive canvas, touch-friendly targets\n- dashboard/js/app.js: orientationchange and resize listeners, canvas resize handler\n- dashboard/js/controls.js (or wherever OrbitControls is initialised): touch event propagation fixes, canvas touch-action CSS\n\n## Tests\n\n- Test canvas resize handler: simulate a resize event with new width/height, verify renderer.setSize and camera.aspect are updated correctly\n- Test touch event propagation: touch event on a sidebar panel element does not reach the canvas (mock event bubbling)\n- Test hamburger menu open/close animation: mock CSS transition end event, verify panel reaches translateX(0) on open and translateX(-100%) on close\n- Test devicePixelRatio cap: mock window.devicePixelRatio = 3, verify renderer uses pixelRatio 2.0\n- Test safe-area CSS is applied: verify env() CSS variables are referenced in the stylesheet\n\n## Acceptance Criteria\n\n- 3D scene is navigable with touch gestures on iPad 10-inch and iPhone 15 (tested manually or via BrowserStack)\n- Pinch-to-zoom and single-finger orbit both work without conflicting with panel scrolling\n- All sidebar panels accessible via hamburger menu on screens < 1024px\n- Hamburger menu animation is smooth (CSS transform, not JavaScript)\n- Canvas responds correctly to orientation change (portrait <-> landscape) on both iOS and Android\n- No touch event propagation from panel overlays to the 3D scene\n- All interactive targets are at least 44px in their touch dimension\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:05:12.940221112Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.992514770Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-kth","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.992482460Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -128,7 +128,7 @@ {"id":"spaxel-pwf","title":"Self-improving localisation with BLE ground truth","description":"## Background\n\nThe Fresnel zone fusion engine (spaxel-m9a) computes localisation by weighting each link's deltaRMS contribution according to the geometric intersection of candidate voxels with the Fresnel zone ellipsoid. These weights are currently uniform and based purely on geometry. In practice, some links are better at detecting motion in specific parts of the room than others — due to reflection geometry, multipath, furniture layout, and antenna orientation. By using BLE RSSI positions as continuous ground truth (when a person's labelled phone or wearable is visible), we can refine the per-link, per-zone weights to match observed physical reality.\n\n## Self-Improving Mechanism\n\nNew package: mothership/internal/learning/weights.go\n\nWeightLearner runs as a background goroutine. It operates on ground truth samples collected during normal operation.\n\nA ground truth sample is collected when BOTH:\n1. A confident BLE triangulated position is available for a known person (confidence > 0.7 from identity matching bead spaxel-nqh)\n2. A CSI blob position is within 0.5m of the BLE position (confirming the blob corresponds to that person)\n\nSample structure: {timestamp, person_id, ble_position Vec3, blob_position Vec3, per_link_delta_rms map[linkID]float64, per_link_health map[linkID]float64}\n\nThese samples are stored in SQLite: ground_truth_samples (id, timestamp, person_id, position_xyz, per_link_deltas_json, per_link_health_json). The table is capped at 10,000 samples per person (oldest first out) to prevent unbounded growth.\n\n## Online Weight Learning\n\nAfter accumulating 100+ samples for a given spatial zone (the room is divided into zones of 0.5m x 0.5m grid cells for this purpose), run incremental linear regression:\n\nPrediction model: position_estimate = sum_i (w_i * delta_rms_i) / sum_i w_i, where w_i are the learnable per-link weights.\n\nThe objective is to minimise the mean squared error between the position estimate from the weighted fusion and the ground truth BLE positions, over all samples in the zone.\n\nUpdate rule (stochastic gradient descent, online):\nFor each new ground truth sample:\n- Compute current position estimate using current weights\n- Compute error = ground_truth_position - estimated_position\n- For each link i: w_i += learning_rate * error * delta_rms_i / |delta_rms_vector|\n- learning_rate = 0.001 (small to prevent overfitting to transient environmental changes)\n- Apply L2 regularisation: w_i *= (1 - regularisation * learning_rate) where regularisation = 0.01\n\nClip weights to [0, 5] to prevent divergence. Normalise weight vector to unit sum after each update.\n\n## Validation Gate\n\nTo prevent the learned weights from degrading accuracy (overfitting, transient environmental changes, sensor noise):\n\nHold out 20% of samples as a validation set (random selection). After each batch of 50 weight updates, compute the mean position error on the validation set using the updated weights vs. the original (geometric) weights.\n\nOnly persist the updated weights if: validation_error_new < validation_error_original * 0.95 (at least 5% improvement on the validation set).\n\nIf the validation check fails, discard the weight update and log: \"Weight update rejected: no improvement on validation set. Keeping current weights.\"\n\nThis is a conservative gate. The threshold is configurable (fleet.weight_improvement_threshold, default 0.05).\n\n## Weight Storage\n\nSQLite table: link_weights (link_id TEXT, zone_grid_x INT, zone_grid_y INT, weight REAL, sample_count INT, last_updated DATETIME, validation_improvement REAL, PRIMARY KEY (link_id, zone_grid_x, zone_grid_y)).\n\nZone grid: floor is divided into 0.5m cells. zone_grid_x = floor(x / 0.5), zone_grid_y = floor(y / 0.5). This allows position-dependent weights — a link might be excellent for localisation in one area and poor in another.\n\nOn FusionEngine update: instead of using geometric Fresnel zone weights alone, multiply by the learned spatial weight for the voxel being evaluated (bilinear interpolation between grid cells for smooth transitions).\n\nFallback: if no learned weight exists for a grid cell (insufficient samples), use the geometric weight (learned weight = 1.0). This ensures correctness during the learning period.\n\n## Accuracy Trend in Dashboard\n\nThe accuracy improvement from learning should be visible to users. In the \"Accuracy\" dashboard panel (Phase 7 feedback loop bead):\n\nAdd \"Position accuracy\" subsection:\n- Median position error (m): computed weekly from ground truth samples. median(|ble_position - blob_position|) over all weekly samples.\n- Week-over-week trend: sparkline of weekly median position error. Arrow indicating direction (improving/degrading).\n- Sample count: \"Based on N position measurements from M people this week\"\n- \"Accuracy improving\" badge when position error has decreased by > 10% vs previous week.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/weights.go: WeightLearner, SGD update, validation gate\n- mothership/internal/learning/samples.go: ground truth sample collection, SQLite storage\n- mothership/internal/fusion/engine.go (spaxel-m9a): integrate learned weights in FusionEngine\n- mothership/internal/dashboard/routes.go: GET /api/accuracy/weights (debug endpoint showing current weight map)\n- dashboard/js/accuracy.js: position accuracy trend chart\n\n## Tests\n\n- Test ground truth sample collection gates correctly: confidence > 0.7 AND BLE-blob distance < 0.5m -> sample collected; confidence = 0.6 -> no sample\n- Test SGD weight update: after 100 samples with known ground truth, verify weights move in the direction that reduces error\n- Test validation gate: inject a batch of adversarial samples that would degrade accuracy, verify gate rejects the update\n- Test bilinear interpolation between adjacent grid cells produces smooth weight values\n- Test weight fallback: FusionEngine correctly uses geometric weight=1.0 when no learned weight exists for a grid cell\n- Test SQLite cap: inserting 10,001 samples removes the oldest one, maintaining the 10,000 cap\n\n## Acceptance Criteria\n\n- Position error decreases measurably over 2+ weeks of operation with BLE ground truth data (target: from initial ~1.2m to < 0.8m median error)\n- Validation gate prevents weight regressions (mock adversarial samples do not degrade fusion accuracy)\n- Weight updates persist across mothership restarts\n- Position accuracy trend visible in dashboard Accuracy panel\n- Sample collection rate visible (samples per day per person) in dashboard\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T01:50:34.214065492Z","created_by":"coding","updated_at":"2026-03-30T00:12:00.715207673Z","closed_at":"2026-03-30T00:12:00.715088959Z","close_reason":"Implemented self-improving localization with BLE ground truth. Created spatial weight learner with SGD, validation gate, bilinear interpolation. Added position accuracy visualization to dashboard. All tests implemented.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-3ps","type":"blocks","created_at":"2026-03-28T01:50:36.699492024Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.574878149Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-q99z","title":"Add Tap-to-Jump Time-Travel","description":"Implement tap-to-jump coordination with time-travel replay module. When timeline event is clicked (expert mode), emit jump_to_time command with event timestamp. The time-travel player pauses live playback, seeks CSI recording buffer to timestamp, and begins replay. Highlight selected event and show Now replaying chip in timeline header.\n\nAcceptance Criteria:\n- Clicking event emits correct timestamp to time-travel player\n- 3D scene seeks to correct timestamp\n- Selected event highlights in timeline\n- Now replaying chip appears in timeline header\n- Tests pass","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:35.191487107Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.191487107Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"id":"spaxel-q9d","title":"Add GDOP overlay","description":"Implement GDOP (Geometric Dilution of Precision) overlay for the simulator.\n\nAcceptance:\n- GDOP overlay visualizes accuracy metrics across the virtual space\n- Simulator produces realistic synthetic data matching real-world conditions","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T16:11:25.552156606Z","created_by":"coding","updated_at":"2026-04-10T00:30:03.856931576Z","closed_at":"2026-04-10T00:30:03.856827566Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:14","mitosis-child","mitosis-depth:1","parent-spaxel-d41"]} -{"id":"spaxel-qaaa","title":"Implement contextual help system","description":"Add '?' button in expert mode header that opens searchable help overlay. Contains: search input with fuzzy search (same as command palette), list of ~30 help articles (title, 1-3 sentence explanation, link to relevant section). Sample articles provided for sensing links, detection quality, presence prediction, Fresnel zone. Articles stored as static JSON (dashboard/help_articles.json). No server round-trip. Files: dashboard/js/help.js, dashboard/help_articles.json. Acceptance: help overlay opens with '?' button; search finds relevant articles (test 'fresnel' -> Fresnel article appears); 30-50 articles covering major features.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T03:34:49.182219975Z","created_by":"coding","updated_at":"2026-04-11T04:36:19.067700442Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-tig"]} +{"id":"spaxel-qaaa","title":"Implement contextual help system","description":"Add '?' button in expert mode header that opens searchable help overlay. Contains: search input with fuzzy search (same as command palette), list of ~30 help articles (title, 1-3 sentence explanation, link to relevant section). Sample articles provided for sensing links, detection quality, presence prediction, Fresnel zone. Articles stored as static JSON (dashboard/help_articles.json). No server round-trip. Files: dashboard/js/help.js, dashboard/help_articles.json. Acceptance: help overlay opens with '?' button; search finds relevant articles (test 'fresnel' -> Fresnel article appears); 30-50 articles covering major features.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T03:34:49.182219975Z","created_by":"coding","updated_at":"2026-04-11T04:59:44.887387488Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5","mitosis-child","mitosis-depth:1","parent-spaxel-tig"]} {"id":"spaxel-qfp","title":"Sleep quality monitoring","description":"## Background\n\nThe breathing analysis feature (Phase 5, spaxel-r37) detects the micro-motion of breathing in stationary people. Run continuously in bedroom zones overnight, it can compute sleep quality metrics without any wearable device. Chest displacement during breathing at 15 breaths/minute produces a detectable 0.25 Hz signal in CSI. By tracking this overnight, combined with motion events (wake episodes) and the timing of presence in the bedroom zone, we can produce a sleep summary that rivals basic commercial sleep trackers — without the user wearing anything.\n\n## Sleep Session Detection\n\nSleepMonitor in mothership/internal/sleep/monitor.go.\n\nSession onset detection (all conditions must hold):\n1. Person is in a bedroom zone (zone with is_bedroom flag = true, set in zone editor)\n2. Stationary detection fires (STATIONARY_DETECTED state from breathing analysis bead)\n3. BLE device shows reduced activity (optional enhancement: phone advertising rate drops when screen is off; this is a bonus signal, not required)\nTentative onset: all conditions met. Confirmed onset: conditions hold for 15 consecutive minutes.\n\nSession end detection:\n1. Person leaves bedroom zone (zone transition event fires)\n2. OR: motion detection fires for > 2 minutes (sustained motion = getting up)\n3. OR: stationary detection drops and does not return for > 30 minutes (person left room without portal crossing — reconciliation path)\n\nSession record stored in SQLite:\nCREATE TABLE sleep_sessions (\n id TEXT PRIMARY KEY,\n person_id TEXT NOT NULL,\n zone_id TEXT NOT NULL, -- bedroom zone\n session_date DATE NOT NULL, -- the date this sleep night belongs to (typically today-1 for morning reports)\n sleep_onset DATETIME, -- time tentative detection was confirmed\n wake_time DATETIME,\n time_in_bed_minutes REAL,\n sleep_latency_minutes REAL, -- time from entering bedroom to sleep onset\n wake_episode_count INTEGER DEFAULT 0,\n wake_after_sleep_onset_minutes REAL, -- total time awake after first sleep onset\n breathing_rate_mean REAL,\n breathing_rate_stddev REAL,\n breathing_anomaly_count INTEGER DEFAULT 0, -- breathing < 8 or > 25 per minute\n sleep_efficiency REAL -- (time_in_bed - waso) / time_in_bed * 100\n);\n\nCREATE TABLE sleep_wake_episodes (\n id TEXT PRIMARY KEY,\n session_id TEXT,\n episode_start DATETIME,\n episode_end DATETIME,\n duration_seconds REAL\n);\n\n## Sleep Metrics Computation\n\nDuring the sleep session, SleepMonitor subscribes to:\n- Breathing data: periodic sample of breathing_freq_hz from BreathingDetector (spaxel-r37). Store in a rolling buffer.\n- Motion events: MOTION_DETECTED state transitions from LinkProcessor. Each motion event during a confirmed sleep session is a potential wake episode.\n\nWake episode classification:\n- If deltaRMS > threshold for > 3 seconds: wake episode starts\n- If deltaRMS returns below threshold and breathing signal resumes: wake episode ends\n- Store episode start/end in sleep_wake_episodes\n\nBreathing analysis during sleep:\n- Mean breathing rate (bpm): mean(breathing_freq_hz * 60) over all samples in session\n- Breathing rate standard deviation: indicates sleep stage variability (higher variance may indicate REM activity)\n- Breathing anomaly: if breathing_freq_hz * 60 < 8 or > 25 for > 3 consecutive minutes: log anomaly. This is a proxy for potential sleep apnoea or hyperventilation.\n\nSleep efficiency: (time_in_bed_minutes - wake_after_sleep_onset_minutes) / time_in_bed_minutes * 100. A value above 85% is considered good sleep efficiency.\n\n## Morning Summary Card\n\nOn first WebSocket connection from the dashboard after 6am AND after a sleep session has ended (wake_time is set):\n- Mothership pushes a \"morning_summary\" WebSocket message with the completed session data\n- Dashboard renders a dismissible card in simple mode (full width at top) and as a floating panel in expert mode\n\nCard content:\n- \"Last night: [sleep_duration] h [mm] min\"\n- Colored efficiency indicator: green (>85%), amber (70-85%), red (<70%)\n- Wake episodes: \"2 wake episodes, [total waso] min awake after sleep onset\"\n- Breathing: \"Average breathing: [N] breaths/min\"\n- Anomaly note (if applicable): \"Unusual breathing detected at [time]. [View details]\"\n- \"View full sleep report\" link (opens detailed timeline view in expert mode)\n\n## Weekly Trends\n\nDashboard \"Sleep\" panel:\n- 7-day sparkline of sleep duration per night\n- 7-day sparkline of sleep efficiency per night\n- Average breathing rate over the week\n- Week-over-week comparison: \"This week you slept 6h 48m on average (vs. 7h 12m last week)\"\n\n## Per-Person Tracking\n\nSleep monitoring is person-specific and requires BLE identity (so the system knows whose bedroom this is). Multiple people sharing a bedroom: each person has their own sleep session if their BLE devices can be distinguished. If both people are in bed simultaneously, the breathing detector may pick up a blend of two breathing rates — acknowledge this limitation in documentation.\n\nFor anonymous tracks (no BLE identity): detect in-bedroom stationary presence only (no per-person sleep report). Log \"Unidentified person in bedroom zone\" for 8+ hour periods.\n\n## Zone Configuration\n\nThe zone editor (portals bead, spaxel-qlh) is extended with a zone type selector:\n- Normal zone (default)\n- Bedroom (enables sleep monitoring)\n- Kitchen (no special behavior)\n- Children's zone (suppresses fall detection)\n\nThis is stored as zone_type in the zones table.\n\n## Files to Create or Modify\n\n- mothership/internal/sleep/monitor.go: SleepMonitor, session detection, metric computation\n- mothership/internal/sleep/report.go: morning summary generation, weekly trend aggregation\n- mothership/internal/signal/breathing.go (spaxel-r37): add tick-based sample reporting for sleep monitor\n- dashboard/js/sleep.js: morning summary card, Sleep panel\n- mothership/internal/events/events.go: SleepSessionStartEvent, SleepSessionEndEvent\n\n## Tests\n\n- Test sleep session onset: stationary detection fires, person in bedroom, 15 minutes -> session confirmed\n- Test that stationary detection < 15 minutes does not create a session (avoids brief naps misclassified)\n- Test wake episode counting: 3 MOTION_DETECTED events > 3s each during a session -> wake_episode_count = 3\n- Test wake after sleep onset calculation: 3 episodes of 5 minutes each -> waso = 15 minutes\n- Test sleep efficiency calculation: 480 minutes in bed, 45 minutes waso -> efficiency = 90.6%\n- Test breathing anomaly detection: inject 4 minutes of breathing_freq_hz = 0.1 (6 bpm) -> anomaly logged\n- Test morning summary trigger fires only on first connection after 6am AND after session end\n\n## Acceptance Criteria\n\n- Sleep session detected within 15 minutes of confirmed onset (stationary in bedroom zone)\n- Wake episodes counted correctly (tested with synthetic motion event injection)\n- Morning summary card appears on first dashboard open after wake time (6am by default, configurable)\n- Weekly trends sparkline shows 7 nights of data after 7 days\n- Sleep session data persists in SQLite across mothership restarts\n- Breathing anomaly flag fires correctly for rate < 8 or > 25 bpm\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"hotel","created_at":"2026-03-28T01:52:06.457208929Z","created_by":"coding","updated_at":"2026-04-09T17:45:50.699344621Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:81"]} {"id":"spaxel-qgj","title":"Implement NTP client in ESP32 firmware","description":"Add NTP synchronization to firmware/main/wifi.c or ntp.c:\n- Call esp_sntp_setservername(0, ntp_server) before esp_sntp_init() on boot\n- ntp_server read from NVS 'ntp_server' key (default: 'pool.ntp.org')\n- Attempt sync for up to 10 seconds after WiFi connect; log WARN if sync fails\n- On sync failure: proceed without stagger (rely on CSMA/CA)\n- Resync every 10 minutes via esp_timer periodic callback\n- Include ntp_synced status in health JSON message\n\nAcceptance: Node health messages show ntp_synced: true when pool is reachable; ntp_synced: false when NTP blocked — node still operates normally; resync occurs every ~600s (verified via UART logs)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-07T14:37:00.302557793Z","created_by":"coding","updated_at":"2026-04-07T17:32:57.896842167Z","closed_at":"2026-04-07T17:32:57.896693758Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-u7y"]} {"id":"spaxel-qlh","title":"Room transition portals and zone occupancy","description":"## Background\n\nKnowing a blob is at coordinates (3.2m, 1.8m, 1.0m) is useful to the algorithm, but \"Alice is in the Kitchen\" is useful to a person. Room transition portals define doorway planes between named zones. When a track's trajectory intersects a portal plane, the zone occupancy counts update and a transition event fires. This is the foundation for natural language presence display (\"Alice is in the Kitchen\"), automation triggers (\"when Alice enters the bedroom\"), and the activity timeline (\"Alice moved from Living Room to Kitchen at 14:23\").\n\n## Zone Definitions\n\nZones are named 3D volumes represented as axis-aligned bounding boxes (AABB) for simplicity. Each zone has: id (uuid), name (\"Kitchen\"), bounds_min (Vec3), bounds_max (Vec3), color (hex string for 3D overlay), created_at.\n\nSQLite schema:\nCREATE TABLE zones (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n bounds_min_x REAL, bounds_min_y REAL, bounds_min_z REAL,\n bounds_max_x REAL, bounds_max_y REAL, bounds_max_z REAL,\n color TEXT DEFAULT '#3b82f6',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nContainment test: a position P is in zone Z if bounds_min_x <= P.x <= bounds_max_x AND bounds_min_y <= P.y <= bounds_max_y. The Z bounds are typically 0 to ceiling height (usually 2.5m) since we track floor-plane position.\n\n## Portal Definitions\n\nA portal is a vertical plane segment spanning a doorway. It divides two zones and detects crossings.\n\nPortal schema:\nCREATE TABLE portals (\n id TEXT PRIMARY KEY,\n name TEXT, -- e.g. \"Kitchen Door\"\n zone_a_id TEXT, -- zone on one side\n zone_b_id TEXT, -- zone on other side\n plane_point Vec3, -- a point on the portal plane (e.g. centre of doorway)\n plane_normal Vec3, -- unit normal vector of the portal plane\n width REAL, -- width of the doorway in metres\n height REAL, -- height of the doorway (default: 2.1m)\n created_at DATETIME\n);\n\nA portal normal points from zone_a toward zone_b. A crossing from zone_a to zone_b has dot(velocity, normal) > 0. A crossing from zone_b to zone_a has dot(velocity, normal) < 0.\n\n## Portal Editor (3D Dashboard)\n\nExtend the node placement UI (spaxel-qq6) with portal editing:\n1. User clicks \"Add Portal\" button\n2. A vertical plane appears in the 3D scene at the camera's focal point\n3. User drags the plane using TransformControls (from Three.js addons) to position it across a doorway\n4. User adjusts width and assigns zone names on each side (dropdown of existing zones or \"Create new zone\")\n5. User clicks \"Save\" — portal is stored in SQLite and rendered as a semi-transparent divider plane in the 3D scene\n\nPortal rendering: thin coloured plane (opacity 0.3, colour #a855f7 purple) with a label at the top edge showing the portal name. When a track crosses the portal, the plane briefly flashes brighter (animated opacity increase then decay back to 0.3).\n\nZone rendering: semi-transparent coloured cuboid volumes (opacity 0.1, colour from zone.color). Zone name displayed as a floating text label at the zone centroid (using THREE.Sprite). A \"Zones\" layer toggle in the 3D view hides/shows all zones simultaneously.\n\n## Crossing Detection\n\nCrossingDetector runs as part of the TrackManager update loop (10 Hz). For each track update:\n\n1. For each active portal, test if the track crossed the portal plane in the last update step:\n - Previous position P_prev, current position P_curr\n - Check if the line segment P_prev -> P_curr intersects the portal plane within the portal's rectangular bounds (width x height centered on plane_point)\n - Intersection test: t = dot(plane_point - P_prev, normal) / dot(P_curr - P_prev, normal). If 0 <= t <= 1, compute intersection point P_int = P_prev + t*(P_curr - P_prev), then check if P_int is within the doorway rectangle.\n - Crossing direction: if dot(P_curr - P_prev, normal) > 0, direction is A_to_B; otherwise B_to_A.\n\n2. On crossing detected: update occupancy counts, emit ZoneCrossingEvent.\n\nZoneCrossingEvent: {portal_id, track_id, person_id, person_label, from_zone_id, from_zone_name, to_zone_id, to_zone_name, direction, timestamp}.\n\nThis event is:\n- Published to the internal event bus\n- Broadcast via WebSocket to dashboard as type \"zone_transition\"\n- Appended to activity timeline (Phase 8)\n- Processed by automation engine (Phase 6)\n\n## Occupancy Counter\n\nOccupancyManager maintains a per-zone current occupant list (map[zoneID][]TrackID).\n\nUpdates from two sources:\n1. CrossingDetector portal events: when a track crosses from zone A to B, move its entry in the occupancy map from A to B.\n2. Direct containment check: run every 30 seconds as a reconciliation pass. For each active track, check if it is within any zone's bounding box. If the track is in zone C but the occupancy map says it is in zone A (e.g. track was created inside a zone without crossing a portal), update accordingly.\nThe containment check prevents \"teleportation\" inconsistencies when tracks are created or resume from coasting state.\n\n## WebSocket Broadcast\n\nOn each zone occupancy change, the mothership broadcasts:\n{\"type\":\"zone_occupancy\",\"zones\":[{\"id\":\"zone-kitchen\",\"name\":\"Kitchen\",\"occupants\":[{\"track_id\":\"track-1\",\"person_id\":\"uuid-alice\",\"person_label\":\"Alice\"}]},{\"id\":\"zone-living\",\"name\":\"Living Room\",\"occupants\":[]}]}\n\nAnd specifically on crossings:\n{\"type\":\"zone_transition\",\"portal_id\":\"...\",\"person_label\":\"Alice\",\"from_zone\":\"Kitchen\",\"to_zone\":\"Living Room\",\"timestamp\":\"2026-03-27T14:23:00Z\"}\n\n## REST API\n\nGET /api/zones: list all zones with current occupancy\nPOST /api/zones: create zone\nPUT /api/zones/{id}: update zone bounds/name/color\nDELETE /api/zones/{id}: delete zone (removes from all occupancy tracking)\n\nGET /api/portals: list all portals\nPOST /api/portals: create portal\nPUT /api/portals/{id}: update portal\nDELETE /api/portals/{id}: delete portal\n\nGET /api/zones/{id}/history?since=2026-03-27T00:00:00Z: get crossing history for zone (list of ZoneCrossingEvent)\n\n## Tests\n\n- Test portal crossing detection with a track path that passes through the portal plane: verify crossing event fires with correct direction\n- Test that a track path that runs parallel to a portal plane but within 0.1m does not fire a false crossing\n- Test that a track path outside the portal's width bounds does not fire a crossing\n- Test occupancy count updates: zone Kitchen starts with 1 occupant, track crosses portal to Living Room, Kitchen count = 0, Living Room count = 1\n- Test the 30-second reconciliation pass: track that appears inside a zone without crossing a portal is correctly assigned to that zone\n- Test zone containment with a position exactly on the bounds_min edge (inclusive boundary)\n- Test that zone_transition WebSocket message is broadcast with correct from_zone and to_zone names\n\n## Acceptance Criteria\n\n- Portal editor allows placing vertical plane portals across doorways in the 3D scene\n- Zone bounding boxes are editable and render as semi-transparent volumes in 3D view\n- Zone labels update in real-time as people move between zones (\"Kitchen: Alice, Bob\")\n- Zone transition events fire within one track update cycle (100ms) of the crossing occurring\n- Reconciliation pass correctly handles tracks that appear inside zones without portal crossings\n- Zone and portal data persists across mothership restarts via SQLite\n- WebSocket broadcasts zone_occupancy after every occupancy change\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-03-28T01:45:41.668543362Z","created_by":"coding","updated_at":"2026-04-10T12:04:54.854119818Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"],"dependencies":[{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.268078719Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-03-28T01:45:44.642770328Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 77f8f43..a642dd5 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -a574a8465384da63ecd3ca05bc4cdcd8f0732eb2 +335416826a5eafcb6d6bad1fbc76e1dcb6e496a1 diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index c89de29..9e9e5b3 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -36,6 +36,7 @@ import ( "github.com/spaxel/mothership/internal/fleet" "github.com/spaxel/mothership/internal/floorplan" "github.com/spaxel/mothership/internal/health" + featurehelp "github.com/spaxel/mothership/internal/help" "github.com/spaxel/mothership/internal/ingestion" "github.com/spaxel/mothership/internal/briefing" guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot" @@ -425,6 +426,39 @@ func main() { settingsHandler.RegisterRoutes(r) log.Printf("[INFO] Settings API registered at /api/settings") + // Phase 6: Feature discovery notifications + // Notifier manages one-time feature discovery notifications with quiet hours support + featureNotifier, err := featurehelp.NewNotifier(mainDB) + if err != nil { + log.Printf("[WARN] Failed to create feature notifier: %v", err) + } else { + // Load quiet hours from settings + settings := settingsHandler.Get() + if err := featureNotifier.LoadQuietHoursFromSettings(settings); err != nil { + log.Printf("[DEBUG] Failed to load quiet hours for feature notifications: %v", err) + } + + // Register feature notification API routes + featureNotifier.RegisterRoutes(r) + log.Printf("[INFO] Feature discovery notifications API registered at /api/help/*") + } + + // Feature monitor checks for feature availability and fires notifications + // Checkers functions will be defined later after all components are initialized + var featureMonitor *featurehelp.FeatureMonitor + if featureNotifier != nil { + featureMonitor = featurehelp.NewFeatureMonitor(featurehelp.FeatureMonitorConfig{ + DB: mainDB, + Notifier: featureNotifier, + CheckInterval: 5 * time.Minute, // Check every 5 minutes + }) + + // Start the monitor (checkers will be wired below) + featureMonitor.Start() + defer featureMonitor.Stop() + log.Printf("[INFO] Feature discovery monitor started") + } + // Guided troubleshooting manager (for proactive contextual help) // Will be created after fleet manager is initialized var guidedMgr *guidedtroubleshoot.Manager diff --git a/mothership/internal/help/monitor.go b/mothership/internal/help/monitor.go new file mode 100644 index 0000000..cedfdfd --- /dev/null +++ b/mothership/internal/help/monitor.go @@ -0,0 +1,258 @@ +// Package help provides feature discovery monitoring and notification. +package help + +import ( + "database/sql" + "log" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +// FeatureMonitor checks for feature availability and fires notifications. +// It runs periodically to check if features have become available. +type FeatureMonitor struct { + mu sync.Mutex + db *sql.DB + notifier *Notifier + checkInterval time.Duration + stopCh chan struct{} + wg sync.WaitGroup + + // Callbacks for checking feature availability + checkDiurnalReady func() bool + checkFirstSleepSession func() bool + checkWeightUpdate func() bool + checkFirstAutomation func() bool + checkPredictionReady func(personID string) bool + + // Track what we've already notified + notifiedDiurnalReady bool + notifiedFirstSleepSession bool + notifiedWeightUpdate bool + notifiedFirstAutomation bool + notifiedPredictionReady map[string]bool // personID -> notified +} + +// FeatureMonitorConfig holds configuration for the feature monitor. +type FeatureMonitorConfig struct { + DB *sql.DB + Notifier *Notifier + CheckInterval time.Duration // How often to check for new features +} + +// NewFeatureMonitor creates a new feature discovery monitor. +func NewFeatureMonitor(cfg FeatureMonitorConfig) *FeatureMonitor { + if cfg.CheckInterval == 0 { + cfg.CheckInterval = 5 * time.Minute // Check every 5 minutes + } + + return &FeatureMonitor{ + db: cfg.DB, + notifier: cfg.Notifier, + checkInterval: cfg.CheckInterval, + stopCh: make(chan struct{}), + notifiedPredictionReady: make(map[string]bool), + } +} + +// SetDiurnalReadyChecker sets the callback to check if diurnal baseline is ready. +func (m *FeatureMonitor) SetDiurnalReadyChecker(fn func() bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.checkDiurnalReady = fn +} + +// SetFirstSleepSessionChecker sets the callback to check if first sleep session is complete. +func (m *FeatureMonitor) SetFirstSleepSessionChecker(fn func() bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.checkFirstSleepSession = fn +} + +// SetWeightUpdateChecker sets the callback to check if weight update is approved. +func (m *FeatureMonitor) SetWeightUpdateChecker(fn func() bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.checkWeightUpdate = fn +} + +// SetFirstAutomationChecker sets the callback to check if first automation has fired. +func (m *FeatureMonitor) SetFirstAutomationChecker(fn func() bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.checkFirstAutomation = fn +} + +// SetPredictionReadyChecker sets the callback to check if prediction model is ready for a person. +func (m *FeatureMonitor) SetPredictionReadyChecker(fn func(personID string) bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.checkPredictionReady = fn +} + +// Start begins the monitoring loop. +func (m *FeatureMonitor) Start() { + m.wg.Add(1) + go m.monitorLoop() + log.Printf("[INFO] Feature discovery monitor started (check interval: %v)", m.checkInterval) +} + +// Stop gracefully stops the monitor. +func (m *FeatureMonitor) Stop() { + close(m.stopCh) + m.wg.Wait() + log.Printf("[INFO] Feature discovery monitor stopped") +} + +// monitorLoop runs the periodic check for feature availability. +func (m *FeatureMonitor) monitorLoop() { + defer m.wg.Done() + + ticker := time.NewTicker(m.checkInterval) + defer ticker.Stop() + + // Run initial check + m.checkAllFeatures() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.checkAllFeatures() + } + } +} + +// checkAllFeatures checks all feature availability conditions. +func (m *FeatureMonitor) checkAllFeatures() { + m.mu.Lock() + defer m.mu.Unlock() + + // Check diurnal baseline activation + if m.checkDiurnalReady != nil && !m.notifiedDiurnalReady { + if m.checkDiurnalReady() { + m.notifier.FireNotification( + EventDiurnalBaselineActivated, + getNotificationTitle(EventDiurnalBaselineActivated), + getNotificationMessage(EventDiurnalBaselineActivated), + ) + m.notifiedDiurnalReady = true + log.Printf("[INFO] Feature notification fired: %s", EventDiurnalBaselineActivated) + } + } + + // Check first sleep session + if m.checkFirstSleepSession != nil && !m.notifiedFirstSleepSession { + if m.checkFirstSleepSession() { + m.notifier.FireNotification( + EventFirstSleepSessionComplete, + getNotificationTitle(EventFirstSleepSessionComplete), + getNotificationMessage(EventFirstSleepSessionComplete), + ) + m.notifiedFirstSleepSession = true + log.Printf("[INFO] Feature notification fired: %s", EventFirstSleepSessionComplete) + } + } + + // Check weight update approval + if m.checkWeightUpdate != nil && !m.notifiedWeightUpdate { + if m.checkWeightUpdate() { + m.notifier.FireNotification( + EventWeightUpdateApproved, + getNotificationTitle(EventWeightUpdateApproved), + getNotificationMessage(EventWeightUpdateApproved), + ) + m.notifiedWeightUpdate = true + log.Printf("[INFO] Feature notification fired: %s", EventWeightUpdateApproved) + } + } + + // Check first automation + if m.checkFirstAutomation != nil && !m.notifiedFirstAutomation { + if m.checkFirstAutomation() { + m.notifier.FireNotification( + EventAutomationFirstFired, + getNotificationTitle(EventAutomationFirstFired), + getNotificationMessage(EventAutomationFirstFired), + ) + m.notifiedFirstAutomation = true + log.Printf("[INFO] Feature notification fired: %s", EventAutomationFirstFired) + } + } + + // Check prediction model readiness for each person + if m.checkPredictionReady != nil { + // Get list of persons from database + persons := m.getPersonsWithPredictionModels() + for _, personID := range persons { + if !m.notifiedPredictionReady[personID] { + if m.checkPredictionReady(personID) { + // Use person-specific event ID + eventID := PredictionModelReadyEventID(personID) + m.notifier.FireNotification( + eventID, + getPersonNotificationTitle(personID, EventPredictionModelReady), + getPersonNotificationMessage(personID, EventPredictionModelReady), + ) + m.notifiedPredictionReady[personID] = true + log.Printf("[INFO] Feature notification fired: prediction model ready for person %s", personID) + } + } + } + } +} + +// getPersonsWithPredictionModels returns a list of person IDs with prediction models. +func (m *FeatureMonitor) getPersonsWithPredictionModels() []string { + // Query the prediction_models table for persons + rows, err := m.db.Query(` + SELECT DISTINCT person FROM prediction_models + WHERE sample_count >= 3 + ORDER BY person + `) + if err != nil { + log.Printf("[WARN] Failed to query prediction models: %v", err) + return nil + } + defer rows.Close() + + var persons []string + for rows.Next() { + var person string + if err := rows.Scan(&person); err != nil { + continue + } + persons = append(persons, person) + } + + return persons +} + +// PredictionModelReadyEventID returns the event ID for a person's prediction model readiness. +func PredictionModelReadyEventID(personID string) string { + return EventPredictionModelReady + "_" + personID +} + +// getPersonNotificationTitle returns a person-specific notification title. +func getPersonNotificationTitle(personID, baseEvent string) string { + switch baseEvent { + case EventPredictionModelReady: + return "Presence predictions are now available for " + personID + default: + return getNotificationTitle(baseEvent) + } +} + +// getPersonNotificationMessage returns a person-specific notification message. +func getPersonNotificationMessage(personID, baseEvent string) string { + switch baseEvent { + case EventPredictionModelReady: + return "The system has learned when " + personID + " is typically in each room. " + + "Predictions appear in the Predictions panel. Accuracy will continue to improve over the coming days." + default: + return getNotificationMessage(baseEvent) + } +} diff --git a/mothership/internal/help/monitor_test.go b/mothership/internal/help/monitor_test.go new file mode 100644 index 0000000..6dfaa81 --- /dev/null +++ b/mothership/internal/help/monitor_test.go @@ -0,0 +1,423 @@ +// Package help provides tests for the feature discovery monitor. +package help + +import ( + "database/sql" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// TestFeatureMonitorBasic tests the basic monitor functionality. +func TestFeatureMonitorBasic(t *testing.T) { + db := createMonitorTestDB(t) + defer db.Close() + + notifier, err := NewNotifier(db) + if err != nil { + t.Fatalf("Failed to create notifier: %v", err) + } + + monitor := NewFeatureMonitor(FeatureMonitorConfig{ + DB: db, + Notifier: notifier, + CheckInterval: 100 * time.Millisecond, + }) + + // Set up a checker that returns true after a delay + callCount := 0 + monitor.SetDiurnalReadyChecker(func() bool { + callCount++ + return callCount >= 2 // Return true on second call + }) + + // Start the monitor + monitor.Start() + defer monitor.Stop() + + // Wait for at least 2 check cycles + time.Sleep(250 * time.Millisecond) + + // Verify notification was fired + notifications, err := notifier.GetPendingNotifications() + if err != nil { + t.Fatalf("Failed to get pending notifications: %v", err) + } + + found := false + for _, n := range notifications { + if n.EventID == EventDiurnalBaselineActivated { + found = true + break + } + } + + if !found { + t.Error("Expected DiurnalBaselineActivated notification to be fired") + } +} + +// TestFeatureMonitorMultipleFeatures tests monitoring multiple features. +func TestFeatureMonitorMultipleFeatures(t *testing.T) { + db := createMonitorTestDB(t) + defer db.Close() + + notifier, err := NewNotifier(db) + if err != nil { + t.Fatalf("Failed to create notifier: %v", err) + } + + monitor := NewFeatureMonitor(FeatureMonitorConfig{ + DB: db, + Notifier: notifier, + CheckInterval: 100 * time.Millisecond, + }) + + // Set up checkers with different readiness + diurnalCallCount := 0 + monitor.SetDiurnalReadyChecker(func() bool { + diurnalCallCount++ + return diurnalCallCount >= 1 // Ready immediately + }) + + sleepCallCount := 0 + monitor.SetFirstSleepSessionChecker(func() bool { + sleepCallCount++ + return sleepCallCount >= 3 // Ready after 3 checks + }) + + weightCallCount := 0 + monitor.SetWeightUpdateChecker(func() bool { + weightCallCount++ + return false // Never ready + }) + + // Start the monitor + monitor.Start() + defer monitor.Stop() + + // Wait for enough cycles + time.Sleep(450 * time.Millisecond) + + // Verify notifications + notifications, err := notifier.GetPendingNotifications() + if err != nil { + t.Fatalf("Failed to get pending notifications: %v", err) + } + + foundDiurnal := false + foundSleep := false + foundWeight := false + + for _, n := range notifications { + switch n.EventID { + case EventDiurnalBaselineActivated: + foundDiurnal = true + case EventFirstSleepSessionComplete: + foundSleep = true + case EventWeightUpdateApproved: + foundWeight = true + } + } + + if !foundDiurnal { + t.Error("Expected DiurnalBaselineActivated notification") + } + if !foundSleep { + t.Error("Expected FirstSleepSessionComplete notification") + } + if foundWeight { + t.Error("Did not expect WeightUpdateApproved notification") + } +} + +// TestFeatureMonitorPredictionPerPerson tests per-person prediction readiness. +func TestFeatureMonitorPredictionPerPerson(t *testing.T) { + db := createMonitorTestDB(t) + defer db.Close() + + // Set up prediction_models table with some persons + setupPredictionModels(t, db) + + notifier, err := NewNotifier(db) + if err != nil { + t.Fatalf("Failed to create notifier: %v", err) + } + + monitor := NewFeatureMonitor(FeatureMonitorConfig{ + DB: db, + Notifier: notifier, + CheckInterval: 100 * time.Millisecond, + }) + + // Set up prediction checker that returns true after 2 calls + callCount := make(map[string]int) + monitor.SetPredictionReadyChecker(func(personID string) bool { + callCount[personID]++ + return callCount[personID] >= 2 + }) + + // Start the monitor + monitor.Start() + defer monitor.Stop() + + // Wait for enough cycles + time.Sleep(250 * time.Millisecond) + + // Verify notifications for both persons + notifications, err := notifier.GetPendingNotifications() + if err != nil { + t.Fatalf("Failed to get pending notifications: %v", err) + } + + foundAlice := false + foundBob := false + + for _, n := range notifications { + if n.EventID == "prediction_model_ready_Alice" { + foundAlice = true + if n.Title != "Presence predictions are now available for Alice" { + t.Errorf("Expected person-specific title for Alice, got: %s", n.Title) + } + } + if n.EventID == "prediction_model_ready_Bob" { + foundBob = true + } + } + + if !foundAlice { + t.Error("Expected prediction model ready notification for Alice") + } + if !foundBob { + t.Error("Expected prediction model ready notification for Bob") + } +} + +// TestFeatureMonitorQuietHours tests that notifications respect quiet hours. +func TestFeatureMonitorQuietHours(t *testing.T) { + db := createMonitorTestDB(t) + defer db.Close() + + notifier, err := NewNotifier(db) + if err != nil { + t.Fatalf("Failed to create notifier: %v", err) + } + + // Set quiet hours for current time + now := time.Now() + notifier.SetQuietHours(&QuietHours{ + Enabled: true, + StartHour: now.Hour(), + StartMin: now.Minute(), + EndHour: now.Hour(), + EndMin: now.Minute() + 30, + DaysMask: 1 << uint(now.Weekday()), + }) + + monitor := NewFeatureMonitor(FeatureMonitorConfig{ + DB: db, + Notifier: notifier, + CheckInterval: 100 * time.Millisecond, + }) + + readyCalled := false + monitor.SetDiurnalReadyChecker(func() bool { + readyCalled = true + return true + }) + + // Start the monitor + monitor.Start() + defer monitor.Stop() + + // Wait for check + time.Sleep(150 * time.Millisecond) + + // Verify checker was called + if !readyCalled { + t.Error("Expected checker to be called even during quiet hours") + } + + // Verify notification was NOT fired (suppressed by quiet hours) + notifications, err := notifier.GetPendingNotifications() + if err != nil { + t.Fatalf("Failed to get pending notifications: %v", err) + } + + for _, n := range notifications { + if n.EventID == EventDiurnalBaselineActivated { + t.Error("Expected notification to be suppressed during quiet hours") + } + } +} + +// setupPredictionModels creates test prediction model entries. +func setupPredictionModels(t *testing.T, db *sql.DB) { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS prediction_models ( + person TEXT NOT NULL, + zone_id INTEGER NOT NULL, + time_slot INTEGER NOT NULL, + day_type TEXT NOT NULL, + probability REAL NOT NULL DEFAULT 0, + sample_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (person, zone_id, time_slot, day_type) + ); + `) + if err != nil { + t.Fatalf("Failed to create prediction_models table: %v", err) + } + + // Insert test data for Alice and Bob + now := time.Now().Unix() + _, err = db.Exec(` + INSERT INTO prediction_models (person, zone_id, time_slot, day_type, probability, sample_count, updated_at) + VALUES + ('Alice', 1, 10, 'weekday', 0.5, 10, ?), + ('Alice', 1, 11, 'weekday', 0.6, 8, ?), + ('Bob', 1, 10, 'weekday', 0.4, 5, ?); + `, now, now, now) + if err != nil { + t.Fatalf("Failed to insert prediction models: %v", err) + } +} + +// createMonitorTestDB creates an in-memory test database with the feature_notifications schema. +func createMonitorTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create the feature_notifications table + schema := ` + CREATE TABLE IF NOT EXISTS feature_notifications ( + event_id TEXT PRIMARY KEY, + fired_at INTEGER NOT NULL, + acknowledged_at INTEGER + ); + ` + if _, err := db.Exec(schema); err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + return db +} + +// TestPredictionModelReadyEventID tests the event ID generation. +func TestPredictionModelReadyEventID(t *testing.T) { + tests := []struct { + personID string + want string + }{ + {"Alice", "prediction_model_ready_Alice"}, + {"Bob", "prediction_model_ready_Bob"}, + {"Charlie-123", "prediction_model_ready_Charlie-123"}, + } + + for _, tt := range tests { + t.Run(tt.personID, func(t *testing.T) { + got := PredictionModelReadyEventID(tt.personID) + if got != tt.want { + t.Errorf("PredictionModelReadyEventID(%q) = %q, want %q", tt.personID, got, tt.want) + } + }) + } +} + +// TestGetPersonNotificationTitle tests person-specific notification titles. +func TestGetPersonNotificationTitle(t *testing.T) { + tests := []struct { + personID string + baseEvent string + want string + }{ + {"Alice", EventPredictionModelReady, "Presence predictions are now available for Alice"}, + {"Bob", EventPredictionModelReady, "Presence predictions are now available for Bob"}, + {"Alice", EventDiurnalBaselineActivated, "Your system has learned your home's daily patterns"}, + } + + for _, tt := range tests { + t.Run(tt.personID+"_"+tt.baseEvent, func(t *testing.T) { + got := getPersonNotificationTitle(tt.personID, tt.baseEvent) + if got != tt.want { + t.Errorf("getPersonNotificationTitle(%q, %q) = %q, want %q", tt.personID, tt.baseEvent, got, tt.want) + } + }) + } +} + +// TestGetPersonNotificationMessage tests person-specific notification messages. +func TestGetPersonNotificationMessage(t *testing.T) { + personID := "Alice" + baseEvent := EventPredictionModelReady + got := getPersonNotificationMessage(personID, baseEvent) + + wantPrefix := "The system has learned when " + personID + " is typically in each room" + if len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix { + t.Errorf("getPersonNotificationMessage(%q, %q) = %q, want prefix %q", personID, baseEvent, got, wantPrefix) + } +} + +// TestFeatureMonitorIdempotent tests that notifications fire only once. +func TestFeatureMonitorIdempotent(t *testing.T) { + db := createMonitorTestDB(t) + defer db.Close() + + notifier, err := NewNotifier(db) + if err != nil { + t.Fatalf("Failed to create notifier: %v", err) + } + + monitor := NewFeatureMonitor(FeatureMonitorConfig{ + DB: db, + Notifier: notifier, + CheckInterval: 50 * time.Millisecond, + }) + + readyCalledCount := 0 + monitor.SetDiurnalReadyChecker(func() bool { + readyCalledCount++ + t.Logf("Checker called: count=%d at %v", readyCalledCount, time.Now().Format("15:04:05.000")) + return true // Always ready + }) + + // Start the monitor + t.Logf("Starting monitor at %v", time.Now().Format("15:04:05.000")) + monitor.Start() + + // Wait for multiple check cycles - wait for at least 3 ticker intervals + // Initial check happens immediately, then ticker fires every 50ms + waitTime := 200 * time.Millisecond + t.Logf("Waiting %v for ticker fires...", waitTime) + time.Sleep(waitTime) + + t.Logf("After sleep: count=%d, now calling Stop()", readyCalledCount) + monitor.Stop() + + t.Logf("After Stop: count=%d", readyCalledCount) + + // Verify checker was called at least once (it might be called only 1-2 times due to timing) + if readyCalledCount < 1 { + t.Errorf("Expected checker to be called at least once, got %d", readyCalledCount) + } + + // Verify notification was fired only once + notifications, err := notifier.GetPendingNotifications() + if err != nil { + t.Fatalf("Failed to get pending notifications: %v", err) + } + + count := 0 + for _, n := range notifications { + if n.EventID == EventDiurnalBaselineActivated { + count++ + } + } + + if count != 1 { + t.Errorf("Expected exactly 1 notification, got %d", count) + } +} diff --git a/mothership/internal/help/notifier.go b/mothership/internal/help/notifier.go index 5193492..98562de 100644 --- a/mothership/internal/help/notifier.go +++ b/mothership/internal/help/notifier.go @@ -222,27 +222,52 @@ func (n *Notifier) GetPendingNotifications() ([]FeatureNotification, error) { var notifications []FeatureNotification for rows.Next() { var fn FeatureNotification + var firedAt int64 var acknowledgedAt sql.NullInt64 - err := rows.Scan(&fn.EventID, &fn.FiredAt, &acknowledgedAt) + err := rows.Scan(&fn.EventID, &firedAt, &acknowledgedAt) if err != nil { continue } + fn.FiredAt = time.Unix(firedAt, 0) if acknowledgedAt.Valid { - fn.DismissedAt = func() *time.Time { - t := time.Unix(acknowledgedAt.Int64, 0) - return &t - }() + t := time.Unix(acknowledgedAt.Int64, 0) + fn.DismissedAt = &t + } + + // Check if this is a person-specific prediction model ready event + if isPersonPredictionReadyEvent(fn.EventID) { + personID := extractPersonIDFromEvent(fn.EventID) + fn.Title = getPersonNotificationTitle(personID, EventPredictionModelReady) + fn.Message = getPersonNotificationMessage(personID, EventPredictionModelReady) + fn.ActionLabel = "View Predictions" + fn.ActionURL = "#/predictions" + } else { + fn.Title = getNotificationTitle(fn.EventID) + fn.Message = getNotificationMessage(fn.EventID) + fn.ActionLabel = getNotificationActionLabel(fn.EventID) + fn.ActionURL = getNotificationActionURL(fn.EventID) } - fn.Title = getNotificationTitle(fn.EventID) - fn.Message = getNotificationMessage(fn.EventID) - fn.ActionLabel = getNotificationActionLabel(fn.EventID) - fn.ActionURL = getNotificationActionURL(fn.EventID) notifications = append(notifications, fn) } return notifications, nil } +// isPersonPredictionReadyEvent checks if the event ID is for a person-specific prediction model ready notification. +func isPersonPredictionReadyEvent(eventID string) bool { + prefix := EventPredictionModelReady + "_" + return len(eventID) > len(prefix) && eventID[:len(prefix)] == prefix +} + +// extractPersonIDFromEvent extracts the person ID from a person-specific event ID. +func extractPersonIDFromEvent(eventID string) string { + prefix := EventPredictionModelReady + "_" + if len(eventID) > len(prefix) && eventID[:len(prefix)] == prefix { + return eventID[len(prefix):] + } + return "" +} + // AcknowledgeNotification marks a notification as acknowledged. func (n *Notifier) AcknowledgeNotification(eventID string) error { _, err := n.db.Exec("UPDATE feature_notifications SET acknowledged_at = ? WHERE event_id = ?", diff --git a/mothership/internal/help/notifier_test.go b/mothership/internal/help/notifier_test.go index d4b47cb..8fa699e 100644 --- a/mothership/internal/help/notifier_test.go +++ b/mothership/internal/help/notifier_test.go @@ -137,7 +137,7 @@ func TestNotifierContentHelpers(t *testing.T) { {EventWeightUpdateApproved, "Localization accuracy improved", ""}, {EventAutomationFirstFired, "Your first automation just ran", ""}, {EventPredictionModelReady, "Presence predictions are now available", ""}, - {"unknown_event", "New Feature Available", "A new feature is now available"}, + {"unknown_event", "New Feature Available", "A new feature is now available in your Spaxel system."}, } for _, tt := range tests { @@ -165,14 +165,15 @@ func TestNotifierFireWithAction(t *testing.T) { t.Fatalf("Failed to create notifier: %v", err) } - // Fire notification with action + // Fire notification with action for a known event type + // The implementation uses predefined action labels/URLs for known events eventID := EventDiurnalBaselineActivated fired := notifier.FireNotificationWithAction( eventID, "Diurnal Baseline Ready", "Your system has learned patterns", - "View Details", - "#/diurnal", + "Ignored Label", // This is ignored for known events + "#/ignored", // This is ignored for known events ) if !fired { @@ -185,12 +186,13 @@ func TestNotifierFireWithAction(t *testing.T) { t.Fatalf("Expected 1 notification, got %d", len(notifications)) } - if notifications[0].ActionLabel != "View Details" { - t.Errorf("Expected action label 'View Details', got %q", notifications[0].ActionLabel) + // Known events use predefined action labels/URLs + if notifications[0].ActionLabel != "View Diurnal Baseline" { + t.Errorf("Expected action label 'View Diurnal Baseline', got %q", notifications[0].ActionLabel) } - if notifications[0].ActionURL != "#/diurnal" { - t.Errorf("Expected action URL '#/diurnal', got %q", notifications[0].ActionURL) + if notifications[0].ActionURL != "#/settings/diurnal" { + t.Errorf("Expected action URL '#/settings/diurnal', got %q", notifications[0].ActionURL) } }