diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 55f5dea..b8e0570 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":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T03:34:49.150476063Z","created_by":"coding","updated_at":"2026-04-11T03:34:49.150476063Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["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:28:26.511130461Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","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":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T03:34:49.182219975Z","created_by":"coding","updated_at":"2026-04-11T03:34:49.182219975Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["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:28:04.157154027Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","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 c92242e..3001f25 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -6812da8ccb1b0675133a2e545faee98e07559a87 +0485d746ba3001bad6ee9eabfe485db2771a9cf0 diff --git a/dashboard/js/help.js b/dashboard/js/help.js index ce25281..55c77ea 100644 --- a/dashboard/js/help.js +++ b/dashboard/js/help.js @@ -42,7 +42,7 @@ */ async function loadArticles() { try { - const response = await fetch('/help_articles.json'); + const response = await fetch('help_articles.json'); if (!response.ok) { throw new Error('Failed to load help articles'); } @@ -121,16 +121,16 @@ * Add help button to expert mode header */ function addHelpButton() { - // Look for expert mode header or status bar - const header = document.querySelector('.expert-header, .view-header, .toolbar, #status-bar'); - if (!header) { - // Try again after a delay - setTimeout(addHelpButton, 1000); + // Check if button already exists + if (document.getElementById('help-button')) { return; } - // Check if button already exists - if (document.getElementById('help-button')) { + // Look for the status bar button container (margin-left:auto; gap:6px) + const buttonContainer = document.querySelector('#status-bar > div[style*="margin-left:auto"]'); + if (!buttonContainer) { + // Try again after a delay + setTimeout(addHelpButton, 500); return; } @@ -141,7 +141,13 @@ helpBtn.title = 'Help & Documentation (Ctrl+?)'; helpBtn.onclick = () => HelpOverlay.open(); - header.appendChild(helpBtn); + // Insert before the last status-item (FPS counter) + const fpsItem = buttonContainer.parentElement.querySelector('.status-item:last-child'); + if (fpsItem) { + buttonContainer.insertBefore(helpBtn, fpsItem); + } else { + buttonContainer.appendChild(helpBtn); + } } /** diff --git a/mothership/internal/db/migrations.go b/mothership/internal/db/migrations.go index c53f650..a26c81c 100644 --- a/mothership/internal/db/migrations.go +++ b/mothership/internal/db/migrations.go @@ -78,6 +78,11 @@ func AllMigrations() []Migration { Description: "add id, delivered, acknowledged columns to briefings table", Up: migration_014_add_briefing_delivery_columns, }, + { + Version: 15, + Description: "add feature_notifications table for feature discovery", + Up: migration_015_add_feature_notifications, + }, } } @@ -705,3 +710,17 @@ func migration_014_add_briefing_delivery_columns(tx *sql.Tx) error { return nil } + +// migration_015_add_feature_notifications adds the feature_notifications table +// for one-time feature discovery notifications. +func migration_015_add_feature_notifications(tx *sql.Tx) error { + schema := ` + CREATE TABLE IF NOT EXISTS feature_notifications ( + event_id TEXT PRIMARY KEY, + fired_at INTEGER NOT NULL, + acknowledged_at INTEGER + ); + ` + _, err := tx.Exec(schema) + return err +} diff --git a/mothership/internal/help/notifier.go b/mothership/internal/help/notifier.go index 5b62f18..1a5abd8 100644 --- a/mothership/internal/help/notifier.go +++ b/mothership/internal/help/notifier.go @@ -53,30 +53,14 @@ const ( ) // NewNotifier creates a new feature notification manager. +// The feature_notifications table must already exist via migration. func NewNotifier(db *sql.DB) (*Notifier, error) { - // Ensure the feature_notifications table exists - if err := ensureSchema(db); err != nil { - return nil, err - } - return &Notifier{ db: db, quietHours: &QuietHours{}, }, nil } -// ensureSchema creates the feature_notifications table if it doesn't exist. -func ensureSchema(db *sql.DB) error { - query := ` - CREATE TABLE IF NOT EXISTS feature_notifications ( - event_id TEXT PRIMARY KEY, - fired_at INTEGER NOT NULL, - acknowledged_at INTEGER - );` - _, err := db.Exec(query) - return err -} - // SetQuietHours sets the quiet hours configuration. func (n *Notifier) SetQuietHours(qh *QuietHours) { n.mu.Lock() diff --git a/mothership/internal/help/notifier_test.go b/mothership/internal/help/notifier_test.go index 9b96b44..d4b47cb 100644 --- a/mothership/internal/help/notifier_test.go +++ b/mothership/internal/help/notifier_test.go @@ -194,15 +194,22 @@ func TestNotifierFireWithAction(t *testing.T) { } } -// createTestDB creates an in-memory test database. +// createTestDB creates an in-memory test database with the feature_notifications schema. func createTestDB(t *testing.T) *sql.DB { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("Failed to open test database: %v", err) } - // Create schema - if err := ensureSchema(db); err != nil { + // Create the feature_notifications table directly (same as migration_015) + 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) }