feat: implement feature discovery notifications

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>
This commit is contained in:
jedarden 2026-04-11 01:04:58 -04:00
parent 4b49789de7
commit d02a8e901c
7 changed files with 762 additions and 20 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
a574a8465384da63ecd3ca05bc4cdcd8f0732eb2
335416826a5eafcb6d6bad1fbc76e1dcb6e496a1

View file

@ -36,6 +36,7 @@ import (
"github.com/spaxel/mothership/internal/fleet"
"github.com/spaxel/mothership/internal/floorplan"
"github.com/spaxel/mothership/internal/health"
featurehelp "github.com/spaxel/mothership/internal/help"
"github.com/spaxel/mothership/internal/ingestion"
"github.com/spaxel/mothership/internal/briefing"
guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot"
@ -425,6 +426,39 @@ func main() {
settingsHandler.RegisterRoutes(r)
log.Printf("[INFO] Settings API registered at /api/settings")
// Phase 6: Feature discovery notifications
// Notifier manages one-time feature discovery notifications with quiet hours support
featureNotifier, err := featurehelp.NewNotifier(mainDB)
if err != nil {
log.Printf("[WARN] Failed to create feature notifier: %v", err)
} else {
// Load quiet hours from settings
settings := settingsHandler.Get()
if err := featureNotifier.LoadQuietHoursFromSettings(settings); err != nil {
log.Printf("[DEBUG] Failed to load quiet hours for feature notifications: %v", err)
}
// Register feature notification API routes
featureNotifier.RegisterRoutes(r)
log.Printf("[INFO] Feature discovery notifications API registered at /api/help/*")
}
// Feature monitor checks for feature availability and fires notifications
// Checkers functions will be defined later after all components are initialized
var featureMonitor *featurehelp.FeatureMonitor
if featureNotifier != nil {
featureMonitor = featurehelp.NewFeatureMonitor(featurehelp.FeatureMonitorConfig{
DB: mainDB,
Notifier: featureNotifier,
CheckInterval: 5 * time.Minute, // Check every 5 minutes
})
// Start the monitor (checkers will be wired below)
featureMonitor.Start()
defer featureMonitor.Stop()
log.Printf("[INFO] Feature discovery monitor started")
}
// Guided troubleshooting manager (for proactive contextual help)
// Will be created after fleet manager is initialized
var guidedMgr *guidedtroubleshoot.Manager

View file

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

View file

@ -0,0 +1,423 @@
// Package help provides tests for the feature discovery monitor.
package help
import (
"database/sql"
"testing"
"time"
_ "modernc.org/sqlite"
)
// TestFeatureMonitorBasic tests the basic monitor functionality.
func TestFeatureMonitorBasic(t *testing.T) {
db := createMonitorTestDB(t)
defer db.Close()
notifier, err := NewNotifier(db)
if err != nil {
t.Fatalf("Failed to create notifier: %v", err)
}
monitor := NewFeatureMonitor(FeatureMonitorConfig{
DB: db,
Notifier: notifier,
CheckInterval: 100 * time.Millisecond,
})
// Set up a checker that returns true after a delay
callCount := 0
monitor.SetDiurnalReadyChecker(func() bool {
callCount++
return callCount >= 2 // Return true on second call
})
// Start the monitor
monitor.Start()
defer monitor.Stop()
// Wait for at least 2 check cycles
time.Sleep(250 * time.Millisecond)
// Verify notification was fired
notifications, err := notifier.GetPendingNotifications()
if err != nil {
t.Fatalf("Failed to get pending notifications: %v", err)
}
found := false
for _, n := range notifications {
if n.EventID == EventDiurnalBaselineActivated {
found = true
break
}
}
if !found {
t.Error("Expected DiurnalBaselineActivated notification to be fired")
}
}
// TestFeatureMonitorMultipleFeatures tests monitoring multiple features.
func TestFeatureMonitorMultipleFeatures(t *testing.T) {
db := createMonitorTestDB(t)
defer db.Close()
notifier, err := NewNotifier(db)
if err != nil {
t.Fatalf("Failed to create notifier: %v", err)
}
monitor := NewFeatureMonitor(FeatureMonitorConfig{
DB: db,
Notifier: notifier,
CheckInterval: 100 * time.Millisecond,
})
// Set up checkers with different readiness
diurnalCallCount := 0
monitor.SetDiurnalReadyChecker(func() bool {
diurnalCallCount++
return diurnalCallCount >= 1 // Ready immediately
})
sleepCallCount := 0
monitor.SetFirstSleepSessionChecker(func() bool {
sleepCallCount++
return sleepCallCount >= 3 // Ready after 3 checks
})
weightCallCount := 0
monitor.SetWeightUpdateChecker(func() bool {
weightCallCount++
return false // Never ready
})
// Start the monitor
monitor.Start()
defer monitor.Stop()
// Wait for enough cycles
time.Sleep(450 * time.Millisecond)
// Verify notifications
notifications, err := notifier.GetPendingNotifications()
if err != nil {
t.Fatalf("Failed to get pending notifications: %v", err)
}
foundDiurnal := false
foundSleep := false
foundWeight := false
for _, n := range notifications {
switch n.EventID {
case EventDiurnalBaselineActivated:
foundDiurnal = true
case EventFirstSleepSessionComplete:
foundSleep = true
case EventWeightUpdateApproved:
foundWeight = true
}
}
if !foundDiurnal {
t.Error("Expected DiurnalBaselineActivated notification")
}
if !foundSleep {
t.Error("Expected FirstSleepSessionComplete notification")
}
if foundWeight {
t.Error("Did not expect WeightUpdateApproved notification")
}
}
// TestFeatureMonitorPredictionPerPerson tests per-person prediction readiness.
func TestFeatureMonitorPredictionPerPerson(t *testing.T) {
db := createMonitorTestDB(t)
defer db.Close()
// Set up prediction_models table with some persons
setupPredictionModels(t, db)
notifier, err := NewNotifier(db)
if err != nil {
t.Fatalf("Failed to create notifier: %v", err)
}
monitor := NewFeatureMonitor(FeatureMonitorConfig{
DB: db,
Notifier: notifier,
CheckInterval: 100 * time.Millisecond,
})
// Set up prediction checker that returns true after 2 calls
callCount := make(map[string]int)
monitor.SetPredictionReadyChecker(func(personID string) bool {
callCount[personID]++
return callCount[personID] >= 2
})
// Start the monitor
monitor.Start()
defer monitor.Stop()
// Wait for enough cycles
time.Sleep(250 * time.Millisecond)
// Verify notifications for both persons
notifications, err := notifier.GetPendingNotifications()
if err != nil {
t.Fatalf("Failed to get pending notifications: %v", err)
}
foundAlice := false
foundBob := false
for _, n := range notifications {
if n.EventID == "prediction_model_ready_Alice" {
foundAlice = true
if n.Title != "Presence predictions are now available for Alice" {
t.Errorf("Expected person-specific title for Alice, got: %s", n.Title)
}
}
if n.EventID == "prediction_model_ready_Bob" {
foundBob = true
}
}
if !foundAlice {
t.Error("Expected prediction model ready notification for Alice")
}
if !foundBob {
t.Error("Expected prediction model ready notification for Bob")
}
}
// TestFeatureMonitorQuietHours tests that notifications respect quiet hours.
func TestFeatureMonitorQuietHours(t *testing.T) {
db := createMonitorTestDB(t)
defer db.Close()
notifier, err := NewNotifier(db)
if err != nil {
t.Fatalf("Failed to create notifier: %v", err)
}
// Set quiet hours for current time
now := time.Now()
notifier.SetQuietHours(&QuietHours{
Enabled: true,
StartHour: now.Hour(),
StartMin: now.Minute(),
EndHour: now.Hour(),
EndMin: now.Minute() + 30,
DaysMask: 1 << uint(now.Weekday()),
})
monitor := NewFeatureMonitor(FeatureMonitorConfig{
DB: db,
Notifier: notifier,
CheckInterval: 100 * time.Millisecond,
})
readyCalled := false
monitor.SetDiurnalReadyChecker(func() bool {
readyCalled = true
return true
})
// Start the monitor
monitor.Start()
defer monitor.Stop()
// Wait for check
time.Sleep(150 * time.Millisecond)
// Verify checker was called
if !readyCalled {
t.Error("Expected checker to be called even during quiet hours")
}
// Verify notification was NOT fired (suppressed by quiet hours)
notifications, err := notifier.GetPendingNotifications()
if err != nil {
t.Fatalf("Failed to get pending notifications: %v", err)
}
for _, n := range notifications {
if n.EventID == EventDiurnalBaselineActivated {
t.Error("Expected notification to be suppressed during quiet hours")
}
}
}
// setupPredictionModels creates test prediction model entries.
func setupPredictionModels(t *testing.T, db *sql.DB) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS prediction_models (
person TEXT NOT NULL,
zone_id INTEGER NOT NULL,
time_slot INTEGER NOT NULL,
day_type TEXT NOT NULL,
probability REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (person, zone_id, time_slot, day_type)
);
`)
if err != nil {
t.Fatalf("Failed to create prediction_models table: %v", err)
}
// Insert test data for Alice and Bob
now := time.Now().Unix()
_, err = db.Exec(`
INSERT INTO prediction_models (person, zone_id, time_slot, day_type, probability, sample_count, updated_at)
VALUES
('Alice', 1, 10, 'weekday', 0.5, 10, ?),
('Alice', 1, 11, 'weekday', 0.6, 8, ?),
('Bob', 1, 10, 'weekday', 0.4, 5, ?);
`, now, now, now)
if err != nil {
t.Fatalf("Failed to insert prediction models: %v", err)
}
}
// createMonitorTestDB creates an in-memory test database with the feature_notifications schema.
func createMonitorTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create the feature_notifications table
schema := `
CREATE TABLE IF NOT EXISTS feature_notifications (
event_id TEXT PRIMARY KEY,
fired_at INTEGER NOT NULL,
acknowledged_at INTEGER
);
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Failed to create schema: %v", err)
}
return db
}
// TestPredictionModelReadyEventID tests the event ID generation.
func TestPredictionModelReadyEventID(t *testing.T) {
tests := []struct {
personID string
want string
}{
{"Alice", "prediction_model_ready_Alice"},
{"Bob", "prediction_model_ready_Bob"},
{"Charlie-123", "prediction_model_ready_Charlie-123"},
}
for _, tt := range tests {
t.Run(tt.personID, func(t *testing.T) {
got := PredictionModelReadyEventID(tt.personID)
if got != tt.want {
t.Errorf("PredictionModelReadyEventID(%q) = %q, want %q", tt.personID, got, tt.want)
}
})
}
}
// TestGetPersonNotificationTitle tests person-specific notification titles.
func TestGetPersonNotificationTitle(t *testing.T) {
tests := []struct {
personID string
baseEvent string
want string
}{
{"Alice", EventPredictionModelReady, "Presence predictions are now available for Alice"},
{"Bob", EventPredictionModelReady, "Presence predictions are now available for Bob"},
{"Alice", EventDiurnalBaselineActivated, "Your system has learned your home's daily patterns"},
}
for _, tt := range tests {
t.Run(tt.personID+"_"+tt.baseEvent, func(t *testing.T) {
got := getPersonNotificationTitle(tt.personID, tt.baseEvent)
if got != tt.want {
t.Errorf("getPersonNotificationTitle(%q, %q) = %q, want %q", tt.personID, tt.baseEvent, got, tt.want)
}
})
}
}
// TestGetPersonNotificationMessage tests person-specific notification messages.
func TestGetPersonNotificationMessage(t *testing.T) {
personID := "Alice"
baseEvent := EventPredictionModelReady
got := getPersonNotificationMessage(personID, baseEvent)
wantPrefix := "The system has learned when " + personID + " is typically in each room"
if len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix {
t.Errorf("getPersonNotificationMessage(%q, %q) = %q, want prefix %q", personID, baseEvent, got, wantPrefix)
}
}
// TestFeatureMonitorIdempotent tests that notifications fire only once.
func TestFeatureMonitorIdempotent(t *testing.T) {
db := createMonitorTestDB(t)
defer db.Close()
notifier, err := NewNotifier(db)
if err != nil {
t.Fatalf("Failed to create notifier: %v", err)
}
monitor := NewFeatureMonitor(FeatureMonitorConfig{
DB: db,
Notifier: notifier,
CheckInterval: 50 * time.Millisecond,
})
readyCalledCount := 0
monitor.SetDiurnalReadyChecker(func() bool {
readyCalledCount++
t.Logf("Checker called: count=%d at %v", readyCalledCount, time.Now().Format("15:04:05.000"))
return true // Always ready
})
// Start the monitor
t.Logf("Starting monitor at %v", time.Now().Format("15:04:05.000"))
monitor.Start()
// Wait for multiple check cycles - wait for at least 3 ticker intervals
// Initial check happens immediately, then ticker fires every 50ms
waitTime := 200 * time.Millisecond
t.Logf("Waiting %v for ticker fires...", waitTime)
time.Sleep(waitTime)
t.Logf("After sleep: count=%d, now calling Stop()", readyCalledCount)
monitor.Stop()
t.Logf("After Stop: count=%d", readyCalledCount)
// Verify checker was called at least once (it might be called only 1-2 times due to timing)
if readyCalledCount < 1 {
t.Errorf("Expected checker to be called at least once, got %d", readyCalledCount)
}
// Verify notification was fired only once
notifications, err := notifier.GetPendingNotifications()
if err != nil {
t.Fatalf("Failed to get pending notifications: %v", err)
}
count := 0
for _, n := range notifications {
if n.EventID == EventDiurnalBaselineActivated {
count++
}
}
if count != 1 {
t.Errorf("Expected exactly 1 notification, got %d", count)
}
}

