spaxel/mothership/internal/sleep/integration.go
jedarden 0cb2353a08 feat: implement activity timeline with tap-to-jump and inline feedback
Phase 8 implementation: Activity Timeline (Component 27)

- Tap-to-jump navigation: Click any event to create replay session and seek to that moment
- Inline feedback display: Thumbs up/down buttons on each event for detection feedback
- Replay API integration: Creates replay window around event timestamp (±5 seconds)
- Feedback API: New /api/feedback endpoint for correct/incorrect/missed detection reports
- Event loading improvements: Real-time WebSocket event insertion with animation
- Filter UI: Type, zone, person, time range, and search filters
- Load more pagination: Keyset cursor-based pagination for large event sets

Acceptance criteria met:
- Users can view all system events chronologically
- Tap any event to jump to that moment in time via replay mode
- Inline feedback buttons allow marking detections correct/incorrect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:07:16 -04:00

655 lines
20 KiB
Go

package sleep
import (
"fmt"
"log"
"sync"
"time"
"github.com/spaxel/mothership/internal/events"
"github.com/spaxel/mothership/internal/signal"
"github.com/spaxel/mothership/internal/zones"
)
// SessionState tracks the sleep session state for a link
type SessionState int
const (
SessionStateNone SessionState = iota
SessionStateTentative // In bedroom, stationary detected, waiting for 15-min confirmation
SessionStateConfirmed // Sleep session confirmed (15 min stationary)
SessionStateEnded // Session ended, waiting for morning report
)
// LinkSessionState tracks the sleep session state per link
type LinkSessionState struct {
State SessionState
TentativeStartTime time.Time // When tentative detection started
ConfirmedStartTime time.Time // When sleep was confirmed (15 min after tentative)
SessionID string
ZoneID string
PersonID string
LastStationaryTime time.Time // Last time stationary was detected
LastMotionTime time.Time // Last time motion was detected
InBedroomZone bool
SustainedMotionStart time.Time // When sustained motion started (for wake detection)
}
// Monitor integrates the sleep analyzer with the signal processing pipeline.
// It periodically samples breathing and motion data during sleep hours.
type Monitor struct {
mu sync.RWMutex
// Dependencies
analyzer *SleepAnalyzer
processorMgr *signal.ProcessorManager
zoneMgr *zones.Manager
storage *Storage
// Configuration
sampleInterval time.Duration
reportHour int // Hour of day to generate morning reports (0-23)
sleepStartHour int
sleepEndHour int
sessionConfirmMinutes int // Minutes of stationary detection to confirm sleep onset (default 15)
wakeConfirmMinutes int // Minutes of sustained motion to confirm wake (default 2)
// State
running bool
stopCh chan struct{}
lastSample map[string]time.Time
lastReport time.Time
linkSessionStates map[string]*LinkSessionState // Per-link session tracking
firstConnectionToday bool // Track if morning summary was pushed today
morningSummaryPushed time.Time // When morning summary was last pushed
// Event callbacks
onSessionStart func(event events.SleepSessionStartEvent)
onSessionEnd func(event events.SleepSessionEndEvent)
}
// MonitorConfig holds configuration for the sleep monitor
type MonitorConfig struct {
SampleInterval time.Duration // How often to sample data (default 30s)
ReportHour int // Hour to generate morning reports (default 7)
SleepStartHour int // Start of sleep window (default 22)
SleepEndHour int // End of sleep window (default 7)
SessionConfirmMinutes int // Minutes of stationary to confirm sleep (default 15)
WakeConfirmMinutes int // Minutes of sustained motion to confirm wake (default 2)
}
// NewMonitor creates a new sleep monitor
func NewMonitor(cfg MonitorConfig) *Monitor {
if cfg.SampleInterval == 0 {
cfg.SampleInterval = SampleInterval
}
if cfg.ReportHour == 0 {
cfg.ReportHour = 7
}
if cfg.SleepStartHour == 0 {
cfg.SleepStartHour = DefaultSleepStartHour
}
if cfg.SleepEndHour == 0 {
cfg.SleepEndHour = DefaultSleepEndHour
}
if cfg.SessionConfirmMinutes == 0 {
cfg.SessionConfirmMinutes = 15
}
if cfg.WakeConfirmMinutes == 0 {
cfg.WakeConfirmMinutes = 2
}
analyzer := NewSleepAnalyzer()
analyzer.SetSleepWindow(cfg.SleepStartHour, cfg.SleepEndHour)
return &Monitor{
analyzer: analyzer,
sampleInterval: cfg.SampleInterval,
reportHour: cfg.ReportHour,
sleepStartHour: cfg.SleepStartHour,
sleepEndHour: cfg.SleepEndHour,
sessionConfirmMinutes: cfg.SessionConfirmMinutes,
wakeConfirmMinutes: cfg.WakeConfirmMinutes,
stopCh: make(chan struct{}),
lastSample: make(map[string]time.Time),
linkSessionStates: make(map[string]*LinkSessionState),
}
}
// SetProcessorManager sets the signal processor manager
func (m *Monitor) SetProcessorManager(pm *signal.ProcessorManager) {
m.mu.Lock()
defer m.mu.Unlock()
m.processorMgr = pm
}
// SetZoneManager sets the zone manager for bedroom detection
func (m *Monitor) SetZoneManager(zm *zones.Manager) {
m.mu.Lock()
defer m.mu.Unlock()
m.zoneMgr = zm
}
// SetStorage sets the storage backend for persisting sessions
func (m *Monitor) SetStorage(s *Storage) {
m.mu.Lock()
defer m.mu.Unlock()
m.storage = s
}
// SetSessionCallbacks sets callbacks for session start/end events
func (m *Monitor) SetSessionCallbacks(onStart func(events.SleepSessionStartEvent), onEnd func(events.SleepSessionEndEvent)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onSessionStart = onStart
m.onSessionEnd = onEnd
}
// SetReportCallback sets the callback for when reports are generated
func (m *Monitor) SetReportCallback(cb func(linkID string, report *SleepReport)) {
m.analyzer.SetReportCallback(cb)
}
// Start starts the sleep monitoring loop
func (m *Monitor) Start() {
m.mu.Lock()
if m.running {
m.mu.Unlock()
return
}
m.running = true
m.mu.Unlock()
go m.runLoop()
log.Printf("[INFO] Sleep monitor started (window: %d:00-%d:00, report at %d:00)",
m.sleepStartHour, m.sleepEndHour, m.reportHour)
}
// Stop stops the sleep monitoring loop
func (m *Monitor) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if !m.running {
return
}
m.running = false
close(m.stopCh)
log.Printf("[INFO] Sleep monitor stopped")
}
// runLoop is the main monitoring loop
func (m *Monitor) runLoop() {
sampleTicker := time.NewTicker(m.sampleInterval)
defer sampleTicker.Stop()
reportTicker := time.NewTicker(1 * time.Minute)
defer reportTicker.Stop()
for {
select {
case <-m.stopCh:
return
case <-sampleTicker.C:
m.collectSamples()
case <-reportTicker.C:
m.checkReportGeneration()
}
}
}
// collectSamples collects breathing and motion samples from all links,
// runs the session detection state machine, and tracks wake episodes.
func (m *Monitor) collectSamples() {
m.mu.RLock()
pm := m.processorMgr
analyzer := m.analyzer
zoneMgr := m.zoneMgr
m.mu.RUnlock()
if pm == nil {
return
}
now := time.Now()
// Get all link states
states := pm.GetAllMotionStates()
for _, state := range states {
// Throttle sampling per link
if last, exists := m.lastSample[state.LinkID]; exists {
if now.Sub(last) < m.sampleInterval {
continue
}
}
m.lastSample[state.LinkID] = now
// Run session detection state machine
m.updateSessionState(state.LinkID, now, state.SmoothDeltaRMS, state.MotionDetected,
state.BreathingDetected, state.BreathingRate, zoneMgr)
// Only feed samples to the analyzer if a session is confirmed
m.mu.RLock()
ls := m.linkSessionStates[state.LinkID]
m.mu.RUnlock()
if ls != nil && ls.State == SessionStateConfirmed {
// Create motion sample
motionSample := MotionSample{
Timestamp: now,
DeltaRMS: state.SmoothDeltaRMS,
MotionDetected: state.MotionDetected,
}
analyzer.ProcessMotion(state.LinkID, motionSample)
// Create breathing sample
breathingSample := BreathingSample{
Timestamp: now,
RateBPM: state.BreathingRate,
Confidence: state.AmbientConfidence,
IsDetected: state.BreathingDetected,
HealthGated: false,
}
analyzer.ProcessBreathing(state.LinkID, breathingSample)
}
}
// Check for session end conditions even outside sleep hours (e.g., person wakes at 6:50)
m.mu.Lock()
for linkID, ls := range m.linkSessionStates {
if ls.State == SessionStateConfirmed {
m.checkSessionEnd(linkID, now)
}
}
m.mu.Unlock()
}
// updateSessionState runs the session detection state machine for a link.
// Session onset requires all of: in bedroom zone, stationary detection, for 15 consecutive minutes.
// Session end requires: leaving bedroom zone, sustained motion > 2 min, or stationary loss > 30 min.
func (m *Monitor) updateSessionState(linkID string, now time.Time,
smoothDeltaRMS float64, motionDetected, breathingDetected bool, breathingRate float64,
zoneMgr *zones.Manager) {
m.mu.Lock()
defer m.mu.Unlock()
ls, exists := m.linkSessionStates[linkID]
if !exists {
ls = &LinkSessionState{
State: SessionStateNone,
}
m.linkSessionStates[linkID] = ls
}
stationary := !motionDetected && smoothDeltaRMS < 0.03 && breathingDetected
switch ls.State {
case SessionStateNone:
// Check onset conditions: stationary in a bedroom zone
if stationary && m.isInBedroomZone(linkID, zoneMgr) {
ls.State = SessionStateTentative
ls.TentativeStartTime = now
ls.LastStationaryTime = now
ls.LastMotionTime = time.Time{}
log.Printf("[DEBUG] Sleep: tentative session for %s", linkID)
}
case SessionStateTentative:
if stationary {
ls.LastStationaryTime = now
ls.LastMotionTime = time.Time{}
// Check if 15-minute confirmation threshold met
if now.Sub(ls.TentativeStartTime) >= time.Duration(m.sessionConfirmMinutes)*time.Minute {
ls.State = SessionStateConfirmed
ls.ConfirmedStartTime = now
ls.SessionID = fmt.Sprintf("sleep-%s-%d", linkID, now.Unix())
log.Printf("[INFO] Sleep: session confirmed for %s after %.1f min",
linkID, now.Sub(ls.TentativeStartTime).Minutes())
// Fire session start callback
if m.onSessionStart != nil {
m.onSessionStart(events.SleepSessionStartEvent{
ZoneID: ls.ZoneID,
PersonID: ls.PersonID,
Timestamp: now,
})
}
}
} else {
// Motion detected — reset tentative if sustained motion > 2 min
if ls.LastMotionTime.IsZero() {
ls.LastMotionTime = now
} else if now.Sub(ls.LastMotionTime) >= time.Duration(m.wakeConfirmMinutes)*time.Minute {
// Sustained motion for > 2 min — cancel tentative
log.Printf("[DEBUG] Sleep: tentative session cancelled for %s (sustained motion)", linkID)
ls.State = SessionStateNone
ls.TentativeStartTime = time.Time{}
ls.LastMotionTime = time.Time{}
}
}
case SessionStateConfirmed:
if stationary {
ls.LastStationaryTime = now
ls.LastMotionTime = time.Time{}
ls.SustainedMotionStart = time.Time{}
} else if motionDetected && smoothDeltaRMS > WakeMotionThreshold {
ls.LastMotionTime = now
if ls.SustainedMotionStart.IsZero() {
ls.SustainedMotionStart = now
}
} else {
// Motion subsided — reset sustained motion timer
ls.SustainedMotionStart = time.Time{}
}
}
}
// checkSessionEnd evaluates end conditions for a confirmed session.
func (m *Monitor) checkSessionEnd(linkID string, now time.Time) {
ls := m.linkSessionStates[linkID]
if ls == nil || ls.State != SessionStateConfirmed {
return
}
var ended bool
var reason string
// End condition 1: sustained motion > wakeConfirmMinutes
if !ls.SustainedMotionStart.IsZero() &&
now.Sub(ls.SustainedMotionStart) >= time.Duration(m.wakeConfirmMinutes)*time.Minute {
ended = true
reason = "sustained_motion"
}
// End condition 2: stationary detection dropped for > 30 minutes
// (person left room without portal crossing — reconciliation path)
if !ended && !ls.LastStationaryTime.IsZero() &&
now.Sub(ls.LastStationaryTime) > 30*time.Minute {
ended = true
reason = "stationary_lost"
}
// End condition 3: left bedroom zone (checked by zone transition events)
if ended {
log.Printf("[INFO] Sleep: session ended for %s (reason: %s, duration: %.1f min)",
linkID, reason, now.Sub(ls.ConfirmedStartTime).Minutes())
ls.State = SessionStateEnded
// Fire session end callback
if m.onSessionEnd != nil {
m.onSessionEnd(events.SleepSessionEndEvent{
ZoneID: ls.ZoneID,
PersonID: ls.PersonID,
StartTimestamp: ls.ConfirmedStartTime,
EndTimestamp: now,
DurationMin: now.Sub(ls.ConfirmedStartTime).Minutes(),
})
}
}
}
// isInBedroomZone checks if a link's detected blob is in a bedroom zone.
// Returns true if any zone manager zone with zone_type='bedroom' has occupancy.
func (m *Monitor) isInBedroomZone(linkID string, zoneMgr *zones.Manager) bool {
if zoneMgr == nil {
return false
}
allZones := zoneMgr.GetAllZones()
occupancy := zoneMgr.GetOccupancy()
for _, z := range allZones {
if z.ZoneType == zones.ZoneTypeBedroom && occupancy[z.ID] != nil && occupancy[z.ID].Count > 0 {
return true
}
}
return false
}
// NotifyZoneTransition is called when a zone transition event fires.
// If the person leaves a bedroom zone, it ends any active sleep session for that link.
func (m *Monitor) NotifyZoneTransition(linkID string, zoneID string, entered bool) {
m.mu.Lock()
defer m.mu.Unlock()
ls, exists := m.linkSessionStates[linkID]
if !exists || ls.State != SessionStateConfirmed {
return
}
if ls.ZoneID == zoneID && !entered {
// Person left the bedroom zone — end the session
now := time.Now()
log.Printf("[INFO] Sleep: session ended for %s (reason: left bedroom zone, duration: %.1f min)",
linkID, now.Sub(ls.ConfirmedStartTime).Minutes())
ls.State = SessionStateEnded
if m.onSessionEnd != nil {
m.onSessionEnd(events.SleepSessionEndEvent{
ZoneID: ls.ZoneID,
PersonID: ls.PersonID,
StartTimestamp: ls.ConfirmedStartTime,
EndTimestamp: now,
DurationMin: now.Sub(ls.ConfirmedStartTime).Minutes(),
})
}
}
// If the person entered a bedroom zone, update the tracking
if entered {
ls.ZoneID = zoneID
ls.InBedroomZone = true
}
}
// ShouldPushMorningSummary returns true if the morning summary should be pushed.
// It fires only on the first connection after 6am AND after a sleep session has ended.
// Returns (shouldPush, summaryMap) where summaryMap is the report converted to map[string]interface{}.
func (m *Monitor) ShouldPushMorningSummary() (bool, map[string]interface{}) {
now := time.Now()
if now.Hour() < 6 {
return false, nil
}
m.mu.Lock()
defer m.mu.Unlock()
// Check if we already pushed today
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if !m.morningSummaryPushed.IsZero() && m.morningSummaryPushed.After(today) {
return false, nil
}
// Check if any sessions ended today
for linkID, ls := range m.linkSessionStates {
if ls.State == SessionStateEnded {
session := m.analyzer.GetSession(linkID)
if session == nil {
continue
}
report := session.GenerateReport()
if report != nil && report.Metrics.TimeInBed > 0 {
m.morningSummaryPushed = now
// Convert report to map[string]interface{} format
summaryMap := map[string]interface{}{
"link_id": report.LinkID,
"session_date": report.SessionDate.Format("2006-01-02"),
"generated_at": report.GeneratedAt.UnixMilli(),
"overall_score": report.Metrics.OverallScore,
"quality_rating": report.Metrics.QualityRating,
"breathing_summary": report.BreathingSummary,
"motion_summary": report.MotionSummary,
"recommendations": report.Recommendations,
"metrics": map[string]interface{}{
"total_duration_hours": report.Metrics.TotalDuration.Hours(),
"time_in_bed_hours": report.Metrics.TimeInBed.Hours(),
"sleep_efficiency": report.Metrics.SleepEfficiency,
"sleep_latency_minutes": report.Metrics.SleepLatencyMinutes,
"waso_minutes": report.Metrics.WASOMinutes,
"wake_episode_count": report.Metrics.WakeEpisodeCount,
"avg_breathing_rate": report.Metrics.AvgBreathingRate,
"breathing_rate_std_dev": report.Metrics.BreathingRateStdDev,
"breathing_regularity": report.Metrics.BreathingRegularity,
"breathing_score": report.Metrics.BreathingScore,
"breathing_anomaly": report.Metrics.BreathingAnomaly,
"breathing_anomaly_count": report.Metrics.BreathingAnomalyCount,
"quiet_time_pct": report.Metrics.QuietTimePct,
"motion_events": report.Metrics.MotionEvents,
"restless_periods": report.Metrics.RestlessPeriods,
"motion_score": report.Metrics.MotionScore,
"interruptions": report.Metrics.Interruptions,
"longest_deep_period_mins": report.Metrics.LongestDeepPeriod.Minutes(),
"continuity_score": report.Metrics.ContinuityScore,
},
}
// Add breathing rate range
if report.Metrics.MinBreathingRate > 0 {
summaryMap["metrics"].(map[string]interface{})["min_breathing_rate"] = report.Metrics.MinBreathingRate
summaryMap["metrics"].(map[string]interface{})["max_breathing_rate"] = report.Metrics.MaxBreathingRate
}
if report.Metrics.PersonalAvgBPM > 0 {
summaryMap["metrics"].(map[string]interface{})["personal_avg_bpm"] = report.Metrics.PersonalAvgBPM
}
return true, summaryMap
}
}
}
return false, nil
}
// checkReportGeneration checks if it's time to generate morning reports
func (m *Monitor) checkReportGeneration() {
now := time.Now()
// Check if it's report hour and we haven't reported today
if now.Hour() == m.reportHour {
reportDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
m.mu.Lock()
lastReport := m.lastReport
m.mu.Unlock()
// Only generate if we haven't reported today
if lastReport.IsZero() || lastReport.Before(reportDate) {
m.generateMorningReports()
m.mu.Lock()
m.lastReport = now
m.mu.Unlock()
}
}
}
// generateMorningReports generates reports for all sessions
func (m *Monitor) generateMorningReports() {
reports := m.analyzer.GenerateMorningReports()
for linkID, report := range reports {
log.Printf("[INFO] Sleep report generated for %s: score=%.1f rating=%s",
linkID, report.Metrics.OverallScore, report.Metrics.QualityRating)
}
}
// GetAnalyzer returns the sleep analyzer for direct access
func (m *Monitor) GetAnalyzer() *SleepAnalyzer {
return m.analyzer
}
// GetCurrentState returns the current sleep state for a link
func (m *Monitor) GetCurrentState(linkID string) SleepState {
return m.analyzer.GetCurrentState(linkID)
}
// GetAllSessions returns all current sleep sessions
func (m *Monitor) GetAllSessions() map[string]*SleepSession {
return m.analyzer.GetAllSessions()
}
// GetSleepReport generates a report for a specific link
func (m *Monitor) GetSleepReport(linkID string) *SleepReport {
session := m.analyzer.GetSession(linkID)
if session == nil {
return nil
}
return session.GenerateReport()
}
// ForceReportGeneration forces generation of reports for all sessions
func (m *Monitor) ForceReportGeneration() map[string]*SleepReport {
return m.analyzer.GenerateMorningReports()
}
// IsInSleepHours returns whether current time is within sleep hours
func (m *Monitor) IsInSleepHours() bool {
now := time.Now()
hour := now.Hour()
if m.sleepStartHour > m.sleepEndHour {
return hour >= m.sleepStartHour || hour < m.sleepEndHour
}
return hour >= m.sleepStartHour && hour < m.sleepEndHour
}
// SleepStatus represents the current sleep monitoring status
type SleepStatus struct {
InSleepHours bool `json:"in_sleep_hours"`
SleepStartHour int `json:"sleep_start_hour"`
SleepEndHour int `json:"sleep_end_hour"`
ActiveSessions int `json:"active_sessions"`
LinkStates map[string]SleepLinkState `json:"link_states"`
}
// SleepLinkState represents sleep state for a single link
type SleepLinkState struct {
LinkID string `json:"link_id"`
SleepState string `json:"sleep_state"`
SamplesCollected int `json:"samples_collected"`
SessionActive bool `json:"session_active"`
CurrentBreathingRate float64 `json:"current_breathing_rate"`
CurrentMotion bool `json:"current_motion"`
}
// GetStatus returns the current sleep monitoring status
func (m *Monitor) GetStatus() SleepStatus {
m.mu.RLock()
defer m.mu.RUnlock()
status := SleepStatus{
InSleepHours: m.IsInSleepHours(),
SleepStartHour: m.sleepStartHour,
SleepEndHour: m.sleepEndHour,
LinkStates: make(map[string]SleepLinkState),
}
sessions := m.analyzer.GetAllSessions()
status.ActiveSessions = len(sessions)
for linkID, session := range sessions {
state := SleepLinkState{
LinkID: linkID,
SleepState: session.GetCurrentState().String(),
SessionActive: session.isActive,
}
session.mu.RLock()
state.SamplesCollected = len(session.breathingSamples) + len(session.motionSamples)
// Get latest breathing rate
if len(session.breathingSamples) > 0 {
state.CurrentBreathingRate = session.breathingSamples[len(session.breathingSamples)-1].RateBPM
}
// Get latest motion state
if len(session.motionSamples) > 0 {
state.CurrentMotion = session.motionSamples[len(session.motionSamples)-1].MotionDetected
}
session.mu.RUnlock()
status.LinkStates[linkID] = state
}
return status
}