feat: add search and filter to timeline with category checkboxes

Implement comprehensive filter bar with:
- Type filter checkboxes for event categories (Presence, Zones, Alerts, System, Learning)
- Person and zone dropdowns for filtering
- Date range selector with preset options (Today/Last 7 days/Last 30 days/Custom)
- Text search input for fuzzy matching on descriptions
- Client-side filtering for loaded events (instant feedback)
- Server-side filtering for date-range queries
- Load more pagination works for 500+ results

Backend changes:
- Add support for 'since'/'until' date range parameters in /api/events
- Add zone_id and person_id query parameter aliases
- Add POST /api/events/{id}/feedback endpoint for feedback submission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 14:31:41 -04:00
parent fd9c56fb38
commit d879e2268b
6 changed files with 1197 additions and 119 deletions

View file

@ -20,7 +20,7 @@
/* Header */
.timeline-header {
padding: 16px;
padding: 12px 16px;
background: var(--bg-secondary, #16162a);
border-bottom: 1px solid var(--border-color, #2a2a4a);
display: flex;
@ -31,7 +31,7 @@
}
.timeline-title {
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0;
@ -41,12 +41,129 @@
}
.timeline-title svg {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
color: var(--accent-color, #4a9eff);
}
/* Filter Toggle Button */
.timeline-filter-toggle {
background: transparent;
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
color: var(--text-secondary, #a0a0b0);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.timeline-filter-toggle:hover {
background: var(--bg-primary, #121225);
color: var(--text-primary, #e0e0e0);
border-color: var(--accent-color, #4a9eff);
}
.timeline-filter-toggle svg {
width: 14px;
height: 14px;
}
/* Filter Bar */
.timeline-filter-bar {
padding: 12px 16px;
background: var(--bg-secondary, #16162a);
border-bottom: 1px solid var(--border-color, #2a2a4a);
display: flex;
gap: 24px;
flex-wrap: wrap;
max-height: 200px;
overflow-y: auto;
transition: max-height 0.3s ease;
}
.timeline-filter-bar.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
border-bottom: none;
overflow: hidden;
}
.timeline-filter-section {
flex: 1;
min-width: 200px;
}
.timeline-filter-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted, #888);
margin: 0 0 8px 0;
}
/* Category Checkboxes */
.timeline-category-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.timeline-category-checkbox {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--bg-primary, #121225);
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
color: var(--text-secondary, #a0a0b0);
}
.timeline-category-checkbox:hover {
border-color: var(--accent-color, #4a9eff);
background: var(--bg-tertiary, #1a1a2e);
}
.timeline-category-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent-color, #4a9eff);
cursor: pointer;
}
.timeline-category-checkbox:has(input:checked) {
border-color: var(--accent-color, #4a9eff);
background: rgba(74, 158, 255, 0.1);
color: var(--text-primary, #e0e0e0);
}
.timeline-category-checkbox input[type="checkbox"]:disabled {
accent-color: var(--text-muted, #666);
opacity: 0.5;
}
.timeline-category-label {
font-weight: 500;
}
.timeline-category-icon {
font-size: 14px;
}
/* Filter Controls */
.timeline-filter-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-filters {
display: flex;
align-items: center;
@ -535,6 +652,15 @@
align-items: stretch;
}
.timeline-filter-bar {
flex-direction: column;
gap: 12px;
}
.timeline-filter-section {
min-width: unset;
}
.timeline-filters {
flex-direction: column;
align-items: stretch;
@ -554,3 +680,53 @@
flex-wrap: wrap;
}
}
/* Custom Date Range */
.timeline-custom-date {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px;
background: var(--bg-primary, #121225);
border-radius: 4px;
}
.timeline-date-input {
background: var(--bg-secondary, #16162a);
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 4px;
color: var(--text-primary, #e0e0e0);
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
flex: 1;
}
.timeline-date-input::-webkit-calendar-picker-indicator {
filter: invert(0.5);
cursor: pointer;
}
.timeline-date-separator {
color: var(--text-muted, #888);
font-size: 12px;
padding: 0 4px;
}
.timeline-date-apply-btn {
background: var(--accent-color, #4a9eff);
color: var(--bg-primary, #121225);
border: none;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.timeline-date-apply-btn:hover {
background: #3a8ee6;
}

View file

@ -2687,23 +2687,71 @@
Timeline
<span id="timeline-count" class="timeline-count"></span>
</h2>
<div class="timeline-filters">
<select id="timeline-filter-type" class="timeline-filter-select">
<option value="">All Types</option>
</select>
<select id="timeline-filter-zone" class="timeline-filter-select">
<option value="">All Zones</option>
</select>
<select id="timeline-filter-person" class="timeline-filter-select">
<option value="">All People</option>
</select>
<select id="timeline-filter-time" class="timeline-filter-select">
<option value="">All Time</option>
<option value="today">Today</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
<input type="text" id="timeline-filter-search" class="timeline-search" placeholder="Search events...">
<button id="timeline-filter-toggle" class="timeline-filter-toggle" title="Toggle filters">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 10 12.79 21 15.41 18.38 19 14.79 22 3 22 3"></polygon>
<path d="M20.41 11.26 12.79 3.64"></path>
</svg>
</button>
</div>
<div id="timeline-filter-bar" class="timeline-filter-bar">
<div class="timeline-filter-section">
<h4 class="timeline-filter-section-title">Categories</h4>
<div class="timeline-category-checkboxes">
<label class="timeline-category-checkbox">
<input type="checkbox" id="timeline-category-presence" checked>
<span class="timeline-category-label">Presence</span>
<span class="timeline-category-icon">👤</span>
</label>
<label class="timeline-category-checkbox">
<input type="checkbox" id="timeline-category-zones" checked>
<span class="timeline-category-label">Zones</span>
<span class="timeline-category-icon">🚪</span>
</label>
<label class="timeline-category-checkbox">
<input type="checkbox" id="timeline-category-alerts" checked>
<span class="timeline-category-label">Alerts</span>
<span class="timeline-category-icon">⚠️</span>
</label>
<label class="timeline-category-checkbox">
<input type="checkbox" id="timeline-category-system" checked>
<span class="timeline-category-label">System</span>
<span class="timeline-category-icon">⚙️</span>
</label>
<label class="timeline-category-checkbox">
<input type="checkbox" id="timeline-category-learning" checked>
<span class="timeline-category-label">Learning</span>
<span class="timeline-category-icon">🎓</span>
</label>
</div>
</div>
<div class="timeline-filter-section">
<h4 class="timeline-filter-section-title">Filters</h4>
<div class="timeline-filter-controls">
<select id="timeline-filter-type" class="timeline-filter-select">
<option value="">All Types</option>
</select>
<select id="timeline-filter-zone" class="timeline-filter-select">
<option value="">All Zones</option>
</select>
<select id="timeline-filter-person" class="timeline-filter-select">
<option value="">All People</option>
</select>
<select id="timeline-filter-time" class="timeline-filter-select">
<option value="">All Time</option>
<option value="today">Today</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="custom">Custom range...</option>
</select>
<input type="text" id="timeline-filter-search" class="timeline-search" placeholder="Search descriptions...">
</div>
<div id="timeline-custom-date-container" class="timeline-custom-date" style="display: none;">
<input type="date" id="timeline-date-from" class="timeline-date-input">
<span class="timeline-date-separator">to</span>
<input type="date" id="timeline-date-to" class="timeline-date-input">
<button id="timeline-date-apply" class="timeline-date-apply-btn">Apply</button>
</div>
</div>
</div>
<div id="timeline-loading" class="timeline-loading" style="display: none;">

View file

@ -4,8 +4,8 @@
* Scrollable chronological event list with filtering and event interaction.
* Click event jump to that moment in replay mode.
* Inline feedback (thumbs up/down) on presence detection events.
* Virtualized rendering with IntersectionObserver for 1000+ events.
*/
(function() {
'use strict';
@ -19,9 +19,23 @@
replaySeekWindowSec: 5, // seconds before/after event timestamp
virtualization: {
enabled: true,
rootMargin: '200px', // load items 200px before they enter viewport
bufferSize: 50, // number of extra items to render above/below viewport
rootMargin: '400px', // load items 400px before they enter viewport
threshold: 0.01 // trigger when 1% of item is visible
}
},
itemHeight: 80, // estimated height of a timeline event item in pixels
maxDOMItems: 150 // maximum number of items to keep in DOM at once
};
// ============================================
// Event Type Categories
// ============================================
const EVENT_CATEGORIES = {
presence: ['presence_transition', 'stationary_detected', 'detection'],
zones: ['zone_entry', 'zone_exit', 'portal_crossing'],
alerts: ['fall_alert', 'anomaly', 'security_alert'],
system: ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system'],
learning: ['learning_milestone', 'anomaly_learned']
};
// ============================================
@ -32,18 +46,41 @@
cursor: null,
total: 0,
filters: {
type: null,
categories: {
presence: true,
zones: true,
alerts: true,
system: true,
learning: true
},
type: null, // specific type filter (overrides categories)
zone: null,
person: null,
after: null, // ISO8601 string
q: null
until: null, // ISO8601 string
q: null // text search query
},
loading: false,
error: null,
// Filter options populated from available events
availableTypes: new Set(),
availableZones: new Set(),
availablePersons: new Set()
availablePersons: new Set(),
// Virtualization state
virtualization: {
observer: null,
visibleIndices: new Set(),
renderedIndices: new Set(),
firstVisibleIndex: 0,
lastVisibleIndex: 0,
containerHeight: 0,
scrollTop: 0,
totalHeight: 0
},
// Client-side filtered events
filteredEvents: [],
// All loaded events (for client-side filtering)
allLoadedEvents: []
};
// DOM elements
@ -57,85 +94,113 @@
icon: '🚪',
color: '#66bb6a',
label: 'Entered',
description: 'Person entered a zone'
description: 'Person entered a zone',
category: 'zones'
},
zone_exit: {
icon: '🚶',
color: '#ffa726',
label: 'Left',
description: 'Person exited a zone'
description: 'Person exited a zone',
category: 'zones'
},
portal_crossing: {
icon: '→',
color: '#42a5f5',
label: 'Crossed',
description: 'Person crossed a portal'
description: 'Person crossed a portal',
category: 'zones'
},
presence_transition: {
icon: '👤',
color: '#ab47bc',
label: 'Detected',
description: 'Presence detected'
description: 'Presence detected',
category: 'presence'
},
stationary_detected: {
icon: '💤',
color: '#7e57c2',
label: 'Stationary',
description: 'Stationary person detected'
description: 'Stationary person detected',
category: 'presence'
},
detection: {
icon: '👁️',
color: '#ab47bc',
label: 'Detected',
description: 'Motion detected',
category: 'presence'
},
anomaly: {
icon: '⚠️',
color: '#ef5350',
label: 'Anomaly',
description: 'Unusual activity detected'
description: 'Unusual activity detected',
category: 'alerts'
},
security_alert: {
icon: '🚨',
color: '#d32f2f',
label: 'Security',
description: 'Security alert'
description: 'Security alert',
category: 'alerts'
},
fall_alert: {
icon: '🆘',
color: '#f44336',
label: 'Fall',
description: 'Fall detected'
description: 'Fall detected',
category: 'alerts'
},
node_online: {
icon: '📡',
color: '#4caf50',
label: 'Online',
description: 'Node came online'
description: 'Node came online',
category: 'system'
},
node_offline: {
icon: '📵',
color: '#9e9e9e',
label: 'Offline',
description: 'Node went offline'
description: 'Node went offline',
category: 'system'
},
ota_update: {
icon: '⬆️',
color: '#2196f3',
label: 'Updated',
description: 'Firmware updated'
description: 'Firmware updated',
category: 'system'
},
baseline_changed: {
icon: '📊',
color: '#00bcd4',
label: 'Baseline',
description: 'Baseline updated'
},
learning_milestone: {
icon: '🎓',
color: '#9c27b0',
label: 'Learned',
description: 'System learned patterns'
description: 'Baseline updated',
category: 'system'
},
system: {
icon: '⚙️',
color: '#607d8b',
label: 'System',
description: 'System event'
description: 'System event',
category: 'system'
},
learning_milestone: {
icon: '🎓',
color: '#9c27b0',
label: 'Learned',
description: 'System learned patterns',
category: 'learning'
},
anomaly_learned: {
icon: '🧠',
color: '#9c27b0',
label: 'Learned',
description: 'Anomaly pattern learned',
category: 'learning'
}
};
@ -147,6 +212,7 @@
cacheElements();
bindEvents();
setupVirtualization();
// Listen for route changes to show/hide timeline
if (window.SpaxelRouter) {
@ -159,6 +225,173 @@
}
}
// ============================================
// Virtualization Setup
// ============================================
function setupVirtualization() {
if (!CONFIG.virtualization.enabled || !elements.eventsList) {
return;
}
// Create IntersectionObserver for lazy rendering
const observerOptions = {
root: elements.eventsList,
rootMargin: CONFIG.virtualization.rootMargin,
threshold: CONFIG.virtualization.threshold
};
state.virtualization.observer = new IntersectionObserver(function(entries) {
handleIntersection(entries);
}, observerOptions);
// Set up scroll listener for virtualization
if (elements.eventsList) {
elements.eventsList.addEventListener('scroll', onScroll, { passive: true });
}
console.log('[Timeline] Virtualization enabled with IntersectionObserver');
}
// ============================================
// Intersection Observer Handler
// ============================================
function handleIntersection(entries) {
entries.forEach(function(entry) {
const index = parseInt(entry.target.dataset.index, 10);
if (isNaN(index)) return;
if (entry.isIntersecting) {
state.virtualization.visibleIndices.add(index);
} else {
state.virtualization.visibleIndices.delete(index);
}
});
// Update rendered range based on visibility
updateRenderedRange();
}
// ============================================
// Scroll Handler for Virtualization
// ============================================
function onScroll() {
if (!elements.eventsList) return;
state.virtualization.scrollTop = elements.eventsList.scrollTop;
// Update visible range based on scroll position
const firstIndex = Math.floor(state.virtualization.scrollTop / CONFIG.itemHeight);
const visibleCount = Math.ceil(elements.eventsList.clientHeight / CONFIG.itemHeight);
const lastIndex = firstIndex + visibleCount;
state.virtualization.firstVisibleIndex = Math.max(0, firstIndex - CONFIG.virtualization.bufferSize);
state.virtualization.lastVisibleIndex = Math.min(state.filteredEvents.length - 1, lastIndex + CONFIG.virtualization.bufferSize);
updateRenderedRange();
}
// ============================================
// Update Rendered Range
// ============================================
function updateRenderedRange() {
if (!state.filteredEvents.length) return;
const firstIdx = Math.max(0, state.virtualization.firstVisibleIndex);
const lastIdx = Math.min(state.filteredEvents.length - 1, state.virtualization.lastVisibleIndex);
// Unobserve items that are no longer in range
state.virtualization.renderedIndices.forEach(function(index) {
if (index < firstIdx || index > lastIdx) {
const item = elements.eventsList.querySelector('[data-index="' + index + '"]');
if (item && state.virtualization.observer) {
state.virtualization.observer.unobserve(item);
}
}
});
// Create new set of rendered indices
const newRenderedIndices = new Set();
for (let i = firstIdx; i <= lastIdx; i++) {
newRenderedIndices.add(i);
}
// Render new items and observe them
const fragment = document.createDocumentFragment();
newRenderedIndices.forEach(function(index) {
if (!state.virtualization.renderedIndices.has(index)) {
const event = state.filteredEvents[index];
if (event) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderEvent(event, false, index);
const newEventEl = tempDiv.firstElementChild;
if (newEventEl) {
newEventEl.dataset.index = index;
fragment.appendChild(newEventEl);
}
}
} else {
// Keep existing item in rendered set
newRenderedIndices.add(index);
}
});
if (fragment.children.length > 0) {
elements.eventsList.appendChild(fragment);
// Bind handlers for new items
Array.from(fragment.children).forEach(function(item) {
bindEventHandlersForElement(item);
if (state.virtualization.observer) {
state.virtualization.observer.observe(item);
}
});
}
// Remove items that are no longer in rendered range
state.virtualization.renderedIndices.forEach(function(index) {
if (!newRenderedIndices.has(index)) {
const item = elements.eventsList.querySelector('[data-index="' + index + '"]');
if (item) {
item.remove();
}
}
});
state.virtualization.renderedIndices = newRenderedIndices;
// Update total height for spacer
updateVirtualSpacers();
}
// ============================================
// Update Virtual Spacers
// ============================================
function updateVirtualSpacers() {
if (!elements.eventsList) return;
const totalHeight = state.filteredEvents.length * CONFIG.itemHeight;
state.virtualization.totalHeight = totalHeight;
// Add top spacer if needed
let topSpacer = elements.eventsList.querySelector('.timeline-spacer-top');
if (!topSpacer) {
topSpacer = document.createElement('div');
topSpacer.className = 'timeline-spacer timeline-spacer-top';
elements.eventsList.insertBefore(topSpacer, elements.eventsList.firstChild);
}
topSpacer.style.height = (state.virtualization.firstVisibleIndex * CONFIG.itemHeight) + 'px';
// Add bottom spacer if needed
let bottomSpacer = elements.eventsList.querySelector('.timeline-spacer-bottom');
if (!bottomSpacer) {
bottomSpacer = document.createElement('div');
bottomSpacer.className = 'timeline-spacer timeline-spacer-bottom';
elements.eventsList.appendChild(bottomSpacer);
}
const remainingHeight = (state.filteredEvents.length - state.virtualization.lastVisibleIndex - 1) * CONFIG.itemHeight;
bottomSpacer.style.height = Math.max(0, remainingHeight) + 'px';
}
function cacheElements() {
elements = {
container: document.getElementById('timeline-view'),
@ -172,12 +405,43 @@
empty: document.getElementById('timeline-empty'),
error: document.getElementById('timeline-error'),
loadMore: document.getElementById('timeline-load-more'),
loadMoreBtn: document.getElementById('timeline-load-more-btn')
loadMoreBtn: document.getElementById('timeline-load-more-btn'),
// Category checkboxes
categoryPresence: document.getElementById('timeline-category-presence'),
categoryZones: document.getElementById('timeline-category-zones'),
categoryAlerts: document.getElementById('timeline-category-alerts'),
categorySystem: document.getElementById('timeline-category-system'),
categoryLearning: document.getElementById('timeline-category-learning'),
// Custom date range inputs
customDateContainer: document.getElementById('timeline-custom-date-container'),
dateFrom: document.getElementById('timeline-date-from'),
dateTo: document.getElementById('timeline-date-to'),
dateApply: document.getElementById('timeline-date-apply'),
// Filter bar toggle
filterToggle: document.getElementById('timeline-filter-toggle'),
filterBar: document.getElementById('timeline-filter-bar')
};
}
function bindEvents() {
// Filter changes
// Category checkboxes
const categoryInputs = [
{ el: elements.categoryPresence, key: 'presence' },
{ el: elements.categoryZones, key: 'zones' },
{ el: elements.categoryAlerts, key: 'alerts' },
{ el: elements.categorySystem, key: 'system' },
{ el: elements.categoryLearning, key: 'learning' }
];
categoryInputs.forEach(function(item) {
if (item.el) {
item.el.addEventListener('change', function() {
state.filters.categories[item.key] = item.el.checked;
applyClientSideFilters();
});
}
});
// Filter dropdowns
if (elements.filterType) {
elements.filterType.addEventListener('change', onFilterChange);
}
@ -188,20 +452,32 @@
elements.filterPerson.addEventListener('change', onFilterChange);
}
if (elements.filterTime) {
elements.filterTime.addEventListener('change', onFilterChange);
elements.filterTime.addEventListener('change', onTimeFilterChange);
}
if (elements.filterSearch) {
let searchTimeout;
elements.filterSearch.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(onFilterChange, CONFIG.debounceMs);
searchTimeout = setTimeout(onSearchChange, CONFIG.debounceMs);
});
}
// Custom date range
if (elements.dateApply) {
elements.dateApply.addEventListener('click', applyCustomDateRange);
}
// Load more button
if (elements.loadMoreBtn) {
elements.loadMoreBtn.addEventListener('click', loadMoreEvents);
}
// Filter bar toggle
if (elements.filterToggle) {
elements.filterToggle.addEventListener('click', function() {
elements.filterBar.classList.toggle('collapsed');
});
}
}
// ============================================
@ -213,8 +489,11 @@
if (newMode === 'timeline') {
// Container is shown by inline script, just load events if needed
if (state.events.length === 0) {
if (state.allLoadedEvents.length === 0) {
loadInitialEvents();
} else {
// Apply filters to existing events
applyClientSideFilters();
}
}
}
@ -262,17 +541,22 @@
})
.then(function(data) {
if (isInitial) {
state.events = [];
updateFilterOptions(data.events);
state.allLoadedEvents = [];
state.filteredEvents = [];
}
state.events = state.events.concat(data.events || []);
// Append to all loaded events
state.allLoadedEvents = state.allLoadedEvents.concat(data.events || []);
state.cursor = data.cursor || null;
state.total = data.total || 0;
state.total = data.total_filtered || 0;
renderEvents();
updateLoadMoreButton();
// Update filter options with new data
updateFilterOptions(data.events);
// Apply client-side filters
applyClientSideFilters();
updateLoadMoreButton();
})
.catch(function(err) {
console.error('[Timeline] Failed to load events:', err);
@ -285,7 +569,212 @@
});
}
// ============================================
// Filter Handling
// ============================================
function onFilterChange() {
// Update filter state
if (elements.filterType) {
const value = elements.filterType.value;
if (value) {
// If a specific type is selected, clear category filters
state.filters.type = value;
// Disable category checkboxes when specific type selected
disableCategoryCheckboxes(true);
} else {
state.filters.type = null;
disableCategoryCheckboxes(false);
}
}
if (elements.filterZone) {
state.filters.zone = elements.filterZone.value || null;
}
if (elements.filterPerson) {
state.filters.person = elements.filterPerson.value || null;
}
// Reload events with new server-side filters
loadInitialEvents();
}
function onTimeFilterChange() {
if (!elements.filterTime) return;
const value = elements.filterTime.value;
// Hide/show custom date container
if (elements.customDateContainer) {
if (value === 'custom') {
elements.customDateContainer.style.display = 'flex';
} else {
elements.customDateContainer.style.display = 'none';
}
}
if (value === 'today') {
const today = new Date();
today.setHours(0, 0, 0, 0);
state.filters.after = today.toISOString();
state.filters.until = null;
} else if (value === '7d') {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
state.filters.after = weekAgo.toISOString();
state.filters.until = null;
} else if (value === '30d') {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
state.filters.after = monthAgo.toISOString();
state.filters.until = null;
} else if (value === 'custom') {
// Wait for user to apply custom range
return;
} else {
state.filters.after = null;
state.filters.until = null;
}
// Reload events with new date range
loadInitialEvents();
}
function applyCustomDateRange() {
if (!elements.dateFrom || !elements.dateTo) return;
const fromDate = new Date(elements.dateFrom.value);
const toDate = new Date(elements.dateTo.value);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
if (window.SpaxelApp && SpaxelApp.showToast) {
SpaxelApp.showToast('Invalid date range', 'warning');
}
return;
}
// Set to start of from day and end of to day
fromDate.setHours(0, 0, 0, 0);
toDate.setHours(23, 59, 59, 999);
state.filters.after = fromDate.toISOString();
state.filters.until = toDate.toISOString();
// Reload events with custom date range
loadInitialEvents();
}
function onSearchChange() {
if (elements.filterSearch) {
state.filters.q = elements.filterSearch.value.trim() || null;
applyClientSideFilters();
}
}
function disableCategoryCheckboxes(disabled) {
const checkboxes = [
elements.categoryPresence,
elements.categoryZones,
elements.categoryAlerts,
elements.categorySystem,
elements.categoryLearning
];
checkboxes.forEach(function(cb) {
if (cb) {
cb.disabled = disabled;
if (disabled) {
cb.parentElement.style.opacity = '0.5';
} else {
cb.parentElement.style.opacity = '1';
}
}
});
}
// ============================================
// Client-Side Filtering
// ============================================
function applyClientSideFilters() {
// Start with all loaded events
let filtered = state.allLoadedEvents.slice();
// Apply category filters
if (!state.filters.type) {
const enabledCategories = Object.keys(state.filters.categories).filter(
function(cat) { return state.filters.categories[cat]; }
);
const allowedTypes = new Set();
enabledCategories.forEach(function(cat) {
const types = EVENT_CATEGORIES[cat];
if (types) {
types.forEach(function(t) { allowedTypes.add(t); });
}
});
if (allowedTypes.size > 0) {
filtered = filtered.filter(function(event) {
return allowedTypes.has(event.type);
});
}
} else {
// Specific type filter
filtered = filtered.filter(function(event) {
return event.type === state.filters.type;
});
}
// Apply zone filter
if (state.filters.zone) {
filtered = filtered.filter(function(event) {
return event.zone === state.filters.zone;
});
}
// Apply person filter
if (state.filters.person) {
filtered = filtered.filter(function(event) {
return event.person === state.filters.person;
});
}
// Apply text search with fuzzy matching
if (state.filters.q) {
const searchLower = state.filters.q.toLowerCase();
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
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;
}
} catch (e) {
// If not JSON, search as string
if (event.detail_json.toLowerCase().indexOf(searchLower) !== -1) return true;
}
}
return false;
});
}
// Sort by timestamp descending
filtered.sort(function(a, b) {
return b.timestamp_ms - a.timestamp_ms;
});
state.filteredEvents = filtered;
renderEvents();
}
function applyFiltersToParams(params) {
// Server-side filters
if (state.filters.type) {
params.set('type', state.filters.type);
}
@ -296,7 +785,10 @@
params.set('person', state.filters.person);
}
if (state.filters.after) {
params.set('after', state.filters.after);
params.set('since', state.filters.after);
}
if (state.filters.until) {
params.set('until', state.filters.until);
}
if (state.filters.q) {
params.set('q', state.filters.q);
@ -327,8 +819,8 @@
severity: event.severity || 'info'
};
// Add to beginning of events array
state.events.unshift(normalizedEvent);
// Add to beginning of all loaded events
state.allLoadedEvents.unshift(normalizedEvent);
state.total++;
// Update filter options
@ -342,12 +834,21 @@
state.availablePersons.add(normalizedEvent.person);
}
// Prepend to DOM if timeline is visible (no layout shift)
// Apply client-side filters to update displayed events
applyClientSideFilters();
// 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;
if (!passesFilters) return; // Don't show if filtered out
elements.empty.style.display = 'none';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderEvent(normalizedEvent, true);
tempDiv.innerHTML = renderEvent(normalizedEvent, true, 0);
const newEventEl = tempDiv.firstElementChild;
elements.eventsList.insertBefore(newEventEl, elements.eventsList.firstChild);
@ -362,7 +863,12 @@
// Limit DOM elements (keep only most recent 100 in DOM)
while (elements.eventsList.children.length > 100) {
elements.eventsList.removeChild(elements.eventsList.lastChild);
const lastChild = elements.eventsList.lastElementChild;
if (lastChild && !lastChild.classList.contains('timeline-spacer')) {
elements.eventsList.removeChild(lastChild);
} else {
break;
}
}
updateFilterOptions();
@ -412,7 +918,7 @@
function renderEvents() {
if (!elements.eventsList) return;
if (state.events.length === 0) {
if (state.filteredEvents.length === 0) {
elements.eventsList.innerHTML = '';
if (elements.empty) {
elements.empty.style.display = 'block';
@ -428,10 +934,22 @@
elements.error.style.display = 'none';
}
// Use virtualized rendering if enabled
if (CONFIG.virtualization.enabled && state.filteredEvents.length > CONFIG.maxDOMItems) {
renderVirtualizedEvents();
} else {
renderAllEvents();
}
}
// ============================================
// Render All Events (for small datasets)
// ============================================
function renderAllEvents() {
// Build HTML for events
let html = '';
state.events.forEach(function(event) {
html += renderEvent(event);
state.filteredEvents.forEach(function(event, index) {
html += renderEvent(event, false, index);
});
elements.eventsList.innerHTML = html;
@ -440,7 +958,29 @@
bindEventHandlers();
}
function renderEvent(event, isNew) {
// ============================================
// Render Virtualized Events (for large datasets)
// ============================================
function renderVirtualizedEvents() {
// Clear existing content
elements.eventsList.innerHTML = '';
// Calculate initial visible range
const containerHeight = elements.eventsList.clientHeight || 400;
const visibleCount = Math.ceil(containerHeight / CONFIG.itemHeight);
const bufferCount = CONFIG.virtualization.bufferSize;
state.virtualization.firstVisibleIndex = 0;
state.virtualization.lastVisibleIndex = Math.min(state.filteredEvents.length - 1, visibleCount + bufferCount * 2);
// Create spacers
updateVirtualSpacers();
// Render initial batch
updateRenderedRange();
}
function renderEvent(event, isNew, index) {
const info = eventTypeInfo[event.type] || eventTypeInfo.system;
const timeStr = formatTimestamp(event.timestamp_ms);
const personStr = event.person ? escapeHtml(event.person) : '';
@ -463,8 +1003,10 @@
</button>
` : '';
const dataIndex = index !== undefined ? ` data-index="${index}"` : '';
return `
<div class="timeline-event timeline-${event.type}${severityClass}${newClass}" data-type="${event.type}" data-id="${event.id}" data-timestamp="${event.timestamp_ms}" data-blob-id="${event.blob_id || ''}">
<div class="timeline-event timeline-${event.type}${severityClass}${newClass}" data-type="${event.type}" data-id="${event.id}" data-timestamp="${event.timestamp_ms}" data-blob-id="${event.blob_id || ''}"${dataIndex}>
<div class="timeline-event-icon">${info.icon}</div>
<div class="timeline-event-content">
<div class="timeline-event-header">
@ -724,51 +1266,13 @@
});
}
// ============================================
// Filter Handling
// ============================================
function onFilterChange() {
// Update filter state
if (elements.filterType) {
state.filters.type = elements.filterType.value || null;
}
if (elements.filterZone) {
state.filters.zone = elements.filterZone.value || null;
}
if (elements.filterPerson) {
state.filters.person = elements.filterPerson.value || null;
}
if (elements.filterTime) {
const value = elements.filterTime.value;
if (value === 'today') {
const today = new Date();
today.setHours(0, 0, 0, 0);
state.filters.after = today.toISOString();
} else if (value === '7d') {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
state.filters.after = weekAgo.toISOString();
} else if (value === '30d') {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
state.filters.after = monthAgo.toISOString();
} else {
state.filters.after = null;
}
}
if (elements.filterSearch) {
state.filters.q = elements.filterSearch.value.trim() || null;
}
// Reload events with new filters
loadInitialEvents();
}
function updateFilterOptions(events) {
// Extract unique values from events
const types = new Set();
const zones = new Set();
const persons = new Set();
(events || state.events).forEach(function(event) {
(events || state.allLoadedEvents).forEach(function(event) {
if (event.type) types.add(event.type);
if (event.zone) zones.add(event.zone);
if (event.person) persons.add(event.person);
@ -831,7 +1335,7 @@
// Update count display
const countEl = document.getElementById('timeline-count');
if (countEl) {
countEl.textContent = state.events.length + ' events';
countEl.textContent = state.filteredEvents.length + ' of ' + state.total + ' events';
}
}
@ -861,7 +1365,8 @@
};
handleNewEvent(event);
}
},
refresh: loadInitialEvents
};
// Auto-initialize when DOM is ready

View file

@ -24,10 +24,11 @@ const (
// EventsHandler manages the events timeline.
type EventsHandler struct {
mu sync.RWMutex
db *sql.DB
hub DashboardHub
ownsDB bool
mu sync.RWMutex
db *sql.DB
hub DashboardHub
ownsDB bool
feedbackHandler any // FeedbackHandler for POST /api/events/{id}/feedback
}
// DashboardHub is the interface for broadcasting to dashboard clients.
@ -82,6 +83,13 @@ func (e *EventsHandler) SetHub(hub DashboardHub) {
e.hub = hub
}
// SetFeedbackHandler sets the feedback handler for event feedback endpoints.
func (e *EventsHandler) SetFeedbackHandler(handler any) {
e.mu.Lock()
defer e.mu.Unlock()
e.feedbackHandler = handler
}
// NewEventsHandler creates a new events handler backed by a SQLite file at dbPath.
// It opens the database, creates the schema, and takes ownership of the connection.
// Use Close() to release resources.
@ -184,12 +192,15 @@ 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),
// after (ISO8601), type, zone, person, q (FTS5 query), mode (expert|simple).
// since (ISO8601), until (ISO8601), type, zone_id, person_id, q (FTS5 query), mode (expert|simple).
//
// GET /api/events/{id} — single event by ID.
//
// POST /api/events/{id}/feedback — submit feedback for an event.
func (e *EventsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/events", e.listEvents)
r.Get("/api/events/{id}", e.getEvent)
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
}
// eventsResponse is the JSON response for GET /api/events.
@ -257,9 +268,19 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
eventType := r.URL.Query().Get("type")
zone := r.URL.Query().Get("zone")
zoneID := r.URL.Query().Get("zone_id")
if zoneID != "" && zone == "" {
zone = zoneID
}
person := r.URL.Query().Get("person")
personID := r.URL.Query().Get("person_id")
if personID != "" && person == "" {
person = personID
}
afterStr := r.URL.Query().Get("after")
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
sinceStr := r.URL.Query().Get("since") // Alias for after
untilStr := r.URL.Query().Get("until") // Upper bound timestamp
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
// Validate event type
if eventType != "" && !isValidEventType(eventType) {
@ -267,17 +288,32 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
return
}
// Validate after timestamp
// Validate after/since timestamp (prefer since if both provided)
var afterTS int64
if afterStr != "" {
t, err := time.Parse(time.RFC3339, afterStr)
timeStr := afterStr
if sinceStr != "" {
timeStr = sinceStr
}
if timeStr != "" {
t, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid after timestamp")
writeJSONError(w, http.StatusBadRequest, "invalid since/after timestamp")
return
}
afterTS = t.UnixNano() / 1e6
}
// Validate until timestamp (upper bound)
var untilTS int64
if untilStr != "" {
t, err := time.Parse(time.RFC3339, untilStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid until timestamp")
return
}
untilTS = t.UnixNano() / 1e6
}
// In simple mode, filter out system-only event types
// Simple mode shows: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, security_alert, learning_milestone
// Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system
@ -347,6 +383,10 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
whereSQL += " AND " + p + "timestamp_ms >= ?"
whereArgs = append(whereArgs, afterTS)
}
if untilTS > 0 {
whereSQL += " AND " + p + "timestamp_ms <= ?"
whereArgs = append(whereArgs, untilTS)
}
// COUNT for total_filtered
countSQL := "SELECT COUNT(*) FROM " + fromTable + " WHERE " + whereSQL
@ -363,6 +403,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
dataWhere += " AND " + p + "timestamp_ms < ?"
dataArgs = append(dataArgs, beforeTS)
}
// untilTS is already included in the base WHERE clause via whereArgs
dataSQL := "SELECT " + selectCols + " FROM " + fromTable +
" WHERE " + dataWhere +
@ -430,3 +471,74 @@ func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, event)
}
// postEventFeedback handles POST /api/events/{id}/feedback
// It delegates to the feedback module after validating the event exists.
func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
eventID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid event id")
return
}
// Verify the event exists
var exists bool
err = e.db.QueryRow("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?)", eventID).Scan(&exists)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to query event")
return
}
if !exists {
writeJSONError(w, http.StatusNotFound, "event not found")
return
}
// Decode request body
var req FeedbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
// Set the event ID from the URL path
req.EventID = eventID
// Validate feedback type
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
return
}
// Delegate to feedback handler if available
if e.feedbackHandler != nil {
// Use the feedback handler to process the request
type submitter interface {
SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest)
}
if fh, ok := e.feedbackHandler.(submitter); ok {
fh.SubmitFeedback(w, r, req)
return
}
}
// Fallback: log a feedback event
_ = e.LogEvent("feedback", time.Now(), "", "", 0,
fmt.Sprintf(`{"event_id":%d,"type":"%s","blob_id":%d}`, eventID, req.Type, req.BlobID), "info")
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
})
}
// FeedbackRequest represents a feedback submission for an event.
type FeedbackRequest struct {
Type string `json:"type"` // "correct" or "incorrect"
BlobID int `json:"blob_id"` // Optional: blob ID being rated
Position *struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
} `json:"position,omitempty"` // For "missed" feedback
}

