From bfaa166e0a7def0566af68f08650c902cf226b6a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 21:52:58 +0200 Subject: [PATCH] fix(home): keep finished series off the Continue-reading shelf A "Continue reading" shelf listing series the reader already finished (read to the last page, nothing new) is odd. Expose the last-read chapter's page_count on the read-progress list, and filter the shelf to drop entries that are caught up (on the last page of the latest chapter with no new chapters). Mid-chapter and has-new-chapters entries stay; the full history view is unaffected. Finished detection is a pure, unit-tested helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/domain/read_progress.rs | 5 +++ backend/src/repo/read_progress.rs | 1 + backend/tests/api_history.rs | 26 ++++++++++++++ frontend/e2e/continue-reading-shelf.spec.ts | 35 +++++++++++++++++++ frontend/package-lock.json | 4 +-- frontend/package.json | 2 +- frontend/src/lib/api/read_progress.ts | 3 ++ .../ContinueReadingShelf.svelte.test.ts | 1 + .../lib/components/HistoryList.svelte.test.ts | 1 + frontend/src/lib/continueReading.test.ts | 32 +++++++++++++++++ frontend/src/lib/continueReading.ts | 20 +++++++++++ frontend/src/routes/+page.svelte | 5 ++- 14 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/continueReading.test.ts create mode 100644 frontend/src/lib/continueReading.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6320fec..c105851 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.103.0" +version = "0.104.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4f81849..74313d0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.103.0" +version = "0.104.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/domain/read_progress.rs b/backend/src/domain/read_progress.rs index fa71b97..84d54ed 100644 --- a/backend/src/domain/read_progress.rs +++ b/backend/src/domain/read_progress.rs @@ -24,6 +24,11 @@ pub struct ReadProgressSummary { /// `None` when the chapter was deleted after this row was written /// (FK ON DELETE SET NULL on `chapter_id`). pub chapter_number: Option, + /// Page count of the last-read chapter (`None` when unknown / deleted). + /// Lets a client distinguish a finished series (read to the last page, + /// nothing new) from one still in progress — e.g. to keep finished + /// series off a "Continue reading" shelf. + pub chapter_page_count: Option, pub page: i32, pub updated_at: DateTime, /// How many chapters sit past the reader's last-read chapter (by diff --git a/backend/src/repo/read_progress.rs b/backend/src/repo/read_progress.rs index d48db89..1a6f926 100644 --- a/backend/src/repo/read_progress.rs +++ b/backend/src/repo/read_progress.rs @@ -141,6 +141,7 @@ pub async fn list_for_user( m.cover_image_path AS manga_cover_image_path, rp.chapter_id, c.number AS chapter_number, + c.page_count AS chapter_page_count, rp.page, rp.updated_at, -- Personal "new since last read": how many distinct chapter diff --git a/backend/tests/api_history.rs b/backend/tests/api_history.rs index 8d5a78f..a9091ed 100644 --- a/backend/tests/api_history.rs +++ b/backend/tests/api_history.rs @@ -191,6 +191,32 @@ async fn list_reports_new_chapters_since_last_read(pool: PgPool) { assert_eq!(body["items"][0]["new_chapters_count"], 2); } +#[sqlx::test(migrations = "./migrations")] +async fn list_reports_last_read_chapter_page_count(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + // seed_chapter uploads a single page, so page_count is 1. + let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await; + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }), + ) + .await; + + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + // Exposes the last-read chapter's page count so a client can tell a + // finished series (on the last page) from one still in progress. + assert_eq!(body["items"][0]["chapter_page_count"], 1); +} + #[sqlx::test(migrations = "./migrations")] async fn list_new_chapters_count_is_zero_at_latest_chapter(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/e2e/continue-reading-shelf.spec.ts b/frontend/e2e/continue-reading-shelf.spec.ts index 38a3511..6e5ab12 100644 --- a/frontend/e2e/continue-reading-shelf.spec.ts +++ b/frontend/e2e/continue-reading-shelf.spec.ts @@ -47,6 +47,7 @@ test('shows the continue-reading shelf with a new-chapter badge when signed in', manga_cover_image_path: null, chapter_id: 'c1', chapter_number: 3, + chapter_page_count: 20, page: 4, updated_at: '2026-01-01T00:00:00Z', new_chapters_count: 2 @@ -65,6 +66,40 @@ test('shows the continue-reading shelf with a new-chapter badge when signed in', await expect(page.getByTestId('continue-new-badge-m1')).toContainText('2'); }); +test('keeps finished series off the shelf but shows in-progress ones', async ({ page }) => { + await mockCommon(page); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', is_admin: false } }) }) + ); + await page.route('**/api/v1/me/read-progress*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + // Finished: last page of the latest chapter, nothing new. + { + manga_id: 'done', manga_title: 'Finished', manga_cover_image_path: null, + chapter_id: 'dc', chapter_number: 5, chapter_page_count: 10, page: 10, + updated_at: '2026-01-02T00:00:00Z', new_chapters_count: 0 + }, + // In progress: mid-chapter. + { + manga_id: 'wip', manga_title: 'Ongoing', manga_cover_image_path: null, + chapter_id: 'wc', chapter_number: 2, chapter_page_count: 10, page: 3, + updated_at: '2026-01-01T00:00:00Z', new_chapters_count: 0 + } + ], + page: { limit: 50, offset: 0, total: 2 } + }) + }) + ); + + await page.goto('/'); + await expect(page.getByTestId('continue-card-wip')).toBeVisible(); + await expect(page.getByTestId('continue-card-done')).toHaveCount(0); +}); + test('hides the continue-reading shelf for anonymous visitors', async ({ page }) => { await mockCommon(page); await page.route('**/api/v1/auth/me', (route) => diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64a3f1c..d53574e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.103.0", + "version": "0.104.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.103.0", + "version": "0.104.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 4d4eab1..d88b6ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.103.0", + "version": "0.104.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/read_progress.ts b/frontend/src/lib/api/read_progress.ts index 70909ec..a61d424 100644 --- a/frontend/src/lib/api/read_progress.ts +++ b/frontend/src/lib/api/read_progress.ts @@ -15,6 +15,9 @@ export type ReadProgressSummary = { chapter_id: string | null; /** `null` if the chapter was deleted after the progress was written. */ chapter_number: number | null; + /** Page count of the last-read chapter (`null` when unknown / deleted). + * Lets the shelf tell a finished series from one still in progress. */ + chapter_page_count: number | null; page: number; updated_at: string; /** Chapters past the reader's last-read chapter — a personal "new diff --git a/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts b/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts index a3b6dfd..231d4c5 100644 --- a/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts +++ b/frontend/src/lib/components/ContinueReadingShelf.svelte.test.ts @@ -14,6 +14,7 @@ function entry(over: Partial = {}): Entry { manga_cover_image_path: null, chapter_id: 'c1', chapter_number: 3, + chapter_page_count: 20, page: 3, updated_at: '2026-01-01T00:00:00Z', new_chapters_count: 0, diff --git a/frontend/src/lib/components/HistoryList.svelte.test.ts b/frontend/src/lib/components/HistoryList.svelte.test.ts index a2f254d..3bed83a 100644 --- a/frontend/src/lib/components/HistoryList.svelte.test.ts +++ b/frontend/src/lib/components/HistoryList.svelte.test.ts @@ -12,6 +12,7 @@ function entry(over: Partial = {}): ReadProgressSummary { manga_cover_image_path: null, chapter_id: 'c1', chapter_number: 5, + chapter_page_count: 10, page: 1, updated_at: '2026-01-02T00:00:00Z', new_chapters_count: 0, diff --git a/frontend/src/lib/continueReading.test.ts b/frontend/src/lib/continueReading.test.ts new file mode 100644 index 0000000..f3e9846 --- /dev/null +++ b/frontend/src/lib/continueReading.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { isCaughtUp } from './continueReading'; + +const base = { + chapter_number: 5 as number | null, + chapter_page_count: 10 as number | null, + page: 3, + new_chapters_count: 0 +}; + +describe('isCaughtUp', () => { + it('is true on the last page of the latest chapter with nothing new', () => { + expect(isCaughtUp({ ...base, page: 10 })).toBe(true); + }); + + it('is false mid-chapter (still something to continue)', () => { + expect(isCaughtUp({ ...base, page: 3 })).toBe(false); + }); + + it('is false when newer chapters exist', () => { + expect(isCaughtUp({ ...base, page: 10, new_chapters_count: 2 })).toBe(false); + }); + + it('is false when the last-read position is unknown', () => { + expect(isCaughtUp({ ...base, page: 10, chapter_number: null })).toBe(false); + }); + + it('is false when the chapter page count is unknown or zero', () => { + expect(isCaughtUp({ ...base, page: 10, chapter_page_count: null })).toBe(false); + expect(isCaughtUp({ ...base, page: 1, chapter_page_count: 0 })).toBe(false); + }); +}); diff --git a/frontend/src/lib/continueReading.ts b/frontend/src/lib/continueReading.ts new file mode 100644 index 0000000..04ed2a6 --- /dev/null +++ b/frontend/src/lib/continueReading.ts @@ -0,0 +1,20 @@ +import type { ReadProgressSummary } from '$lib/api/read_progress'; + +// Whether a read-progress entry has nothing left to continue: the reader is +// on the last page of the latest chapter and no newer chapters exist. Such +// finished series are kept off the homepage "Continue reading" shelf (the +// full history view still shows them). +export function isCaughtUp( + e: Pick< + ReadProgressSummary, + 'new_chapters_count' | 'chapter_number' | 'chapter_page_count' | 'page' + > +): boolean { + return ( + e.new_chapters_count === 0 && + e.chapter_number != null && + e.chapter_page_count != null && + e.chapter_page_count > 0 && + e.page >= e.chapter_page_count + ); +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6b2e9b3..8978ce5 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -26,6 +26,7 @@ listMyReadProgressOrEmpty, type ReadProgressSummary } from '$lib/api/read_progress'; + import { isCaughtUp } from '$lib/continueReading'; import Chip from '$lib/components/Chip.svelte'; import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte'; import MangaCard from '$lib/components/MangaCard.svelte'; @@ -312,7 +313,9 @@ // empty for guests (401 swallowed), which hides the shelf. try { const progress = await listMyReadProgressOrEmpty(); - continueEntries = progress.items; + // Drop finished series (read to the end, nothing new) — a + // "Continue reading" shelf is for what's still in progress. + continueEntries = progress.items.filter((e) => !isCaughtUp(e)); } catch { // Never let a history hiccup break the catalogue — leave the // shelf hidden.