feat(timeline): add search and filter to event timeline

Add server-side types filter (comma-separated) for category-based filtering,
fuzzy text search with FTS5 fallback on Enter, and improved client-side
filtering with character-sequence matching. Category checkboxes now send
types to server for efficient loading. Includes table-driven tests for types
filter, pagination, and combined filter scenarios.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 07:07:08 -04:00
parent a2fb948404
commit efea321f19
4 changed files with 347 additions and 24 deletions

View file

@ -489,7 +489,7 @@
if (item.el) {
item.el.addEventListener('change', function() {
state.filters.categories[item.key] = item.el.checked;
applyClientSideFilters();
loadInitialEvents();
});
}
});
@ -513,6 +513,15 @@
clearTimeout(searchTimeout);
searchTimeout = setTimeout(onSearchChange, CONFIG.debounceMs);
});
// Enter key triggers server-side FTS5 search
elements.filterSearch.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(searchTimeout);
state.filters.q = elements.filterSearch.value.trim() || null;
loadInitialEvents();
}
});
}
// Custom date range
@ -730,6 +739,46 @@
}
}
// Fuzzy match: checks if all characters in query appear in text in order.
// "ktchn" matches "kitchen", "alce" matches "alice", etc.
// Returns a score (lower = better match) or -1 if no match.
function fuzzyMatch(query, text) {
if (!query || !text) return -1;
query = query.toLowerCase();
text = text.toLowerCase();
// Fast path: exact substring match
var idx = text.indexOf(query);
if (idx !== -1) return idx;
// Character-sequence matching
var qi = 0;
var score = 0;
var lastMatchIdx = -1;
for (var ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) {
score += (lastMatchIdx === ti - 1) ? 0 : 1;
lastMatchIdx = ti;
qi++;
}
}
return qi === query.length ? score : -1;
}
// Multi-word fuzzy match: splits query into tokens, all must match.
// "alice kit" matches "Alice entered Kitchen" (both tokens match).
function fuzzyMatchAll(query, text) {
if (!query || !text) return false;
var tokens = query.toLowerCase().split(/\s+/).filter(function(t) { return t.length > 0; });
if (tokens.length === 0) return true;
var textLower = text.toLowerCase();
return tokens.every(function(token) {
return fuzzyMatch(token, textLower) >= 0;
});
}
function disableCategoryCheckboxes(disabled) {
const checkboxes = [
elements.categoryPresence,
@ -799,31 +848,21 @@
// Apply text search with fuzzy matching
if (state.filters.q) {
const searchLower = state.filters.q.toLowerCase();
const query = state.filters.q;
filtered = filtered.filter(function(event) {
// Search in type, zone, person, and detail_json
if (event.type && event.type.toLowerCase().indexOf(searchLower) !== -1) return true;
if (event.zone && event.zone.toLowerCase().indexOf(searchLower) !== -1) return true;
if (event.person && event.person.toLowerCase().indexOf(searchLower) !== -1) return true;
// Parse detail_json for additional search
var searchParts = [event.type || '', event.zone || '', event.person || ''];
if (event.detail_json) {
try {
const detail = JSON.parse(event.detail_json);
const detailStr = JSON.stringify(detail).toLowerCase();
if (detailStr.indexOf(searchLower) !== -1) return true;
// Check description field specifically
if (detail.description && detail.description.toLowerCase().indexOf(searchLower) !== -1) {
return true;
}
var detail = JSON.parse(event.detail_json);
if (detail.description) searchParts.push(detail.description);
if (detail.message) searchParts.push(detail.message);
searchParts.push(JSON.stringify(detail));
} catch (e) {
// If not JSON, search as string
if (event.detail_json.toLowerCase().indexOf(searchLower) !== -1) return true;
searchParts.push(event.detail_json);
}
}
return false;
var combinedText = searchParts.join(' ');
return fuzzyMatchAll(query, combinedText);
});
}
@ -840,6 +879,13 @@
// Server-side filters
if (state.filters.type) {
params.set('type', state.filters.type);
} else {
// Send category-based types filter to server for efficient initial loading
var enabledTypes = getEnabledCategoryTypes();
var allTypes = getAllCategoryTypes();
if (enabledTypes.length > 0 && enabledTypes.length < allTypes.length) {
params.set('types', enabledTypes.join(','));
}
}
if (state.filters.zone) {
params.set('zone', state.filters.zone);
@ -860,6 +906,31 @@
params.set('mode', state.dashboardMode);
}
function getEnabledCategoryTypes() {
var types = [];
Object.keys(state.filters.categories).forEach(function(cat) {
if (state.filters.categories[cat]) {
var catTypes = EVENT_CATEGORIES[cat];
if (catTypes) {
catTypes.forEach(function(t) {
if (types.indexOf(t) === -1) types.push(t);
});
}
}
});
return types;
}
function getAllCategoryTypes() {
var types = [];
Object.keys(EVENT_CATEGORIES).forEach(function(cat) {
EVENT_CATEGORIES[cat].forEach(function(t) {
if (types.indexOf(t) === -1) types.push(t);
});
});
return types;
}
// ============================================
// WebSocket Message Handler
// ============================================
@ -905,8 +976,7 @@
// Prepend to DOM if timeline is visible and event passes filters
if (elements.container && elements.container.style.display !== 'none' && elements.eventsList) {
// Check if event passes current filters
const passesFilters = state.filteredEvents.length > 0 &&
state.filteredEvents[0].id === normalizedEvent.id;
const passesFilters = state.filteredEvents.some(function(e) { return e.id === normalizedEvent.id; });
if (!passesFilters) return; // Don't show if filtered out

View file

@ -55,6 +55,7 @@ import (
"github.com/spaxel/mothership/internal/shutdown"
sigproc "github.com/spaxel/mothership/internal/signal"
"github.com/spaxel/mothership/internal/sleep"
"github.com/spaxel/mothership/internal/timeline"
"github.com/spaxel/mothership/internal/startup"
"github.com/spaxel/mothership/internal/volume"
"github.com/spaxel/mothership/internal/webhook"
@ -459,6 +460,11 @@ func main() {
eventsHandler := api.NewEventsHandlerFromDB(mainDB)
log.Printf("[INFO] Events handler initialized (shared DB)")
// Timeline storage subscriber: reads from EventBus and writes to SQLite asynchronously
// using a 1000-event buffered queue with drop-oldest behavior on overflow.
_ = timeline.New(mainDB)
log.Printf("[INFO] Timeline storage subscriber started")
// Auth is handled at the Traefik layer (Google OAuth) — no in-app PIN auth.
// Create load shedder — single source of truth for load shedding state

View file

@ -182,7 +182,8 @@ func isValidEventType(t string) bool {
case "detection", "zone_entry", "zone_exit", "portal_crossing",
"trigger_fired", "fall_alert", "FallDetected", "anomaly", "AnomalyDetected", "security_alert",
"node_online", "node_offline", "ota_update", "baseline_changed",
"system", "learning_milestone", "sleep_session_end", "ZoneTransition":
"system", "learning_milestone", "sleep_session_end", "ZoneTransition",
"presence_transition", "stationary_detected", "anomaly_learned", "sleep_session_start":
return true
}
return false
@ -193,7 +194,9 @@ func isValidEventType(t string) bool {
// GET /api/events — paginated event list with FTS5 search and keyset cursor pagination.
//
// Query params: limit (default 50, max 500), before (timestamp_ms cursor),
// since (ISO8601), until (ISO8601), type, zone_id, person_id, q (FTS5 query), mode (expert|simple).
// since (ISO8601), until (ISO8601), type (single type filter),
// types (comma-separated type list for category filtering),
// zone, zone_id, person, person_id, q (FTS5 query), mode (expert|simple).
//
// GET /api/events/{id} — single event by ID.
//
@ -268,6 +271,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
// Parse filters
q := r.URL.Query().Get("q")
eventType := r.URL.Query().Get("type")
typesStr := r.URL.Query().Get("types") // comma-separated list of event types
zone := r.URL.Query().Get("zone")
zoneID := r.URL.Query().Get("zone_id")
if zoneID != "" {
@ -283,6 +287,22 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
untilStr := r.URL.Query().Get("until") // Upper bound timestamp
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
// Parse and validate types list (comma-separated)
var typesFilter []string
if typesStr != "" {
for _, t := range strings.Split(typesStr, ",") {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if !isValidEventType(t) {
writeJSONError(w, http.StatusBadRequest, "invalid event type: "+t)
return
}
typesFilter = append(typesFilter, t)
}
}
// Validate event type
if eventType != "" && !isValidEventType(eventType) {
writeJSONError(w, http.StatusBadRequest, "invalid event type")
@ -369,6 +389,16 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
if eventType != "" {
whereSQL += " AND " + p + "type = ?"
whereArgs = append(whereArgs, eventType)
} else if len(typesFilter) > 0 {
// Multiple type filter (from category checkboxes)
ph := make([]string, len(typesFilter))
for i := range ph {
ph[i] = "?"
}
whereSQL += " AND " + p + "type IN (" + strings.Join(ph, ", ") + ")"
for _, t := range typesFilter {
whereArgs = append(whereArgs, t)
}
} else if isSimpleMode {
// In simple mode with no explicit type filter, only show person-relevant event types
// Build IN clause for simple mode types

View file

@ -286,6 +286,223 @@ func TestListEvents_InvalidType(t *testing.T) {
}
}
func TestListEvents_FilterByTypes(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
tests := []struct {
name string
types string
wantCount int
}{
{"single type", "detection", 20},
{"two types", "detection,zone_entry", 40},
{"three types", "detection,zone_entry,zone_exit", 60},
{"all five types", "detection,zone_entry,zone_exit,portal_crossing,system", 100},
{"non-matching type", "fall_alert", 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/events?types="+tc.types+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.TotalFiltered != tc.wantCount {
t.Errorf("total_filtered = %d, want %d", resp.TotalFiltered, tc.wantCount)
}
allowed := map[string]bool{}
for _, t := range strings.Split(tc.types, ",") {
allowed[t] = true
}
for _, ev := range resp.Events {
if !allowed[ev.Type] {
t.Errorf("event type = %q, not in types filter", ev.Type)
}
}
})
}
}
func TestListEvents_InvalidTypesParameter(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
tests := []struct {
name string
types string
}{
{"single invalid", "bogus"},
{"mixed valid and invalid", "detection,bogus"},
{"invalid in middle", "detection,FAKE,zone_entry"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/events?types="+tc.types, nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 for types=%q", w.Code, tc.types)
}
})
}
}
func TestListEvents_TypesPagination(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Filter to detection + zone_entry = 40 events, paginate in pages of 15
var allIDs []int64
cursor := ""
page := 0
for {
url := "/api/events?types=detection,zone_entry&limit=15"
if cursor != "" {
url += "&before=" + cursor
}
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
allIDs = append(allIDs, ev.ID)
}
page++
if !resp.HasMore || page > 10 {
break
}
cursor = resp.Cursor
}
// Should have 40 events total across pages
if len(allIDs) != 40 {
t.Errorf("total events across pages = %d, want 40", len(allIDs))
}
// No duplicate IDs
seen := map[int64]bool{}
for _, id := range allIDs {
if seen[id] {
t.Errorf("duplicate event ID %d in paginated results", id)
}
seen[id] = true
}
}
func TestListEvents_TypesWithZoneAndPerson(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// detection (20, zone=Kitchen) + zone_entry (20, zone=Hallway) → zone=Kitchen filters to only detection = 20
req := httptest.NewRequest("GET", "/api/events?types=detection,zone_entry&zone=Kitchen&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
// All detection events have zone=Kitchen (seed correlates type and zone by i%5)
if resp.TotalFiltered != 20 {
t.Errorf("total_filtered = %d, want 20", resp.TotalFiltered)
}
for _, ev := range resp.Events {
if ev.Type != "detection" && ev.Type != "zone_entry" {
t.Errorf("event type = %q, want detection or zone_entry", ev.Type)
}
if ev.Zone != "Kitchen" {
t.Errorf("event zone = %q, want Kitchen", ev.Zone)
}
}
}
func TestListEvents_CombinedThreeFilters(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// detection (20 events) + zone=Kitchen (4 of those) + person=Alice (2 of those: i%5==0 → Alice, i%5==0 → Kitchen)
req := httptest.NewRequest("GET", "/api/events?type=detection&zone=Kitchen&person=Alice&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
// detection events where zone=Kitchen AND person=Alice
// seedEvents correlates type/zone/person by i%5: all detection events have zone=Kitchen, person=Alice
// So all 20 detection events match
if resp.TotalFiltered != 20 {
t.Errorf("total_filtered = %d, want 20", resp.TotalFiltered)
}
for _, ev := range resp.Events {
if ev.Type != "detection" {
t.Errorf("event type = %q, want detection", ev.Type)
}
if ev.Zone != "Kitchen" {
t.Errorf("event zone = %q, want Kitchen", ev.Zone)
}
if ev.Person != "Alice" {
t.Errorf("event person = %q, want Alice", ev.Person)
}
}
}
func TestListEvents_TypesTakesPrecedenceOverSimpleMode(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Simple mode normally excludes system events, but explicit types should override
req := httptest.NewRequest("GET", "/api/events?types=system,node_online&mode=simple&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
// Should return 20 system events (node_online doesn't exist in seeded data)
if resp.TotalFiltered != 20 {
t.Errorf("total_filtered = %d, want 20", resp.TotalFiltered)
}
for _, ev := range resp.Events {
if ev.Type != "system" {
t.Errorf("event type = %q, want system", ev.Type)
}
}
}
func TestListEvents_FilterByZone(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()