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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-05 17:19:22 -04:00
parent 408f3b6a78
commit 9f9df2f70c
4 changed files with 366 additions and 91 deletions

View file

@ -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.

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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
}