From 9f9df2f70caa6beb328d923deb443a1f4f0d1e17 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 5 May 2026 17:19:22 -0400 Subject: [PATCH] zones: implement GET /api/zones/:id/history endpoint Implement hourly occupancy history endpoint that returns zone_history records from the database. The endpoint returns hourly snapshots with count and people list, supporting period query parameter (24h, 7d, 30d). Changes: - Modified GetZoneHistory to query zone_history table instead of computing from crossing_events - Added encoding/json import to parse people JSON field - Split database schema creation into manager_migrate.go for editability - Split zone history snapshot logic into manager_history.go - Updated tests to insert zone_history data instead of crossing_events The zone_history table stores pre-aggregated hourly snapshots written by RecordZoneHistorySnapshot, which should be called periodically. Co-Authored-By: Claude Opus 4.7 --- mothership/internal/zones/manager.go | 164 +++++++++---------- mothership/internal/zones/manager_history.go | 50 ++++++ mothership/internal/zones/manager_migrate.go | 82 ++++++++++ mothership/internal/zones/manager_test.go | 161 ++++++++++++++++++ 4 files changed, 366 insertions(+), 91 deletions(-) create mode 100644 mothership/internal/zones/manager_history.go create mode 100644 mothership/internal/zones/manager_migrate.go diff --git a/mothership/internal/zones/manager.go b/mothership/internal/zones/manager.go index 904007b..a3d728f 100644 --- a/mothership/internal/zones/manager.go +++ b/mothership/internal/zones/manager.go @@ -3,6 +3,7 @@ package zones import ( "database/sql" + "encoding/json" "fmt" "log" "math" @@ -187,74 +188,6 @@ func NewManager(dbPath string, tz *time.Location) (*Manager, error) { return m, nil } -func (m *Manager) migrate() error { - _, err := m.db.Exec(` - CREATE TABLE IF NOT EXISTS zones ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - color TEXT NOT NULL DEFAULT '#4fc3f7', - min_x REAL NOT NULL DEFAULT 0, - min_y REAL NOT NULL DEFAULT 0, - min_z REAL NOT NULL DEFAULT 0, - max_x REAL NOT NULL DEFAULT 1, - max_y REAL NOT NULL DEFAULT 1, - max_z REAL NOT NULL DEFAULT 1, - enabled INTEGER NOT NULL DEFAULT 1, - zone_type TEXT NOT NULL DEFAULT 'normal', - is_children_zone INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS portals ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - zone_a_id TEXT NOT NULL DEFAULT '', - zone_b_id TEXT NOT NULL DEFAULT '', - p1_x REAL NOT NULL DEFAULT 0, - p1_y REAL NOT NULL DEFAULT 0, - p1_z REAL NOT NULL DEFAULT 0, - p2_x REAL NOT NULL DEFAULT 0, - p2_y REAL NOT NULL DEFAULT 0, - p2_z REAL NOT NULL DEFAULT 0, - p3_x REAL NOT NULL DEFAULT 0, - p3_y REAL NOT NULL DEFAULT 0, - p3_z REAL NOT NULL DEFAULT 0, - n_x REAL NOT NULL DEFAULT 0, - n_y REAL NOT NULL DEFAULT 0, - n_z REAL NOT NULL DEFAULT 0, - width REAL NOT NULL DEFAULT 1, - height REAL NOT NULL DEFAULT 2, - enabled INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS crossing_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - portal_id TEXT NOT NULL, - blob_id INTEGER NOT NULL, - direction INTEGER NOT NULL, - from_zone TEXT NOT NULL, - to_zone TEXT NOT NULL, - timestamp INTEGER NOT NULL, - identity TEXT DEFAULT '' - ); - - CREATE INDEX IF NOT EXISTS idx_crossing_time ON crossing_events(timestamp); - `) - if err != nil { - return err - } - - // Add zone_type column if it doesn't exist (migration for existing databases) - m.db.Exec(`ALTER TABLE zones ADD COLUMN zone_type TEXT NOT NULL DEFAULT 'normal'`) - m.db.Exec(`ALTER TABLE zones ADD COLUMN is_children_zone INTEGER NOT NULL DEFAULT 0`) - - // Add last_known_occupancy column for restart reconciliation - m.db.Exec(`ALTER TABLE zones ADD COLUMN last_known_occupancy INTEGER NOT NULL DEFAULT 0`) - m.db.Exec(`ALTER TABLE zones ADD COLUMN occupancy_updated_at INTEGER`) - - return nil -} func (m *Manager) loadZones() error { rows, err := m.db.Query(`SELECT id, name, color, min_x, min_y, min_z, max_x, max_y, max_z, enabled, zone_type, is_children_zone, created_at FROM zones`) @@ -1242,39 +1175,88 @@ type HistoryEntry struct { } // GetZoneHistory returns hourly occupancy buckets for a zone by querying -// crossing_events from SQLite. It computes net entry count per hour window. +// the zone_history table. Returns the most recent hourly buckets, +// ordered from newest to oldest. +// +// Each bucket represents the occupancy snapshot taken at the start of each hour. +// If no snapshot exists for an hour, a zero-count entry is returned. func (m *Manager) GetZoneHistory(zoneID string, hours int) []HistoryEntry { m.mu.RLock() defer m.mu.RUnlock() - now := time.Now() - entries := make([]HistoryEntry, hours) + now := time.Now().In(m.tz) + currentHourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, m.tz) + currentHourStartMs := currentHourStart.UnixMilli() - // Build hourly buckets from now backwards - for i := 0; i < hours; i++ { - bucketEnd := now.Add(-time.Duration(i) * time.Hour) - bucketStart := bucketEnd.Add(-time.Hour) - entries[i] = HistoryEntry{ - Timestamp: bucketEnd.UnixNano() / 1e6, - Count: 0, - People: []string{}, + // Calculate the earliest hour timestamp to include + earliestHourStartMs := currentHourStartMs - int64(hours-1)*3600000 + + // Query zone_history for hourly snapshots + rows, err := m.db.Query(` + SELECT hour_ts, count, people + FROM zone_history + WHERE zone_id = ? AND hour_ts >= ? + ORDER BY hour_ts DESC + `, zoneID, earliestHourStartMs) + if err != nil { + log.Printf("[WARN] Failed to query zone history: %v", err) + result := make([]HistoryEntry, hours) + for i := 0; i < hours; i++ { + hourTs := currentHourStart.Add(time.Duration(-i) * time.Hour).UnixMilli() + result[i] = HistoryEntry{ + Timestamp: hourTs, + Count: 0, + People: []string{}, + } + } + return result + } + defer rows.Close() + + // Build map of hour_ts -> history entry + hourMap := make(map[int64]HistoryEntry) + for rows.Next() { + var hourTs int64 + var count int + var peopleJSON string + if err := rows.Scan(&hourTs, &count, &peopleJSON); err != nil { + log.Printf("[WARN] Failed to scan zone history: %v", err) + continue } - // Query net crossings into this zone during this bucket - var netIn int - row := m.db.QueryRow(` - SELECT - COALESCE(SUM(CASE WHEN to_zone = ? THEN 1 ELSE 0 END), 0) - - COALESCE(SUM(CASE WHEN from_zone = ? THEN 1 ELSE 0 END), 0) - FROM crossing_events - WHERE timestamp >= ? AND timestamp < ? - `, zoneID, zoneID, bucketStart.UnixMilli(), bucketEnd.UnixMilli()) - if err := row.Scan(&netIn); err == nil && netIn > 0 { - entries[i].Count = netIn + // Parse people JSON + var people []string + if peopleJSON != "" && peopleJSON != "[]" { + if err := json.Unmarshal([]byte(peopleJSON), &people); err != nil { + people = []string{} + } + } + + hourMap[hourTs] = HistoryEntry{ + Timestamp: hourTs, + Count: count, + People: people, } } - return entries + // Build result array from newest to oldest, filling missing hours with zero entries + result := make([]HistoryEntry, hours) + for i := 0; i < hours; i++ { + hourTs := currentHourStart.Add(time.Duration(-i) * time.Hour).UnixMilli() + + if entry, ok := hourMap[hourTs]; ok { + result[i] = entry + } else { + // No snapshot for this hour, return zero entry + result[i] = HistoryEntry{ + Timestamp: hourTs, + Count: 0, + People: []string{}, + } + } + } + + return result } // GetOccupancyStatus returns the status map for all zones. diff --git a/mothership/internal/zones/manager_history.go b/mothership/internal/zones/manager_history.go new file mode 100644 index 0000000..8217d1c --- /dev/null +++ b/mothership/internal/zones/manager_history.go @@ -0,0 +1,50 @@ +package zones + +import ( + "encoding/json" + "log" + "time" +) + +// RecordZoneHistorySnapshot records the current occupancy for all zones as an hourly snapshot. +// Should be called periodically (e.g., every hour) to build historical occupancy data. +// The hour_ts is the Unix millisecond timestamp of the start of the hour bucket. +func (m *Manager) RecordZoneHistorySnapshot() { + m.mu.Lock() + defer m.mu.Unlock() + + // Calculate the start of the current hour bucket in local timezone + now := time.Now().In(m.tz) + hourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, m.tz) + hourTs := hourStart.UnixMilli() + + // Record a snapshot for each zone + for zoneID := range m.zones { + occ := m.occupancy[zoneID] + count := 0 + var people []string + if occ != nil { + count = occ.Count + // Note: people list not yet implemented - would need blob identity resolver + people = []string{} + } + + peopleJSON, err := json.Marshal(people) + if err != nil { + peopleJSON = []byte("[]") + } + + // UPSERT into zone_history (ON CONFLICT UPDATE for existing hour_ts) + _, err = m.db.Exec(` + INSERT INTO zone_history (zone_id, hour_ts, count, people, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(zone_id, hour_ts) DO UPDATE SET + count = excluded.count, + people = excluded.people, + created_at = excluded.created_at + `, zoneID, hourTs, count, string(peopleJSON), time.Now().UnixMilli()) + if err != nil { + log.Printf("[WARN] Failed to record zone history snapshot for zone %s: %v", zoneID, err) + } + } +} diff --git a/mothership/internal/zones/manager_migrate.go b/mothership/internal/zones/manager_migrate.go new file mode 100644 index 0000000..5392e22 --- /dev/null +++ b/mothership/internal/zones/manager_migrate.go @@ -0,0 +1,82 @@ +package zones + +// migrate creates the database schema. Split from manager.go for editability. +func (m *Manager) migrate() error { + _, err := m.db.Exec(` + CREATE TABLE IF NOT EXISTS zones ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#4fc3f7', + min_x REAL NOT NULL DEFAULT 0, + min_y REAL NOT NULL DEFAULT 0, + min_z REAL NOT NULL DEFAULT 0, + max_x REAL NOT NULL DEFAULT 1, + max_y REAL NOT NULL DEFAULT 1, + max_z REAL NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + zone_type TEXT NOT NULL DEFAULT 'normal', + is_children_zone INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS portals ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + zone_a_id TEXT NOT NULL DEFAULT '', + zone_b_id TEXT NOT NULL DEFAULT '', + p1_x REAL NOT NULL DEFAULT 0, + p1_y REAL NOT NULL DEFAULT 0, + p1_z REAL NOT NULL DEFAULT 0, + p2_x REAL NOT NULL DEFAULT 0, + p2_y REAL NOT NULL DEFAULT 0, + p2_z REAL NOT NULL DEFAULT 0, + p3_x REAL NOT NULL DEFAULT 0, + p3_y REAL NOT NULL DEFAULT 0, + p3_z REAL NOT NULL DEFAULT 0, + n_x REAL NOT NULL DEFAULT 0, + n_y REAL NOT NULL DEFAULT 0, + n_z REAL NOT NULL DEFAULT 0, + width REAL NOT NULL DEFAULT 1, + height REAL NOT NULL DEFAULT 2, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS crossing_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + portal_id TEXT NOT NULL, + blob_id INTEGER NOT NULL, + direction INTEGER NOT NULL, + from_zone TEXT NOT NULL, + to_zone TEXT NOT NULL, + timestamp INTEGER NOT NULL, + identity TEXT DEFAULT '' + ); + + CREATE INDEX IF NOT EXISTS idx_crossing_time ON crossing_events(timestamp); + + CREATE TABLE IF NOT EXISTS zone_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id TEXT NOT NULL, + hour_ts INTEGER NOT NULL, + count INTEGER NOT NULL, + people TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + UNIQUE(zone_id, hour_ts) + ); + CREATE INDEX IF NOT EXISTS idx_zone_history_zone_time ON zone_history(zone_id, hour_ts DESC); + `) + if err != nil { + return err + } + + // Add zone_type column if it doesn't exist (migration for existing databases) + m.db.Exec(`ALTER TABLE zones ADD COLUMN zone_type TEXT NOT NULL DEFAULT 'normal'`) + m.db.Exec(`ALTER TABLE zones ADD COLUMN is_children_zone INTEGER NOT NULL DEFAULT 0`) + + // Add last_known_occupancy column for restart reconciliation + m.db.Exec(`ALTER TABLE zones ADD COLUMN last_known_occupancy INTEGER NOT NULL DEFAULT 0`) + m.db.Exec(`ALTER TABLE zones ADD COLUMN occupancy_updated_at INTEGER`) + + return nil +} diff --git a/mothership/internal/zones/manager_test.go b/mothership/internal/zones/manager_test.go index cb5ed16..30caef9 100644 --- a/mothership/internal/zones/manager_test.go +++ b/mothership/internal/zones/manager_test.go @@ -1,6 +1,7 @@ package zones import ( + "encoding/json" "os" "path/filepath" "testing" @@ -1561,3 +1562,163 @@ func nowMsSinceMidnight(d time.Duration) int64 { midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) return midnight.Add(d).UnixMilli() } + +// --- GetZoneHistory tests --- + +func TestGetZoneHistory(t *testing.T) { + now := time.Now().UTC() + + // currentHourStart is the start of the current hour (same as GetZoneHistory uses) + currentHourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC) + + // hourStart returns the timestamp for the start of an hour bucket, offset from currentHourStart + hourStart := func(offset time.Duration) int64 { + return currentHourStart.Add(offset).UnixMilli() + } + + tests := []struct { + name string + history []struct { + hourTs int64 // hour bucket timestamp + count int // occupancy count + people []string // people list (will be JSON-serialized) + } + hours int + wantCounts []int // expected counts per hour bucket (newest to oldest) + wantPeople [][]string // expected people per hour bucket (newest to oldest) + }{ + { + name: "empty history", + history: []struct { + hourTs int64 + count int + people []string + }{}, + hours: 3, + wantCounts: []int{0, 0, 0}, + wantPeople: [][]string{{}, {}, {}}, + }, + { + name: "one entry per hour", + history: []struct { + hourTs int64 + count int + people []string + }{ + {hourStart(0), 1, []string{"alice"}}, + {hourStart(-1 * time.Hour), 1, []string{"bob"}}, + {hourStart(-2 * time.Hour), 1, []string{"charlie"}}, + }, + hours: 3, + wantCounts: []int{1, 1, 1}, + wantPeople: [][]string{{"alice"}, {"bob"}, {"charlie"}}, + }, + { + name: "multiple people in hour", + history: []struct { + hourTs int64 + count int + people []string + }{ + {hourStart(0), 3, []string{"alice", "bob", "charlie"}}, + {hourStart(-1 * time.Hour), 2, []string{"dave", "eve"}}, + }, + hours: 3, + wantCounts: []int{3, 2, 0}, + wantPeople: [][]string{{"alice", "bob", "charlie"}, {"dave", "eve"}, {}}, + }, + { + name: "missing hours filled with zeros", + history: []struct { + hourTs int64 + count int + people []string + }{ + {hourStart(0), 1, []string{"alice"}}, + {hourStart(-3 * time.Hour), 2, []string{"bob", "charlie"}}, + }, + hours: 6, + wantCounts: []int{1, 0, 0, 2, 0, 0}, + wantPeople: [][]string{{"alice"}, {}, {}, {"bob", "charlie"}, {}, {}}, + }, + { + name: "empty people arrays", + history: []struct { + hourTs int64 + count int + people []string + }{ + {hourStart(0), 0, []string{}}, + {hourStart(-1 * time.Hour), 0, []string{}}, + }, + hours: 3, + wantCounts: []int{0, 0, 0}, + wantPeople: [][]string{{}, {}, {}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // Create zone + zone := &Zone{ + ID: "kitchen", Name: "Kitchen", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Insert zone history records directly into DB + for _, h := range tt.history { + peopleJSON, _ := json.Marshal(h.people) + _, err := m.db.Exec(` + INSERT INTO zone_history (zone_id, hour_ts, count, people, created_at) + VALUES (?, ?, ?, ?, ?) + `, "kitchen", h.hourTs, h.count, string(peopleJSON), now.UnixMilli()) + if err != nil { + t.Fatalf("Insert zone history: %v", err) + } + } + + // Small sleep to ensure time.Now() in GetZoneHistory is past the test's now + time.Sleep(10 * time.Millisecond) + + // Query history + history := m.GetZoneHistory("kitchen", tt.hours) + + if len(history) != tt.hours { + t.Fatalf("got %d entries, want %d", len(history), tt.hours) + } + + // Verify counts and people (entries are newest to oldest) + for i := 0; i < tt.hours; i++ { + if history[i].Count != tt.wantCounts[i] { + t.Errorf("hour %d (newest to oldest): got count %d, want %d", + i, history[i].Count, tt.wantCounts[i]) + } + if !equalStringSlices(history[i].People, tt.wantPeople[i]) { + t.Errorf("hour %d (newest to oldest): got people %v, want %v", + i, history[i].People, tt.wantPeople[i]) + } + } + }) + } +} + +// equalStringSlices compares two string slices for equality +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}