Skip to content

Commit 39c6f9b

Browse files
committed
Fix timeline DST rendering and UI follow-ups
1 parent 9877c85 commit 39c6f9b

9 files changed

Lines changed: 261 additions & 92 deletions

File tree

tests/integration/specs/timeline-boundary.ui.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,33 @@ test.describe('Timeline boundary flows @ui @timeline', () => {
212212
await page.evaluate(() => document.exitFullscreen());
213213
await expect.poll(() => page.evaluate(() => document.fullscreenElement)).toBeNull();
214214
});
215+
});
216+
217+
test.describe('Timeline DST rendering @ui @timeline', () => {
218+
test.use({ timezoneId: 'America/New_York' });
219+
220+
test.beforeEach(async ({ page }) => {
221+
await login(page, USERS.admin);
222+
});
223+
224+
test('skips the nonexistent 2am ruler label on spring-forward days while preserving playback selection', async ({ page }) => {
225+
const stream = 'front_door';
226+
const selectedDate = '2026-03-08';
227+
const segments: Segment[] = [
228+
{ id: 601, stream, start_timestamp: Math.floor(Date.parse('2026-03-08T06:50:00Z') / 1000), end_timestamp: Math.floor(Date.parse('2026-03-08T06:55:00Z') / 1000) },
229+
{ id: 602, stream, start_timestamp: Math.floor(Date.parse('2026-03-08T07:10:00Z') / 1000), end_timestamp: Math.floor(Date.parse('2026-03-08T07:15:00Z') / 1000) }
230+
];
231+
232+
await mockTimelineApis(page, stream, segments);
233+
await page.goto(`/timeline.html?stream=${stream}&date=${selectedDate}&time=03:10:00`, { waitUntil: 'domcontentloaded' });
234+
235+
const timelinePage = new TimelinePage(page);
236+
const ruler = page.locator('.timeline-ruler');
237+
238+
await expect(timelinePage.timelineContainer).toBeVisible();
239+
await expect(timelinePage.timeDisplay).toHaveText('front_door - 03:10:00');
240+
await expect(timelinePage.videoPlayer).toHaveAttribute('src', /\/api\/recordings\/play\/602(?:\?|$)/);
241+
await expect(ruler.getByText('3:00', { exact: true })).toBeVisible();
242+
await expect(ruler.getByText('2:00', { exact: true })).toHaveCount(0);
243+
});
215244
});

tests/unit/test_config.c

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ void test_save_config_accepts_hidden_ini_dotfile(void) {
319319
rmdir(dir);
320320
}
321321

