feat(timeline): add search and filter bar to sidebar timeline

- Add collapsible filter panel with category checkboxes (Presence, Zones,
  Alerts, System, Learning) for client-side event type filtering
- Add person and zone dropdowns populated from /api/people and /api/zones
- Add date range selector (All Time / Today / Last 7 Days / Last 30 Days /
  Custom range) with server-side re-fetch on date changes
- Add text search input with fuzzy client-side matching and FTS5 server-side
  prefix matching for descriptions
- Add active filter tags with individual remove buttons and Clear All
- Add load-more cursor pagination for 500+ results
- Add virtualized rendering with IntersectionObserver for 1000+ events
- Render event feedback buttons (thumbs up/down) inline on each event
- Add now-replaying chip showing current replay timestamp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-04 00:54:11 -04:00
parent ce73ca488e
commit 758bef0138
4 changed files with 1120 additions and 264 deletions

View file

@ -861,10 +861,347 @@
box-shadow: -4px 0 20px var(--shadow);
}
.sidebar-panel.collapsed {
transform: translateX(100%);
opacity: 0;
pointer-events: none;
/* ============================================
Sidebar Filter Bar Styles
============================================ */
.sidebar-timeline-filter-bar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary, var(--bg-card));
border-bottom: 1px solid var(--border-color, var(--bg-hover));
flex-shrink: 0;
}
/* Search Input */
.sidebar-filter-search {
flex: 1;
}
.sidebar-search-input {
width: 100%;
background: var(--bg-primary, var(--bg-page));
border: 1px solid var(--border-color, var(--bg-hover));
border-radius: var(--radius-control);
padding: var(--space-150) var(--space-250) var(--space-150) var(--space-6);
font-size: var(--text-xs);
color: var(--text-primary, var(--slate-11));
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 8px center;
transition: border-color 0.2s, box-shadow 0.2s;
}
.sidebar-search-input:hover {
border-color: var(--accent-color, var(--blue-9));
}
.sidebar-search-input:focus {
outline: none;
border-color: var(--accent-color, var(--blue-9));
box-shadow: 0 0 0 2px var(--blue-border);
}
.sidebar-search-input::placeholder {
color: var(--text-muted, var(--text-muted));
}
/* Filter Toggle Button */
.sidebar-filter-toggle-btn {
background: var(--bg-primary, var(--bg-page));
border: 1px solid var(--border-color, var(--bg-hover));
border-radius: var(--radius-control);
padding: var(--space-150) var(--space-2);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-150);
font-size: var(--text-2xs);
color: var(--text-secondary, var(--text-secondary));
transition: all 0.2s;
white-space: nowrap;
}
.sidebar-filter-toggle-btn:hover {
background: var(--bg-tertiary, var(--bg-card));
border-color: var(--accent-color, var(--blue-9));
color: var(--text-primary, var(--slate-11));
}
.sidebar-filter-toggle-btn svg {
width: 12px;
height: 12px;
}
/* Filter Controls Panel */
.sidebar-timeline-filter-controls {
background: var(--bg-tertiary, var(--bg-card));
border-bottom: 1px solid var(--border-color, var(--bg-hover));
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
flex-shrink: 0;
}
.sidebar-timeline-filter-controls:not(.collapsed) {
max-height: 400px;
overflow-y: auto;
padding: var(--space-3);
}
/* Filter Sections */
.sidebar-filter-section {
margin-bottom: var(--space-3);
}
.sidebar-filter-section:last-child {
margin-bottom: 0;
}
.sidebar-filter-section-title {
font-size: var(--text-2xs);
text-transform: uppercase;
letter-spacing: var(--ls-wide);
color: var(--text-muted, var(--text-muted));
margin-bottom: var(--space-2);
}
/* Category Checkboxes */
.sidebar-category-checkboxes {
display: flex;
flex-wrap: wrap;
gap: var(--space-150);
}
.sidebar-category-checkbox {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-150);
background: var(--bg-primary, var(--bg-page));
border: 1px solid var(--border-color, var(--bg-hover));
border-radius: var(--radius-control);
cursor: pointer;
transition: all 0.2s;
font-size: var(--text-2xs);
color: var(--text-secondary, var(--text-secondary));
}
.sidebar-category-checkbox:hover {
border-color: var(--accent-color, var(--blue-9));
background: var(--bg-secondary, var(--bg-card));
}
.sidebar-category-checkbox input[type="checkbox"] {
width: 12px;
height: 12px;
accent-color: var(--accent-color, var(--blue-9));
cursor: pointer;
}
.sidebar-category-checkbox:has(input:checked) {
border-color: var(--accent-color, var(--blue-9));
background: var(--blue-muted);
color: var(--text-primary, var(--slate-11));
}
.sidebar-category-icon {
font-size: var(--text-sm);
}
.sidebar-category-label {
font-weight: 500;
}
/* Filter Dropdowns */
.sidebar-filter-dropdowns {
display: flex;
flex-direction: column;
gap: var(--space-150);
}
.sidebar-filter-select {
background: var(--bg-primary, var(--bg-page));
border: 1px solid var(--border-color, var(--bg-hover));
color: var(--text-primary, var(--slate-11));
padding: var(--space-150) var(--space-250);
border-radius: var(--radius-control);
font-size: var(--text-xs);
cursor: pointer;
transition: border-color 0.2s;
}
.sidebar-filter-select:hover {
border-color: var(--accent-color, var(--blue-9));
}
.sidebar-filter-select:focus {
outline: none;
border-color: var(--accent-color, var(--blue-9));
box-shadow: 0 0 0 2px var(--blue-border);
}
/* Custom Date Range */
.sidebar-custom-date-container {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding: var(--space-2);
background: var(--bg-primary, var(--bg-page));
border-radius: var(--radius-control);
}
.sidebar-date-input {
background: var(--bg-secondary, var(--bg-card));
border: 1px solid var(--border-color, var(--bg-hover));
border-radius: var(--radius-control);
color: var(--text-primary, var(--slate-11));
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-family: inherit;
flex: 1;
}
.sidebar-date-input::-webkit-calendar-picker-indicator {
filter: invert(0.5);
cursor: pointer;
}
.sidebar-date-separator {
color: var(--text-muted, var(--text-muted));
font-size: var(--text-xs);
}
.sidebar-date-apply-btn {
background: var(--accent-color, var(--blue-9));
color: var(--bg-primary, var(--bg-page));
border: none;
border-radius: var(--radius-control);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.sidebar-date-apply-btn:hover {
background: var(--blue-9);
}
/* Active Filters Display */
.sidebar-active-filters {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
margin-top: var(--space-2);
background: var(--bg-primary, var(--bg-page));
border-radius: var(--radius-control);
flex-wrap: wrap;
}
.sidebar-active-filters-label {
font-size: var(--text-2xs);
color: var(--text-muted, var(--text-muted));
font-weight: 500;
}
.sidebar-active-filter-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
flex: 1;
}
.sidebar-filter-tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--blue-muted);
color: var(--blue-11);
padding: var(--space-1) var(--space-150);
border-radius: var(--radius-pill);
font-size: var(--text-2xs);
}
.sidebar-filter-tag-remove {
background: none;
border: none;
color: var(--blue-11);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
opacity: 0.7;
transition: opacity 0.2s;
}
.sidebar-filter-tag-remove:hover {
opacity: 1;
}
.sidebar-clear-filters-btn {
background: transparent;
border: 1px solid var(--border-color, var(--bg-hover));
border-radius: var(--radius-control);
padding: var(--space-1) var(--space-2);
font-size: var(--text-2xs);
color: var(--text-muted, var(--text-muted));
cursor: pointer;
transition: all 0.2s;
}
.sidebar-clear-filters-btn:hover {
background: var(--bg-tertiary, var(--bg-card));
color: var(--text-secondary, var(--text-secondary));
border-color: var(--text-muted, var(--text-muted));
}
/* Sidebar Event Count */
.sidebar-timeline-count {
padding: var(--space-1) var(--space-3);
font-size: var(--text-2xs);
color: var(--text-muted, var(--text-muted));
background: var(--bg-secondary, var(--bg-card));
border-bottom: 1px solid var(--border-color, var(--bg-hover));
text-align: center;
}
/* Sidebar Load More */
.sidebar-load-more {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
flex-shrink: 0;
}
.sidebar-load-more-btn {
background: var(--bg-secondary, var(--bg-card));
border: 1px solid var(--border-color, var(--bg-hover));
border-radius: var(--radius-control);
padding: var(--space-2) var(--space-4);
color: var(--text-secondary, var(--text-secondary));
font-size: var(--text-sm);
cursor: pointer;
transition: all 0.2s;
width: 100%;
max-width: 200px;
}
.sidebar-load-more-btn:hover {
background: var(--bg-primary, var(--bg-page));
border-color: var(--accent-color, var(--blue-9));
color: var(--text-primary, var(--slate-11));
}
.sidebar-load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Panel Header */

