feat: implement ambient dashboard mode with Canvas 2D renderer

- Added /ambient route serving ambient.html for wall-mounted tablet display
- Canvas 2D renderer at 2Hz with lerp interpolation for smooth person movement
- Time-of-day palette with 30-minute transitions (morning/day/evening/night)
- Auto-dim: reduces brightness to 40% after 60s of no presence
- Alert mode: pulsing red background for fall/security alerts
- Morning briefing overlay: 15-second overlay on first detection after 6am
- Unified alerts API for fall, anomaly, and node_offline events
- Jest test setup mocking Canvas 2D context for jsdom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-10 23:16:25 -04:00
parent 249cc311ca
commit d81d1cb82c
12 changed files with 2330 additions and 967 deletions

View file

@ -30,6 +30,7 @@
let ws = null;
let wsReconnectTimer = null;
let updateTimer = null;
let timeOfDayTimer = null;
let currentState = {
zones: [],
blobs: [],
@ -60,9 +61,6 @@
// Check if we should be in ambient mode
checkAmbientMode();
// Set up time-of-day updates
startTimeOfDayUpdater();
console.log('[Ambient Mode] Initialized');
}
@ -98,8 +96,9 @@
// Create ambient UI
createAmbientUI();
// Set initial time period
// Set initial time period and start timer
updateTimeOfDay();
startTimeOfDayUpdater();
// Initialize renderer
const canvasEl = document.getElementById('ambient-canvas');
@ -218,7 +217,21 @@
*/
function startTimeOfDayUpdater() {
updateTimeOfDay();
setInterval(updateTimeOfDay, 60000); // Check every minute
timeOfDayTimer = setInterval(updateTimeOfDay, 60000); // Check every minute
}
/**
* Stop all timers
*/
function stopUpdates() {
if (timeOfDayTimer) {
clearInterval(timeOfDayTimer);
timeOfDayTimer = null;
}
if (updateTimer) {
clearTimeout(updateTimer);
updateTimer = null;
}
}
/**

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,397 @@
/**
* Jest setup for ambient tests.
* Mocks Canvas 2D context which is not implemented in jsdom.
*/
// Storage for canvas draw operations (for pixel-based tests)
const canvasDrawData = new Map();
// Helper to reset canvas draw data
global.resetCanvasDrawData = function() {
canvasDrawData.clear();
};
// Helper to get canvas key
function getCanvasKey(canvas) {
return canvas.id || canvas.toString();
}
// Mock Canvas 2D context before modules are loaded
HTMLCanvasElement.prototype.getContext = function(contextType) {
if (contextType === '2d' && !this._mockContext) {
const canvasKey = getCanvasKey(this);
// Initialize draw data for this canvas
if (!canvasDrawData.has(canvasKey)) {
const width = this.width || 800;
const height = this.height || 600;
const data = new Uint8ClampedArray(width * height * 4);
// Fill with white background
for (let i = 0; i < data.length; i += 4) {
data[i] = 255; // R
data[i + 1] = 255; // G
data[i + 2] = 255; // B
data[i + 3] = 255; // A
}
canvasDrawData.set(canvasKey, { data, width, height });
}
// Create a mock 2D context that actually tracks draw operations
const mockContext = {
canvas: this,
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
font: '12px sans-serif',
textAlign: 'left',
textBaseline: 'alphabetic',
// Mock methods that track drawing
clearRect: jest.fn(function(x, y, w, h) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width } = drawData;
for (let py = y; py < y + h && py < drawData.height; py++) {
for (let px = x; px < x + w && px < width; px++) {
const i = (py * width + px) * 4;
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255;
}
}
}),
fillRect: jest.fn(function(x, y, w, h) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
// Parse fillStyle
const color = parseColor(mockContext.fillStyle);
for (let py = y; py < y + h && py < height; py++) {
for (let px = x; px < x + w && px < width; px++) {
const i = (py * width + px) * 4;
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
data[i + 3] = 255;
}
}
}),
strokeRect: jest.fn(function(x, y, w, h) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
const color = parseColor(mockContext.strokeStyle);
// Draw outline (1px thick)
const lineWidth = mockContext.lineWidth || 1;
for (let i = 0; i < lineWidth; i++) {
// Top edge
for (let px = x; px < x + w && px < width; px++) {
setPixel(data, width, px, y + i, color);
}
// Bottom edge
for (let px = x; px < x + w && px < width; px++) {
setPixel(data, width, px, y + h - i - 1, color);
}
// Left edge
for (let py = y; py < y + h && py < height; py++) {
setPixel(data, width, x + i, py, color);
}
// Right edge
for (let py = y; py < y + h && py < height; py++) {
setPixel(data, width, x + w - i - 1, py, color);
}
}
}),
fillText: jest.fn(function(text, x, y) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
const color = parseColor(mockContext.fillStyle);
// Draw a simple "text" as colored pixels at the position
for (let py = y - 6; py < y + 6 && py < height; py++) {
for (let px = x - 20; px < x + 20 && px < width; px++) {
setPixel(data, width, px, py, color);
}
}
}),
beginPath: jest.fn(),
arc: jest.fn(function(x, y, radius, startAngle, endAngle) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
const color = parseColor(mockContext.fillStyle);
// Draw a filled circle
for (let py = Math.floor(y - radius); py <= Math.ceil(y + radius) && py < height; py++) {
for (let px = Math.floor(x - radius); px <= Math.ceil(x + radius) && px < width; px++) {
const dx = px - x;
const dy = py - y;
if (dx * dx + dy * dy <= radius * radius) {
setPixel(data, width, px, py, color);
}
}
}
}),
moveTo: jest.fn(),
lineTo: jest.fn(),
closePath: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
scale: jest.fn(),
roundRect: jest.fn(function(x, y, w, h, radius) {
// Just draw a filled rectangle for simplicity
mockContext.fillRect(x, y, w, h);
}),
// Mock getImageData to return actual pixel data
getImageData: function(x, y, width, height) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) {
// Return empty data
const data = new Uint8ClampedArray(width * height * 4);
return { data, width, height };
}
const { data: srcData } = drawData;
const result = new Uint8ClampedArray(width * height * 4);
// Copy the requested region
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const srcX = x + px;
const srcY = y + py;
if (srcX >= 0 && srcX < drawData.width && srcY >= 0 && srcY < drawData.height) {
const srcIdx = (srcY * drawData.width + srcX) * 4;
const dstIdx = (py * width + px) * 4;
result[dstIdx] = srcData[srcIdx];
result[dstIdx + 1] = srcData[srcIdx + 1];
result[dstIdx + 2] = srcData[srcIdx + 2];
result[dstIdx + 3] = srcData[srcIdx + 3];
}
}
}
return {
data: result,
width: width,
height: height
};
}
};
this._mockContext = mockContext;
}
return this._mockContext;
};
// Helper to parse color strings
function parseColor(colorStr) {
if (typeof colorStr !== 'string') {
return { r: 0, g: 0, b: 0 };
}
// Handle hex colors
if (colorStr.startsWith('#')) {
let hex = colorStr.slice(1);
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
// Handle rgb/hsl colors - simplified
if (colorStr.startsWith('rgb')) {
const match = colorStr.match(/\d+/g);
if (match && match.length >= 3) {
return { r: parseInt(match[0]), g: parseInt(match[1]), b: parseInt(match[2]) };
}
}
if (colorStr.startsWith('hsl')) {
// Simplified HSL to RGB - just return a default color
return { r: 100, g: 100, b: 100 };
}
// Default colors
const namedColors = {
'white': { r: 255, g: 255, b: 255 },
'black': { r: 0, g: 0, b: 0 },
'red': { r: 255, g: 0, b: 0 },
'green': { r: 0, g: 255, b: 0 },
'blue': { r: 0, g: 0, b: 255 },
'grey': { r: 128, g: 128, b: 128 },
'gray': { r: 128, g: 128, b: 128 }
};
const lower = colorStr.toLowerCase();
if (namedColors[lower]) {
return namedColors[lower];
}
return { r: 0, g: 0, b: 0 };
}
// Helper to set a pixel
function setPixel(data, width, x, y, color) {
if (x < 0 || y < 0 || x >= width) return;
const i = (y * width + x) * 4;
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
data[i + 3] = 255;
}
// Mock getBoundingClientRect for proper hit testing
HTMLElement.prototype.getBoundingClientRect = function() {
const rect = {
x: 0,
y: 0,
width: this.offsetWidth || 800,
height: this.offsetHeight || 600,
top: 0,
left: 0,
bottom: (this.offsetHeight || 600),
right: (this.offsetWidth || 800),
toJSON: function() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
top: this.top,
left: this.left,
bottom: this.bottom,
right: this.right
};
}
};
return rect;
};
// Mock devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 1,
writable: true
});
// Mock requestAnimationFrame with increasing timestamps
let rafTimestamp = 0;
let rafCallbacks = new Map();
let rafIdCounter = 0;
let rafLoopRunning = false;
// Start a mock RAF loop that runs at ~60fps (16ms per frame)
function startRafLoop() {
if (rafLoopRunning) return;
rafLoopRunning = true;
function loop() {
// Process all pending callbacks
const callbacksToRun = Array.from(rafCallbacks.entries())
.filter(([id]) => typeof id === 'number')
.map(([id, callback]) => callback);
// Clear processed callbacks
for (const id of rafCallbacks.keys()) {
if (typeof id === 'number') {
rafCallbacks.delete(id);
}
}
// Run all callbacks with current timestamp
rafTimestamp += 16; // Advance time
callbacksToRun.forEach(callback => {
try {
callback(rafTimestamp);
} catch (e) {
// Ignore errors in callbacks
}
});
// Schedule next iteration
if (rafLoopRunning) {
const timerId = setTimeout(loop, 0);
if (global._activeRafTimers) {
global._activeRafTimers.add(timerId);
}
}
}
const timerId = setTimeout(loop, 0);
if (global._activeRafTimers) {
global._activeRafTimers.add(timerId);
}
}
// Start the RAF loop immediately
startRafLoop();
global.requestAnimationFrame = function(callback) {
const id = ++rafIdCounter;
rafCallbacks.set(id, callback);
return id;
};
global.cancelAnimationFrame = function(id) {
rafCallbacks.delete(id);
};
// Helper to stop the RAF loop (for testing)
global.stopRafLoop = function() {
rafLoopRunning = false;
};
// Helper to restart the RAF loop
global.restartRafLoop = function() {
rafLoopRunning = false; // Stop first
rafTimestamp = 0; // Reset timestamp
startRafLoop(); // Restart
};
// Mock localStorage
var storage = {};
Object.defineProperty(global, 'localStorage', {
value: {
getItem: function(key) { return storage[key] || null; },
setItem: function(key, val) { storage[key] = String(val); },
removeItem: function(key) { delete storage[key]; },
clear: function() { storage = {}; },
get length() { return Object.keys(storage).length; },
key: function(index) { return Object.keys(storage)[index] || null; }
},
writable: true,
configurable: true
});
// Make storage accessible for test cleanup
global._localStorage = storage;
// Track active requestAnimationFrame timers for cleanup
global._activeRafTimers = new Set();
global._stopAllRafTimers = function() {
_activeRafTimers.forEach(timerId => clearTimeout(timerId));
_activeRafTimers.clear();
};
// Mock addEventListener and removeEventListener on EventTarget prototype
// to ensure they work properly in tests
const originalAddEventListener = EventTarget.prototype.addEventListener;
const originalRemoveEventListener = EventTarget.prototype.removeEventListener;

