Implement single non-blocking notifications when features become available. Events: - DiurnalBaselineActivated (7 days) - FirstSleepSessionComplete - WeightUpdateApproved - AutomationFirstFired - PredictionModelReady (7 days per person) Each notification is keyed by unique event ID in SQLite (feature_notifications table). Never fires twice. Dismissed by tapping. Respects quiet hours. Files: - mothership/internal/help/notifier.go: Notifier manages one-time feature notifications - mothership/internal/help/notifier_test.go: Tests for notifier - mothership/internal/help/monitor.go: FeatureMonitor checks for feature availability - mothership/internal/help/monitor_test.go: Tests for monitor - mothership/cmd/mothership/main.go: Integration with mothership - mothership/internal/db/migrations.go: Add migration_015 for feature_notifications table Acceptance: - Each notification fires exactly once per feature - Plain language messages - Respects quiet hours - SQLite persistence prevents duplicates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
7.6 KiB
Go
258 lines
7.6 KiB
Go
// Package help provides feature discovery monitoring and notification.
|
|
package help
|
|
|
|
import (
|
|
"database/sql"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// FeatureMonitor checks for feature availability and fires notifications.
|
|
// It runs periodically to check if features have become available.
|
|
type FeatureMonitor struct {
|
|
mu sync.Mutex
|
|
db *sql.DB
|
|
notifier *Notifier
|
|
checkInterval time.Duration
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
|
|
// Callbacks for checking feature availability
|
|
checkDiurnalReady func() bool
|
|
checkFirstSleepSession func() bool
|
|
checkWeightUpdate func() bool
|
|
checkFirstAutomation func() bool
|
|
checkPredictionReady func(personID string) bool
|
|
|
|
// Track what we've already notified
|
|
notifiedDiurnalReady bool
|
|
notifiedFirstSleepSession bool
|
|
notifiedWeightUpdate bool
|
|
notifiedFirstAutomation bool
|
|
notifiedPredictionReady map[string]bool // personID -> notified
|
|
}
|
|
|
|
// FeatureMonitorConfig holds configuration for the feature monitor.
|
|
type FeatureMonitorConfig struct {
|
|
DB *sql.DB
|
|
Notifier *Notifier
|
|
CheckInterval time.Duration // How often to check for new features
|
|
}
|
|
|
|
// NewFeatureMonitor creates a new feature discovery monitor.
|
|
func NewFeatureMonitor(cfg FeatureMonitorConfig) *FeatureMonitor {
|
|
if cfg.CheckInterval == 0 {
|
|
cfg.CheckInterval = 5 * time.Minute // Check every 5 minutes
|
|
}
|
|
|
|
return &FeatureMonitor{
|
|
db: cfg.DB,
|
|
notifier: cfg.Notifier,
|
|
checkInterval: cfg.CheckInterval,
|
|
stopCh: make(chan struct{}),
|
|
notifiedPredictionReady: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// SetDiurnalReadyChecker sets the callback to check if diurnal baseline is ready.
|
|
func (m *FeatureMonitor) SetDiurnalReadyChecker(fn func() bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.checkDiurnalReady = fn
|
|
}
|
|
|
|
// SetFirstSleepSessionChecker sets the callback to check if first sleep session is complete.
|
|
func (m *FeatureMonitor) SetFirstSleepSessionChecker(fn func() bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.checkFirstSleepSession = fn
|
|
}
|
|
|
|
// SetWeightUpdateChecker sets the callback to check if weight update is approved.
|
|
func (m *FeatureMonitor) SetWeightUpdateChecker(fn func() bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.checkWeightUpdate = fn
|
|
}
|
|
|
|
// SetFirstAutomationChecker sets the callback to check if first automation has fired.
|
|
func (m *FeatureMonitor) SetFirstAutomationChecker(fn func() bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.checkFirstAutomation = fn
|
|
}
|
|
|
|
// SetPredictionReadyChecker sets the callback to check if prediction model is ready for a person.
|
|
func (m *FeatureMonitor) SetPredictionReadyChecker(fn func(personID string) bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.checkPredictionReady = fn
|
|
}
|
|
|
|
// Start begins the monitoring loop.
|
|
func (m *FeatureMonitor) Start() {
|
|
m.wg.Add(1)
|
|
go m.monitorLoop()
|
|
log.Printf("[INFO] Feature discovery monitor started (check interval: %v)", m.checkInterval)
|
|
}
|
|
|
|
// Stop gracefully stops the monitor.
|
|
func (m *FeatureMonitor) Stop() {
|
|
close(m.stopCh)
|
|
m.wg.Wait()
|
|
log.Printf("[INFO] Feature discovery monitor stopped")
|
|
}
|
|
|
|
// monitorLoop runs the periodic check for feature availability.
|
|
func (m *FeatureMonitor) monitorLoop() {
|
|
defer m.wg.Done()
|
|
|
|
ticker := time.NewTicker(m.checkInterval)
|
|
defer ticker.Stop()
|
|
|
|
// Run initial check
|
|
m.checkAllFeatures()
|
|
|
|
for {
|
|
select {
|
|
case <-m.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
m.checkAllFeatures()
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkAllFeatures checks all feature availability conditions.
|
|
func (m *FeatureMonitor) checkAllFeatures() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Check diurnal baseline activation
|
|
if m.checkDiurnalReady != nil && !m.notifiedDiurnalReady {
|
|
if m.checkDiurnalReady() {
|
|
m.notifier.FireNotification(
|
|
EventDiurnalBaselineActivated,
|
|
getNotificationTitle(EventDiurnalBaselineActivated),
|
|
getNotificationMessage(EventDiurnalBaselineActivated),
|
|
)
|
|
m.notifiedDiurnalReady = true
|
|
log.Printf("[INFO] Feature notification fired: %s", EventDiurnalBaselineActivated)
|
|
}
|
|
}
|
|
|
|
// Check first sleep session
|
|
if m.checkFirstSleepSession != nil && !m.notifiedFirstSleepSession {
|
|
if m.checkFirstSleepSession() {
|
|
m.notifier.FireNotification(
|
|
EventFirstSleepSessionComplete,
|
|
getNotificationTitle(EventFirstSleepSessionComplete),
|
|
getNotificationMessage(EventFirstSleepSessionComplete),
|
|
)
|
|
m.notifiedFirstSleepSession = true
|
|
log.Printf("[INFO] Feature notification fired: %s", EventFirstSleepSessionComplete)
|
|
}
|
|
}
|
|
|
|
// Check weight update approval
|
|
if m.checkWeightUpdate != nil && !m.notifiedWeightUpdate {
|
|
if m.checkWeightUpdate() {
|
|
m.notifier.FireNotification(
|
|
EventWeightUpdateApproved,
|
|
getNotificationTitle(EventWeightUpdateApproved),
|
|
getNotificationMessage(EventWeightUpdateApproved),
|
|
)
|
|
m.notifiedWeightUpdate = true
|
|
log.Printf("[INFO] Feature notification fired: %s", EventWeightUpdateApproved)
|
|
}
|
|
}
|
|
|
|
// Check first automation
|
|
if m.checkFirstAutomation != nil && !m.notifiedFirstAutomation {
|
|
if m.checkFirstAutomation() {
|
|
m.notifier.FireNotification(
|
|
EventAutomationFirstFired,
|
|
getNotificationTitle(EventAutomationFirstFired),
|
|
getNotificationMessage(EventAutomationFirstFired),
|
|
)
|
|
m.notifiedFirstAutomation = true
|
|
log.Printf("[INFO] Feature notification fired: %s", EventAutomationFirstFired)
|
|
}
|
|
}
|
|
|
|
// Check prediction model readiness for each person
|
|
if m.checkPredictionReady != nil {
|
|
// Get list of persons from database
|
|
persons := m.getPersonsWithPredictionModels()
|
|
for _, personID := range persons {
|
|
if !m.notifiedPredictionReady[personID] {
|
|
if m.checkPredictionReady(personID) {
|
|
// Use person-specific event ID
|
|
eventID := PredictionModelReadyEventID(personID)
|
|
m.notifier.FireNotification(
|
|
eventID,
|
|
getPersonNotificationTitle(personID, EventPredictionModelReady),
|
|
getPersonNotificationMessage(personID, EventPredictionModelReady),
|
|
)
|
|
m.notifiedPredictionReady[personID] = true
|
|
log.Printf("[INFO] Feature notification fired: prediction model ready for person %s", personID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// getPersonsWithPredictionModels returns a list of person IDs with prediction models.
|
|
func (m *FeatureMonitor) getPersonsWithPredictionModels() []string {
|
|
// Query the prediction_models table for persons
|
|
rows, err := m.db.Query(`
|
|
SELECT DISTINCT person FROM prediction_models
|
|
WHERE sample_count >= 3
|
|
ORDER BY person
|
|
`)
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to query prediction models: %v", err)
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var persons []string
|
|
for rows.Next() {
|
|
var person string
|
|
if err := rows.Scan(&person); err != nil {
|
|
continue
|
|
}
|
|
persons = append(persons, person)
|
|
}
|
|
|
|
return persons
|
|
}
|
|
|
|
// PredictionModelReadyEventID returns the event ID for a person's prediction model readiness.
|
|
func PredictionModelReadyEventID(personID string) string {
|
|
return EventPredictionModelReady + "_" + personID
|
|
}
|
|
|
|
// getPersonNotificationTitle returns a person-specific notification title.
|
|
func getPersonNotificationTitle(personID, baseEvent string) string {
|
|
switch baseEvent {
|
|
case EventPredictionModelReady:
|
|
return "Presence predictions are now available for " + personID
|
|
default:
|
|
return getNotificationTitle(baseEvent)
|
|
}
|
|
}
|
|
|
|
// getPersonNotificationMessage returns a person-specific notification message.
|
|
func getPersonNotificationMessage(personID, baseEvent string) string {
|
|
switch baseEvent {
|
|
case EventPredictionModelReady:
|
|
return "The system has learned when " + personID + " is typically in each room. " +
|
|
"Predictions appear in the Predictions panel. Accuracy will continue to improve over the coming days."
|
|
default:
|
|
return getNotificationMessage(baseEvent)
|
|
}
|
|
}
|