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:
parent
ce73ca488e
commit
758bef0138
4 changed files with 1120 additions and 264 deletions
|
|
@ -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
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue