From 2715275065b36e1c33e31f2a234768fd38d7d05f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 08:05:51 +0200 Subject: [PATCH] feat(history): share one HistoryList across desktop and mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop /profile/history page and the mobile Library "History" tab each hand-rendered reading history with separate markup, which had drifted: the Library tab was a read-only 2-column list with no date and no removed-chapter / whole-manga fallbacks, while /profile/history was a 3-column list with a per-row clear button. Extract a shared HistoryList component so the two can't diverge again. - New HistoryList.svelte owns row rendering, the optimistic-removal UX (instant remove, rollback + inline error on failure), and the empty state. Clear is opt-in via an `onClear` prop so a caller can render read-only, but both surfaces now pass it. - The mobile Library History tab gains the clear action, the "Read {date}" line, and the (chapter removed) / whole-manga fallbacks it was missing. - Continue label is built in script (was inline) so the " — page N" suffix keeps its spaces — Svelte trimmed them at the {#if} edge, rendering "Chapter 3— page 7". Both surfaces now read "Continue Chapter N". Net -204 lines across the two pages. Uploads parity on the mobile tab is left as a follow-up (it needs the library loader to fetch uploads). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/package.json | 2 +- .../src/lib/components/HistoryList.svelte | 230 ++++++++++++++++++ .../lib/components/HistoryList.svelte.test.ts | 103 ++++++++ frontend/src/routes/library/+page.svelte | 122 +--------- .../src/routes/profile/history/+page.svelte | 124 +--------- 7 files changed, 357 insertions(+), 228 deletions(-) create mode 100644 frontend/src/lib/components/HistoryList.svelte create mode 100644 frontend/src/lib/components/HistoryList.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5a0b785..77e9e38 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.88.1" +version = "0.89.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 20507fc..765d388 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.88.1" +version = "0.89.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index bac57a1..50639c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.88.1", + "version": "0.89.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/HistoryList.svelte b/frontend/src/lib/components/HistoryList.svelte new file mode 100644 index 0000000..e9b987e --- /dev/null +++ b/frontend/src/lib/components/HistoryList.svelte @@ -0,0 +1,230 @@ + + +{#if clearError} + +{/if} + +{#if rows.length === 0} +

{emptyText}

+{:else} + +{/if} + + diff --git a/frontend/src/lib/components/HistoryList.svelte.test.ts b/frontend/src/lib/components/HistoryList.svelte.test.ts new file mode 100644 index 0000000..7fcad8d --- /dev/null +++ b/frontend/src/lib/components/HistoryList.svelte.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/svelte'; +import HistoryList from './HistoryList.svelte'; +import type { ReadProgressSummary } from '$lib/api/read_progress'; + +afterEach(() => cleanup()); + +function entry(over: Partial = {}): ReadProgressSummary { + return { + manga_id: 'm1', + manga_title: 'Berserk', + manga_cover_image_path: null, + chapter_id: 'c1', + chapter_number: 5, + page: 1, + updated_at: '2026-01-02T00:00:00Z', + ...over + }; +} + +describe('HistoryList', () => { + it('renders one row per entry with a continue link to the chapter', () => { + render(HistoryList, { + props: { entries: [entry({ manga_id: 'm1', chapter_id: 'c9', chapter_number: 9 })] } + }); + const cont = screen.getByRole('link', { name: /Continue Chapter 9/i }); + expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9'); + }); + + it('appends the page number to the continue line only past page 1', () => { + // The page suffix is a separate text node from "Continue Chapter N", + // so assert against the link's normalized text rather than getByText. + render(HistoryList, { props: { entries: [entry({ page: 7, chapter_number: 3 })] } }); + const link = screen.getByRole('link', { name: /Continue Chapter 3/i }); + expect(link.textContent?.replace(/\s+/g, ' ').trim()).toBe('Continue Chapter 3 — page 7'); + cleanup(); + render(HistoryList, { props: { entries: [entry({ page: 1, chapter_number: 3 })] } }); + expect(screen.queryByText(/page 1/i)).toBeNull(); + }); + + it('shows a "chapter removed" fallback when the chapter id survives but its number is gone', () => { + render(HistoryList, { + props: { entries: [entry({ chapter_id: 'c1', chapter_number: null })] } + }); + expect(screen.getByText(/chapter removed/i)).toBeTruthy(); + expect(screen.queryByRole('link', { name: /Continue/i })).toBeNull(); + }); + + it('shows a "whole manga" fallback when there is no chapter', () => { + render(HistoryList, { + props: { entries: [entry({ chapter_id: null, chapter_number: null, page: 4 })] } + }); + expect(screen.getByText(/Whole manga, page 4/i)).toBeTruthy(); + }); + + it('renders the empty state with the provided copy and testid when there are no entries', () => { + render(HistoryList, { + props: { entries: [], emptyText: 'Nothing here yet.', testid: 'library-history' } + }); + const empty = screen.getByTestId('library-history-empty'); + expect(empty.textContent).toContain('Nothing here yet.'); + expect(screen.queryByTestId('library-history-list')).toBeNull(); + }); + + it('omits clear buttons entirely when no onClear is supplied (read-only mode)', () => { + render(HistoryList, { props: { entries: [entry()] } }); + expect(screen.queryByRole('button', { name: /Clear/i })).toBeNull(); + }); + + it('optimistically removes a row and calls onClear when the clear button is pressed', async () => { + const onClear = vi.fn().mockResolvedValue(undefined); + render(HistoryList, { + props: { + entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })], + onClear, + testid: 'history-reading' + } + }); + await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i })); + expect(onClear).toHaveBeenCalledWith( + expect.objectContaining({ manga_id: 'm1' }) + ); + await waitFor(() => expect(screen.queryByText('Berserk')).toBeNull()); + }); + + it('restores the row and surfaces an inline error when onClear rejects', async () => { + const onClear = vi.fn().mockRejectedValue(new Error('network down')); + render(HistoryList, { + props: { + entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })], + onClear, + testid: 'history-reading' + } + }); + await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i })); + await waitFor(() => { + const err = screen.getByTestId('history-reading-error'); + expect(err.textContent).toMatch(/network down/i); + }); + // Optimistic removal rolled back — the row is back. + expect(screen.getByText('Berserk')).toBeTruthy(); + }); +}); diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 89f8194..09af536 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -1,16 +1,19 @@