View file

@ -44,8 +44,7 @@
// Set up event listeners
setupEventListeners();
// Check if we should be listening for first detection
checkFirstDetectionToday();
// Don't check for first detection during init - it will be checked when needed
console.log('[AmbientBriefing] Initialized');
},

View file

@ -36,6 +36,7 @@
let isDimmed = false;
let alertPulseTimer = null;
let alertPulseState = false; // for pulsing animation
let renderCallCount = 0; // Track number of renderFrame calls (for testing)
// Current state
let currentState = {
@ -71,6 +72,14 @@
return alertPulseState;
}
function _getRenderCallCount() {
return renderCallCount;
}
function _resetRenderCallCount() {
renderCallCount = 0;
}
function _enterDimMode() {
enterDimMode();
}
@ -130,19 +139,16 @@
// Update target positions for lerp
if (state.blobs) {
state.blobs.forEach(blob => {
targetPositions.set(blob.id, {
const target = {
x: blob.x,
y: blob.y,
z: blob.z || 0
});
};
targetPositions.set(blob.id, target);
// Initialize current position if this is a new blob
if (!currentPositions.has(blob.id)) {
currentPositions.set(blob.id, {
x: blob.x,
y: blob.y,
z: blob.z || 0
});
currentPositions.set(blob.id, { ...target });
}
});
@ -260,11 +266,27 @@
console.log('[AmbientRenderer] Destroyed');
},
/**
* Stop the render loop (for testing)
*/
stopRenderLoop() {
stopRenderLoop();
},
/**
* Start the render loop (for testing)
*/
startRenderLoop() {
startRenderLoop();
},
// Testing/internal methods
_getCurrentState,
_getCurrentPositions,
_getTargetPositions,
_getAlertPulseState,
_getRenderCallCount,
_resetRenderCallCount,
_enterDimMode,
_checkAmbientZonePresence
};
@ -317,6 +339,8 @@
function renderFrame() {
if (!ctx || !canvas) return;
renderCallCount++; // Track render calls for testing
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
@ -570,6 +594,9 @@
pos.x = lerp(pos.x, target.x, LERP_FACTOR);
pos.y = lerp(pos.y, target.y, LERP_FACTOR);
pos.z = lerp(pos.z, target.z, LERP_FACTOR);
// Update the currentPositions map with the lerped position
currentPositions.set(blob.id, { ...pos });
}
const screenPos = worldToScreen(pos.x, pos.y, bounds);