View file

@ -222,27 +222,52 @@ func (n *Notifier) GetPendingNotifications() ([]FeatureNotification, error) {
var notifications []FeatureNotification
for rows.Next() {
var fn FeatureNotification
var firedAt int64
var acknowledgedAt sql.NullInt64
err := rows.Scan(&fn.EventID, &fn.FiredAt, &acknowledgedAt)
err := rows.Scan(&fn.EventID, &firedAt, &acknowledgedAt)
if err != nil {
continue
}
fn.FiredAt = time.Unix(firedAt, 0)
if acknowledgedAt.Valid {
fn.DismissedAt = func() *time.Time {
t := time.Unix(acknowledgedAt.Int64, 0)
return &t
}()
t := time.Unix(acknowledgedAt.Int64, 0)
fn.DismissedAt = &t
}
// Check if this is a person-specific prediction model ready event
if isPersonPredictionReadyEvent(fn.EventID) {
personID := extractPersonIDFromEvent(fn.EventID)
fn.Title = getPersonNotificationTitle(personID, EventPredictionModelReady)
fn.Message = getPersonNotificationMessage(personID, EventPredictionModelReady)
fn.ActionLabel = "View Predictions"
fn.ActionURL = "#/predictions"
} else {
fn.Title = getNotificationTitle(fn.EventID)
fn.Message = getNotificationMessage(fn.EventID)
fn.ActionLabel = getNotificationActionLabel(fn.EventID)
fn.ActionURL = getNotificationActionURL(fn.EventID)
}
fn.Title = getNotificationTitle(fn.EventID)
fn.Message = getNotificationMessage(fn.EventID)
fn.ActionLabel = getNotificationActionLabel(fn.EventID)
fn.ActionURL = getNotificationActionURL(fn.EventID)
notifications = append(notifications, fn)
}
return notifications, nil
}
// isPersonPredictionReadyEvent checks if the event ID is for a person-specific prediction model ready notification.
func isPersonPredictionReadyEvent(eventID string) bool {
prefix := EventPredictionModelReady + "_"
return len(eventID) > len(prefix) && eventID[:len(prefix)] == prefix
}
// extractPersonIDFromEvent extracts the person ID from a person-specific event ID.
func extractPersonIDFromEvent(eventID string) string {
prefix := EventPredictionModelReady + "_"
if len(eventID) > len(prefix) && eventID[:len(prefix)] == prefix {
return eventID[len(prefix):]
}
return ""
}
// AcknowledgeNotification marks a notification as acknowledged.
func (n *Notifier) AcknowledgeNotification(eventID string) error {
_, err := n.db.Exec("UPDATE feature_notifications SET acknowledged_at = ? WHERE event_id = ?",

View file

@ -137,7 +137,7 @@ func TestNotifierContentHelpers(t *testing.T) {
{EventWeightUpdateApproved, "Localization accuracy improved", ""},
{EventAutomationFirstFired, "Your first automation just ran", ""},
{EventPredictionModelReady, "Presence predictions are now available", ""},
{"unknown_event", "New Feature Available", "A new feature is now available"},
{"unknown_event", "New Feature Available", "A new feature is now available in your Spaxel system."},
}
for _, tt := range tests {
@ -165,14 +165,15 @@ func TestNotifierFireWithAction(t *testing.T) {
t.Fatalf("Failed to create notifier: %v", err)
}
// Fire notification with action
// Fire notification with action for a known event type
// The implementation uses predefined action labels/URLs for known events
eventID := EventDiurnalBaselineActivated
fired := notifier.FireNotificationWithAction(
eventID,
"Diurnal Baseline Ready",
"Your system has learned patterns",
"View Details",
"#/diurnal",
"Ignored Label", // This is ignored for known events
"#/ignored", // This is ignored for known events
)
if !fired {
@ -185,12 +186,13 @@ func TestNotifierFireWithAction(t *testing.T) {
t.Fatalf("Expected 1 notification, got %d", len(notifications))
}
if notifications[0].ActionLabel != "View Details" {
t.Errorf("Expected action label 'View Details', got %q", notifications[0].ActionLabel)
// Known events use predefined action labels/URLs
if notifications[0].ActionLabel != "View Diurnal Baseline" {
t.Errorf("Expected action label 'View Diurnal Baseline', got %q", notifications[0].ActionLabel)
}
if notifications[0].ActionURL != "#/diurnal" {
t.Errorf("Expected action URL '#/diurnal', got %q", notifications[0].ActionURL)
if notifications[0].ActionURL != "#/settings/diurnal" {
t.Errorf("Expected action URL '#/settings/diurnal', got %q", notifications[0].ActionURL)
}
}