feat(dashboard): implement tap-to-jump time-travel coordination
Timeline events now delegate to SpaxelReplay.jumpToTime() for coordinated replay session creation instead of making direct fetch calls. Selected events highlight with timeline-event-selected class, and cross-module selection is cleared between sidebar and full-page timeline views. - timeline.js: use SpaxelReplay.jumpToTime() in handleSeek, export clearSelection and hideNowReplayingChip, clear sidebar on jump - sidebar-timeline.js: clear full-page timeline selection on sidebar jump - replay.js: return promise from exitReplayMode for proper chaining - timeline.test.js: 14 tests covering timestamp emission, highlighting, now-replaying chip, cross-module coordination, simple mode gating, and error handling Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
60cac48a02
commit
4960fbfd51
4 changed files with 498 additions and 43 deletions
|
|
@ -486,7 +486,7 @@
|
|||
console.log('[Replay] Exiting replay mode');
|
||||
|
||||
// Stop replay session
|
||||
stopReplaySession().then(() => {
|
||||
return stopReplaySession().then(() => {
|
||||
state.isReplayMode = false;
|
||||
state.isPaused = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -729,6 +729,11 @@
|
|||
if (state.dashboardMode === 'expert' && window.SpaxelReplay) {
|
||||
SpaxelReplay.jumpToTime(timestamp).then(function() {
|
||||
updateNowReplayingChip(true, timestamp);
|
||||
|
||||
// Clear full-page timeline selection to avoid stale highlight
|
||||
if (window.SpaxelTimeline && SpaxelTimeline.clearSelection) {
|
||||
SpaxelTimeline.clearSelection();
|
||||
}
|
||||
}).catch(function(err) {
|
||||
console.error('[SidebarTimeline] Jump-to-time failed:', err);
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
|
|
|
|||
|
|
@ -1363,51 +1363,43 @@
|
|||
state.replay.selectedEventId = entryElement.dataset.id;
|
||||
}
|
||||
|
||||
// Use jump-to-time API for single-call replay session creation
|
||||
var payload = {
|
||||
timestamp_ms: timestamp,
|
||||
window_ms: CONFIG.replaySeekWindowSec * 1000
|
||||
};
|
||||
// Use SpaxelReplay.jumpToTime for coordinated replay session creation
|
||||
if (window.SpaxelReplay) {
|
||||
SpaxelReplay.jumpToTime(timestamp, CONFIG.replaySeekWindowSec * 1000)
|
||||
.then(function() {
|
||||
state.replay.activeSessionId = SpaxelReplay.getSession().id;
|
||||
state.replay.isReplaying = true;
|
||||
state.replay.replayTimestamp = timestamp;
|
||||
|
||||
fetch('/api/replay/jump-to-time', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to jump to time');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
state.replay.activeSessionId = data.session_id;
|
||||
state.replay.isReplaying = true;
|
||||
state.replay.replayTimestamp = timestamp;
|
||||
// Show "Now replaying" chip in this timeline
|
||||
showNowReplayingChip(timestamp);
|
||||
|
||||
// Show "Now replaying" chip
|
||||
showNowReplayingChip(timestamp);
|
||||
// Navigate to replay mode if router available
|
||||
if (window.SpaxelRouter) {
|
||||
SpaxelRouter.navigate('replay');
|
||||
}
|
||||
|
||||
// Navigate to replay mode if router available
|
||||
if (window.SpaxelRouter) {
|
||||
SpaxelRouter.navigate('replay');
|
||||
}
|
||||
// Clear sidebar selection to avoid stale highlight
|
||||
if (window.SpaxelSidebarTimeline) {
|
||||
SpaxelSidebarTimeline.clearSelection();
|
||||
}
|
||||
|
||||
// Notify replay module about the jump
|
||||
if (window.SpaxelReplay && SpaxelReplay.onJumpToTime) {
|
||||
SpaxelReplay.onJumpToTime(data.session_id, timestamp);
|
||||
}
|
||||
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Viewing ' + formatTimestamp(timestamp), 'info');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Timeline] Jump to time failed:', err);
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Failed to jump to replay: ' + err.message, 'warning');
|
||||
}
|
||||
});
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Viewing ' + formatTimestamp(timestamp), 'info');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Timeline] Jump to time failed:', err);
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Failed to jump to replay: ' + err.message, 'warning');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('[Timeline] SpaxelReplay module not available');
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Replay module not available', 'warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -1548,7 +1540,9 @@
|
|||
|
||||
handleNewEvent(event);
|
||||
},
|
||||
refresh: loadInitialEvents
|
||||
refresh: loadInitialEvents,
|
||||
clearSelection: clearSelectedEvent,
|
||||
hideNowReplayingChip: hideNowReplayingChip
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
|
|
|
|||
456
dashboard/js/timeline.test.js
Normal file
456
dashboard/js/timeline.test.js
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* Tests for Activity Timeline tap-to-jump time-travel
|
||||
*
|
||||
* Covers:
|
||||
* - Clicking event emits jumpToTime with correct timestamp
|
||||
* - Selected event highlights with timeline-event-selected class
|
||||
* - Clicking new event clears previous selection
|
||||
* - Now replaying chip appears in timeline header
|
||||
* - hideNowReplayingChip hides the chip and clears replay state
|
||||
* - clearSelection removes selected class from event
|
||||
* - Simple mode does not trigger replay jump
|
||||
* - Cross-module coordination: sidebar selection cleared on timeline jump
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Mock dependencies
|
||||
var mockJumpToTime;
|
||||
global.fetch = jest.fn(function() {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: function() {
|
||||
return Promise.resolve({ events: [], cursor: null, total_filtered: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mock Feedback module (unused but referenced in module)
|
||||
global.Feedback = { sendFeedback: jest.fn() };
|
||||
|
||||
// Mock SpaxelApp
|
||||
global.SpaxelApp = {
|
||||
registerMessageHandler: jest.fn(),
|
||||
showToast: jest.fn()
|
||||
};
|
||||
|
||||
// Mock SpaxelRouter
|
||||
global.SpaxelRouter = {
|
||||
onModeChange: jest.fn(),
|
||||
navigate: jest.fn()
|
||||
};
|
||||
|
||||
// Mock SpaxelSimpleModeDetection
|
||||
global.SpaxelSimpleModeDetection = {
|
||||
onModeChange: jest.fn()
|
||||
};
|
||||
|
||||
// Mock SpaxelSidebarTimeline
|
||||
global.SpaxelSidebarTimeline = {
|
||||
clearSelection: jest.fn(),
|
||||
hideNowReplayingChip: jest.fn()
|
||||
};
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = jest.fn(function(callback, options) {
|
||||
this.observe = jest.fn();
|
||||
this.unobserve = jest.fn();
|
||||
this.disconnect = jest.fn();
|
||||
this.callback = callback;
|
||||
this.options = options;
|
||||
});
|
||||
|
||||
describe('Timeline tap-to-jump', function() {
|
||||
var Timeline;
|
||||
var mockElements;
|
||||
var testEvents;
|
||||
|
||||
beforeEach(function() {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockJumpToTime = jest.fn(function() {
|
||||
return Promise.resolve({
|
||||
session_id: 'test-session-1',
|
||||
timestamp_ms: 1710519800000,
|
||||
from_ms: 1710519795000,
|
||||
to_ms: 1710519805000,
|
||||
state: 'paused'
|
||||
});
|
||||
});
|
||||
|
||||
global.SpaxelReplay = {
|
||||
jumpToTime: mockJumpToTime,
|
||||
onJumpToTime: jest.fn(),
|
||||
isReplayMode: jest.fn(function() { return false; }),
|
||||
getSession: jest.fn(function() {
|
||||
return { id: 'test-session-1', fromMs: 1710519795000, toMs: 1710519805000, currentMs: 1710519800000, speed: 1, state: 'paused' };
|
||||
})
|
||||
};
|
||||
|
||||
// Setup DOM structure matching live.html
|
||||
document.body.innerHTML = `
|
||||
<div id="timeline-view" style="display: none;">
|
||||
<div class="timeline-header">
|
||||
<h2 class="timeline-title">Activity Timeline</h2>
|
||||
<div id="timeline-now-replaying" class="timeline-now-replaying" style="display: none;">
|
||||
<span class="now-replaying-dot"></span>
|
||||
<span class="now-replaying-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-filter-toggle">Filter</div>
|
||||
<div id="timeline-filter-bar" class="timeline-filter-bar">
|
||||
<select id="timeline-filter-type"><option value="">All Types</option></select>
|
||||
<select id="timeline-filter-zone"><option value="">All Zones</option></select>
|
||||
<select id="timeline-filter-person"><option value="">All People</option></select>
|
||||
<select id="timeline-filter-time"><option value="">All Time</option></select>
|
||||
<input id="timeline-filter-search" type="text" placeholder="Search events...">
|
||||
<div id="timeline-custom-date-container" style="display: none;">
|
||||
<input id="timeline-date-from" type="date">
|
||||
<input id="timeline-date-to" type="date">
|
||||
<button id="timeline-date-apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-category-filters">
|
||||
<label><input type="checkbox" id="timeline-category-presence" checked> Presence</label>
|
||||
<label><input type="checkbox" id="timeline-category-zones" checked> Zones</label>
|
||||
<label><input type="checkbox" id="timeline-category-alerts" checked> Alerts</label>
|
||||
<label><input type="checkbox" id="timeline-category-system" checked> System</label>
|
||||
<label><input type="checkbox" id="timeline-category-learning" checked> Learning</label>
|
||||
</div>
|
||||
<div id="timeline-events" class="timeline-events-list"></div>
|
||||
<div id="timeline-loading" style="display: none;">Loading...</div>
|
||||
<div id="timeline-empty" style="display: none;">No events</div>
|
||||
<div id="timeline-error" style="display: none;">Error</div>
|
||||
<div id="timeline-load-more" style="display: none;">
|
||||
<button id="timeline-load-more-btn">Load More</button>
|
||||
</div>
|
||||
<div id="timeline-count"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load the module fresh
|
||||
jest.isolateModules(function() {
|
||||
require('./timeline.js');
|
||||
Timeline = window.SpaxelTimeline;
|
||||
});
|
||||
|
||||
testEvents = [
|
||||
{
|
||||
id: 100,
|
||||
timestamp_ms: 1710519800000,
|
||||
type: 'zone_entry',
|
||||
zone: 'Kitchen',
|
||||
person: 'Alice',
|
||||
blob_id: 0,
|
||||
detail_json: '',
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: 200,
|
||||
timestamp_ms: 1710519860000,
|
||||
type: 'zone_exit',
|
||||
zone: 'Kitchen',
|
||||
person: 'Alice',
|
||||
blob_id: 0,
|
||||
detail_json: '',
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: 300,
|
||||
timestamp_ms: 1710519920000,
|
||||
type: 'fall_alert',
|
||||
zone: 'Bathroom',
|
||||
person: 'Bob',
|
||||
blob_id: 42,
|
||||
detail_json: JSON.stringify({ description: 'Fall detected in Bathroom' }),
|
||||
severity: 'critical'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
document.body.innerHTML = '';
|
||||
delete window.SpaxelTimeline;
|
||||
delete window.SpaxelReplay;
|
||||
});
|
||||
|
||||
// Helper: load events and return a promise that resolves when rendered
|
||||
function loadEvents(events) {
|
||||
global.fetch.mockImplementation(function() {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: function() {
|
||||
return Promise.resolve({
|
||||
events: events,
|
||||
cursor: null,
|
||||
total_filtered: events.length
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Timeline.refresh();
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(resolve, 150);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tap-to-Jump: correct timestamp emission
|
||||
// ============================================
|
||||
describe('timestamp emission', function() {
|
||||
test('clicking event calls SpaxelReplay.jumpToTime with event timestamp', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
expect(eventEl).toBeTruthy();
|
||||
|
||||
eventEl.click();
|
||||
|
||||
expect(mockJumpToTime).toHaveBeenCalledTimes(1);
|
||||
expect(mockJumpToTime).toHaveBeenCalledWith(1710519800000, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
test('clicking different events emits correct timestamps', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var first = document.querySelector('[data-id="100"]');
|
||||
var second = document.querySelector('[data-id="200"]');
|
||||
|
||||
first.click();
|
||||
expect(mockJumpToTime).toHaveBeenCalledWith(1710519800000, 5000);
|
||||
|
||||
second.click();
|
||||
expect(mockJumpToTime).toHaveBeenCalledWith(1710519860000, 5000);
|
||||
|
||||
expect(mockJumpToTime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('seek button on event card calls jumpToTime', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var seekBtn = document.querySelector('[data-id="100"] .timeline-seek-btn');
|
||||
expect(seekBtn).toBeTruthy();
|
||||
|
||||
seekBtn.click();
|
||||
|
||||
expect(mockJumpToTime).toHaveBeenCalledWith(1710519800000, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Selected event highlighting
|
||||
// ============================================
|
||||
describe('event highlighting', function() {
|
||||
test('clicked event gets timeline-event-selected class', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
expect(eventEl.classList.contains('timeline-event-selected')).toBe(false);
|
||||
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(eventEl.classList.contains('timeline-event-selected')).toBe(true);
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clicking new event clears previous selection', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var first = document.querySelector('[data-id="100"]');
|
||||
var second = document.querySelector('[data-id="200"]');
|
||||
|
||||
first.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(first.classList.contains('timeline-event-selected')).toBe(true);
|
||||
|
||||
second.click();
|
||||
|
||||
setTimeout(function() {
|
||||
expect(first.classList.contains('timeline-event-selected')).toBe(false);
|
||||
expect(second.classList.contains('timeline-event-selected')).toBe(true);
|
||||
resolve();
|
||||
}, 50);
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearSelection removes selected class', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(eventEl.classList.contains('timeline-event-selected')).toBe(true);
|
||||
Timeline.clearSelection();
|
||||
expect(eventEl.classList.contains('timeline-event-selected')).toBe(false);
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Now replaying chip
|
||||
// ============================================
|
||||
describe('Now replaying chip', function() {
|
||||
test('chip appears in timeline header after jump', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
var chip = document.getElementById('timeline-now-replaying');
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip.style.display).not.toBe('none');
|
||||
expect(chip.textContent).toContain('Now replaying');
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('hideNowReplayingChip hides the chip', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
var chip = document.getElementById('timeline-now-replaying');
|
||||
expect(chip.style.display).not.toBe('none');
|
||||
|
||||
Timeline.hideNowReplayingChip();
|
||||
expect(chip.style.display).toBe('none');
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('hideNowReplayingChip clears replay state and selection', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(eventEl.classList.contains('timeline-event-selected')).toBe(true);
|
||||
|
||||
Timeline.hideNowReplayingChip();
|
||||
expect(eventEl.classList.contains('timeline-event-selected')).toBe(false);
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Cross-module coordination
|
||||
// ============================================
|
||||
describe('cross-module coordination', function() {
|
||||
test('jump clears sidebar timeline selection', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(global.SpaxelSidebarTimeline.clearSelection).toHaveBeenCalled();
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('navigates to replay mode via router', function() {
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(global.SpaxelRouter.navigate).toHaveBeenCalledWith('replay');
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Simple mode gating
|
||||
// ============================================
|
||||
describe('simple mode gating', function() {
|
||||
test('clicking event does not trigger jump in simple mode', function() {
|
||||
// Simulate simple mode by triggering the registered callback
|
||||
var simpleCallback = null;
|
||||
var calls = global.SpaxelSimpleModeDetection.onModeChange.mock.calls;
|
||||
if (calls.length > 0) {
|
||||
simpleCallback = calls[0][0];
|
||||
}
|
||||
if (simpleCallback) {
|
||||
simpleCallback('simple');
|
||||
}
|
||||
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
if (eventEl) {
|
||||
eventEl.click();
|
||||
// In simple mode, jumpToTime should NOT be called
|
||||
expect(mockJumpToTime).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Error handling
|
||||
// ============================================
|
||||
describe('error handling', function() {
|
||||
test('jumpToTime failure shows error toast', function() {
|
||||
mockJumpToTime.mockImplementation(function() {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
});
|
||||
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() {
|
||||
expect(global.SpaxelApp.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed'),
|
||||
'warning'
|
||||
);
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('missing SpaxelReplay shows warning toast', function() {
|
||||
delete window.SpaxelReplay;
|
||||
|
||||
return loadEvents(testEvents).then(function() {
|
||||
var eventEl = document.querySelector('[data-id="100"]');
|
||||
eventEl.click();
|
||||
|
||||
expect(global.SpaxelApp.showToast).toHaveBeenCalledWith(
|
||||
'Replay module not available',
|
||||
'warning'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue