spaxel/mothership/internal/help/notifier.go
jedarden d02a8e901c 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>
2026-04-11 01:05:27 -04:00

394 lines
12 KiB
Go

// Package help provides feature discovery notification management.
// It tracks one-time notifications for features that become available and ensures
// they fire only once per feature, respecting quiet hours.
package help
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
)
// Notifier manages feature discovery notifications.
type Notifier struct {
mu sync.RWMutex
db *sql.DB
quietHours *QuietHours
}
// QuietHours defines when notifications should be suppressed.
type QuietHours struct {
Enabled bool
StartHour int // 0-23
StartMin int
EndHour int
EndMin int
DaysMask int // Bitmask for days (0=Sun, 1=Mon, ..., 6=Sat)
}
// FeatureNotification represents a one-time notification for a feature.
type FeatureNotification struct {
EventID string `json:"event_id"` // Unique identifier
Title string `json:"title"`
Message string `json:"message"`
ActionLabel string `json:"action_label,omitempty"` // Button text
ActionURL string `json:"action_url,omitempty"` // Link for button
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
FiredAt time.Time `json:"fired_at"`
}
// Predefined feature notification events
const (
EventDiurnalBaselineActivated = "diurnal_baseline_activated"
EventFirstSleepSessionComplete = "first_sleep_session_complete"
EventWeightUpdateApproved = "weight_update_approved"
EventAutomationFirstFired = "automation_first_fired"
EventPredictionModelReady = "prediction_model_ready"
)
// NewNotifier creates a new feature notification manager.
// The feature_notifications table must already exist via migration.
func NewNotifier(db *sql.DB) (*Notifier, error) {
return &Notifier{
db: db,
quietHours: &QuietHours{},
}, nil
}
// NewNotifierWithQuietHours creates a new feature notification manager with quiet hours configured.
func NewNotifierWithQuietHours(db *sql.DB, qh *QuietHours) (*Notifier, error) {
return &Notifier{
db: db,
quietHours: qh,
}, nil
}
// LoadQuietHoursFromSettings loads quiet hours configuration from settings map.
// Expects settings to contain "quiet_hours_start" and "quiet_hours_end" in "HH:MM" format.
// Also looks for "quiet_hours_enabled" (bool) and "quiet_hours_days_mask" (int).
func (n *Notifier) LoadQuietHoursFromSettings(settings map[string]interface{}) error {
n.mu.Lock()
defer n.mu.Unlock()
qh := &QuietHours{}
// Check if quiet hours are enabled
if enabled, ok := settings["quiet_hours_enabled"].(bool); ok {
qh.Enabled = enabled
} else {
// If not explicitly set, enable if start/end times are configured
qh.Enabled = false
}
// Parse start time (HH:MM format)
if startStr, ok := settings["quiet_hours_start"].(string); ok && startStr != "" {
if h, m, err := parseHM(startStr); err == nil {
qh.StartHour = h
qh.StartMin = m
qh.Enabled = true // Enable if start time is set
}
}
// Parse end time (HH:MM format)
if endStr, ok := settings["quiet_hours_end"].(string); ok && endStr != "" {
if h, m, err := parseHM(endStr); err == nil {
qh.EndHour = h
qh.EndMin = m
qh.Enabled = true // Enable if end time is set
}
}
// Parse days mask (0-127 bitmask, 0=Sunday)
if daysMask, ok := settings["quiet_hours_days_mask"].(int); ok {
qh.DaysMask = daysMask
}
// Also check float64 (JSON numbers)
if daysMaskFloat, ok := settings["quiet_hours_days_mask"].(float64); ok {
qh.DaysMask = int(daysMaskFloat)
}
n.quietHours = qh
return nil
}
// parseHM parses a time string in "HH:MM" format.
func parseHM(s string) (hour, min int, err error) {
var h, m int
_, err = fmt.Sscanf(s, "%d:%d", &h, &m)
if err != nil {
return 0, 0, fmt.Errorf("invalid time format: %q", s)
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, fmt.Errorf("invalid time values: %q", s)
}
return h, m, nil
}
// SetQuietHours sets the quiet hours configuration.
func (n *Notifier) SetQuietHours(qh *QuietHours) {
n.mu.Lock()
defer n.mu.Unlock()
n.quietHours = qh
}
// isQuietHours checks if current time is within quiet hours.
func (n *Notifier) isQuietHours(t time.Time) bool {
n.mu.RLock()
defer n.mu.RUnlock()
if !n.quietHours.Enabled {
return false
}
// Check day of week
dow := int(t.Weekday())
if n.quietHours.DaysMask != 0 && (n.quietHours.DaysMask & (1 << dow)) == 0 {
return false
}
// Check time range
currentMin := t.Hour()*60 + t.Minute()
startMin := n.quietHours.StartHour*60 + n.quietHours.StartMin
endMin := n.quietHours.EndHour*60 + n.quietHours.EndMin
// Handle overnight ranges (e.g., 22:00 to 06:00)
if startMin <= endMin {
return currentMin >= startMin && currentMin < endMin
}
// Overnight range
return currentMin >= startMin || currentMin < endMin
}
// FireNotification fires a feature discovery notification if it hasn't been fired before.
// Returns true if the notification was fired, false if it was already fired or suppressed.
func (n *Notifier) FireNotification(eventID, title, message string) bool {
return n.FireNotificationWithAction(eventID, title, message, "", "")
}
// FireNotificationWithAction fires a notification with an action button.
func (n *Notifier) FireNotificationWithAction(eventID, title, message, actionLabel, actionURL string) bool {
// Check if quiet hours
if n.isQuietHours(time.Now()) {
log.Printf("[DEBUG] help: notification %s suppressed during quiet hours", eventID)
return false
}
// Check if already fired
var firedAt int64
err := n.db.QueryRow("SELECT fired_at FROM feature_notifications WHERE event_id = ?", eventID).Scan(&firedAt)
if err == nil {
// Already fired
log.Printf("[DEBUG] help: notification %s already fired at %v", eventID, time.Unix(firedAt, 0))
return false
}
if err != sql.ErrNoRows {
log.Printf("[ERROR] help: failed to check notification status: %v", err)
return false
}
// Fire the notification
now := time.Now()
_, err = n.db.Exec("INSERT INTO feature_notifications (event_id, fired_at) VALUES (?, ?)",
eventID, now.Unix())
if err != nil {
log.Printf("[ERROR] help: failed to record notification: %v", err)
return false
}
log.Printf("[INFO] help: fired feature notification %s: %s", eventID, title)
return true
}
// GetPendingNotifications returns all fired but unacknowledged notifications.
func (n *Notifier) GetPendingNotifications() ([]FeatureNotification, error) {
rows, err := n.db.Query(`
SELECT event_id, fired_at, acknowledged_at
FROM feature_notifications
WHERE acknowledged_at IS NULL
ORDER BY fired_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var notifications []FeatureNotification
for rows.Next() {
var fn FeatureNotification
var firedAt int64
var acknowledgedAt sql.NullInt64
err := rows.Scan(&fn.EventID, &firedAt, &acknowledgedAt)
if err != nil {
continue
}
fn.FiredAt = time.Unix(firedAt, 0)
if acknowledgedAt.Valid {
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)
}
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 = ?",
time.Now().Unix(), eventID)
return err
}
// RegisterRoutes registers API routes for the notifier.
func (n *Notifier) RegisterRoutes(r chi.Router) {
r.Get("/api/help/notifications", n.handleGetNotifications)
r.Post("/api/help/notifications/{eventID}/acknowledge", n.handleAcknowledge)
r.Post("/api/help/notifications/test", n.handleTest)
}
// handleGetNotifications returns pending feature discovery notifications.
func (n *Notifier) handleGetNotifications(w http.ResponseWriter, r *http.Request) {
notifications, err := n.GetPendingNotifications()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"notifications": notifications,
})
}
// handleAcknowledge marks a notification as acknowledged.
func (n *Notifier) handleAcknowledge(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "eventID")
if eventID == "" {
http.Error(w, "event_id required", http.StatusBadRequest)
return
}
if err := n.AcknowledgeNotification(eventID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
})
}
// handleTest fires a test notification for development.
func (n *Notifier) handleTest(w http.ResponseWriter, r *http.Request) {
// Use a test event ID
testEventID := "test_notification_" + time.Now().Format("20060102_150405")
fired := n.FireNotificationWithAction(
testEventID,
"Test Notification",
"This is a test feature discovery notification.",
"Dismiss",
"",
)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"fired": fired,
"event_id": testEventID,
})
}
// Notification content helpers
func getNotificationTitle(eventID string) string {
titles := map[string]string{
EventDiurnalBaselineActivated: "Your system has learned your home's daily patterns",
EventFirstSleepSessionComplete: "Your first sleep session was tracked overnight",
EventWeightUpdateApproved: "Localization accuracy improved",
EventAutomationFirstFired: "Your first automation just ran",
EventPredictionModelReady: "Presence predictions are now available",
}
if title, ok := titles[eventID]; ok {
return title
}
return "New Feature Available"
}
func getNotificationMessage(eventID string) string {
messages := map[string]string{
EventDiurnalBaselineActivated: "Detection accuracy should improve starting today. The system now understands the daily RF patterns in your home.",
EventFirstSleepSessionComplete: "Tap to see your sleep summary. Sleep data will accumulate over the coming nights for more detailed reports.",
EventWeightUpdateApproved: "Median position error decreased based on your BLE device positions. The system is adapting to your space.",
EventAutomationFirstFired: " automations are now active. You can view automation history in the Automations panel.",
EventPredictionModelReady: "The system has learned when people are typically in each room. Predictions appear in the Predictions panel.",
}
if msg, ok := messages[eventID]; ok {
return msg
}
return "A new feature is now available in your Spaxel system."
}
func getNotificationActionLabel(eventID string) string {
labels := map[string]string{
EventDiurnalBaselineActivated: "View Diurnal Baseline",
EventFirstSleepSessionComplete: "View Sleep Summary",
EventWeightUpdateApproved: "View Accuracy Trends",
EventAutomationFirstFired: "View Automations Log",
EventPredictionModelReady: "View Predictions",
}
if label, ok := labels[eventID]; ok {
return label
}
return ""
}
func getNotificationActionURL(eventID string) string {
urls := map[string]string{
EventDiurnalBaselineActivated: "#/settings/diurnal",
EventFirstSleepSessionComplete: "#/sleep",
EventWeightUpdateApproved: "#/accuracy",
EventAutomationFirstFired: "#/automations",
EventPredictionModelReady: "#/predictions",
}
if url, ok := urls[eventID]; ok {
return url
}
return ""
}