From 0e9ceb9569d7a7082396e92fbee1cba2dcf9c1a5 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 4 Sep 2026 23:13:38 +0200 Subject: [PATCH 01/50] Fix calendar-day splits in daily page statistics (instead of 24h slots) --- backend/app/routers/statistics.py | 27 ++++++++++++++++----------- backend/tests/test_statistics.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 900f25a0..1f33f345 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -130,17 +130,22 @@ def _extract_progress_daily_pages( book_entries.sort(key=lambda e: (e.created_at, e.page)) for prev, curr in zip(book_entries, book_entries[1:]): delta = curr.page - prev.page - if delta > 0: - day_diff = (curr.created_at - prev.created_at).days + 1 - if day_diff > 0: - daily_avg = delta / day_diff - start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) - if start is None or end is None: - continue - while start <= end: - date_key = start.astimezone(tz).strftime("%Y-%m-%d") - daily[date_key] += daily_avg - start += timedelta(days=1) + if delta <= 0: + continue + prev_day = prev.created_at.astimezone(tz).date() + curr_day = curr.created_at.astimezone(tz).date() + day_diff = (curr_day - prev_day).days + 1 + if day_diff <= 0: + continue + daily_avg = delta / day_diff + start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) + if start is None or end is None: + continue + day = start.astimezone(tz).date() + last = end.astimezone(tz).date() + while day <= last: + daily[day.isoformat()] += daily_avg + day += timedelta(days=1) return daily diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index f44a9769..5c1b4589 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -630,6 +630,30 @@ def test_extract_progress_daily_pages_skips_outside_window() -> None: assert result == {} +def test_extract_progress_daily_pages_splits_delta_across_calendar_days() -> None: + """A delta spanning two calendar days must be split, even when the span is <24h.""" + from app.routers.statistics import _extract_progress_daily_pages + + entries = [ + SimpleNamespace(book_id=1, page=202, created_at=datetime(2026, 9, 2, 21, 16, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=320, created_at=datetime(2026, 9, 3, 20, 54, tzinfo=timezone.utc)), + ] + result = _extract_progress_daily_pages(entries, ZoneInfo("Europe/Berlin")) + assert result == {"2026-09-02": 59.0, "2026-09-03": 59.0} + + +def test_extract_progress_daily_pages_keeps_last_day_of_partial_span() -> None: + """The final calendar day must not be dropped when prev is later in the day than curr.""" + from app.routers.statistics import _extract_progress_daily_pages + + entries = [ + SimpleNamespace(book_id=1, page=10, created_at=datetime(2026, 5, 1, 23, 0, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=30, created_at=datetime(2026, 5, 2, 22, 0, tzinfo=timezone.utc)), + ] + result = _extract_progress_daily_pages(entries, ZoneInfo("UTC")) + assert result == {"2026-05-01": 10.0, "2026-05-02": 10.0} + + def test_extract_book_level_daily_pages_skips_outside_window() -> None: from app.routers.statistics import _extract_book_level_daily_pages From 73fdf1218d66a5635fbf769454205dc757a16680 Mon Sep 17 00:00:00 2001 From: codebude Date: Sat, 5 Sep 2026 00:10:38 +0200 Subject: [PATCH 02/50] Fixed user timezone input and improved usability --- .../lib/components/SearchableSelect.svelte | 237 ++++++++++++++++++ .../lib/components/SearchableSelect.test.ts | 93 +++++++ 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 + frontend/src/routes/profile/+page.svelte | 16 +- 8 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/components/SearchableSelect.svelte create mode 100644 frontend/src/lib/components/SearchableSelect.test.ts diff --git a/frontend/src/lib/components/SearchableSelect.svelte b/frontend/src/lib/components/SearchableSelect.svelte new file mode 100644 index 00000000..39ad2f5e --- /dev/null +++ b/frontend/src/lib/components/SearchableSelect.svelte @@ -0,0 +1,237 @@ + + +
+ + + {#if isOpen} +
+
+
+ + +
+
+
    + {#if filtered.length === 0} +
  • {noResultsText}
  • + {:else} + {#each filtered as option, i (option)} +
  • selectOption(option)} + onmouseenter={() => (highlightedIndex = i)} + > + {option} +
  • + {/each} + {/if} +
+
+ {/if} +
\ No newline at end of file diff --git a/frontend/src/lib/components/SearchableSelect.test.ts b/frontend/src/lib/components/SearchableSelect.test.ts new file mode 100644 index 00000000..839d1049 --- /dev/null +++ b/frontend/src/lib/components/SearchableSelect.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/svelte'; +import SearchableSelect from './SearchableSelect.svelte'; + +describe('SearchableSelect', () => { + const options = ['Europe/Berlin', 'Europe/London', 'Asia/Tokyo']; + + afterEach(() => { + cleanup(); + }); + + it('renders the current value on the trigger', () => { + render(SearchableSelect, { props: { value: 'Europe/Berlin', options, ariaLabel: 'timezone' } }); + expect(screen.getByRole('button', { name: 'timezone' })).toHaveTextContent('Europe/Berlin'); + }); + + it('shows the placeholder when no value is selected', () => { + render(SearchableSelect, { + props: { value: '', options, placeholder: 'Pick one', ariaLabel: 'timezone' }, + }); + expect(screen.getByRole('button', { name: 'timezone' })).toHaveTextContent('Pick one'); + }); + + it('opens the dropdown on click and lists all options', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.click(screen.getByRole('button', { name: 'timezone' })); + expect(screen.getAllByRole('option')).toHaveLength(3); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('filters options while typing', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.click(screen.getByRole('button', { name: 'timezone' })); + + const search = screen.getByRole('combobox'); + await fireEvent.input(search, { target: { value: 'Lon' } }); + + const remaining = screen.getAllByRole('option'); + expect(remaining).toHaveLength(1); + expect(remaining[0]).toHaveTextContent('Europe/London'); + }); + + it('selects an option on mousedown and closes the dropdown', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.click(screen.getByRole('button', { name: 'timezone' })); + + await fireEvent.mouseDown(screen.getByText('Europe/London')); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'timezone' })).toHaveTextContent('Europe/London'); + }); + + it('closes the dropdown on Escape', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.click(screen.getByRole('button', { name: 'timezone' })); + + await fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('closes the dropdown when clicking outside', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.click(screen.getByRole('button', { name: 'timezone' })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + await fireEvent.click(document.body); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('selects the highlighted option with ArrowDown and Enter', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.click(screen.getByRole('button', { name: 'timezone' })); + + const search = screen.getByRole('combobox'); + await fireEvent.keyDown(search, { key: 'ArrowDown' }); + await fireEvent.keyDown(search, { key: 'Enter' }); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'timezone' })).toHaveTextContent('Europe/Berlin'); + }); + + it('opens the dropdown and starts filtering when a character is typed', async () => { + render(SearchableSelect, { props: { value: '', options, ariaLabel: 'timezone' } }); + await fireEvent.keyDown(screen.getByRole('button', { name: 'timezone' }), { key: 'L' }); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(2); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Europe/Berlin'); + expect(screen.getAllByRole('option')[1]).toHaveTextContent('Europe/London'); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index c092f1fe..4b82a691 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -270,6 +270,7 @@ "themeCustom": "Anpassen", "themeSelect": "Wähle ein benutzerdefiniertes Design", "timezonePlaceholder": "Zeitzone suchen...", + "timezoneNoResults": "Keine passende Zeitzone", "apiDocsTitle": "API-Dokumentation", "apiDocsHelp": "Erkunde und teste Backend-Endpunkte direkt in der App.", "apiDocsViewLabel": "Ansicht", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index fdcf470d..306dfaaa 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -270,6 +270,7 @@ "themeCustom": "Customize", "themeSelect": "Select a custom theme", "timezonePlaceholder": "Search timezone...", + "timezoneNoResults": "No matching timezone", "apiDocsTitle": "API Documentation", "apiDocsHelp": "Explore and test backend endpoints directly from the app.", "apiDocsViewLabel": "View", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 90396b04..1b9d8a00 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -270,6 +270,7 @@ "themeCustom": "Personalizar", "themeSelect": "Selecciona un tema personalizado", "timezonePlaceholder": "Buscar zona horaria...", + "timezoneNoResults": "No hay ninguna zona horaria coincidente", "apiDocsTitle": "Documentación de la API", "apiDocsHelp": "Explora y prueba los endpoints del backend directamente desde la app.", "apiDocsViewLabel": "Ver", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 8f5fb132..60607a65 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -270,6 +270,7 @@ "themeCustom": "Personnaliser", "themeSelect": "Choisir un thème personnalisé", "timezonePlaceholder": "Rechercher un fuseau horaire...", + "timezoneNoResults": "Aucun fuseau horaire correspondant", "apiDocsTitle": "Documentation de l'API", "apiDocsHelp": "Explore et teste les points d'accès du backend directement depuis l'application.", "apiDocsViewLabel": "Voir", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index e2b49813..aeb25769 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -270,6 +270,7 @@ "themeCustom": "自定义", "themeSelect": "选择自定义主题", "timezonePlaceholder": "搜索时区...", + "timezoneNoResults": "没有匹配的时区", "apiDocsTitle": "API 文档", "apiDocsHelp": "直接在应用中探索和测试后端接口。", "apiDocsViewLabel": "查看", diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 083cdaad..df53091b 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -10,6 +10,7 @@ import { getTimezone, setTimezone, detectTimezone } from '$lib/stores/timezone'; import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, restoreFromPoint, saveRestorePoint, clearRestorePoint, DAISYUI_THEMES } from '$lib/stores/theme'; import Alert from '$lib/components/Alert.svelte'; + import SearchableSelect from '$lib/components/SearchableSelect.svelte'; import { toasts } from '$lib/toasts'; import { localizeError } from '$lib/errors'; import { toDateInputValue, today } from '$lib/date'; @@ -580,19 +581,14 @@ {timezoneMessage.text} {/if} - - - {#each allTimezones as tz} - - {/each} -

{$_('settings.timezoneDetected', { values: { tz: browserTz } })}

{$_('settings.timezoneSelected', { values: { tz: timezone } })}

From 12b8758eeee4bff84c3c053b71c5c7256bf89843 Mon Sep 17 00:00:00 2001 From: codebude Date: Sat, 5 Sep 2026 08:20:20 +0200 Subject: [PATCH 03/50] Improved visibility of selected item in suggestion input --- .../src/lib/components/SuggestionInput.svelte | 28 +++++++++---------- frontend/src/lib/components/TagInput.svelte | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index 0b412fb8..eef57dd7 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -112,7 +112,7 @@ const before = text.slice(0, idx); const match = text.slice(idx, idx + query.length); const after = text.slice(idx + query.length); - return `${before}${match}${after}`; + return `${before}${match}${after}`; } @@ -150,19 +150,19 @@ class="z-50 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-48 overflow-y-auto" style={dropdownStyle || 'position:absolute;left:0;right:0;margin-top:0.25rem'} > - {#each suggestions as suggestion, i} -
  • selectSuggestion(suggestion)} - onmouseenter={() => (highlightedIndex = i)} - > - {@html highlightMatch(suggestion, inputValue)} -
  • - {/each} + {#each suggestions as suggestion, i} +
  • selectSuggestion(suggestion)} + onmouseenter={() => (highlightedIndex = i)} + > + {@html highlightMatch(suggestion, inputValue)} +
  • + {/each} {/if} diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index ad3fe682..26cfb664 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -290,7 +290,7 @@ aria-selected={i === highlightedIndex} class="px-3 py-2 cursor-pointer text-sm" class:bg-base-200={i !== highlightedIndex} - style={i === highlightedIndex ? 'background-color: oklch(var(--p) / 0.1); color: oklch(var(--p));' : ''} + style={i === highlightedIndex ? 'background-color: oklch(var(--p) / 0.2); color: oklch(var(--p)); font-weight: 600; border: 1px solid oklch(var(--p) / 0.4);' : ''} onmousedown={() => selectSuggestion(suggestion)} onmouseenter={() => (highlightedIndex = i)} > From d61156adac59746824097c820544e3e707f23671 Mon Sep 17 00:00:00 2001 From: codebude Date: Sat, 5 Sep 2026 08:33:11 +0200 Subject: [PATCH 04/50] fix(#79): improve contrast and add visual border to selected dropdown items - Increase background opacity from 0.1 to 0.2 for selected items in TagInput and SuggestionInput - Add font-weight: 600 (semibold) for better text prominence - Add inset box-shadow (2px solid) as visual border indicator for accessibility - Use consistent color palette: primary opacity 0.6 for border, 0.2 for background This resolves GitHub issue #79 by making the selection indicator clearly visible while maintaining consistent styling across both dropdown components. --- frontend/src/lib/components/SuggestionInput.svelte | 2 +- frontend/src/lib/components/TagInput.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index eef57dd7..760c367e 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -156,7 +156,7 @@ aria-selected={i === highlightedIndex} class="px-3 py-2 cursor-pointer text-sm" class:bg-base-200={i !== highlightedIndex} - style={i === highlightedIndex ? 'background-color: oklch(var(--p) / 0.2); color: oklch(var(--p)); font-weight: 600; border: 1px solid oklch(var(--p) / 0.4);' : ''} + style={i === highlightedIndex ? 'background-color: oklch(var(--p) / 0.2); color: oklch(var(--p)); font-weight: 600; box-shadow: inset 0 0 0 2px oklch(var(--p) / 0.6);' : ''} onmousedown={() => selectSuggestion(suggestion)} onmouseenter={() => (highlightedIndex = i)} > diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 26cfb664..1b22d13a 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -290,7 +290,7 @@ aria-selected={i === highlightedIndex} class="px-3 py-2 cursor-pointer text-sm" class:bg-base-200={i !== highlightedIndex} - style={i === highlightedIndex ? 'background-color: oklch(var(--p) / 0.2); color: oklch(var(--p)); font-weight: 600; border: 1px solid oklch(var(--p) / 0.4);' : ''} + style={i === highlightedIndex ? 'background-color: oklch(var(--p) / 0.2); color: oklch(var(--p)); font-weight: 600; box-shadow: inset 0 0 0 2px oklch(var(--p) / 0.6);' : ''} onmousedown={() => selectSuggestion(suggestion)} onmouseenter={() => (highlightedIndex = i)} > From 875cdfd86112033acbd9dd02fb1aa9e6c2729801 Mon Sep 17 00:00:00 2001 From: codebude Date: Sat, 5 Sep 2026 09:11:51 +0200 Subject: [PATCH 05/50] Better border for selected tag/author items --- frontend/src/lib/components/SuggestionInput.svelte | 11 +++++++++-- frontend/src/lib/components/TagInput.svelte | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index 760c367e..19b01352 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -154,9 +154,16 @@
  • selectSuggestion(suggestion)} onmouseenter={() => (highlightedIndex = i)} > diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 1b22d13a..e7429d6b 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -288,9 +288,16 @@
  • selectSuggestion(suggestion)} onmouseenter={() => (highlightedIndex = i)} > From f2d5c7d1feb28ccc3440ebf696d8f6549a4bb3d0 Mon Sep 17 00:00:00 2001 From: codebude Date: Sat, 5 Sep 2026 12:47:16 +0200 Subject: [PATCH 06/50] Adaptive date input with additional validation --- .../lib/components/AdaptiveDateInput.svelte | 81 ++++ frontend/src/lib/components/BookDrawer.svelte | 40 +- .../src/lib/components/BookDrawer.test.ts | 33 +- .../lib/components/SegmentedDateInput.svelte | 415 ++++++++++++++++++ .../lib/components/SegmentedDateInput.test.ts | 130 ++++++ frontend/src/lib/i18n/locales/de.json | 3 +- frontend/src/lib/i18n/locales/en.json | 3 +- frontend/src/lib/i18n/locales/es.json | 3 +- frontend/src/lib/i18n/locales/fr.json | 3 +- frontend/src/lib/i18n/locales/zh.json | 3 +- frontend/src/routes/profile/+page.svelte | 41 +- 11 files changed, 738 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/components/AdaptiveDateInput.svelte create mode 100644 frontend/src/lib/components/SegmentedDateInput.svelte create mode 100644 frontend/src/lib/components/SegmentedDateInput.test.ts diff --git a/frontend/src/lib/components/AdaptiveDateInput.svelte b/frontend/src/lib/components/AdaptiveDateInput.svelte new file mode 100644 index 00000000..a1719e9c --- /dev/null +++ b/frontend/src/lib/components/AdaptiveDateInput.svelte @@ -0,0 +1,81 @@ + + +
    +
    + +
    + + + + +
    diff --git a/frontend/src/lib/components/BookDrawer.svelte b/frontend/src/lib/components/BookDrawer.svelte index 455d198a..acc2b4ad 100644 --- a/frontend/src/lib/components/BookDrawer.svelte +++ b/frontend/src/lib/components/BookDrawer.svelte @@ -10,6 +10,7 @@ import CoverPicker from './CoverPicker.svelte'; import SuggestionInput from './SuggestionInput.svelte'; import TagInput from './TagInput.svelte'; + import AdaptiveDateInput from './AdaptiveDateInput.svelte'; import DateConflictDialog from './DateConflictDialog.svelte'; import AutoSearchCoverModal from './AutoSearchCoverModal.svelte'; import BarcodeScanner from './BarcodeScanner.svelte'; @@ -61,6 +62,10 @@ let tags = $state(''); let date_started = $state(''); let date_finished = $state(''); + let dateStartedInvalid = $state(false); + let dateFinishedInvalid = $state(false); + let dateStartedHasInput = $state(false); + let dateFinishedHasInput = $state(false); let cover_url = $state(null); // ── Android back button: close drawer instead of navigating away ────────── @@ -216,6 +221,14 @@ toasts.add($_('error.pageCountRequired'), 'error'); return; } + if (dateStartedInvalid && dateStartedHasInput) { + toasts.add($_('error.invalidDate'), 'error'); + return; + } + if (dateFinishedInvalid && dateFinishedHasInput) { + toasts.add($_('error.invalidDate'), 'error'); + return; + } const ds = date_started.trim(); const df = date_finished.trim(); if (ds && df && ds > df) { @@ -501,12 +514,30 @@
  • {$_('profile.goals.title')}
  • {$_('user.apiKeys')}
  • {$_('user.embedTokens')}
  • +
  • {$_('profile.shareProfile.title')}
  • {$_('profile.dataManagement.title')}
  • {#if oidcConfig.enabled}
  • {$_('oidc.profileTitle')}
  • diff --git a/frontend/src/routes/statistics/page.test.ts b/frontend/src/routes/statistics/page.test.ts index 0503d774..0153e192 100644 --- a/frontend/src/routes/statistics/page.test.ts +++ b/frontend/src/routes/statistics/page.test.ts @@ -27,6 +27,7 @@ function createMockStats(overrides?: Partial): StatisticsRes language_distribution: [{ language: 'EN', count: 3 }], status_distribution: { want_to_read: 1, currently_reading: 0, read: 2, did_not_finish: 0 }, acquisition_status_distribution: { owned: 2, borrowed: 1, digital_access: 0, to_acquire: 1 }, + medium_distribution: [], page_buckets: { pages_to_read: 100, pages_read: 200, pages_wasted: 0 }, pages_read_per_month: [], books_finished_per_month: [], From 0dc71dc38a07008458e3acbe3dcdb11bb08a9b5b Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:26:00 +0200 Subject: [PATCH 26/50] Add copy and open actions for share links --- ...a7_add_raw_token_to_public_profile_link.py | 29 ++++++++++ backend/app/models.py | 1 + backend/app/routers/share_links.py | 16 ++++++ backend/app/schemas.py | 6 ++ backend/librislog.db | 0 backend/tests/test_public_profile.py | 57 ++++++++++++++++++- frontend/src/lib/api.ts | 7 +++ frontend/src/lib/types.ts | 4 ++ frontend/src/routes/profile/+page.svelte | 45 ++++++++++++++- 9 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py create mode 100644 backend/librislog.db diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py b/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py new file mode 100644 index 00000000..5e8c3584 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_raw_token_to_public_profile_link.py @@ -0,0 +1,29 @@ +"""add raw token to public_profile_link + +Revision ID: b2c3d4e5f6a7 +Revises: c9a4b7d8e3f1 +Create Date: 2026-09-10 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, Sequence[str], None] = "c9a4b7d8e3f1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.add_column(sa.Column("token", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("public_profile_link") as batch_op: + batch_op.drop_column("token") diff --git a/backend/app/models.py b/backend/app/models.py index b346f65b..419db6b0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -329,6 +329,7 @@ class PublicProfileLink(SQLModel, table=True): user_id: int = Field(foreign_key="user.id", index=True) name: str = Field(max_length=255) token_prefix: str = Field(index=True) + token: Optional[str] = Field(default=None, nullable=True) token_hash: str = Field(index=True, unique=True) audience: PublicProfileAudience = Field(default=PublicProfileAudience.public) visibility_config_json: str = Field( diff --git a/backend/app/routers/share_links.py b/backend/app/routers/share_links.py index e4f26fee..27d62c83 100644 --- a/backend/app/routers/share_links.py +++ b/backend/app/routers/share_links.py @@ -19,6 +19,7 @@ PublicProfileLinkRead, PublicProfileLinkUpdate, PublicProfileVisibilityConfig, + ShareLinkRevealResponse, ) from app.services.public_profile import ( parse_visibility_config, @@ -93,6 +94,7 @@ def create_share_link( user_id=current_user.id, name=body.name, token_prefix=get_public_profile_token_prefix(plain_token), + token=plain_token, token_hash=hash_public_profile_token(plain_token), audience=audience, visibility_config_json=serialize_visibility_config(body.visibility_config), @@ -107,6 +109,20 @@ def create_share_link( ) +@router.post("/{link_id}/reveal", response_model=ShareLinkRevealResponse) +def reveal_share_link( + link_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> ShareLinkRevealResponse: + """Return the raw token for a share link owned by the current user.""" + assert current_user.id is not None + link = _get_owned_link(link_id, current_user.id, session) + if not link.token: + raise HTTPException(status_code=404, detail="Token not available for legacy link") + return ShareLinkRevealResponse(token=link.token) + + @router.patch("/{link_id}", response_model=PublicProfileLinkRead) def update_share_link( link_id: int, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 133b2a25..08631ee4 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -848,6 +848,12 @@ class PublicProfileLinkCreateResponse(SQLModel): link: PublicProfileLinkRead +class ShareLinkRevealResponse(SQLModel): + """Response for the reveal endpoint, returning the raw token.""" + + token: str + + class PublicProfileUserInfo(SQLModel): """Public-safe owner identity shown on a public profile. diff --git a/backend/librislog.db b/backend/librislog.db new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_public_profile.py b/backend/tests/test_public_profile.py index ef7ea42a..52c85c79 100644 --- a/backend/tests/test_public_profile.py +++ b/backend/tests/test_public_profile.py @@ -404,4 +404,59 @@ def test_public_profile_rejects_past_expiry_on_update(client: Any) -> None: json={"expires_at": None}, ) assert resp.status_code == 200 - assert resp.json()["expires_at"] is None \ No newline at end of file + assert resp.json()["expires_at"] is None + + +def test_reveal_share_link_returns_raw_token(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + resp = client.post(f"/api/profile/share-links/{link_id}/reveal") + assert resp.status_code == 200 + body = resp.json() + assert body["token"] == data["token"] + assert body["token"].startswith("lp_") + + +def test_reveal_share_link_404_for_other_user( + client: Any, create_user_with_key: Any +) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + user_b, key_b = create_user_with_key(email="other@example.com") + resp = client.post( + f"/api/profile/share-links/{link_id}/reveal", + headers={"X-API-Key": key_b}, + ) + assert resp.status_code == 404 + + +def test_reveal_share_link_404_for_revoked(client: Any) -> None: + data = _create_share_link(client) + link_id = data["link"]["id"] + + # Revoke + client.delete(f"/api/profile/share-links/{link_id}") + + resp = client.post(f"/api/profile/share-links/{link_id}/reveal") + assert resp.status_code == 404 + + +def test_reveal_share_link_404_for_legacy_without_token(client: Any, session: Session) -> None: + """Legacy links with token=None cannot be revealed.""" + from app.auth import get_public_profile_token_prefix, hash_public_profile_token + from app.models import PublicProfileLink + + token_hash = hash_public_profile_token("lp_fake_legacy_token") + link = PublicProfileLink( + user_id=1, + name="Legacy", + token_prefix=get_public_profile_token_prefix("lp_fake_legacy_token"), + token_hash=token_hash, + ) + session.add(link) + session.commit() + session.refresh(link) + + resp = client.post(f"/api/profile/share-links/{link.id}/reveal") + assert resp.status_code == 404 \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 13f621c7..5f383f35 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,6 +46,7 @@ import type { PublicProfileLinkCreateResponse, PublicProfileResponse, PublicProfileVisibilityConfig, + ShareLinkRevealResponse, User, UserCreateResponse, UserAdminUpdate, @@ -283,6 +284,12 @@ export const api = { return request(`/profile/share-links/${id}`, { method: 'DELETE' }); }, + revealShareLink(id: number): Promise { + return request(`/profile/share-links/${id}/reveal`, { + method: 'POST' + }); + }, + resetData(confirmation: string): Promise { return request('/profile/reset-data', { method: 'POST', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ce13f92f..bb9f9fe5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -514,6 +514,10 @@ export interface PublicProfileLinkCreateResponse { link: PublicProfileLink; } +export interface ShareLinkRevealResponse { + token: string; +} + export interface PublicProfileUserInfo { firstname: string; lastname: string; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 59c71f95..5a6b045a 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -4,7 +4,7 @@ import { api } from '$lib/api'; import PasswordRequirements from '$lib/components/PasswordRequirements.svelte'; import { currentUser } from '$lib/stores/auth'; - import { Calendar, Info, Pencil, Trash2 } from '@lucide/svelte'; + import { Calendar, Check, Copy, ExternalLink, Info, Pencil, Trash2 } from '@lucide/svelte'; import { _, SUPPORTED_LOCALES, setLocale } from '$lib/i18n'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '$lib/password'; import { getTimezone, setTimezone, detectTimezone } from '$lib/stores/timezone'; @@ -448,6 +448,8 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; let shareTokenCopied = $state(false); let pendingDeleteShareLinkId = $state(null); let shareLinkMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let revealedTokens = $state>({}); + let copiedShareLinkId = $state(null); async function loadShareLinks() { shareLinks = await api.profile.listShareLinks(); @@ -516,6 +518,31 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; } } + async function ensureRevealedToken(link: PublicProfileLink): Promise { + if (revealedTokens[link.id]) return revealedTokens[link.id]; + try { + const result = await api.profile.revealShareLink(link.id); + revealedTokens = { ...revealedTokens, [link.id]: result.token }; + return result.token; + } catch { + return null; + } + } + + async function copyShareLinkUrl(link: PublicProfileLink) { + const token = await ensureRevealedToken(link); + if (!token) return; + await navigator.clipboard.writeText(publicShareUrl(token)); + copiedShareLinkId = link.id; + setTimeout(() => { copiedShareLinkId = null; }, 1500); + } + + async function openShareLinkUrl(link: PublicProfileLink) { + const token = await ensureRevealedToken(link); + if (!token) return; + window.open(publicShareUrl(token), '_blank', 'noopener'); + } + async function startOidcLink() { oidcMessage = null; try { @@ -1034,7 +1061,11 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte'; {$_('publicProfile.active')} {/if}

    -

    {link.token_prefix}...

    + {#if revealedTokens[link.id]} +

    {publicShareUrl(revealedTokens[link.id])}

    + {:else} +

    {link.token_prefix}...

    + {/if}

    {#if link.expires_at} {$_('publicProfile.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()} @@ -1044,6 +1075,16 @@ import ShareLinkDialog from '$lib/components/ShareLinkDialog.svelte';

    + + From a1594621cc4be86334d4e4aceeaaea17b1ad6633 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:27:59 +0200 Subject: [PATCH 27/50] Add backend database to .gitignore --- .gitignore | 3 ++- backend/librislog.db | 0 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 backend/librislog.db diff --git a/.gitignore b/.gitignore index 87c957de..ab13a837 100644 --- a/.gitignore +++ b/.gitignore @@ -219,6 +219,7 @@ __marimo__/ /ideas.txt /backend/data/ +/backend/librislog.db /data/ /data-e2e/ /backend/data/ @@ -235,4 +236,4 @@ node_modules/ /.playwright-mcp /.sverklo .plan/ -/.opencode \ No newline at end of file +/.opencodebackend/librislog.db diff --git a/backend/librislog.db b/backend/librislog.db deleted file mode 100644 index e69de29b..00000000 From 1ec7f12fe71f25680907c4fa21713049d814d8ac Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 10 Sep 2026 12:43:14 +0200 Subject: [PATCH 28/50] Add validation and scrolling to share link dialog --- .../src/lib/components/ShareLinkDialog.svelte | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/ShareLinkDialog.svelte b/frontend/src/lib/components/ShareLinkDialog.svelte index 244eb894..8e0cdc28 100644 --- a/frontend/src/lib/components/ShareLinkDialog.svelte +++ b/frontend/src/lib/components/ShareLinkDialog.svelte @@ -46,6 +46,7 @@ let expiresHasInput = $state(false); let tz = $state('UTC'); let dialogEl = $state(null); + let nameTouched = $state(false); function resetFromLink(value: PublicProfileLink | null) { const defaults = defaultPublicProfileVisibilityConfig(); @@ -66,6 +67,7 @@ } expiresInvalid = false; expiresHasInput = false; + nameTouched = false; } $effect(() => { @@ -125,7 +127,10 @@ function setAllSections(checked: boolean) { sections = checked ? PUBLIC_PROFILE_SECTIONS.map((s) => s.key) - : sections.filter((s) => s === 'statistics'); + : []; + if (!checked) { + statistics = []; + } } function statsForGroup(group: PublicProfileStatisticsGroup) { @@ -168,7 +173,7 @@ aria-modal="true" aria-label={link ? $_('publicProfile.dialogTitleEdit') : $_('publicProfile.dialogTitleCreate')} > -