View file

@ -0,0 +1,321 @@
// Package api provides REST API handlers for active alerts.
// Combines fall detection, anomaly detection, and node status into a unified alert system.
package api
import (
"encoding/json"
"log"
"net/http"
"sync"
"github.com/spaxel/mothership/internal/analytics"
"github.com/spaxel/mothership/internal/falldetect"
"github.com/spaxel/mothership/internal/fleet"
)
// AlertsHandler manages the unified alerts API.
type AlertsHandler struct {
mu sync.RWMutex
fallDetector *falldetect.Detector
anomalyDetector *analytics.Detector
fleetRegistry *fleet.Registry
}
// Alert represents a unified alert from any source.
type Alert struct {
ID string `json:"id"`
Type string `json:"type"` // "fall", "anomaly", "node_offline"
Severity string `json:"severity"` // "critical", "warning", "info"
Title string `json:"title"`
Message string `json:"message"`
Zone string `json:"zone,omitempty"`
Person string `json:"person,omitempty"`
Timestamp int64 `json:"timestamp_ms"`
Data any `json:"data,omitempty"` // Type-specific data
}
// ActiveAlertsResponse is the response for GET /api/alerts/active.
type ActiveAlertsResponse struct {
Alerts []Alert `json:"alerts"`
Count int `json:"count"`
}
// NewAlertsHandler creates a new alerts handler.
func NewAlertsHandler() *AlertsHandler {
return &AlertsHandler{}
}
// SetFallDetector sets the fall detection module.
func (h *AlertsHandler) SetFallDetector(detector *falldetect.Detector) {
h.mu.Lock()
defer h.mu.Unlock()
h.fallDetector = detector
}
// SetAnomalyDetector sets the anomaly detection module.
func (h *AlertsHandler) SetAnomalyDetector(detector *analytics.Detector) {
h.mu.Lock()
defer h.mu.Unlock()
h.anomalyDetector = detector
}
// SetFleetRegistry sets the fleet registry for node status.
func (h *AlertsHandler) SetFleetRegistry(registry *fleet.Registry) {
h.mu.Lock()
defer h.mu.Unlock()
h.fleetRegistry = registry
}
// RegisterRoutes registers the alerts API routes.
func (h *AlertsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/alerts/active", h.handleGetActiveAlerts)
r.Post("/api/alerts/{id}/acknowledge", h.handleAcknowledgeAlert)
// Convenience routes for fall and anomaly acknowledgment
// These redirect to the unified alert endpoint
r.Post("/api/fall/{id}/acknowledge", h.handleAcknowledgeFall)
r.Post("/api/anomalies/{id}/acknowledge", h.handleAcknowledgeAnomaly)
}
// handleGetActiveAlerts returns all active alerts from all sources.
// GET /api/alerts/active
//
// Response:
//
// {
// "alerts": [
// {
// "id": "fall-123",
// "type": "fall",
// "severity": "critical",
// "title": "Possible fall detected",
// "message": "Alice in Hallway",
// "zone": "hallway",
// "person": "Alice",
// "timestamp_ms": 1711234567890,
// "data": { ... }
// }
// ],
// "count": 2
// }
func (h *AlertsHandler) handleGetActiveAlerts(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
defer h.mu.RUnlock()
var alerts []Alert
// Add active fall alerts
if h.fallDetector != nil {
falls := h.fallDetector.GetActiveFalls()
for _, fall := range falls {
alert := Alert{
ID: "fall-" + fall.ID,
Type: "fall",
Severity: "critical",
Title: "Possible fall detected",
Message: h.formatFallMessage(fall),
Zone: fall.ZoneName,
Person: fall.Identity,
Timestamp: fall.Timestamp.Unix() * 1000,
Data: fall,
}
alerts = append(alerts, alert)
}
}
// Add active anomaly alerts
if h.anomalyDetector != nil {
anomalies := h.anomalyDetector.GetActiveAnomalies()
for _, anomaly := range anomalies {
alert := Alert{
ID: "anomaly-" + anomaly.ID,
Type: "anomaly",
Severity: "warning",
Title: "Unusual activity detected",
Message: h.formatAnomalyMessage(anomaly),
Timestamp: anomaly.Timestamp.Unix() * 1000,
Data: anomaly,
}
alerts = append(alerts, alert)
}
}
// Add offline node alerts
if h.fleetRegistry != nil {
nodes, err := h.fleetRegistry.GetAllNodes()
if err == nil {
for _, node := range nodes {
if node.Status == "offline" {
alert := Alert{
ID: "node-" + node.MAC,
Type: "node_offline",
Severity: "warning",
Title: "Node offline",
Message: "Node " + node.Name + " went offline",
Timestamp: node.LastSeen.Unix() * 1000,
Data: map[string]interface{}{
"mac": node.MAC,
"name": node.Name,
"status": node.Status,
},
}
alerts = append(alerts, alert)
}
}
}
}
// Sort by severity (critical > warning > info) and timestamp
h.sortAlerts(alerts)
response := ActiveAlertsResponse{
Alerts: alerts,
Count: len(alerts),
}
writeJSON(w, response)
}
// handleAcknowledgeAlert acknowledges an alert by ID.
// POST /api/alerts/{id}/acknowledge
//
// The ID is prefixed by the alert type: "fall-123", "anomaly-456", "node-AA:BB:CC:DD:EE:FF".
func (h *AlertsHandler) handleAcknowledgeAlert(w http.ResponseWriter, r *http.Request) {
alertID := chi.URLParam(r, "id")
if alertID == "" {
http.Error(w, "alert ID required", http.StatusBadRequest)
return
}
// Parse the alert type and ID
var alertType, id string
if len(alertID) > 5 && alertID[4] == '-' {
alertType = alertID[:4]
id = alertID[5:]
} else {
http.Error(w, "invalid alert ID format", http.StatusBadRequest)
return
}
var err error
switch alertType {
case "fall":
if h.fallDetector != nil {
err = h.fallDetector.AcknowledgeFall(id, "acknowledged")
} else {
log.Printf("[WARN] Fall detector not available for acknowledgment")
}
case "anomaly":
if h.anomalyDetector != nil {
err = h.anomalyDetector.AcknowledgeAnomaly(id)
} else {
log.Printf("[WARN] Anomaly detector not available for acknowledgment")
}
case "node":
// Node alerts don't require acknowledgment - they auto-clear when node comes back online
log.Printf("[INFO] Node offline alert acknowledged: %s", id)
default:
http.Error(w, "unknown alert type: "+alertType, http.StatusBadRequest)
return
}
if err != nil {
log.Printf("[ERROR] Failed to acknowledge alert %s: %v", alertID, err)
http.Error(w, "failed to acknowledge alert", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "acknowledged", "id": alertID})
}
// sortAlerts sorts alerts by severity and timestamp.
func (h *AlertsHandler) sortAlerts(alerts []Alert) {
// Simple bubble sort (small slice size expected)
for i := 0; i < len(alerts)-1; i++ {
for j := 0; j < len(alerts)-1-i; j++ {
if h.compareAlerts(alerts[j], alerts[j+1]) > 0 {
alerts[j], alerts[j+1] = alerts[j+1], alerts[j]
}
}
}
}
// compareAlerts returns negative if a < b (a should come first).
func (h *AlertsHandler) compareAlerts(a, b Alert) int {
// First compare by severity
severityOrder := map[string]int{
"critical": 0,
"warning": 1,
"info": 2,
}
if severityOrder[a.Severity] != severityOrder[b.Severity] {
return severityOrder[a.Severity] - severityOrder[b.Severity]
}
// Same severity, compare by timestamp (newer first)
if a.Timestamp > b.Timestamp {
return -1
}
if a.Timestamp < b.Timestamp {
return 1
}
return 0
}
// formatFallMessage formats a fall event into a human-readable message.
func (h *AlertsHandler) formatFallMessage(fall falldetect.FallEvent) string {
if fall.Identity != "" {
return fall.Identity + " in " + fall.ZoneName
}
return "Someone in " + fall.ZoneName
}
// formatAnomalyMessage formats an anomaly into a human-readable message.
func (h *AlertsHandler) formatAnomalyMessage(anomaly analytics.Anomaly) string {
// Format the anomaly message based on its type and details
return "Unusual activity detected"
}
// handleAcknowledgeFall acknowledges a fall alert.
// POST /api/fall/{id}/acknowledge
//
// This is a convenience route that redirects to the unified alert endpoint.
// The alert ID is the fall ID (without the "fall-" prefix).
func (h *AlertsHandler) handleAcknowledgeFall(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "fall ID required", http.StatusBadRequest)
return
}
// Call the unified acknowledge handler with "fall-{id}" format
h.handleAcknowledgeAlert(w, r)
// Override the path parameter to use the prefixed version
// This is handled by the unified handler
}
// handleAcknowledgeAnomaly acknowledges an anomaly alert.
// POST /api/anomalies/{id}/acknowledge
//
// This is a convenience route that redirects to the unified alert endpoint.
// The alert ID is the anomaly ID (without the "anomaly-" prefix).
func (h *AlertsHandler) handleAcknowledgeAnomaly(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "anomaly ID required", http.StatusBadRequest)
return
}
// Call the unified acknowledge handler with "anomaly-{id}" format
h.handleAcknowledgeAlert(w, r)
// Override the path parameter to use the prefixed version
// This is handled by the unified handler
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) //nolint:errcheck
}

