diff --git a/dashboard/js/replay.js b/dashboard/js/replay.js index 8c5d3ed..d558621 100644 --- a/dashboard/js/replay.js +++ b/dashboard/js/replay.js @@ -486,7 +486,7 @@ console.log('[Replay] Exiting replay mode'); // Stop replay session - stopReplaySession().then(() => { + return stopReplaySession().then(() => { state.isReplayMode = false; state.isPaused = false; diff --git a/dashboard/js/sidebar-timeline.js b/dashboard/js/sidebar-timeline.js index 3ff0e30..770b5db 100644 --- a/dashboard/js/sidebar-timeline.js +++ b/dashboard/js/sidebar-timeline.js @@ -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) { diff --git a/dashboard/js/timeline.js b/dashboard/js/timeline.js index e24cef8..273966a 100644 --- a/dashboard/js/timeline.js +++ b/dashboard/js/timeline.js @@ -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 diff --git a/dashboard/js/timeline.test.js b/dashboard/js/timeline.test.js new file mode 100644 index 0000000..9ec21f4 --- /dev/null +++ b/dashboard/js/timeline.test.js @@ -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 = ` +
+ `; + + // 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' + ); + }); + }); + }); +});