From 646cce22b767805738c7276d207443f18259cc7e Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 8 Sep 2026 11:54:36 +0200 Subject: [PATCH 1/4] Lower telemetry ping from every 24h to every 23h to avoid day misses due to delays --- backend/app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index fa734698..e42c91ef 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,7 +18,7 @@ from app.services.data_import import cleanup_temp_files from app.services.telemetry import send_telemetry_once -_TELEMETRY_INTERVAL_SECONDS = 24 * 3600 +_TELEMETRY_INTERVAL_SECONDS = 23 * 3600 logger = logging.getLogger(__name__) From 59bdbb4489df21fcfdb6bb9a7146ba51e622aac5 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 8 Sep 2026 12:05:28 +0200 Subject: [PATCH 2/4] Add current camera name label to scanner view --- .../src/lib/components/BarcodeScanner.svelte | 26 ++++++++++++------- .../src/lib/components/BarcodeScanner.test.ts | 4 +++ frontend/src/lib/i18n/locales/de.json | 1 + frontend/src/lib/i18n/locales/en.json | 1 + frontend/src/lib/i18n/locales/es.json | 1 + frontend/src/lib/i18n/locales/fr.json | 1 + frontend/src/lib/i18n/locales/zh.json | 1 + 7 files changed, 26 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/BarcodeScanner.svelte b/frontend/src/lib/components/BarcodeScanner.svelte index 6ec53386..609d1ccd 100644 --- a/frontend/src/lib/components/BarcodeScanner.svelte +++ b/frontend/src/lib/components/BarcodeScanner.svelte @@ -429,15 +429,23 @@ {/if} {#if cameras.length > 1} - +
+ + {cameras[cameraIndex]?.label} + + +
{/if} {/if} diff --git a/frontend/src/lib/components/BarcodeScanner.test.ts b/frontend/src/lib/components/BarcodeScanner.test.ts index ac2ee0d6..c2c2dec7 100644 --- a/frontend/src/lib/components/BarcodeScanner.test.ts +++ b/frontend/src/lib/components/BarcodeScanner.test.ts @@ -195,6 +195,8 @@ describe('BarcodeScanner', () => { ); const switchBtn = await screen.findByRole('button', { name: /switch camera/i }); + // The current camera name is shown in a badge next to the switch button. + expect(screen.getByText('Rear Camera')).toBeInTheDocument(); await fireEvent.click(switchBtn); await waitFor(() => { @@ -205,6 +207,8 @@ describe('BarcodeScanner', () => { video: expect.objectContaining({ deviceId: { exact: 'cam-macro' } }) }) ); + // The badge follows the newly selected camera. + expect(screen.getByText('Macro Camera')).toBeInTheDocument(); // The previous stream's track must be stopped before requesting the new one. expect(streams[0].getTracks()[0].stop).toHaveBeenCalled(); // The chosen camera is remembered for the next session. diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index e333e90b..12149b04 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -244,6 +244,7 @@ "startError": "Barcode-Scanner konnte nicht gestartet werden. Prüfe die Kameraberechtigungen.", "noCamera": "Kein Kameragerät gefunden.", "switchCamera": "Kamera wechseln", + "currentCamera": "Aktuelle Kamera: {camera}", "zoom": "Zoom", "zoomLevel": "Zoom {zoom}x", "close": "Scanner schließen", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index a859dbb7..81634b1c 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -244,6 +244,7 @@ "startError": "Unable to start barcode scanner. Check camera permissions.", "noCamera": "No camera device found.", "switchCamera": "Switch camera", + "currentCamera": "Current camera: {camera}", "zoom": "Zoom", "zoomLevel": "Zoom {zoom}x", "close": "Close scanner", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index c70bba2c..2c0471c7 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -244,6 +244,7 @@ "startError": "No se pudo iniciar el escáner. Comprueba los permisos de la cámara.", "noCamera": "No se encontró ningún dispositivo de cámara.", "switchCamera": "Cambiar cámara", + "currentCamera": "Cámara actual: {camera}", "zoom": "Zoom", "zoomLevel": "Zoom {zoom}x", "close": "Cerrar escáner", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 47501d84..18b6de07 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -244,6 +244,7 @@ "startError": "Impossible de démarrer le scanner. Vérifie les autorisations de la caméra.", "noCamera": "Aucun appareil photo trouvé.", "switchCamera": "Changer de caméra", + "currentCamera": "Caméra actuelle : {camera}", "zoom": "Zoom", "zoomLevel": "Zoom {zoom}x", "close": "Fermer le scanner", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index ae4aa89c..d4b35f27 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -244,6 +244,7 @@ "startError": "无法启动条码扫描器。请检查摄像头权限。", "noCamera": "未找到摄像头设备。", "switchCamera": "切换摄像头", + "currentCamera": "当前摄像头:{camera}", "zoom": "缩放", "zoomLevel": "缩放 {zoom}x", "close": "关闭扫描器", From 692a0825f14a15b6669f0ec4775024e4eeaa0db7 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 8 Sep 2026 13:25:44 +0200 Subject: [PATCH 3/4] Fix progress date editing in configured timezone --- docs/releases.md | 4 +- .../lib/components/BookDetailDialog.svelte | 19 ++++-- .../lib/components/BookDetailDialog.test.ts | 37 ++++++++++- frontend/src/lib/date.test.ts | 63 ++++++++++++++++++- frontend/src/lib/date.ts | 15 +++++ 5 files changed, 127 insertions(+), 11 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 97d1bda6..b74d234a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,7 +39,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner -**Summary:** Groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, detects insecure camera contexts, and fixes timezone handling in the daily page statistics. +**Summary:** Groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. **Features** - 📚 **Edition groups in the import search**: results from different providers that describe the same book (same ISBN, or same title and authors) are now grouped into expandable entries with an "N results" badge. Compare the variants side by side and import the one you want; no result is dropped anymore. See the [Library guide](/guide/using-librislog/library#how-results-are-grouped) for the exact grouping rules @@ -48,10 +48,12 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 📅 **Adaptive date input**: date fields in the book form now use a segmented year/month/day input that no longer assumes the month or day after the first keystroke, validates values as you type, and accepts pasting a complete date. A calendar button next to the field opens the native date picker - 🌐 **Searchable timezone picker**: the timezone setting on the profile page is now a searchable dropdown covering all IANA timezones - 📷 **Secure context detection in the barcode scanner**: if LibrisLog is served outside a secure context (plain HTTP on a remote host), the scan button is hidden and the scanner explains why the camera cannot start, instead of showing a black box. See the [Library guide](/guide/using-librislog/library#isbn-barcode-scan) for details +- 🎥 **Active camera name in the scanner**: the barcode scanner now shows the name of the active camera in a badge next to the switch button, so you always know which lens is being used - 🔗 **Heimdall dashboard integration**: new documentation for the LibrisLog enhanced app, which shows your reading statistics directly on [Heimdall](https://github.com/linuxserver/Heimdall) tiles **Bug fixes** - 🗓️ **Timezone-correct daily page statistics**: pages read between two progress updates are now attributed to calendar days in the user's timezone instead of fixed 24h slots, so the pages-per-day view matches your local days. Your heatmap may shift slightly after the upgrade +- 🕐 **Timezone-aware progress date editing**: editing a progress entry's date in the book detail view now interprets the value in your profile timezone instead of the browser's, so entries stay on the correct calendar day and streaks remain accurate - 🏷️ **Better contrast for selected suggestion items**: the selected entry in tag and author suggestion dropdowns now has stronger contrast and a visible border in all themes **Breaking changes:** None. diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index d858f5b6..fd53410f 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -2,7 +2,12 @@ import type { Book, ReadingProgressEntry } from '$lib/types'; import { _ } from '$lib/i18n'; import { locale } from '$lib/i18n'; - import { formatDate, formatDateTime } from '$lib/date'; + import { + formatDate, + formatDateTime, + fromDateTimeInputValue, + toDateTimeInputValue + } from '$lib/date'; import { getTimezone } from '$lib/stores/timezone'; import { api } from '$lib/api'; import { toasts } from '$lib/toasts'; @@ -170,9 +175,7 @@ function startEditEntry(entry: ReadingProgressEntry) { editingEntryId = entry.id; - const d = new Date(entry.created_at); - const pad = (n: number) => n.toString().padStart(2, '0'); - editingDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + editingDate = toDateTimeInputValue(entry.created_at, tz); } function cancelEditEntry() { @@ -182,8 +185,12 @@ async function saveEditEntry(entry: ReadingProgressEntry) { if (!editingDate) return; - const d = new Date(editingDate); - const created_at = d.toISOString(); + const created_at = fromDateTimeInputValue(editingDate, tz); + if (!created_at || created_at === entry.created_at) { + editingEntryId = null; + editingDate = ''; + return; + } try { const updated = await api.books.progress.update(entry.book_id, entry.id, { created_at }); progressEntries = progressEntries.map((e) => (e.id === entry.id ? { ...e, created_at: updated.created_at } : e)); diff --git a/frontend/src/lib/components/BookDetailDialog.test.ts b/frontend/src/lib/components/BookDetailDialog.test.ts index 931da2b7..ca10875f 100644 --- a/frontend/src/lib/components/BookDetailDialog.test.ts +++ b/frontend/src/lib/components/BookDetailDialog.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/svelte'; import { writable } from 'svelte/store'; import BookDetailDialog from './BookDetailDialog.svelte'; +import { setTimezone } from '$lib/stores/timezone'; import type { Book, ReadingProgressEntry } from '$lib/types'; vi.mock('svelte-chartjs', () => ({ @@ -10,6 +11,7 @@ vi.mock('svelte-chartjs', () => ({ const mockProgressList = vi.fn(async (_bookId: number): Promise => []); const mockProgressCreate = vi.fn(async (_bookId: number, _page: number): Promise => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); +const mockProgressUpdate = vi.fn(async (_bookId: number, _entryId: number, _data: { created_at: string }): Promise => ({ id: _entryId, book_id: _bookId, page: 150, created_at: _data.created_at, updated_at: _data.created_at })); const mockProgressDelete = vi.fn(async (_bookId: number, _entryId: number) => {}); const mockBooksDelete = vi.fn(async (_id: number) => {}); const mockBooksUpdate = vi.fn(async (_id: number, _data: Partial) => ({ ...mockBook, ..._data, id: _id })); @@ -22,6 +24,7 @@ vi.mock('$lib/api', () => ({ progress: { list: (bookId: number) => mockProgressList(bookId), create: (bookId: number, page: number) => mockProgressCreate(bookId, page), + update: (bookId: number, entryId: number, data: { created_at: string }) => mockProgressUpdate(bookId, entryId, data), delete: (bookId: number, entryId: number) => mockProgressDelete(bookId, entryId) }, delete: (id: number) => mockBooksDelete(id) @@ -63,10 +66,12 @@ const mockBook = { describe('BookDetailDialog', () => { beforeEach(() => { vi.clearAllMocks(); + setTimezone('UTC'); }); afterEach(() => { cleanup(); + setTimezone('UTC'); }); it('does not render when closed', () => { @@ -274,4 +279,34 @@ describe('BookDetailDialog', () => { await fireEvent.input(input, { target: { value: '-10' } }); expect(input.value).toBe('0'); }); + + it('edits progress entry dates in the configured timezone', async () => { + // Use a timezone well ahead of UTC so any browser-local interpretation + // would shift the day. + setTimezone('Asia/Tokyo'); + mockProgressList.mockResolvedValue([ + { id: 1, book_id: 1, page: 150, created_at: '2026-09-08T15:00:00.000Z', updated_at: '2026-09-08T15:00:00.000Z' } + ]); + render(BookDetailDialog, { props: { book: mockBook, open: true } }); + + await waitFor(() => expect(mockProgressList).toHaveBeenCalled()); + await fireEvent.click(screen.getByRole('button', { name: 'Progress Log' })); + const logDialog = await screen.findByRole('dialog', { name: 'Progress Log' }); + expect(logDialog).toBeInTheDocument(); + + await fireEvent.click(within(logDialog).getByRole('button', { name: 'Edit' })); + + const input = within(logDialog).getByDisplayValue('2026-09-09T00:00') as HTMLInputElement; + expect(input).toBeInTheDocument(); + + // Shift by one minute and save. The new UTC instant must map back to + // the same profile-timezone minute, proving the edit uses tz, not + // browser-local time. + await fireEvent.input(input, { target: { value: '2026-09-09T00:01' } }); + await fireEvent.click(within(logDialog).getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mockProgressUpdate).toHaveBeenCalledWith(1, 1, { created_at: '2026-09-08T15:01:00.000Z' }); + }); + }); }); diff --git a/frontend/src/lib/date.test.ts b/frontend/src/lib/date.test.ts index 0144c3f6..b2a02fb7 100644 --- a/frontend/src/lib/date.test.ts +++ b/frontend/src/lib/date.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { toDateInputValue, fromDateInputValue, formatDate, formatDateTime, today } from './date'; +import { + toDateInputValue, + fromDateInputValue, + formatDate, + formatDateTime, + toDateTimeInputValue, + fromDateTimeInputValue, + today +} from './date'; describe('toDateInputValue', () => { it('converts UTC ISO to YYYY-MM-DD in UTC', () => { @@ -52,8 +60,9 @@ describe('fromDateInputValue', () => { expect(result).toBe('2026-05-16T00:00:00.000Z'); }); - // Note: line 19 (isValid check) is unreachable because dayjs.tz() throws - // for invalid input before isValid() can be called. + it('returns null for invalid input', () => { + expect(fromDateInputValue('garbage', 'UTC')).toBeNull(); + }); }); describe('formatDate', () => { @@ -84,6 +93,54 @@ describe('formatDateTime', () => { }); }); +describe('toDateTimeInputValue', () => { + it('converts UTC ISO to datetime-local value in UTC', () => { + expect(toDateTimeInputValue('2026-05-16T14:30:00.000Z', 'UTC')).toBe('2026-05-16T14:30'); + }); + + it('converts UTC ISO to datetime-local value in Europe/Berlin', () => { + expect(toDateTimeInputValue('2026-05-16T14:30:00.000Z', 'Europe/Berlin')).toBe('2026-05-16T16:30'); + }); + + it('converts UTC ISO to datetime-local value in America/New_York', () => { + expect(toDateTimeInputValue('2026-05-16T04:30:00.000Z', 'America/New_York')).toBe('2026-05-16T00:30'); + }); + + it('returns empty string for null', () => { + expect(toDateTimeInputValue(null, 'UTC')).toBe(''); + }); + + it('returns empty string for invalid date', () => { + expect(toDateTimeInputValue('invalid', 'UTC')).toBe(''); + }); +}); + +describe('fromDateTimeInputValue', () => { + it('converts datetime-local value to UTC ISO for UTC timezone', () => { + expect(fromDateTimeInputValue('2026-05-16T14:30', 'UTC')).toBe('2026-05-16T14:30:00.000Z'); + }); + + it('converts datetime-local value to UTC ISO for Europe/Berlin', () => { + expect(fromDateTimeInputValue('2026-05-16T14:30', 'Europe/Berlin')).toBe('2026-05-16T12:30:00.000Z'); + }); + + it('converts datetime-local value to UTC ISO for America/New_York', () => { + expect(fromDateTimeInputValue('2026-05-16T00:30', 'America/New_York')).toBe('2026-05-16T04:30:00.000Z'); + }); + + it('returns null for empty string', () => { + expect(fromDateTimeInputValue('', 'UTC')).toBeNull(); + }); + + it('trims whitespace', () => { + expect(fromDateTimeInputValue(' 2026-05-16T14:30 ', 'UTC')).toBe('2026-05-16T14:30:00.000Z'); + }); + + it('returns null for invalid input', () => { + expect(fromDateTimeInputValue('garbage', 'UTC')).toBeNull(); + }); +}); + describe('today', () => { it('returns YYYY-MM-DD format', () => { const result = today('UTC'); diff --git a/frontend/src/lib/date.ts b/frontend/src/lib/date.ts index 570cbf86..360e44f6 100644 --- a/frontend/src/lib/date.ts +++ b/frontend/src/lib/date.ts @@ -31,6 +31,21 @@ export function formatDateTime(value: string | null | undefined, timeZone: strin return d.tz(timeZone).format('YYYY-MM-DD HH:mm'); } +export function toDateTimeInputValue(value: string | null | undefined, timeZone: string): string { + if (!value) return ''; + const d = dayjs(value); + if (!d.isValid()) return ''; + return d.tz(timeZone).format('YYYY-MM-DDTHH:mm'); +} + +export function fromDateTimeInputValue(value: string, timeZone: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const d = dayjs.tz(trimmed, timeZone); + if (!d.isValid()) return null; + return d.toISOString(); +} + export function today(timeZone: string): string { return dayjs().tz(timeZone).format('YYYY-MM-DD'); } From b9021a437e793f27cf625ca5c5b56b35c204b571 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 8 Sep 2026 14:39:31 +0200 Subject: [PATCH 4/4] Fix tele interval test case --- backend/tests/test_telemetry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_telemetry.py b/backend/tests/test_telemetry.py index aa1a9143..98aa461c 100644 --- a/backend/tests/test_telemetry.py +++ b/backend/tests/test_telemetry.py @@ -410,8 +410,8 @@ async def test_database_failure_is_swallowed(monkeypatch) -> None: @pytest.mark.anyio -async def test_heartbeat_sends_then_waits_24h() -> None: - """The heartbeat sends on start, then sleeps 24h and keeps going on failure.""" +async def test_heartbeat_sends_then_waits_interval() -> None: + """The heartbeat sends on start, then sleeps the configured interval and keeps going on failure.""" import app.main as main_module calls = 0 @@ -428,7 +428,7 @@ async def failing_send() -> None: await main_module._telemetry_heartbeat() assert calls == 2 - assert mock_sleep.call_args_list[0].args[0] == 24 * 3600 + assert mock_sleep.call_args_list[0].args[0] == main_module._TELEMETRY_INTERVAL_SECONDS @pytest.mark.anyio