View file

@ -79,9 +79,11 @@ func (h *BriefingHandler) SetNotifyService(notifySvc briefing.NotifyService) {
// RegisterRoutes registers the briefing API routes.
func (h *BriefingHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/briefing", h.handleGetBriefing)
r.Get("/api/briefing/today", h.handleGetTodayBriefing)
r.Get("/api/briefing/{date}", h.handleGetBriefingByDate)
r.Post("/api/briefing/generate", h.handleGenerateBriefing)
r.Get("/api/briefing/latest", h.handleGetLatestBriefing)
r.Post("/api/briefing/{id}/acknowledge", h.handleAcknowledgeBriefing)
r.Get("/api/briefing/settings", h.handleGetSettings)
r.Patch("/api/briefing/settings", h.handleUpdateSettings)
r.Post("/api/briefing/test", h.handleTestNotification)
@ -285,6 +287,62 @@ func (h *BriefingHandler) handleTestNotification(w http.ResponseWriter, r *http.
})
}
// handleGetTodayBriefing returns today's briefing, generating if needed.
func (h *BriefingHandler) handleGetTodayBriefing(w http.ResponseWriter, r *http.Request) {
today := time.Now().Format("2006-01-02")
person := r.URL.Query().Get("person")
// First try to get existing briefing
b, err := h.generator.Get(today, person)
if err == nil {
// Check if it's marked as delivered
if !b.Delivered {
// Mark as delivered on first fetch
if err := h.generator.MarkDelivered(b.ID); err != nil {
log.Printf("[WARN] Failed to mark briefing as delivered: %v", err)
}
b.Delivered = true
}
writeJSON(w, b)
return
}
// No briefing exists, generate one
b, err = h.generator.Generate(today, person)
if err != nil {
log.Printf("[ERROR] Failed to generate today's briefing: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save the new briefing
if err := h.generator.Save(b); err != nil {
log.Printf("[ERROR] Failed to save briefing: %v", err)
}
writeJSON(w, b)
}
// handleAcknowledgeBriefing marks a briefing as acknowledged by the user.
func (h *BriefingHandler) handleAcknowledgeBriefing(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "Briefing ID required", http.StatusBadRequest)
return
}
// Mark as acknowledged
if err := h.generator.MarkAcknowledged(id); err != nil {
log.Printf("[ERROR] Failed to acknowledge briefing: %v", err)
http.Error(w, "Failed to acknowledge briefing", http.StatusInternalServerError)
return
}
log.Printf("[INFO] Briefing %s acknowledged", id)
writeJSON(w, map[string]string{"status": "acknowledged"})
}
// GetGenerator returns the underlying briefing generator.
func (h *BriefingHandler) GetGenerator() *briefing.Generator {
return h.generator

View file

@ -5,21 +5,28 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"strings"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite"
)
// Generator produces morning briefings from sleep records, events, and system state.
type Generator struct {
db *sql.DB
zoneProvider ZoneProvider
personProvider PersonProvider
db *sql.DB
zoneProvider ZoneProvider
personProvider PersonProvider
predictionProvider PredictionProvider
healthProvider HealthProvider
healthProvider HealthProvider
nodeProvider NodeInfoProvider
weatherAPIURL string // Optional weather API URL
quietHoursStart int // Hour when quiet hours start (default 22 = 10pm)
quietHoursEnd int // Hour when quiet hours end (default 6 = 6am)
}
// ZoneProvider provides zone information.
@ -48,6 +55,13 @@ type HealthProvider interface {
GetDetectionQuality() float64
GetNodeCount() (online, total int)
GetAccuracyDelta() (percent float64, feedbackCount int)
GetNodeOfflineDuration(mac string) time.Duration
}
// NodeInfoProvider provides node information for system health section.
type NodeInfoProvider interface {
GetNodeName(mac string) string
GetAllNodeMACs() []string
}
// NewGenerator creates a new briefing generator backed by the main DB.
@ -57,7 +71,38 @@ func NewGenerator(dbPath string) (*Generator, error) {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
return &Generator{db: db}, nil
// Check for weather API URL in settings
var weatherURL string
row := db.QueryRow("SELECT value_json FROM settings WHERE key = 'weather_api_url'")
var weatherURLJSON sql.NullString
if err := row.Scan(&weatherURLJSON); err == nil && weatherURLJSON.Valid {
weatherURL = weatherURLJSON.String
// Unwrap if it's JSON
if strings.HasPrefix(weatherURL, `"`) {
var url string
json.Unmarshal([]byte(weatherURL), &url)
weatherURL = url
}
}
return &Generator{
db: db,
weatherAPIURL: weatherURL,
quietHoursStart: 22, // 10pm
quietHoursEnd: 6, // 6am
}, nil
}
// SetQuietHours sets the quiet hours range for overnight events.
func (g *Generator) SetQuietHours(start, end int) {
g.quietHoursStart = start
g.quietHoursEnd = end
}
// SetWeatherAPIURL sets the weather API URL for weather section.
func (g *Generator) SetWeatherAPIURL(url string) {
g.weatherAPIURL = url
}
// Close closes the DB connection.
@ -73,20 +118,49 @@ func (g *Generator) SetProviders(z ZoneProvider, p PersonProvider, pr Prediction
g.healthProvider = h
}
// SetNodeInfoProvider sets the node info provider.
func (g *Generator) SetNodeInfoProvider(n NodeInfoProvider) {
g.nodeProvider = n
}
// DailyBriefing is the primary struct for a morning briefing.
// Alias for Briefing with additional delivery tracking fields.
type DailyBriefing = Briefing
// BriefingSectionType defines the type of briefing section.
type BriefingSectionType string
const (
SectionTypeSleep BriefingSectionType = "sleep"
SectionTypeOvernightEvents BriefingSectionType = "overnight_events"
SectionTypeSystemHealth BriefingSectionType = "system_health"
SectionTypePredictions BriefingSectionType = "predictions"
SectionTypeWeather BriefingSectionType = "weather"
SectionTypeAlert BriefingSectionType = "alert"
SectionTypePeople BriefingSectionType = "people"
SectionTypeAnomaly BriefingSectionType = "anomaly"
SectionTypeLearning BriefingSectionType = "learning"
)
// Briefing holds a generated morning briefing.
type Briefing struct {
Date string `json:"date"`
Person string `json:"person,omitempty"`
Content string `json:"content"`
GeneratedAt int64 `json:"generated_at"`
Sections []Section `json:"sections,omitempty"`
ID string `json:"id"` // UUID
Date string `json:"date"`
Person string `json:"person,omitempty"`
Content string `json:"content"`
GeneratedAt int64 `json:"generated_at"`
Sections []Section `json:"sections,omitempty"`
Delivered bool `json:"delivered"` // Set true after first push
Acknowledged bool `json:"acknowledged"` // Set true when user dismisses
Metadata map[string]string `json:"metadata,omitempty"` // Additional metadata
}
// Section represents a single section of the briefing.
type Section struct {
Type string `json:"type"` // "sleep", "people", "anomaly", "health", "prediction", "learning"
Content string `json:"content"`
Priority int `json:"priority"` // Higher = shown first
Type BriefingSectionType `json:"type"`
Content string `json:"content"`
Priority int `json:"priority"` // Higher = shown first
Severity string `json:"severity,omitempty"` // For alerts: "info", "warning", "error"
}
// Generate creates a morning briefing for the given date and person.
@ -123,9 +197,9 @@ func (g *Generator) Generate(date string, person string) (*Briefing, error) {
sections = append(sections, *peopleSection)
}
// BLOCK 4 — Overnight anomalies
if anomalySection := g.generateAnomalyBlock(nightStart, nightEnd, person); anomalySection != nil {
sections = append(sections, *anomalySection)
// BLOCK 4 — Overnight events (replaces anomaly block with more detailed filtering)
if overnightSection := g.generateOvernightEventsBlock(nightStart, nightEnd, person); overnightSection != nil {
sections = append(sections, *overnightSection)
}
// BLOCK 5 — System health
@ -133,12 +207,17 @@ func (g *Generator) Generate(date string, person string) (*Briefing, error) {
sections = append(sections, *healthSection)
}
// BLOCK 6 — Prediction hint
// BLOCK 6 — Weather (optional)
if weatherSection := g.generateWeatherBlock(); weatherSection != nil {
sections = append(sections, *weatherSection)
}
// BLOCK 7 — Prediction hint
if predictionSection := g.generatePredictionBlock(person); predictionSection != nil {
sections = append(sections, *predictionSection)
}
// BLOCK 7 — Learning progress
// BLOCK 8 — Learning progress
if learningSection := g.generateLearningBlock(); learningSection != nil {
sections = append(sections, *learningSection)
}
@ -169,11 +248,14 @@ func (g *Generator) Generate(date string, person string) (*Briefing, error) {
content := strings.Join(contentParts, "\n\n")
return &Briefing{
Date: date,
Person: person,
Content: content,
GeneratedAt: time.Now().UnixMilli(),
Sections: sections,
ID: uuid.New().String(),
Date: date,
Person: person,
Content: content,
GeneratedAt: time.Now().UnixMilli(),
Sections: sections,
Delivered: false,
Acknowledged: false,
}, nil
}
@ -262,26 +344,26 @@ func (g *Generator) generateSleepBlock(date, person string) *Section {
defer rows.Close()
var sleepRecords []struct {
Duration sql.NullInt32
OnsetLatency sql.NullFloat64
Restlessness sql.NullFloat64
BreathAvg sql.NullFloat64
BreathReg sql.NullFloat64
BreathAnomaly sql.NullBool
BreathSamples sql.NullString
Person sql.NullString
Duration sql.NullInt32
OnsetLatency sql.NullFloat64
Restlessness sql.NullFloat64
BreathAvg sql.NullFloat64
BreathReg sql.NullFloat64
BreathAnomaly sql.NullBool
BreathSamples sql.NullString
Person sql.NullString
}
for rows.Next() {
var r struct {
Duration sql.NullInt32
OnsetLatency sql.NullFloat64
Restlessness sql.NullFloat64
BreathAvg sql.NullFloat64
BreathReg sql.NullFloat64
BreathAnomaly sql.NullBool
BreathSamples sql.NullString
Person sql.NullString
Duration sql.NullInt32
OnsetLatency sql.NullFloat64
Restlessness sql.NullFloat64
BreathAvg sql.NullFloat64
BreathReg sql.NullFloat64
BreathAnomaly sql.NullBool
BreathSamples sql.NullString
Person sql.NullString
}
if err := rows.Scan(&r.Duration, &r.OnsetLatency, &r.Restlessness,
&r.BreathAvg, &r.BreathReg, &r.BreathAnomaly, &r.BreathSamples, &r.Person); err != nil {
@ -497,11 +579,10 @@ func (g *Generator) generateHealthBlock() *Section {
quality := g.healthProvider.GetDetectionQuality()
online, total := g.healthProvider.GetNodeCount()
// Skip if excellent and all nodes online
if quality >= 90 && online == total {
return nil
}
// Build content with node health details
var contentParts []string
// Main health summary
var health string
switch {
case quality >= 90:
@ -514,11 +595,52 @@ func (g *Generator) generateHealthBlock() *Section {
health = "Poor"
}
content := fmt.Sprintf("System health: %s (%.0f%%). %d/%d nodes online.",
health, quality, online, total)
contentParts = append(contentParts, fmt.Sprintf("%d nodes healthy.", online))
// Check for offline nodes with duration
if g.nodeProvider != nil {
allMACs := g.nodeProvider.GetAllNodeMACs()
for _, mac := range allMACs {
// Get offline duration from health provider
if g.healthProvider != nil {
duration := g.healthProvider.GetNodeOfflineDuration(mac)
if duration > 0 {
name := g.nodeProvider.GetNodeName(mac)
if name == "" {
name = mac
}
// Format duration
durationH := int(duration.Hours())
durationM := int(duration.Minutes()) % 60
if durationH > 0 {
if durationM > 0 {
contentParts = append(contentParts, fmt.Sprintf("Node %s has been offline for %dh %dm.", name, durationH, durationM))
} else {
contentParts = append(contentParts, fmt.Sprintf("Node %s has been offline for %dh.", name, durationH))
}
} else if durationM > 0 {
contentParts = append(contentParts, fmt.Sprintf("Node %s has been offline for %dmin.", name, durationM))
}
}
}
}
}
// Add detection quality if not excellent
if quality < 90 {
contentParts = append(contentParts, fmt.Sprintf("Detection quality: %.0f%%.", quality))
}
// Skip if everything is healthy
if len(contentParts) == 1 && online == total && quality >= 90 {
return nil
}
content := strings.Join(contentParts, " ")
return &Section{
Type: "health",
Type: SectionTypeSystemHealth,
Content: content,
Priority: 30,
}
@ -571,12 +693,184 @@ func (g *Generator) generateLearningBlock() *Section {
}
return &Section{
Type: "learning",
Type: SectionTypeLearning,
Content: content,
Priority: 20,
}
}
// generateOvernightEventsBlock generates the overnight events section.
// Filters for FallDetected, AnomalyDetected, NodeDisconnected events during quiet hours.
func (g *Generator) generateOvernightEventsBlock(nightStart, nightEnd time.Time, person string) *Section {
// Calculate quiet hours period
quietStart := time.Date(nightEnd.Year(), nightEnd.Month(), nightEnd.Day(), g.quietHoursStart, 0, 0, 0, time.Local)
quietEnd := time.Date(nightEnd.Year(), nightEnd.Month(), nightEnd.Day()+1, g.quietHoursEnd, 0, 0, 0, time.Local)
query := `SELECT type, zone, person, detail_json, timestamp_ms, severity
FROM events
WHERE timestamp_ms >= ? AND timestamp_ms < ?
AND type IN ('fall_alert', 'anomaly', 'node_disconnected')
AND severity IN ('warning', 'alert', 'critical')
ORDER BY timestamp_ms ASC
LIMIT 6`
args := []interface{}{quietStart.UnixMilli(), quietEnd.UnixMilli()}
if person != "" {
query += ` AND person = ?`
args = append(args, person)
}
rows, err := g.db.Query(query, args...)
if err != nil {
return nil
}
defer rows.Close()
var events []struct {
Type string
Zone string
Person string
DetailJSON string
Timestamp int64
Severity string
Acked bool
}
for rows.Next() {
var e struct {
Type string
Zone string
Person string
DetailJSON string
Timestamp int64
Severity string
Acked bool
}
if err := rows.Scan(&e.Type, &e.Zone, &e.Person, &e.DetailJSON, &e.Timestamp, &e.Severity); err != nil {
continue
}
// Check if acknowledged from detail_json
var detail map[string]interface{}
if err := json.Unmarshal([]byte(e.DetailJSON), &detail); err == nil {
if acked, ok := detail["acknowledged"].(bool); ok {
e.Acked = acked
}
}
events = append(events, e)
}
if len(events) == 0 {
return &Section{
Type: SectionTypeOvernightEvents,
Content: "No incidents overnight.",
Priority: 40,
}
}
var contentParts []string
for i, e := range events {
var eventStr strings.Builder
timeStr := time.Unix(0, e.Timestamp*1e6).Format("3:04pm")
switch e.Type {
case "fall_alert":
eventStr.WriteString(fmt.Sprintf("Possible fall detected at %s", timeStr))
if e.Person != "" {
eventStr.WriteString(fmt.Sprintf(" for %s", e.Person))
}
if e.Zone != "" {
eventStr.WriteString(fmt.Sprintf(" in %s", e.Zone))
}
if e.Acked {
eventStr.WriteString(" (acknowledged)")
}
case "anomaly":
eventStr.WriteString(fmt.Sprintf("Anomaly detected at %s", timeStr))
if e.Zone != "" {
eventStr.WriteString(fmt.Sprintf(" in %s", e.Zone))
}
if e.Acked {
eventStr.WriteString(" (acknowledged)")
}
case "node_disconnected":
eventStr.WriteString(fmt.Sprintf("Node %s went offline at %s", e.Zone, timeStr))
// Try to get reconnection time
var reconnectTime sql.NullInt64
reconnectQuery := `SELECT timestamp_ms FROM events
WHERE type = 'node_connected' AND zone = ?
AND timestamp_ms > ? ORDER BY timestamp_ms ASC LIMIT 1`
err := g.db.QueryRow(reconnectQuery, e.Zone, e.Timestamp).Scan(&reconnectTime)
if err == nil && reconnectTime.Valid {
reconnectStr := time.Unix(0, reconnectTime.Int64*1e6).Format("3:04pm")
eventStr.WriteString(fmt.Sprintf(" and reconnected at %s", reconnectStr))
}
}
contentParts = append(contentParts, eventStr.String())
// Limit to 5 events
if i >= 4 && len(events) > 5 {
contentParts = append(contentParts, fmt.Sprintf("...and %d more events.", len(events)-5))
break
}
}
content := strings.Join(contentParts, ". ")
if len(contentParts) > 1 {
content += "."
}
return &Section{
Type: SectionTypeOvernightEvents,
Content: content,
Priority: 75,
}
}
// generateWeatherBlock generates the optional weather section.
func (g *Generator) generateWeatherBlock() *Section {
if g.weatherAPIURL == "" {
return nil
}
// Fetch weather from wttr.in
// Format: GET https://wttr.in/{location}?format=%t+%C
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(g.weatherAPIURL)
if err != nil {
log.Printf("[WARN] Failed to fetch weather: %v", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARN] Weather API returned status %d", resp.StatusCode)
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("[WARN] Failed to read weather response: %v", err)
return nil
}
weather := strings.TrimSpace(string(body))
if weather == "" || weather == "Unknown" {
return nil
}
return &Section{
Type: SectionTypeWeather,
Content: fmt.Sprintf("Outside: %s", weather),
Priority: 15,
}
}
// getAverageSleepDuration calculates average sleep duration over the past 7 days.
func (g *Generator) getAverageSleepDuration(person string) int {
query := `SELECT AVG(duration_min) FROM sleep_records
@ -762,3 +1056,117 @@ func (g *Generator) ShouldGenerate(date string, person string) bool {
err := g.db.QueryRow(query, args...).Scan(&count)
return err == nil && count == 0
}
// MarkDelivered marks a briefing as delivered.
func (g *Generator) MarkDelivered(id string) error {
// Check if delivered column exists
var deliveredColExists bool
err := g.db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('briefings') WHERE name = 'delivered'
`).Scan(&deliveredColExists)
if err != nil {
return fmt.Errorf("check delivered column: %w", err)
}
if !deliveredColExists {
// Column doesn't exist yet, skip
return nil
}
_, err = g.db.Exec(`UPDATE briefings SET delivered = 1 WHERE id = ?`, id)
return err
}
// MarkAcknowledged marks a briefing as acknowledged by the user.
func (g *Generator) MarkAcknowledged(id string) error {
// Check if acknowledged column exists
var acknowledgedColExists bool
err := g.db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('briefings') WHERE name = 'acknowledged'
`).Scan(&acknowledgedColExists)
if err != nil {
return fmt.Errorf("check acknowledged column: %w", err)
}
if !acknowledgedColExists {
// Column doesn't exist yet, skip
return nil
}
_, err = g.db.Exec(`UPDATE briefings SET acknowledged = 1 WHERE id = ?`, id)
return err
}
// GetTodayBriefing returns today's briefing as a map for the dashboard.
func (g *Generator) GetTodayBriefing() (map[string]interface{}, error) {
today := time.Now().Format("2006-01-02")
b, err := g.Get(today, "")
if err != nil {
// No briefing exists, try generating one
b, err = g.Generate(today, "")
if err != nil {
return nil, err
}
// Save the new briefing
if err := g.Save(b); err != nil {
log.Printf("[WARN] Failed to save briefing: %v", err)
}
}
// Convert to map for JSON marshaling
result := map[string]interface{}{
"id": b.ID,
"date": b.Date,
"content": b.Content,
"generated_at": b.GeneratedAt,
"delivered": b.Delivered,
"acknowledged": b.Acknowledged,
}
if len(b.Sections) > 0 {
result["sections"] = b.Sections
}
return result, nil
}
// ShouldPushBriefing checks if the briefing should be pushed to clients.
// Returns true if it's after 6am and the briefing hasn't been delivered yet.
func (g *Generator) ShouldPushBriefing() bool {
now := time.Now()
hour := now.Hour()
// Only push after 6am
if hour < 6 {
return false
}
today := now.Format("2006-01-02")
// Check if a briefing exists for today
var count int
err := g.db.QueryRow(`SELECT COUNT(*) FROM briefings WHERE date = ?`, today).Scan(&count)
if err != nil || count == 0 {
return false
}
// Check if delivered column exists
var deliveredColExists bool
err = g.db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('briefings') WHERE name = 'delivered'
`).Scan(&deliveredColExists)
if err != nil || !deliveredColExists {
// If column doesn't exist, assume not delivered
return true
}
// Check if already delivered
var delivered int
err = g.db.QueryRow(`SELECT delivered FROM briefings WHERE date = ?`, today).Scan(&delivered)
if err != nil {
return true
}
return delivered == 0
}

View file

@ -13,9 +13,9 @@ import (
// mockZoneProvider implements ZoneProvider for testing.
type mockZoneProvider struct {
zones map[int]string
zones map[int]string
occupancy map[int]int
people map[int][]string
people map[int][]string
}
func (m *mockZoneProvider) GetZoneName(id int) string {
@ -69,9 +69,9 @@ func (m *mockPersonProvider) GetPersonZone(person string) string {
// mockPredictionProvider implements PredictionProvider for testing.
type mockPredictionProvider struct {
predictions map[string]mockPrediction
predictions map[string]mockPrediction
daysComplete map[string]int
modelReady map[string]bool
modelReady map[string]bool
}
type mockPrediction struct {
@ -106,9 +106,9 @@ func (m *mockPredictionProvider) IsModelReady(person string) bool {
// mockHealthProvider implements HealthProvider for testing.
type mockHealthProvider struct {
quality float64
online int
total int
quality float64
online int
total int
accuracyDelta float64
feedbackCount int
}

View file

@ -0,0 +1,109 @@
// Package briefing provides dashboard adapter for morning briefing.
package briefing
import (
"log"
)
// DashboardAdapter adapts the Generator to the dashboard BriefingProvider interface.
type DashboardAdapter struct {
generator *Generator
}
// NewDashboardAdapter creates a new dashboard adapter.
func NewDashboardAdapter(gen *Generator) *DashboardAdapter {
return &DashboardAdapter{generator: gen}
}
// GetTodayBriefing returns today's briefing as a map for the dashboard.
func (a *DashboardAdapter) GetTodayBriefing() (map[string]interface{}, error) {
return a.generator.GetTodayBriefing()
}
// MarkDelivered marks a briefing as delivered.
func (a *DashboardAdapter) MarkDelivered(id string) error {
return a.generator.MarkDelivered(id)
}
// ShouldPushBriefing checks if the briefing should be pushed to clients.
func (a *DashboardAdapter) ShouldPushBriefing() bool {
return a.generator.ShouldPushBriefing()
}
// SetQuietHours sets the quiet hours range for overnight events.
func (a *DashboardAdapter) SetQuietHours(start, end int) {
a.generator.SetQuietHours(start, end)
}
// SetWeatherAPIURL sets the weather API URL for weather section.
func (a *DashboardAdapter) SetWeatherAPIURL(url string) {
a.generator.SetWeatherAPIURL(url)
}
// SetProviders sets the provider interfaces for briefing generation.
func (a *DashboardAdapter) SetProviders(z ZoneProvider, p PersonProvider, pr PredictionProvider, hp HealthProvider) {
a.generator.SetProviders(z, p, pr, hp)
}
// SetNodeInfoProvider sets the node info provider.
func (a *DashboardAdapter) SetNodeInfoProvider(n NodeInfoProvider) {
a.generator.SetNodeInfoProvider(n)
}
// Close closes the underlying generator.
func (a *DashboardAdapter) Close() error {
return a.generator.Close()
}
// GetGenerator returns the underlying generator for direct access.
func (a *DashboardAdapter) GetGenerator() *Generator {
return a.generator
}
// Generate creates a morning briefing for the given date and person.
func (a *DashboardAdapter) Generate(date, person string) (*Briefing, error) {
return a.generator.Generate(date, person)
}
// Save persists a briefing to the database.
func (a *DashboardAdapter) Save(b *Briefing) error {
return a.generator.Save(b)
}
// Get retrieves a previously generated briefing by date and optional person.
func (a *DashboardAdapter) Get(date, person string) (*Briefing, error) {
return a.generator.Get(date, person)
}
// GetLatest retrieves the most recent briefing.
func (a *DashboardAdapter) GetLatest() (*Briefing, error) {
return a.generator.GetLatest()
}
// ShouldGenerate checks if a briefing should be generated for the given date.
func (a *DashboardAdapter) ShouldGenerate(date, person string) bool {
return a.generator.ShouldGenerate(date, person)
}
// MarkAcknowledged marks a briefing as acknowledged by the user.
func (a *DashboardAdapter) MarkAcknowledged(id string) error {
return a.generator.MarkAcknowledged(id)
}
// GetBriefingForAPI returns a briefing formatted for API response with all fields.
func (a *DashboardAdapter) GetBriefingForAPI(date string, person string) (*Briefing, error) {
b, err := a.generator.Get(date, person)
if err != nil {
// Try generating if not found
b, err = a.generator.Generate(date, person)
if err != nil {
log.Printf("[ERROR] Failed to generate briefing for %s: %v", date, err)
return nil, err
}
// Save the new briefing
if err := a.generator.Save(b); err != nil {
log.Printf("[ERROR] Failed to save briefing for %s: %v", date, err)
}
}
return b, nil
}

View file

@ -11,13 +11,13 @@ import (
// Scheduler handles automatic briefing generation and push notifications.
type Scheduler struct {
generator *Generator
notifyService NotifyService
mu sync.RWMutex
config SchedulerConfig
ticker *time.Ticker
stopChan chan struct{}
running bool
generator *Generator
notifyService NotifyService
mu sync.RWMutex
config SchedulerConfig
ticker *time.Ticker
stopChan chan struct{}
running bool
}
// SchedulerConfig holds scheduling configuration.
@ -197,7 +197,7 @@ func (s *Scheduler) checkAndGenerate() {
func (s *Scheduler) sendNotification(b *Briefing) {
notification := Notification{
Title: "Morning Briefing",
Body: s.formatNotificationBody(b),
Body: s.formatNotificationBody(b),
Priority: 1, // Low priority for morning briefings
Tags: []string{"briefing", "morning"},
Timestamp: time.Now(),

View file

@ -33,6 +33,7 @@ type Hub struct {
eventStore EventStore
securityState SecurityStateProvider
sleepState SleepStateProvider
briefingProvider BriefingProvider
// Pending events buffer — events accumulated between 10 Hz delta ticks.
pendingEvents []map[string]interface{}
@ -159,6 +160,13 @@ type SleepStateProvider interface {
ShouldPushMorningSummary() (bool, map[string]interface{})
}
// BriefingProvider provides morning briefing functionality.
type BriefingProvider interface {
GetTodayBriefing() (map[string]interface{}, error)
MarkDelivered(id string) error
ShouldPushBriefing() bool
}
// ReplayHandler is the interface for replay engine operations.
type ReplayHandler interface {
Seek(targetMS int64) error
@ -257,6 +265,13 @@ func (h *Hub) SetSleepState(state SleepStateProvider) {
h.mu.Unlock()
}
// SetBriefingProvider sets the briefing provider for morning briefing push.
func (h *Hub) SetBriefingProvider(provider BriefingProvider) {
h.mu.Lock()
h.briefingProvider = provider
h.mu.Unlock()
}
// Run starts the hub's main loop.
// The 10 Hz delta tick replaces the old 5 s state / 500 ms presence broadcasts.
// BLE scan results are broadcast every 5 s as a separate typed message.
@ -297,6 +312,9 @@ func (h *Hub) Run() {
h.mu.Unlock()
log.Printf("[INFO] Dashboard client connected (total: %d)", len(h.clients))
// Push morning briefing on first connection after 6am
h.pushBriefingToClient(client)
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
@ -1152,6 +1170,54 @@ func (h *Hub) BroadcastMorningSummary(summary map[string]interface{}) {
h.Broadcast(data)
}
// pushBriefingToClient pushes the morning briefing to a specific client on first connection.
func (h *Hub) pushBriefingToClient(client *Client) {
h.mu.RLock()
provider := h.briefingProvider
h.mu.RUnlock()
if provider == nil {
return
}
// Check if briefing should be pushed (after 6am and not yet delivered)
if !provider.ShouldPushBriefing() {
return
}
// Get today's briefing
briefing, err := provider.GetTodayBriefing()
if err != nil {
log.Printf("[WARN] Failed to get today's briefing: %v", err)
return
}
// Mark as delivered
if id, ok := briefing["id"].(string); ok {
if err := provider.MarkDelivered(id); err != nil {
log.Printf("[WARN] Failed to mark briefing as delivered: %v", err)
}
}
// Send briefing to client
msg := map[string]interface{}{
"type": "morning_briefing",
"briefing": briefing,
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[WARN] Failed to marshal briefing: %v", err)
return
}
select {
case client.send <- data:
log.Printf("[INFO] Morning briefing pushed to client")
default:
log.Printf("[WARN] Briefing dropped for new client (buffer full)")
}
}
// BroadcastReplayBlobs broadcasts replay blob updates to all dashboard clients.
// This implements the replay.BlobBroadcaster interface for time-travel debugging.
func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64) {