Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
6 changes: 3 additions & 3 deletions backend/tests/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner

<Badge type="warning" text="Unreleased" /> <Badge type="tip" text="Feature release" />

**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
Expand All @@ -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.
Expand Down
26 changes: 17 additions & 9 deletions frontend/src/lib/components/BarcodeScanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -429,15 +429,23 @@
</div>
{/if}
{#if cameras.length > 1}
<button
class="btn btn-outline btn-sm gap-2"
onclick={() => void switchCamera()}
disabled={starting}
aria-label={$_('scanner.switchCamera')}
>
<RefreshCw class="w-4 h-4" />
{$_('scanner.switchCamera')}
</button>
<div class="flex items-center gap-2">
<span
class="badge badge-ghost badge-sm max-w-40 truncate"
title={$_('scanner.currentCamera', { values: { camera: cameras[cameraIndex]?.label ?? '' } })}
>
{cameras[cameraIndex]?.label}
</span>
<button
class="btn btn-outline btn-sm gap-2"
onclick={() => void switchCamera()}
disabled={starting}
aria-label={$_('scanner.switchCamera')}
>
<RefreshCw class="w-4 h-4" />
{$_('scanner.switchCamera')}
</button>
</div>
{/if}
</div>
{/if}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/components/BarcodeScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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.
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/lib/components/BookDetailDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -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));
Expand Down
37 changes: 36 additions & 1 deletion frontend/src/lib/components/BookDetailDialog.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -10,6 +11,7 @@ vi.mock('svelte-chartjs', () => ({

const mockProgressList = vi.fn(async (_bookId: number): Promise<ReadingProgressEntry[]> => []);
const mockProgressCreate = vi.fn(async (_bookId: number, _page: number): Promise<ReadingProgressEntry> => ({ 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<ReadingProgressEntry> => ({ 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<Book>) => ({ ...mockBook, ..._data, id: _id }));
Expand All @@ -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)
Expand Down Expand Up @@ -63,10 +66,12 @@ const mockBook = {
describe('BookDetailDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
setTimezone('UTC');
});

afterEach(() => {
cleanup();
setTimezone('UTC');
});

it('does not render when closed', () => {
Expand Down Expand Up @@ -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' });
});
});
});
63 changes: 60 additions & 3 deletions frontend/src/lib/date.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/lib/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@
"startError": "无法启动条码扫描器。请检查摄像头权限。",
"noCamera": "未找到摄像头设备。",
"switchCamera": "切换摄像头",
"currentCamera": "当前摄像头:{camera}",
"zoom": "缩放",
"zoomLevel": "缩放 {zoom}x",
"close": "关闭扫描器",
Expand Down