View file

@ -1,11 +1,13 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@ -914,3 +916,182 @@ func TestFTSRebuildOnStartup(t *testing.T) {
t.Errorf("after rebuild: total_filtered = %d, want 10", resp.TotalFiltered)
}
}
// --- Tests for since/until query parameters ---
func TestListEvents_SinceParameter(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// Filter using since parameter (alias for after)
sinceTime := base.Add(4 * time.Second).Format(time.RFC3339)
req := httptest.NewRequest("GET", "/api/events?since="+sinceTime+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.TotalFiltered != 6 { // events 4..9
t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered)
}
for _, ev := range resp.Events {
if ev.Timestamp < base.Add(4*time.Second).UnixNano()/1e6 {
t.Errorf("event ts %d before since time", ev.Timestamp)
}
}
}
func TestListEvents_UntilParameter(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// Filter using until parameter (upper bound)
untilTime := base.Add(5 * time.Second).Format(time.RFC3339)
req := httptest.NewRequest("GET", "/api/events?until="+untilTime+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.TotalFiltered != 6 { // events 0..5
t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered)
}
for _, ev := range resp.Events {
if ev.Timestamp > base.Add(5*time.Second).UnixNano()/1e6 {
t.Errorf("event ts %d after until time", ev.Timestamp)
}
}
}
func TestListEvents_SinceAndUntil(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// Filter using both since and until
sinceTime := base.Add(2 * time.Second).Format(time.RFC3339)
untilTime := base.Add(7 * time.Second).Format(time.RFC3339)
req := httptest.NewRequest("GET", "/api/events?since="+sinceTime+"&until="+untilTime+"&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.TotalFiltered != 6 { // events 2..7
t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered)
}
}
func TestListEvents_InvalidUntil(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/api/events?until=not-a-date", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// --- Tests for person_id and zone_id parameter aliases ---
func TestListEvents_PersonIDAlias(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Filter using person_id parameter (alias for person)
req := httptest.NewRequest("GET", "/api/events?person_id=Alice&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Person != "Alice" {
t.Errorf("event person = %q, want Alice", ev.Person)
}
}
}
func TestListEvents_ZoneIDAlias(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 100)
// Filter using zone_id parameter (alias for zone)
req := httptest.NewRequest("GET", "/api/events?zone_id=Kitchen&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Zone != "Kitchen" {
t.Errorf("event zone = %q, want Kitchen", ev.Zone)
}
}
}
func TestListEvents_ZoneTakesPrecedence(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// When both zone and zone_id are provided, zone_id should take precedence
req := httptest.NewRequest("GET", "/api/events?zone=Hallway&zone_id=Kitchen&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Zone != "Kitchen" {
t.Errorf("event zone = %q, want Kitchen (zone_id should take precedence)", ev.Zone)
}
}
}
func TestListEvents_PersonIDTakesPrecedence(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
base := time.Now()
seedEvents(t, h, base, 10)
// When both person and person_id are provided, person_id should take precedence
req := httptest.NewRequest("GET", "/api/events?person=Bob&person_id=Alice&limit=100", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var resp eventsResponse
json.NewDecoder(w.Body).Decode(&resp)
for _, ev := range resp.Events {
if ev.Person != "Alice" {
t.Errorf("event person = %q, want Alice (person_id should take precedence)", ev.Person)
}
}
}

View file

@ -126,3 +126,59 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re
"message": "Feedback recorded",
})
}
// SubmitFeedback is called by the events handler to process feedback for a specific event.
func (h *FeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
// Validate feedback type
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
return
}
// Get event details for logging
var zone, person string
var detailJSON string
// Create detail JSON for the event
details := make(map[string]interface{})
details["original_event_id"] = req.EventID
details["feedback"] = req.Type
if req.Position != nil {
details["position"] = req.Position
}
detailBytes, _ := json.Marshal(details)
detailJSON = string(detailBytes)
// Log feedback event
if h.eventsHandler != nil {
eventType := "feedback_confirmed"
if req.Type == "incorrect" {
eventType = "feedback_corrected"
} else if req.Type == "missed" {
eventType = "missed_detection"
}
_ = h.eventsHandler.LogEvent(eventType, time.Now(), zone, person, req.BlobID, detailJSON, "info")
}
// If learning handler is available, process the feedback
if h.learningHandler != nil {
type processor interface {
ProcessFeedback(feedbackType string, eventID int64, blobID int, positionJSON string) error
}
if p, ok := h.learningHandler.(processor); ok {
var positionJSON string
if req.Position != nil {
positionBytes, _ := json.Marshal(req.Position)
positionJSON = string(positionBytes)
}
_ = p.ProcessFeedback(req.Type, req.EventID, req.BlobID, positionJSON)
}
}
// Return success response
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
})
}