From 82e943c09519e85bce32ea04d75d2ac775274b18 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 28 May 2026 20:34:04 +0200 Subject: [PATCH 01/40] Fix version rendering in app sidebar --- .../src/lib/components/VersionLink.svelte | 22 +++++++++++++++++++ frontend/src/routes/+layout.svelte | 7 +++--- frontend/src/routes/about/+page.svelte | 12 ++-------- 3 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/components/VersionLink.svelte diff --git a/frontend/src/lib/components/VersionLink.svelte b/frontend/src/lib/components/VersionLink.svelte new file mode 100644 index 0000000..3f84ac7 --- /dev/null +++ b/frontend/src/lib/components/VersionLink.svelte @@ -0,0 +1,22 @@ + + +{#if href} + {displayVersion} +{:else} + {displayVersion} +{/if} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 99fc9e2..258dfed 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -13,7 +13,8 @@ import { setTimezone, setQuoteServiceEnabled } from '$lib/stores/timezone'; import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY } from '$lib/stores/theme'; import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings } from '@lucide/svelte'; - import { version, gitSha } from '$lib/version'; + import { version } from '$lib/version'; + import VersionLink from '$lib/components/VersionLink.svelte'; import { toasts } from '$lib/toasts'; import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; @@ -266,9 +267,7 @@ - - {version}{#if gitSha && gitSha !== 'unknown' && !version.includes(gitSha.slice(0, 7))} ({gitSha.slice(0, 7)}){/if} - + diff --git a/frontend/src/routes/about/+page.svelte b/frontend/src/routes/about/+page.svelte index e31a94b..9072f1b 100644 --- a/frontend/src/routes/about/+page.svelte +++ b/frontend/src/routes/about/+page.svelte @@ -1,7 +1,7 @@
@@ -67,7 +59,7 @@

LibrisLog

-

{displayVersion}

+

From f5063e154e908d3c7d5475ba50f36ded4fe2ae6e Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 28 May 2026 21:07:55 +0200 Subject: [PATCH 02/40] Show commit that generated the docs in docs footer --- docs/.vitepress/config.base.ts | 7 ++++++ .../theme/components/CommitInfo.vue | 23 +++++++++++++++++++ docs/.vitepress/theme/env.d.ts | 2 ++ docs/.vitepress/theme/index.ts | 2 ++ docs/about.md | 4 +++- 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 docs/.vitepress/theme/components/CommitInfo.vue create mode 100644 docs/.vitepress/theme/env.d.ts diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index 3a60375..62e6048 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -1,8 +1,15 @@ import { defineConfig } from 'vitepress' +import { execSync } from 'child_process' + +const gitSha = execSync('git rev-parse HEAD').toString().trim() export default defineConfig({ title: 'LibrisLog', vite: { + define: { + __GIT_SHA__: JSON.stringify(gitSha), + __GIT_SHA_SHORT__: JSON.stringify(gitSha.slice(0, 7)), + }, server: { host: true, port: 5174, diff --git a/docs/.vitepress/theme/components/CommitInfo.vue b/docs/.vitepress/theme/components/CommitInfo.vue new file mode 100644 index 0000000..8ec43fe --- /dev/null +++ b/docs/.vitepress/theme/components/CommitInfo.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/docs/.vitepress/theme/env.d.ts b/docs/.vitepress/theme/env.d.ts new file mode 100644 index 0000000..b4fcf15 --- /dev/null +++ b/docs/.vitepress/theme/env.d.ts @@ -0,0 +1,2 @@ +declare const __GIT_SHA__: string +declare const __GIT_SHA_SHORT__: string diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index b5248c7..b4c603f 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,12 +3,14 @@ import DefaultTheme from 'vitepress/theme' import { useRoute } from 'vitepress' import imageViewer from 'vitepress-plugin-image-viewer' import vImageViewer from 'vitepress-plugin-image-viewer/lib/vImageViewer.vue' +import CommitInfo from './components/CommitInfo.vue' import 'viewerjs/dist/viewer.min.css' export default { extends: DefaultTheme, enhanceApp({ app }) { app.component('vImageViewer', vImageViewer) + app.component('CommitInfo', CommitInfo) }, setup() { const route = useRoute() diff --git a/docs/about.md b/docs/about.md index 5eabd48..91d168f 100644 --- a/docs/about.md +++ b/docs/about.md @@ -38,4 +38,6 @@ Created and maintained by [Raffael Herrmann](https://github.com/codebude). ## License -Released under the MIT License. \ No newline at end of file +Released under the MIT License. + + From 569660831c30579f9aa62eb843c079b2d5293cc1 Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 28 May 2026 21:29:42 +0200 Subject: [PATCH 03/40] Link ghpr from build package badge in Readme.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 384d042..622d368 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

Tests - Docker Build + Docker Build Docs Build Python Svelte From faeac784a52c4fbc9fb8ee0eb86078d1170e15ee Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 08:14:16 +0200 Subject: [PATCH 04/40] Made book rating editable from details drawer --- frontend/e2e/specs/05-edit-book.spec.ts | 30 +++++++++++++++++++ .../lib/components/BookDetailDialog.svelte | 16 +++++++++- .../lib/components/BookDetailDialog.test.ts | 23 ++++++++++++++ frontend/src/lib/i18n/locales/de.json | 1 + frontend/src/lib/i18n/locales/en.json | 1 + 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/specs/05-edit-book.spec.ts b/frontend/e2e/specs/05-edit-book.spec.ts index b2395dc..6f5d0c4 100644 --- a/frontend/e2e/specs/05-edit-book.spec.ts +++ b/frontend/e2e/specs/05-edit-book.spec.ts @@ -58,4 +58,34 @@ test.describe('Edit Book', () => { } await page.waitForTimeout(500); }); + + test('5.3 set and persist rating from detail dialog', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + + await library.switchTab('want to read'); + await page.waitForTimeout(500); + + const cards = library.getBookCards(); + await expect(cards.first()).toBeVisible({ timeout: 5000 }); + await cards.first().click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const star2 = page.locator('[role="dialog"] input[type="radio"][aria-label="2 star"]'); + await expect(star2).toBeVisible({ timeout: 5000 }); + await star2.click(); + + await expect(page.getByText('Rating saved')).toBeVisible({ timeout: 3000 }); + + const closeBtn = page.locator('[role="dialog"] button').filter({ hasText: 'Close' }); + await closeBtn.click(); + await page.waitForTimeout(500); + + await cards.first().click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const checkedStar = page.locator('[role="dialog"] input[type="radio"]:checked'); + await expect(checkedStar).toHaveAttribute('aria-label', '2 star'); + }); }); diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 2197fac..21929b5 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -199,6 +199,20 @@ } } + async function saveRating(newRating: number) { + if (!book) return; + try { + const updated = await api.books.update(book.id, { rating: newRating }); + book.rating = updated.rating; + toasts.add($_('common.ratingSaved'), 'success', 2000); + } catch (e: unknown) { + toasts.add( + e instanceof Error ? e.message : $_('common.actionFailed', { values: { action: $_('common.rating') } }), + 'error' + ); + } + } + const uniqueDays = $derived.by(() => { const seen = new Set(); return progressEntries.filter((e) => { @@ -353,7 +367,7 @@

{$_('common.rating')}
- + saveRating(v)} />
diff --git a/frontend/src/lib/components/BookDetailDialog.test.ts b/frontend/src/lib/components/BookDetailDialog.test.ts index fb2c30c..fae7e16 100644 --- a/frontend/src/lib/components/BookDetailDialog.test.ts +++ b/frontend/src/lib/components/BookDetailDialog.test.ts @@ -11,11 +11,13 @@ const mockProgressList = vi.fn(async () => []); const mockProgressCreate = vi.fn(async (_bookId: number, _page: number) => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); const mockProgressDelete = vi.fn(async () => {}); const mockBooksDelete = vi.fn(async () => {}); +const mockBooksUpdate = vi.fn(async (_id: number, _data: unknown) => ({ ..._data, id: _id })); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ api: { books: { + update: (id: number, data: unknown) => mockBooksUpdate(id, data), progress: { list: (bookId: number) => mockProgressList(bookId), create: (bookId: number, page: number) => mockProgressCreate(bookId, page), @@ -96,6 +98,27 @@ describe('BookDetailDialog', () => { expect(screen.getAllByRole('radio')).toHaveLength(5); }); + it('updates rating and shows toast when star is clicked', async () => { + mockBooksUpdate.mockResolvedValue({ ...mockBook, rating: 3 }); + render(BookDetailDialog, { props: { book: mockBook, open: true } }); + const stars = screen.getAllByRole('radio'); + await fireEvent.click(stars[2]); // click 3rd star (value=3) + await waitFor(() => { + expect(mockBooksUpdate).toHaveBeenCalledWith(1, { rating: 3 }); + expect(mockToastsAdd).toHaveBeenCalledWith('Rating saved', 'success', 2000); + }); + }); + + it('shows error toast on rating update failure', async () => { + mockBooksUpdate.mockRejectedValue(new Error('Network error')); + render(BookDetailDialog, { props: { book: mockBook, open: true } }); + const stars = screen.getAllByRole('radio'); + await fireEvent.click(stars[4]); // click 5th star (value=5) + await waitFor(() => { + expect(mockToastsAdd).toHaveBeenCalledWith('Network error', 'error'); + }); + }); + it('shows book metadata fields', () => { render(BookDetailDialog, { props: { book: mockBook, open: true } }); expect(screen.getByText('English')).toBeInTheDocument(); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index e64a71a..952c813 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -92,6 +92,7 @@ "addFirstBook": "Erstes Buch hinzufügen", "dateAdded": "Hinzugefügt am", "rating": "Bewertung", + "ratingSaved": "Bewertung gespeichert", "desc": "Absteigend", "asc": "Aufsteigend", "close": "Schließen", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 66b4fcc..b5229c9 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -92,6 +92,7 @@ "addFirstBook": "Add your first book", "dateAdded": "Date added", "rating": "Rating", + "ratingSaved": "Rating saved", "desc": "Desc", "asc": "Asc", "close": "Close", From 6dec115924975f5f04cc3612222d1f4e56f82f61 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 09:43:26 +0200 Subject: [PATCH 05/40] Added ratings statistics --- backend/app/routers/statistics.py | 33 ++++ backend/app/schemas.py | 15 ++ frontend/e2e/specs/08-statistics.spec.ts | 17 ++ .../lib/components/RatedBooksSection.svelte | 83 ++++++++++ .../lib/components/RatedBooksSection.test.ts | 149 ++++++++++++++++++ frontend/src/lib/i18n/locales/de.json | 13 +- frontend/src/lib/i18n/locales/en.json | 11 +- frontend/src/lib/types.ts | 14 ++ frontend/src/routes/statistics/+page.svelte | 32 ++++ 9 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/components/RatedBooksSection.svelte create mode 100644 frontend/src/lib/components/RatedBooksSection.test.ts diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 23b1bd7..4d2c9f5 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -25,6 +25,7 @@ StatusDistribution, TopAuthor, TopAuthorCover, + TopRatedBook, YearlyBooks, ) @@ -398,6 +399,33 @@ def get_statistics( for author_name, author_count in top_author_counts ] + # --- Rating stats --- + books_with_rating = sum(1 for b in books if b.rating is not None) + books_without_rating = sum(1 for b in books if b.rating is None) + rating_values = [b.rating for b in books if b.rating is not None] + average_rating = round(mean(rating_values), 2) if rating_values else None + + top_rated_books: list[TopRatedBook] = [] + worst_rated_books: list[TopRatedBook] = [] + + if rating_values: + max_rating = max(rating_values) + min_rating = min(rating_values) + + candidates_top = [b for b in books if b.rating is not None and b.rating == max_rating] + candidates_top.sort(key=lambda x: x.date_added or datetime.min, reverse=True) + top_rated_books = [ + TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + for b in candidates_top + ] + + candidates_worst = [b for b in books if b.rating is not None and b.rating == min_rating] + candidates_worst.sort(key=lambda x: x.date_added or datetime.min, reverse=True) + worst_rated_books = [ + TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + for b in candidates_worst + ] + return StatisticsResponse( avg_books_per_month=avg_books_per_month, busiest_month=busiest_month, @@ -412,4 +440,9 @@ def get_statistics( books_finished_per_month=books_finished_per_month, books_finished_per_year=books_finished_per_year, top_authors=top_authors, + books_with_rating=books_with_rating, + books_without_rating=books_without_rating, + average_rating=average_rating, + top_rated_books=top_rated_books, + worst_rated_books=worst_rated_books, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 22a9f59..c1b2c0f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -227,6 +227,16 @@ class TopAuthorCover(SQLModel): cover_url: str +class TopRatedBook(SQLModel): + """A book appearing in top/worst rated lists.""" + book_id: int + title: str + author: Optional[str] + rating: int + reading_status: ReadingStatus + cover_url: Optional[str] + + class StatisticsResponse(SQLModel): """Full statistics dashboard response.""" avg_books_per_month: Optional[float] @@ -242,6 +252,11 @@ class StatisticsResponse(SQLModel): books_finished_per_month: list[MonthlyBooks] books_finished_per_year: list[YearlyBooks] top_authors: list[TopAuthor] + books_with_rating: int + books_without_rating: int + average_rating: Optional[float] + top_rated_books: list[TopRatedBook] + worst_rated_books: list[TopRatedBook] class UserLogin(SQLModel): diff --git a/frontend/e2e/specs/08-statistics.spec.ts b/frontend/e2e/specs/08-statistics.spec.ts index 591c2d0..8740a37 100644 --- a/frontend/e2e/specs/08-statistics.spec.ts +++ b/frontend/e2e/specs/08-statistics.spec.ts @@ -16,4 +16,21 @@ test.describe('Statistics', () => { const body = page.locator('body'); await expect(body).toContainText(/total|books|pages|rating|read/i); }); + + test('8.2 rating statistics are displayed', async ({ page }) => { + await page.goto('/statistics'); + await page.waitForTimeout(2000); + + await expect(page.getByText('Books with Rating')).toBeVisible(); + await expect(page.getByText('Books without Rating')).toBeVisible(); + await expect(page.getByText('Avg Rating')).toBeVisible(); + + await expect(page.getByText('Top Rated')).toBeVisible(); + await expect(page.getByText('Worst Rated')).toBeVisible(); + + await expect(page.getByText('To Kill a Mockingbird')).toBeVisible(); + await expect(page.getByText('1984')).toBeVisible(); + await expect(page.getByText('The Great Gatsby')).toBeVisible(); + await expect(page.getByText('Brave New World')).toBeVisible(); + }); }); diff --git a/frontend/src/lib/components/RatedBooksSection.svelte b/frontend/src/lib/components/RatedBooksSection.svelte new file mode 100644 index 0000000..a6a3cbc --- /dev/null +++ b/frontend/src/lib/components/RatedBooksSection.svelte @@ -0,0 +1,83 @@ + + +
+
+

{title}

+ {#if displayBooks.length > 0} +
+ {#each displayBooks as book, idx} +
+ {$_('statistics.rankedNumber', { values: { rank: idx + 1 } })} + +
+

{book.title}

+

{book.author ?? '-'}

+
+ {#each Array(maxRating) as _, i} + {i < book.rating ? '★' : '☆'} + {/each} +
+
+
+ {/each} +
+ {#if books.length > initialLimit} + + {/if} + {:else} +

{$_('statistics.noData')}

+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/RatedBooksSection.test.ts b/frontend/src/lib/components/RatedBooksSection.test.ts new file mode 100644 index 0000000..b72e4d6 --- /dev/null +++ b/frontend/src/lib/components/RatedBooksSection.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import RatedBooksSection from './RatedBooksSection.svelte'; +import type { TopRatedBook } from '$lib/types'; + +const mockBooksGet = vi.fn(async (_id: number) => ({ + id: _id, + title: 'Test Book', + author: 'Test Author', + cover_url: 'http://example.com/cover.jpg', + rating: 4, + reading_status: 'read' as const, + date_added: '2024-01-01T00:00:00Z', + page_count: 300, + isbn: null, + publisher: null, + published_year: null, + language: null, + tags: null, + blurb: null, + notes: null, + subtitle: null +})); + +const mockToastsAdd = vi.fn(); + +vi.mock('$lib/api', () => ({ + api: { + books: { + get: (id: number) => mockBooksGet(id) + } + } +})); + +vi.mock('$lib/toasts', () => ({ + toasts: { + add: (...args: unknown[]) => mockToastsAdd(...args), + remove: vi.fn(), + subscribe: vi.fn() + } +})); + +function makeBook(id: number, rating: number, title?: string): TopRatedBook { + return { + book_id: id, + title: title ?? `Book ${id}`, + author: `Author ${id}`, + rating, + reading_status: 'read', + cover_url: `http://example.com/cover${id}.jpg` + }; +} + +describe('RatedBooksSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the section title', () => { + const books = [makeBook(1, 5)]; + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + expect(screen.getByText('Top Rated')).toBeInTheDocument(); + }); + + it('shows only 10 books initially when more than 10 supplied', () => { + const books = Array.from({ length: 12 }, (_, i) => makeBook(i + 1, 5, `Book ${i + 1}`)); + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + + for (let i = 1; i <= 10; i++) { + expect(screen.getByText(`Book ${i}`)).toBeInTheDocument(); + } + expect(screen.queryByText('Book 11')).not.toBeInTheDocument(); + expect(screen.queryByText('Book 12')).not.toBeInTheDocument(); + }); + + it('shows "Show more" button when more than 10 books', () => { + const books = Array.from({ length: 11 }, (_, i) => makeBook(i + 1, 5)); + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + const btn = screen.getByRole('button', { name: /show more/i }); + expect(btn).toBeInTheDocument(); + }); + + it('does not show "Show more" button when 10 or fewer books', () => { + const books = Array.from({ length: 10 }, (_, i) => makeBook(i + 1, 5)); + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument(); + }); + + it('reveals all books when "Show more" is clicked', async () => { + const books = Array.from({ length: 12 }, (_, i) => makeBook(i + 1, 5, `Book ${i + 1}`)); + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + + expect(screen.queryByText('Book 11')).not.toBeInTheDocument(); + + const showMore = screen.getByRole('button', { name: /show more/i }); + await fireEvent.click(showMore); + + expect(screen.getByText('Book 1')).toBeInTheDocument(); + expect(screen.getByText('Book 11')).toBeInTheDocument(); + expect(screen.getByText('Book 12')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument(); + }); + + it('shows "Show more (+2)" with correct count', () => { + const books = Array.from({ length: 12 }, (_, i) => makeBook(i + 1, 5)); + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + const btn = screen.getByRole('button', { name: /show more/i }); + expect(btn.textContent).toContain('2'); + }); + + it('calls api.books.get when a cover is clicked', async () => { + const books = [makeBook(42, 5, 'Clickable Book')]; + const { container } = render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + + const coverBtn = container.querySelector('button[aria-label="Clickable Book"]') as HTMLButtonElement; + expect(coverBtn).not.toBeNull(); + await fireEvent.click(coverBtn); + + await waitFor(() => { + expect(mockBooksGet).toHaveBeenCalledWith(42); + }); + }); + + it('shows rank badges with correct numbers', () => { + const books = [ + makeBook(1, 5, 'A'), + makeBook(2, 5, 'B'), + makeBook(3, 5, 'C') + ]; + render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + + const badges = screen.getAllByText(/#\d/); + expect(badges).toHaveLength(3); + }); + + it('handles API error when clicking a cover', async () => { + mockBooksGet.mockRejectedValueOnce(new Error('Network error')); + const books = [makeBook(1, 5, 'Error Book')]; + const { container } = render(RatedBooksSection, { props: { title: 'Top Rated', books } }); + + const coverBtn = container.querySelector('button[aria-label="Error Book"]') as HTMLButtonElement; + expect(coverBtn).not.toBeNull(); + await fireEvent.click(coverBtn); + + await waitFor(() => { + expect(mockToastsAdd).toHaveBeenCalledWith('Network error', 'error'); + }); + }); +}); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 952c813..3e507ad 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -47,8 +47,17 @@ "pagesPerDay": "Seiten/Tag", "loading": "Lade Statistiken...", "noData": "Noch keine Daten verfügbar. Fange an, Bücher zu lesen und zu erfassen, um Statistiken zu sehen!", - "resetZoom": "Zoom zurücksetzen" - }, + "resetZoom": "Zoom zurücksetzen", + "ratingStats": "Bewertungsstatistiken", + "booksWithRating": "Bewertete Bücher", + "booksWithoutRating": "Unbewertete Bücher", + "averageRating": "Ø Bewertung", + "noRating": "Keine Bewertung", + "topRated": "Am besten bewertet", + "worstRated": "Am schlechtesten bewertet", + "showMore": "Mehr anzeigen (+{count})", + "showLess": "Weniger anzeigen" + }, "dashboard": { "title": "Lese-Dashboard", "subtitle": "Ein schneller Überblick über dein Lesen", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index b5229c9..ecfb291 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -47,7 +47,16 @@ "pagesPerDay": "pages/day", "loading": "Loading statistics...", "noData": "No data available yet. Start reading and tracking books to see statistics!", - "resetZoom": "Reset zoom" + "resetZoom": "Reset zoom", + "ratingStats": "Rating Statistics", + "booksWithRating": "Books with Rating", + "booksWithoutRating": "Books without Rating", + "averageRating": "Avg Rating", + "noRating": "No rating", + "topRated": "Top Rated", + "worstRated": "Worst Rated", + "showMore": "Show more (+{count})", + "showLess": "Show less" }, "dashboard": { "title": "Reading Dashboard", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 42f1f5c..7ed999c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -144,6 +144,15 @@ export interface TopAuthorCover { cover_url: string; } +export interface TopRatedBook { + book_id: number; + title: string; + author: string | null; + rating: number; + reading_status: ReadingStatus; + cover_url: string | null; +} + export interface StatisticsResponse { avg_books_per_month: number | null; busiest_month: string | null; @@ -158,6 +167,11 @@ export interface StatisticsResponse { books_finished_per_month: MonthlyBooks[]; books_finished_per_year: YearlyBooks[]; top_authors: TopAuthor[]; + books_with_rating: number; + books_without_rating: number; + average_rating: number | null; + top_rated_books: TopRatedBook[]; + worst_rated_books: TopRatedBook[]; } export type UserRole = 'admin' | 'user'; diff --git a/frontend/src/routes/statistics/+page.svelte b/frontend/src/routes/statistics/+page.svelte index 7da6886..799eb7e 100644 --- a/frontend/src/routes/statistics/+page.svelte +++ b/frontend/src/routes/statistics/+page.svelte @@ -10,6 +10,7 @@ import CalendarHeatmap from '$lib/components/CalendarHeatmap.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import RatedBooksSection from '$lib/components/RatedBooksSection.svelte'; import { RotateCcw } from '@lucide/svelte'; type Segment = { @@ -486,6 +487,37 @@ {/if}
+ +
+
+
{$_('statistics.booksWithRating')}
+
{formatNumber(stats.books_with_rating, 0)}
+
+
+
{$_('statistics.booksWithoutRating')}
+
{formatNumber(stats.books_without_rating, 0)}
+
+
+
{$_('statistics.averageRating')}
+
+ {stats.average_rating !== null ? formatNumber(stats.average_rating, 1) : $_('statistics.noRating')} +
+
+
+ + {#if stats.top_rated_books.length > 0} + + {/if} + + {#if stats.worst_rated_books.length > 0} + + {/if} {/if} {/if} From d37ba4d0b5c67cf354f84e6b3ba8515cb5274d51 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 10:22:16 +0200 Subject: [PATCH 06/40] Centered calendar view on page. --- frontend/src/lib/components/CalendarHeatmap.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/CalendarHeatmap.svelte b/frontend/src/lib/components/CalendarHeatmap.svelte index ba47737..4dcbae0 100644 --- a/frontend/src/lib/components/CalendarHeatmap.svelte +++ b/frontend/src/lib/components/CalendarHeatmap.svelte @@ -93,7 +93,7 @@ width: ({ chart }: { chart: { chartArea?: { width: number } } }) => { const area = chart.chartArea; if (!area) return 10; - return Math.max((area.width / 54) - 2, 2); + return Math.max((area.width / 53) - 2, 2); }, height: ({ chart }: { chart: { chartArea?: { height: number } } }) => { const area = chart.chartArea; @@ -174,20 +174,23 @@ scales: { x: { type: 'linear', - offset: true, + min: -0.5, + max: 52.5, grid: { display: false }, ticks: { display: false }, border: { display: false }, }, y: { type: 'linear', - offset: true, min: -0.5, max: 6.5, reverse: true, grid: { display: false }, ticks: { display: false }, border: { display: false }, + afterFit: (scale) => { + scale.width = 0; + }, }, }, }; From 53445452f378e3d45691a80bfef2ef677b36f7e1 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 10:26:44 +0200 Subject: [PATCH 07/40] Fix last column of calendar stats --- frontend/src/lib/components/CalendarHeatmap.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/CalendarHeatmap.svelte b/frontend/src/lib/components/CalendarHeatmap.svelte index 4dcbae0..8553409 100644 --- a/frontend/src/lib/components/CalendarHeatmap.svelte +++ b/frontend/src/lib/components/CalendarHeatmap.svelte @@ -19,7 +19,8 @@ const today = $derived(new Date()); const startDate = $derived.by(() => { const d = new Date(today); - d.setDate(today.getDate() - 364); + const dow = d.getDay(); + d.setDate(d.getDate() - dow - 364); return d; }); From fbef3415b25b8605dcf34298318f38d8d2e212ef Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 10:34:14 +0200 Subject: [PATCH 08/40] Update logic for gathering top-/worst rated books for statistics --- backend/app/routers/statistics.py | 29 +++----- .../lib/components/RatedBooksSection.svelte | 17 +---- .../lib/components/RatedBooksSection.test.ts | 66 ++++++++----------- frontend/src/lib/i18n/locales/de.json | 4 +- frontend/src/lib/i18n/locales/en.json | 4 +- 5 files changed, 41 insertions(+), 79 deletions(-) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 4d2c9f5..da25b8b 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -405,26 +405,17 @@ def get_statistics( rating_values = [b.rating for b in books if b.rating is not None] average_rating = round(mean(rating_values), 2) if rating_values else None - top_rated_books: list[TopRatedBook] = [] - worst_rated_books: list[TopRatedBook] = [] - - if rating_values: - max_rating = max(rating_values) - min_rating = min(rating_values) - - candidates_top = [b for b in books if b.rating is not None and b.rating == max_rating] - candidates_top.sort(key=lambda x: x.date_added or datetime.min, reverse=True) - top_rated_books = [ - TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) - for b in candidates_top - ] + rated_books = [b for b in books if b.rating is not None] - candidates_worst = [b for b in books if b.rating is not None and b.rating == min_rating] - candidates_worst.sort(key=lambda x: x.date_added or datetime.min, reverse=True) - worst_rated_books = [ - TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) - for b in candidates_worst - ] + top_rated_books = [ + TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + for b in sorted(rated_books, key=lambda x: (-x.rating, -(x.date_added or datetime.min).timestamp()))[:10] + ] + + worst_rated_books = [ + TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + for b in sorted(rated_books, key=lambda x: (x.rating, -(x.date_added or datetime.min).timestamp()))[:10] + ] return StatisticsResponse( avg_books_per_month=avg_books_per_month, diff --git a/frontend/src/lib/components/RatedBooksSection.svelte b/frontend/src/lib/components/RatedBooksSection.svelte index a6a3cbc..4d8a9ad 100644 --- a/frontend/src/lib/components/RatedBooksSection.svelte +++ b/frontend/src/lib/components/RatedBooksSection.svelte @@ -7,13 +7,9 @@ let { title, books }: { title: string; books: TopRatedBook[] } = $props(); - let expanded = $state(false); let selectedBook = $state(null); let detailOpen = $state(false); - const initialLimit = 10; - const displayBooks = $derived(expanded ? books : books.slice(0, initialLimit)); - const hiddenCount = $derived(books.length - displayBooks.length); const maxRating = 5; async function openCoverBook(bookId: number) { @@ -36,9 +32,9 @@

{title}

- {#if displayBooks.length > 0} + {#if books.length > 0}
- {#each displayBooks as book, idx} + {#each books as book, idx}
{$_('statistics.rankedNumber', { values: { rank: idx + 1 } })}
- {#if books.length > initialLimit} - - {/if} {:else}

{$_('statistics.noData')}

{/if} diff --git a/frontend/src/lib/components/RatedBooksSection.test.ts b/frontend/src/lib/components/RatedBooksSection.test.ts index b72e4d6..3481f97 100644 --- a/frontend/src/lib/components/RatedBooksSection.test.ts +++ b/frontend/src/lib/components/RatedBooksSection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import RatedBooksSection from './RatedBooksSection.svelte'; import type { TopRatedBook } from '$lib/types'; @@ -40,6 +40,26 @@ vi.mock('$lib/toasts', () => ({ } })); +vi.mock('$lib/i18n', () => ({ + _: { + subscribe: (run: (value: (key: string, opts?: Record) => string) => void) => { + run((key: string, opts?: Record) => { + if (opts?.values) { + return key.replace(/\{(\w+)\}/g, (_m: string, k: string) => String((opts.values as Record)[k] ?? '')); + } + return key; + }); + return () => {}; + } + }, + locale: { + subscribe: (run: (value: string) => void) => { + run('en'); + return () => {}; + } + } +})); + function makeBook(id: number, rating: number, title?: string): TopRatedBook { return { book_id: id, @@ -62,50 +82,18 @@ describe('RatedBooksSection', () => { expect(screen.getByText('Top Rated')).toBeInTheDocument(); }); - it('shows only 10 books initially when more than 10 supplied', () => { + it('renders all books', () => { const books = Array.from({ length: 12 }, (_, i) => makeBook(i + 1, 5, `Book ${i + 1}`)); render(RatedBooksSection, { props: { title: 'Top Rated', books } }); - for (let i = 1; i <= 10; i++) { + for (let i = 1; i <= 12; i++) { expect(screen.getByText(`Book ${i}`)).toBeInTheDocument(); } - expect(screen.queryByText('Book 11')).not.toBeInTheDocument(); - expect(screen.queryByText('Book 12')).not.toBeInTheDocument(); }); - it('shows "Show more" button when more than 10 books', () => { - const books = Array.from({ length: 11 }, (_, i) => makeBook(i + 1, 5)); - render(RatedBooksSection, { props: { title: 'Top Rated', books } }); - const btn = screen.getByRole('button', { name: /show more/i }); - expect(btn).toBeInTheDocument(); - }); - - it('does not show "Show more" button when 10 or fewer books', () => { - const books = Array.from({ length: 10 }, (_, i) => makeBook(i + 1, 5)); - render(RatedBooksSection, { props: { title: 'Top Rated', books } }); - expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument(); - }); - - it('reveals all books when "Show more" is clicked', async () => { - const books = Array.from({ length: 12 }, (_, i) => makeBook(i + 1, 5, `Book ${i + 1}`)); - render(RatedBooksSection, { props: { title: 'Top Rated', books } }); - - expect(screen.queryByText('Book 11')).not.toBeInTheDocument(); - - const showMore = screen.getByRole('button', { name: /show more/i }); - await fireEvent.click(showMore); - - expect(screen.getByText('Book 1')).toBeInTheDocument(); - expect(screen.getByText('Book 11')).toBeInTheDocument(); - expect(screen.getByText('Book 12')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument(); - }); - - it('shows "Show more (+2)" with correct count', () => { - const books = Array.from({ length: 12 }, (_, i) => makeBook(i + 1, 5)); - render(RatedBooksSection, { props: { title: 'Top Rated', books } }); - const btn = screen.getByRole('button', { name: /show more/i }); - expect(btn.textContent).toContain('2'); + it('shows no data message when books array is empty', () => { + render(RatedBooksSection, { props: { title: 'Top Rated', books: [] } }); + expect(screen.getByText('statistics.noData')).toBeInTheDocument(); }); it('calls api.books.get when a cover is clicked', async () => { @@ -129,7 +117,7 @@ describe('RatedBooksSection', () => { ]; render(RatedBooksSection, { props: { title: 'Top Rated', books } }); - const badges = screen.getAllByText(/#\d/); + const badges = screen.getAllByText('statistics.rankedNumber'); expect(badges).toHaveLength(3); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 3e507ad..b3ac748 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -54,9 +54,7 @@ "averageRating": "Ø Bewertung", "noRating": "Keine Bewertung", "topRated": "Am besten bewertet", - "worstRated": "Am schlechtesten bewertet", - "showMore": "Mehr anzeigen (+{count})", - "showLess": "Weniger anzeigen" + "worstRated": "Am schlechtesten bewertet" }, "dashboard": { "title": "Lese-Dashboard", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index ecfb291..c35383f 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -54,9 +54,7 @@ "averageRating": "Avg Rating", "noRating": "No rating", "topRated": "Top Rated", - "worstRated": "Worst Rated", - "showMore": "Show more (+{count})", - "showLess": "Show less" + "worstRated": "Worst Rated" }, "dashboard": { "title": "Reading Dashboard", From 496d733388e390ce9dc155a90084ae2d485ce46e Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 11:21:18 +0200 Subject: [PATCH 09/40] Added docker commands to llc cli --- cli/src/llc/docker.py | 74 +++++++++++++++++++++++++++++++++++++++++++ cli/src/llc/main.py | 65 +++++++++++++++++++++++++++++++++++++ docs/guide/cli.md | 16 ++++++++++ 3 files changed, 155 insertions(+) create mode 100644 cli/src/llc/docker.py diff --git a/cli/src/llc/docker.py b/cli/src/llc/docker.py new file mode 100644 index 0000000..162fdba --- /dev/null +++ b/cli/src/llc/docker.py @@ -0,0 +1,74 @@ +import subprocess +from enum import Enum +from pathlib import Path + +import typer +from llc._interactive import console + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_DEV_COMPOSE = _PROJECT_ROOT / "docker-compose.dev.yml" +_PROD_COMPOSE = _PROJECT_ROOT / "docker-compose.yml" +_E2E_COMPOSE = _PROJECT_ROOT / "docker-compose.e2e.yml" + + +class ComposeEnv(str, Enum): + dev = "dev" + prod = "prod" + e2e = "e2e" + + +def _resolve_compose(env: ComposeEnv) -> Path: + return {"dev": _DEV_COMPOSE, "prod": _PROD_COMPOSE, "e2e": _E2E_COMPOSE}[env.value] + + +def _compose_cmd(compose_file: Path, args: list[str]) -> list[str]: + return ["docker", "compose", "-f", str(compose_file), *args] + + +def cmd_up(service: str | None = None, *, env: ComposeEnv = ComposeEnv.dev) -> None: + cmd = ["up", "--build", "-d"] + if service: + cmd.append(service) + label = f" [cyan]{service}[/cyan]" if service else "" + console.print(f"[bold]Building and starting{label}...[/bold]") + code = subprocess.call(_compose_cmd(_resolve_compose(env), cmd)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_down(*, env: ComposeEnv = ComposeEnv.dev) -> None: + console.print("[bold]Stopping containers...[/bold]") + code = subprocess.call(_compose_cmd(_resolve_compose(env), ["down"])) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_logs(follow: bool = False, service: str | None = None, *, env: ComposeEnv = ComposeEnv.dev) -> None: + cmd = ["logs"] + if follow: + cmd.append("-f") + if service: + cmd.append(service) + code = subprocess.call(_compose_cmd(_resolve_compose(env), cmd)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_status(*, env: ComposeEnv = ComposeEnv.dev) -> None: + code = subprocess.call(_compose_cmd(_resolve_compose(env), ["ps"])) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_shell(service: str) -> None: + console.print(f"[bold]Opening shell in [cyan]{service}[/cyan]...[/bold]") + code = subprocess.call(_compose_cmd(_DEV_COMPOSE, ["exec", service, "sh"])) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_restart(service: str, *, env: ComposeEnv = ComposeEnv.dev) -> None: + console.print(f"[bold]Restarting [cyan]{service}[/cyan]...[/bold]") + code = subprocess.call(_compose_cmd(_resolve_compose(env), ["restart", service])) + if code != 0: + raise typer.Exit(code=code) diff --git a/cli/src/llc/main.py b/cli/src/llc/main.py index 243a2ce..4b2dd05 100644 --- a/cli/src/llc/main.py +++ b/cli/src/llc/main.py @@ -1,4 +1,5 @@ import typer +from llc.docker import ComposeEnv app = typer.Typer( name="ll", @@ -32,11 +33,17 @@ help="Build and serve documentation", rich_markup_mode="rich", ) +docker_app = typer.Typer( + name="docker", + help="Manage Docker containers (up, down, logs, status, shell, restart)", + rich_markup_mode="rich", +) app.add_typer(pr_app) app.add_typer(tag_app) app.add_typer(test_app) app.add_typer(branch_app) app.add_typer(docs_app) +app.add_typer(docker_app) @pr_app.command("list") @@ -151,3 +158,61 @@ def docs_preview(): """Preview the built VitePress documentation site.""" from llc.docs import cmd_preview cmd_preview() + + +@docker_app.command("up") +def docker_up( + service: str | None = typer.Argument(None, help="Service to build and start (default: all)"), + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Build and start containers (optionally a single service).""" + from llc.docker import cmd_up + cmd_up(service, env=env) + + +@docker_app.command("down") +def docker_down( + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Stop and remove containers.""" + from llc.docker import cmd_down + cmd_down(env=env) + + +@docker_app.command("logs") +def docker_logs( + follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"), + service: str | None = typer.Argument(None, help="Service name"), + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """View container logs.""" + from llc.docker import cmd_logs + cmd_logs(follow=follow, service=service, env=env) + + +@docker_app.command("status") +def docker_status( + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Show container status.""" + from llc.docker import cmd_status + cmd_status(env=env) + + +@docker_app.command("shell") +def docker_shell( + service: str = typer.Argument(..., help="Service name (e.g. backend, frontend)"), +): + """Open a shell in a running container.""" + from llc.docker import cmd_shell + cmd_shell(service) + + +@docker_app.command("restart") +def docker_restart( + service: str = typer.Argument(..., help="Service name"), + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Restart a service.""" + from llc.docker import cmd_restart + cmd_restart(service, env=env) diff --git a/docs/guide/cli.md b/docs/guide/cli.md index c7ed514..b931f05 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -66,6 +66,22 @@ Create and delete semantic version tags. | `llc tag create` | Interactive tag creation — picks the branch, suggests the next version (major/minor/patch bump), creates and pushes the tag | | `llc tag delete` | Interactive tag deletion — select from recent tags or enter a name, deletes locally and remotely | +### `llc docker` + +Manage Docker containers using compose files. + +All commands accept `--env [dev|prod|e2e]` to select the compose file +(default: `dev`, which uses `docker-compose.dev.yml`). + +| Command | Description | +|---------|-------------| +| `llc docker up [service]` | Build and start containers (all, or a single service) | +| `llc docker down` | Stop and remove containers | +| `llc docker logs [-f] [service]` | View container logs | +| `llc docker status` | Show container status | +| `llc docker shell ` | Open a shell in a running container | +| `llc docker restart ` | Restart a service | + ### `llc branch` Create, delete, and sync local branches. From 3f26286e08109ffd09f1663533a5f1edcb01aba9 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 11:23:55 +0200 Subject: [PATCH 10/40] Only link SHA in buildinfo in docs --- docs/.vitepress/theme/components/CommitInfo.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/.vitepress/theme/components/CommitInfo.vue b/docs/.vitepress/theme/components/CommitInfo.vue index 8ec43fe..6ab0b12 100644 --- a/docs/.vitepress/theme/components/CommitInfo.vue +++ b/docs/.vitepress/theme/components/CommitInfo.vue @@ -1,7 +1,5 @@ -
-

{$_('dataHygiene.title')}

-

{$_('dataHygiene.description')}

+
+
+
+
+

{$_('dataHygiene.title')}

+

{$_('dataHygiene.description')}

+
+
+
- -
- {#each ATTRIBUTES as attr} - - {/each} - +
+
+

{$_('dataHygiene.sectionFilters')}

+
+ {#each ATTRIBUTES as attr} + + {/each} + +
+
{#if loading && books.length === 0} -
- - {$_('dataHygiene.loading')} +
+
+ +

{$_('dataHygiene.loading')}

+
{:else if error && books.length === 0} @@ -220,77 +232,86 @@ {:else if !loading && books.length === 0} -

- {$_('dataHygiene.noMissingBooks')} -

+
+
+

{$_('dataHygiene.noMissingBooks')}

+
+
{:else} -
- - - - - - - - - - - - - {#each books as book (book.id)} - - - - - - - - - {/each} - -
- 0} - onchange={toggleSelectAll} - aria-label={selectedBookIds.size === books.length ? $_('dataHygiene.deselectAll') : $_('dataHygiene.selectAll')} - /> - {$_('book.title')}{$_('dataHygiene.tableHeaderMissing')}
-
- toggleBook(book.id)} - aria-label={$_('common.search')} - /> -
-
{book.title} -
- {#each book.missing_attributes as attr} - {$_(ATTRIBUTES.find(a => a.key === attr)?.labelKey ?? attr)} - {/each} -
-
-
+
{$_('dataHygiene.sectionResults')}
- - {#if hasMore} -
- +
+
+ + + + + + + + + + + + + {#each books as book (book.id)} + + + + + + + + + {/each} + +
+ 0} + onchange={toggleSelectAll} + aria-label={selectedBookIds.size === books.length ? $_('dataHygiene.deselectAll') : $_('dataHygiene.selectAll')} + /> + {$_('book.title')}{$_('dataHygiene.tableHeaderMissing')}
+
+ toggleBook(book.id)} + aria-label={$_('common.search')} + /> +
+
{book.title} +
+ {#each book.missing_attributes as attr} + {$_(ATTRIBUTES.find(a => a.key === attr)?.labelKey ?? attr)} + {/each} +
+
- {/if} + {#if hasMore} +
+ + {$_('dataHygiene.showingCount', { + values: { shown: books.length, total } + })} + + +
+ {/if} +
{/if} From 1ad0d6b2a9b69bedb0158f6ab00620c3cd9d951f Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 12:22:33 +0200 Subject: [PATCH 13/40] Fix broken e2e tests due to ui changes --- frontend/e2e/fixtures/pages/library.page.ts | 19 ++++++++++++++++++- frontend/e2e/specs/05-edit-book.spec.ts | 8 ++++---- frontend/e2e/specs/08-statistics.spec.ts | 18 +++++++++--------- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/frontend/e2e/fixtures/pages/library.page.ts b/frontend/e2e/fixtures/pages/library.page.ts index 8f756cb..7260231 100644 --- a/frontend/e2e/fixtures/pages/library.page.ts +++ b/frontend/e2e/fixtures/pages/library.page.ts @@ -9,7 +9,24 @@ export class LibraryPage { } async switchTab(status: string) { - const tab = this.page.locator(`[role="tab"]`).filter({ hasText: new RegExp(status, 'i') }); + const STATUS_ORDER: Record = { + 'want to read': 0, + 'currently reading': 1, + 'read': 2, + 'did not finish': 3, + }; + const key = status.toLowerCase().replace(/\s+/g, '_'); + const posMap: Record = { + 'want_to_read': 0, + 'currently_reading': 1, + 'read': 2, + 'did_not_finish': 3, + }; + const index = posMap[key] ?? STATUS_ORDER[status.toLowerCase()]; + if (index === undefined) { + throw new Error(`Unknown tab status: ${status}`); + } + const tab = this.page.locator('[role="tab"]').nth(index); await tab.click(); await this.page.waitForTimeout(500); } diff --git a/frontend/e2e/specs/05-edit-book.spec.ts b/frontend/e2e/specs/05-edit-book.spec.ts index 6f5d0c4..e9a4628 100644 --- a/frontend/e2e/specs/05-edit-book.spec.ts +++ b/frontend/e2e/specs/05-edit-book.spec.ts @@ -72,13 +72,13 @@ test.describe('Edit Book', () => { await cards.first().click(); await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); - const star2 = page.locator('[role="dialog"] input[type="radio"][aria-label="2 star"]'); + const star2 = page.locator('[role="dialog"] input[type="radio"][aria-label*="2"]'); await expect(star2).toBeVisible({ timeout: 5000 }); await star2.click(); - await expect(page.getByText('Rating saved')).toBeVisible({ timeout: 3000 }); + await expect(page.getByText(/Rating saved|Bewertung gespeichert/i)).toBeVisible({ timeout: 3000 }); - const closeBtn = page.locator('[role="dialog"] button').filter({ hasText: 'Close' }); + const closeBtn = page.locator('button[aria-label*="Close"], button[aria-label*="Schließen"]').first(); await closeBtn.click(); await page.waitForTimeout(500); @@ -86,6 +86,6 @@ test.describe('Edit Book', () => { await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); const checkedStar = page.locator('[role="dialog"] input[type="radio"]:checked'); - await expect(checkedStar).toHaveAttribute('aria-label', '2 star'); + await expect(checkedStar).toHaveAttribute('aria-label', /2 (star|Stern)/); }); }); diff --git a/frontend/e2e/specs/08-statistics.spec.ts b/frontend/e2e/specs/08-statistics.spec.ts index 8740a37..225aeb7 100644 --- a/frontend/e2e/specs/08-statistics.spec.ts +++ b/frontend/e2e/specs/08-statistics.spec.ts @@ -21,16 +21,16 @@ test.describe('Statistics', () => { await page.goto('/statistics'); await page.waitForTimeout(2000); - await expect(page.getByText('Books with Rating')).toBeVisible(); - await expect(page.getByText('Books without Rating')).toBeVisible(); - await expect(page.getByText('Avg Rating')).toBeVisible(); + await expect(page.getByText(/Books with Rating|Bewertete Bücher/)).toBeVisible(); + await expect(page.getByText(/Books without Rating|Unbewertete Bücher/)).toBeVisible(); + await expect(page.getByText(/Avg Rating|Ø Bewertung/)).toBeVisible(); - await expect(page.getByText('Top Rated')).toBeVisible(); - await expect(page.getByText('Worst Rated')).toBeVisible(); + await expect(page.getByText(/Top Rated|Am besten bewertet/)).toBeVisible(); + await expect(page.getByText(/Worst Rated|Am schlechtesten bewertet/)).toBeVisible(); - await expect(page.getByText('To Kill a Mockingbird')).toBeVisible(); - await expect(page.getByText('1984')).toBeVisible(); - await expect(page.getByText('The Great Gatsby')).toBeVisible(); - await expect(page.getByText('Brave New World')).toBeVisible(); + await expect(page.getByText('To Kill a Mockingbird').first()).toBeVisible(); + await expect(page.getByText('1984').first()).toBeVisible(); + await expect(page.getByText('The Great Gatsby').first()).toBeVisible(); + await expect(page.getByText('Brave New World').first()).toBeVisible(); }); }); From 1bbb54f090c6ec8729ea2481be0d63e3d4a79e18 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 29 May 2026 12:30:16 +0200 Subject: [PATCH 14/40] Harmonize page width over project --- frontend/src/routes/about/+page.svelte | 2 +- frontend/src/routes/admin/+page.svelte | 4 ++-- frontend/src/routes/data-hygiene/+page.svelte | 2 +- frontend/src/routes/profile/+page.svelte | 2 +- frontend/src/routes/search/+page.svelte | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/about/+page.svelte b/frontend/src/routes/about/+page.svelte index 9072f1b..23d23ab 100644 --- a/frontend/src/routes/about/+page.svelte +++ b/frontend/src/routes/about/+page.svelte @@ -50,7 +50,7 @@ ]; -
+

{$_('about.title')}

diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 974131d..4b79e87 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -146,11 +146,11 @@ {#if !isAdmin} -
+
Admin access required.
{:else} -
+

{$_('admin.title')}

diff --git a/frontend/src/routes/data-hygiene/+page.svelte b/frontend/src/routes/data-hygiene/+page.svelte index 1120271..6c85d27 100644 --- a/frontend/src/routes/data-hygiene/+page.svelte +++ b/frontend/src/routes/data-hygiene/+page.svelte @@ -179,7 +179,7 @@ }); -
+
diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 34af44b..44e8495 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -344,7 +344,7 @@ }); -
+

{$_('user.profile')}

diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index 72fdbfa..cb1239a 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -182,7 +182,7 @@ {searchQuery ? `${searchQuery} - ` : ''}{$_('app.title')} -
+