322-
void test_env_integer_with_trailing_whitespace(void) {
322+
void test_env_integer_whitespace_handling(void) {
323323
char temp_dir[] = "/tmp/lightnvr_load_config_XXXXXX";
324324
char *dir = mkdtemp(temp_dir);
325325
TEST_ASSERT_NOT_NULL(dir);
@@ -393,6 +393,8 @@ void test_env_integer_with_trailing_whitespace(void) {
393393
}
394394

395395
unlink(config_path);
396+
unlink(db_path);
397+
unlink(log_path);
396398
rmdir(storage_hls_path);
397399
rmdir(storage_path);
398400
rmdir(models_path);
@@ -454,7 +456,7 @@ int main(void) {
454456
RUN_TEST(test_custom_config_path_roundtrip);
455457
RUN_TEST(test_get_loaded_config_path_initially);
456458
RUN_TEST(test_save_config_accepts_hidden_ini_dotfile);
457-
RUN_TEST(test_env_integer_with_trailing_whitespace);
459+
RUN_TEST(test_env_integer_whitespace_handling);
458460

459461
int result = UNITY_END();
460462
shutdown_logger();

web/js/components/preact/UI.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, conf
159159
</div>
160160
<h3 className="text-base font-semibold pt-2">{title}</h3>
161161
</div>
162-
<p className="text-sm text-muted-foreground mb-6 ml-13">{message}</p>
162+
<p className="text-sm text-muted-foreground mb-6 ml-12">{message}</p>
163163
<div className="flex justify-end space-x-3">
164164
<button
165165
className="px-4 py-2 border border-border rounded-md text-sm font-medium hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary dark:focus:ring-offset-gray-800"
@@ -722,9 +722,15 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) {
722722
})
723723
: null;
724724

725+
const handleWindowResize = () => {
726+
scheduleRedraw();
727+
};
728+
725729
if (resizeObserver) {
726730
resizeObserver.observe(container);
727731
resizeObserver.observe(videoRef.current);
732+
} else {
733+
window.addEventListener('resize', handleWindowResize);
728734
}
729735

730736
document.addEventListener('fullscreenchange', handleFullscreenChange);
@@ -735,6 +741,9 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) {
735741
document.removeEventListener('fullscreenchange', handleFullscreenChange);
736742
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
737743
resizeObserver?.disconnect();
744+
if (!resizeObserver) {
745+
window.removeEventListener('resize', handleWindowResize);
746+
}
738747
if (redrawTimeoutId !== null) {
739748
clearTimeout(redrawTimeoutId);
740749
}
@@ -858,7 +867,6 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) {
858867
className={isFullscreen ? 'w-full h-full object-contain' : 'w-full h-auto max-w-full object-contain'}
859868
controls
860869
controlsList="nofullscreen"
861-
autoPlay
862870
key={videoUrl} /* Add key to force re-render when URL changes */
863871
onError={(e) => {
864872
console.error('Video error:', e);

web/js/components/preact/timeline/TimelineControls.jsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import {
1010
findContainingSegmentIndex,
1111
findNearestSegmentIndex,
1212
formatPlaybackTimeLabel,
13-
MAX_TIMELINE_VIEW_HOURS,
13+
getTimelineDayLengthHours,
1414
MIN_TIMELINE_VIEW_HOURS,
15+
timestampToTimelineOffset,
1516
zoomTimelineRange
1617
} from './timelineUtils.js';
1718

@@ -28,9 +29,10 @@ export function TimelineControls() {
2829
useEffect(() => {
2930
const syncControlsState = (state) => {
3031
setIsPlaying(state.isPlaying);
31-
const range = (state.timelineEndHour ?? MAX_TIMELINE_VIEW_HOURS) - (state.timelineStartHour ?? 0);
32+
const dayLengthHours = getTimelineDayLengthHours(state.selectedDate);
33+
const range = (state.timelineEndHour ?? dayLengthHours) - (state.timelineStartHour ?? 0);
3234
setCanZoomIn(range > MIN_TIMELINE_VIEW_HOURS);
33-
setCanZoomOut(range < MAX_TIMELINE_VIEW_HOURS);
35+
setCanZoomOut(range < dayLengthHours);
3436

3537
const currentSegment = state.currentSegmentIndex >= 0 && state.currentSegmentIndex < state.timelineSegments.length
3638
? state.timelineSegments[state.currentSegmentIndex]
@@ -352,24 +354,25 @@ export function TimelineControls() {
352354
// ── Helper: get the center hour for zoom (cursor position or range midpoint) ──
353355
const getCenter = () => {
354356
const s = timelineState.timelineStartHour ?? 0;
355-
const e = timelineState.timelineEndHour ?? MAX_TIMELINE_VIEW_HOURS;
357+
const dayLengthHours = getTimelineDayLengthHours(timelineState.selectedDate);
358+
const e = timelineState.timelineEndHour ?? dayLengthHours;
356359
if (timelineState.currentTime !== null) {
357-
const d = new Date(timelineState.currentTime * 1000);
358-
const h = d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
360+
const h = timestampToTimelineOffset(timelineState.currentTime, timelineState.selectedDate);
359361
// Only use cursor if it's inside the current view
360-
if (h >= s && h <= e) return h;
362+
if (h !== null && h >= s && h <= e) return h;
361363
}
362364
return (s + e) / 2;
363365
};
364366

365367
// Zoom in — halve the visible range, centered on cursor
366368
const zoomIn = () => {
367369
const s = timelineState.timelineStartHour ?? 0;
368-
const e = timelineState.timelineEndHour ?? MAX_TIMELINE_VIEW_HOURS;
370+
const dayLengthHours = getTimelineDayLengthHours(timelineState.selectedDate);
371+
const e = timelineState.timelineEndHour ?? dayLengthHours;
369372
const range = e - s;
370373
if (range <= MIN_TIMELINE_VIEW_HOURS) return;
371374
const center = getCenter();
372-
const nextRange = zoomTimelineRange(s, e, 0.5, center);
375+
const nextRange = zoomTimelineRange(s, e, 0.5, center, dayLengthHours);
373376
timelineState.setState({
374377
timelineStartHour: nextRange.startHour,
375378
timelineEndHour: nextRange.endHour
@@ -379,11 +382,12 @@ export function TimelineControls() {
379382
// Zoom out — double the visible range, centered on cursor, capped at 0-24
380383
const zoomOut = () => {
381384
const s = timelineState.timelineStartHour ?? 0;
382-
const e = timelineState.timelineEndHour ?? MAX_TIMELINE_VIEW_HOURS;
385+
const dayLengthHours = getTimelineDayLengthHours(timelineState.selectedDate);
386+
const e = timelineState.timelineEndHour ?? dayLengthHours;
383387
const range = e - s;
384-
if (range >= MAX_TIMELINE_VIEW_HOURS) return;
388+
if (range >= dayLengthHours) return;
385389
const center = getCenter();
386-
const nextRange = zoomTimelineRange(s, e, 2, center);
390+
const nextRange = zoomTimelineRange(s, e, 2, center, dayLengthHours);
387391
timelineState.setState({
388392
timelineStartHour: nextRange.startHour,
389393
timelineEndHour: nextRange.endHour
@@ -393,7 +397,7 @@ export function TimelineControls() {
393397
// Fit — reset to the auto-fit range computed on data load
394398
const fitToSegments = () => {
395399
const fs = timelineState.autoFitStartHour ?? 0;
396-
const fe = timelineState.autoFitEndHour ?? MAX_TIMELINE_VIEW_HOURS;
400+
const fe = timelineState.autoFitEndHour ?? getTimelineDayLengthHours(timelineState.selectedDate);
397401
timelineState.setState({ timelineStartHour: fs, timelineEndHour: fe });
398402
};
399403

web/js/components/preact/timeline/TimelineCursor.jsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { timelineState } from './TimelinePage.jsx';
88
import {
99
findContainingSegmentIndex,
1010
findNearestSegmentIndex,
11-
formatTimestampAsClock
11+
formatTimestampAsClock,
12+
getTimelineDayLengthHours,
13+
timestampToTimelineOffset
1214
} from './timelineUtils.js';
1315

1416
/**
@@ -55,12 +57,16 @@ export function TimelineCursor() {
5557
useEffect(() => {
5658
const unsubscribe = timelineState.subscribe(state => {
5759
setStartHour(state.timelineStartHour || 0);
58-
setEndHour(state.timelineEndHour || 24);
60+
setEndHour(state.timelineEndHour || getTimelineDayLengthHours(state.selectedDate));
5961

6062
// Only update current time if not dragging
6163
if (!isDraggingRef.current && !state.userControllingCursor) {
6264
updateTimeDisplay(state.currentTime);
63-
debouncedUpdateCursorPosition(state.currentTime, state.timelineStartHour || 0, state.timelineEndHour || 24);
65+
debouncedUpdateCursorPosition(
66+
state.currentTime,
67+
state.timelineStartHour || 0,
68+
state.timelineEndHour || getTimelineDayLengthHours(state.selectedDate)
69+
);
6470
}
6571
});
6672

@@ -179,8 +185,11 @@ export function TimelineCursor() {
179185
return;
180186
}
181187

182-
const date = new Date(time * 1000);
183-
const hour = date.getHours() + date.getMinutes() / 60 + date.getSeconds() / 3600;
188+
const hour = timestampToTimelineOffset(time, timelineState.selectedDate);
189+
if (hour === null) {
190+
setVisible(false);
191+
return;
192+
}
184193

185194
if (hour < startHr || hour > endHr) {
186195
setVisible(false);
@@ -209,7 +218,7 @@ export function TimelineCursor() {
209218
updateCursorPosition(
210219
timelineState.currentTime,
211220
timelineState.timelineStartHour || 0,
212-
timelineState.timelineEndHour || 24
221+
timelineState.timelineEndHour || getTimelineDayLengthHours(timelineState.selectedDate)
213222
);
214223
return true;
215224
}
@@ -219,7 +228,11 @@ export function TimelineCursor() {
219228
timelineState.currentSegmentIndex = 0;
220229
timelineState.setState({});
221230
setVisible(true);
222-
updateCursorPosition(t, timelineState.timelineStartHour || 0, timelineState.timelineEndHour || 24);
231+
updateCursorPosition(
232+
t,
233+
timelineState.timelineStartHour || 0,
234+
timelineState.timelineEndHour || getTimelineDayLengthHours(timelineState.selectedDate)
235+
);
223236
return true;
224237
}
225238
return false;

web/js/components/preact/timeline/TimelinePage.jsx

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,36 +22,24 @@ import {
2222
getAvailableDatesForSegments,
2323
getClippedSegmentHourRange,
2424
getLocalDayBounds,
25+
getTimelineDayLengthHours,
26+
localClockTimeToTimestamp,
2527
panTimelineRange,
28+
timelineOffsetToTimestamp,
29+
timestampToTimelineOffset,
2630
zoomTimelineRange
2731
} from './timelineUtils.js';
2832

2933
const RECORDINGS_RETURN_URL_KEY = 'lightnvr_recordings_return_url';
3034

31-
// Convert fractional hour (0–24) → Unix timestamp (seconds) for the given date
35+
// Convert an elapsed timeline offset → Unix timestamp (seconds) for the selected local day.
3236
function timelineHourToTimestamp(hour, selectedDate) {
33-
const numericHour = Number(hour);
34-
if (!Number.isFinite(numericHour)) {
35-
throw new Error(`timelineHourToTimestamp: invalid hour value "${hour}"`);
36-
}
37-
38-
const normalizedHour = Math.min(24, Math.max(0, numericHour));
39-
40-
let date;
41-
if (selectedDate && typeof selectedDate === 'string' && selectedDate.includes('-')) {
42-
const [year, month, day] = selectedDate.split('-').map(Number);
43-
date = new Date(year, month - 1, day, 0, 0, 0, 0);
44-
} else {
45-
date = new Date();
46-
date.setHours(0, 0, 0, 0);
47-
}
48-
return Math.floor((date.getTime() + normalizedHour * 3600000) / 1000);
37+
return timelineOffsetToTimestamp(hour, selectedDate);
4938
}
5039

51-
// Utility function to convert timestamp to timeline hour
52-
function timestampToTimelineHour(timestamp) {
53-
const date = new Date(timestamp * 1000);
54-
return date.getHours() + (date.getMinutes() / 60) + (date.getSeconds() / 3600);
40+
// Convert a Unix timestamp to an elapsed timeline offset for the selected local day.
41+
function timestampToTimelineHour(timestamp, selectedDate = null) {
42+
return timestampToTimelineOffset(timestamp, selectedDate);
5543
}
5644

5745
// Global timeline state for child components
@@ -264,7 +252,7 @@ export function TimelinePage() {
264252

265253
const handleWheel = (event) => {
266254
const startHour = timelineState.timelineStartHour ?? 0;
267-
const endHour = timelineState.timelineEndHour ?? 24;
255+
const endHour = timelineState.timelineEndHour ?? getTimelineDayLengthHours(timelineState.selectedDate);
268256
const currentRange = endHour - startHour;
269257
if (currentRange <= 0) {
270258
return;
@@ -280,7 +268,7 @@ export function TimelinePage() {
280268
const pointerRatio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
281269
const anchorHour = startHour + (pointerRatio * currentRange);
282270
const zoomFactor = event.deltaY < 0 ? 0.8 : 1.25;
283-
const nextRange = zoomTimelineRange(startHour, endHour, zoomFactor, anchorHour);
271+
const nextRange = zoomTimelineRange(startHour, endHour, zoomFactor, anchorHour, getTimelineDayLengthHours(timelineState.selectedDate));
284272
timelineState.setState({
285273
timelineStartHour: nextRange.startHour,
286274
timelineEndHour: nextRange.endHour
@@ -295,7 +283,7 @@ export function TimelinePage() {
295283
if (horizontalDelta !== 0) {
296284
event.preventDefault();
297285
const deltaHours = (horizontalDelta / rect.width) * currentRange;
298-
const nextRange = panTimelineRange(startHour, endHour, deltaHours);
286+
const nextRange = panTimelineRange(startHour, endHour, deltaHours, getTimelineDayLengthHours(timelineState.selectedDate));
299287
timelineState.setState({
300288
timelineStartHour: nextRange.startHour,
301289
timelineEndHour: nextRange.endHour
@@ -360,10 +348,11 @@ export function TimelinePage() {
360348
setSegments(segmentsCopy);
361349

362350
const dayBounds = getLocalDayBounds(effectiveDate);
351+
const dayLengthHours = getTimelineDayLengthHours(effectiveDate);
363352
const visibleIndices = [];
364353

365354
// Compute auto-fit range from segments for the selected day
366-
let earliest = 24;
355+
let earliest = dayLengthHours;
367356
let latest = 0;
368357
segmentsCopy.forEach((seg, index) => {
369358
const visibleRange = getClippedSegmentHourRange(seg, effectiveDate);
@@ -382,9 +371,9 @@ export function TimelinePage() {
382371
isPlaying: false,
383372
selectedDate: effectiveDate,
384373
timelineStartHour: 0,
385-
timelineEndHour: 24,
374+
timelineEndHour: dayLengthHours,
386375
autoFitStartHour: 0,
387-
autoFitEndHour: 24
376+
autoFitEndHour: dayLengthHours
388377
});
389378
return false;
390379
}
@@ -428,10 +417,8 @@ export function TimelinePage() {
428417
}
429418

430419
if (initialSegmentIndex === -1 && initialTimeRef.current) {
431-
const timeParts = initialTimeRef.current.match(/^(\d{2}):(\d{2}):(\d{2})$/);
432-
if (timeParts) {
433-
const [, h, m, s] = timeParts.map(Number);
434-
const seekTs = timelineHourToTimestamp(h + m / 60 + s / 3600, effectiveDate);
420+
const seekTs = localClockTimeToTimestamp(initialTimeRef.current, effectiveDate);
421+
if (seekTs !== null) {
435422

436423
const containingIndex = findContainingSegmentIndex(segmentsCopy, seekTs);
437424
if (containingIndex !== -1 && getClippedSegmentHourRange(segmentsCopy[containingIndex], effectiveDate)) {
@@ -460,16 +447,16 @@ export function TimelinePage() {
460447
}
461448

462449
let fitStart = 0;
463-
let fitEnd = 24;
464-
if (earliest < 24 && latest > 0) {
450+
let fitEnd = dayLengthHours;
451+
if (earliest < dayLengthHours && latest > 0) {
465452
const span = latest - earliest;
466453
const pad = Math.max(0.5, Math.min(1, span * 0.1));
467454
fitStart = Math.max(0, Math.floor((earliest - pad) * 2) / 2);
468-
fitEnd = Math.min(24, Math.ceil((latest + pad) * 2) / 2);
455+
fitEnd = Math.min(dayLengthHours, Math.ceil((latest + pad) * 2) / 2);
469456
if (fitEnd - fitStart < 2) {
470457
const center = (earliest + latest) / 2;
471458
fitStart = Math.max(0, Math.floor((center - 1) * 2) / 2);
472-
fitEnd = Math.min(24, fitStart + 2);
459+
fitEnd = Math.min(dayLengthHours, fitStart + 2);
473460
}
474461
}
475462

0 commit comments

Comments
 (0)