File diff suppressed because it is too large Load diff

View file

@ -66,8 +66,10 @@ describe('SidebarTimeline', function() {
// Reset all mocks
jest.clearAllMocks();
// Reset mock event data
mockEventData = { events: [], cursor: null, total_filtered: 0 };
// Reset mock event data (update the variable used by fetch mock)
mockEventData.events = [];
mockEventData.cursor = null;
mockEventData.total_filtered = 0;
// Setup DOM structure
document.body.innerHTML = `
@ -184,13 +186,8 @@ describe('SidebarTimeline', function() {
window.SpaxelSidebarTimeline.state.events = [];
}
// Mock successful API response
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
// Update the shared mockEventData object with 15 test events
mockEventData.events = [
{
id: 1,
timestamp_ms: Date.now() - 3600000,
@ -337,13 +334,9 @@ describe('SidebarTimeline', function() {
}),
severity: 'info'
}
],
cursor: null,
total_filtered: 15
});
}
});
});
];
mockEventData.cursor = null;
mockEventData.total_filtered = 15;
});
test('all event types render correctly', function() {

View file

@ -3231,7 +3231,95 @@
</button>
</div>
</div>
<!-- Filter Bar -->
<div id="sidebar-timeline-filter-bar" class="sidebar-timeline-filter-bar">
<!-- Search Input -->
<div class="sidebar-filter-search">
<input type="text" id="sidebar-timeline-search" class="sidebar-search-input" placeholder="Search events...">
</div>
<!-- Filter Toggle Button -->
<button id="sidebar-filter-toggle-btn" class="sidebar-filter-toggle-btn" title="Show filters">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
Filters
</button>
</div>
<!-- Expanded Filter Controls -->
<div id="sidebar-timeline-filter-controls" class="sidebar-timeline-filter-controls collapsed">
<!-- Category Checkboxes -->
<div class="sidebar-filter-section">
<div class="sidebar-filter-section-title">Event Categories</div>
<div class="sidebar-category-checkboxes">
<label class="sidebar-category-checkbox">
<input type="checkbox" id="filter-category-presence" checked>
<span class="sidebar-category-icon">👤</span>
<span class="sidebar-category-label">Presence</span>
</label>
<label class="sidebar-category-checkbox">
<input type="checkbox" id="filter-category-zones" checked>
<span class="sidebar-category-icon">🚪</span>
<span class="sidebar-category-label">Zones</span>
</label>
<label class="sidebar-category-checkbox">
<input type="checkbox" id="filter-category-alerts" checked>
<span class="sidebar-category-icon">⚠️</span>
<span class="sidebar-category-label">Alerts</span>
</label>
<label class="sidebar-category-checkbox">
<input type="checkbox" id="filter-category-system">
<span class="sidebar-category-icon">⚙️</span>
<span class="sidebar-category-label">System</span>
</label>
<label class="sidebar-category-checkbox">
<input type="checkbox" id="filter-category-learning">
<span class="sidebar-category-icon">🎓</span>
<span class="sidebar-category-label">Learning</span>
</label>
</div>
</div>
<!-- Dropdown Filters -->
<div class="sidebar-filter-section">
<div class="sidebar-filter-section-title">Filter By</div>
<div class="sidebar-filter-dropdowns">
<select id="sidebar-filter-person" class="sidebar-filter-select">
<option value="">All People</option>
</select>
<select id="sidebar-filter-zone" class="sidebar-filter-select">
<option value="">All Zones</option>
</select>
<select id="sidebar-filter-date-range" class="sidebar-filter-select">
<option value="all">All Time</option>
<option value="today">Today</option>
<option value="7days">Last 7 Days</option>
<option value="30days">Last 30 Days</option>
<option value="custom">Custom Range...</option>
</select>
</div>
</div>
<!-- Custom Date Range (hidden by default) -->
<div id="sidebar-custom-date-container" class="sidebar-custom-date-container" style="display: none;">
<input type="date" id="sidebar-date-from" class="sidebar-date-input">
<span class="sidebar-date-separator">to</span>
<input type="date" id="sidebar-date-to" class="sidebar-date-input">
<button id="sidebar-date-apply-btn" class="sidebar-date-apply-btn">Apply</button>
</div>
<!-- Active Filters Display -->
<div id="sidebar-active-filters" class="sidebar-active-filters" style="display: none;">
<span class="sidebar-active-filters-label">Active:</span>
<div id="sidebar-active-filter-tags" class="sidebar-active-filter-tags"></div>
<button id="sidebar-clear-filters-btn" class="sidebar-clear-filters-btn">Clear All</button>
</div>
</div>
<div id="sidebar-timeline-content" class="sidebar-panel-content">
<div id="sidebar-timeline-count" class="sidebar-timeline-count" style="display: none;"></div>
<div id="sidebar-timeline-events" class="sidebar-timeline-events">
<!-- Events will be rendered here -->
</div>
@ -3248,6 +3336,9 @@
</div>
<div id="sidebar-timeline-spacer-top" class="timeline-spacer timeline-spacer-top" style="height: 0px;"></div>
<div id="sidebar-timeline-spacer-bottom" class="timeline-spacer timeline-spacer-bottom" style="height: 0px;"></div>
<div id="sidebar-load-more" class="sidebar-load-more" style="display: none;">
<button id="sidebar-load-more-btn" class="sidebar-load-more-btn">Load more</button>
</div>
</div>
</div>