diff --git a/backend/Cargo.lock b/backend/Cargo.lock index eb444a3..6320fec 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.102.0" +version = "0.103.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 07fc8d0..4f81849 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.102.0" +version = "0.103.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/domain/read_progress.rs b/backend/src/domain/read_progress.rs index 33fa330..fa71b97 100644 --- a/backend/src/domain/read_progress.rs +++ b/backend/src/domain/read_progress.rs @@ -46,6 +46,11 @@ pub struct ReadProgressForManga { pub chapter_number: Option, pub page: i32, pub updated_at: DateTime, + /// Distinct chapter numbers past the last-read chapter — the detail + /// page's authoritative "new since last read" count (computed over all + /// chapters, not the client's paginated list). `0` when caught up or + /// the position is unknown. See [`ReadProgressSummary::new_chapters_count`]. + pub new_chapters_count: i64, } #[derive(Debug, Clone, Deserialize)] diff --git a/backend/src/repo/read_progress.rs b/backend/src/repo/read_progress.rs index 5f794ca..d48db89 100644 --- a/backend/src/repo/read_progress.rs +++ b/backend/src/repo/read_progress.rs @@ -80,7 +80,17 @@ pub async fn get_for_manga( rp.chapter_id, c.number AS chapter_number, rp.page, - rp.updated_at + rp.updated_at, + -- Distinct chapter numbers past the reader's last-read chapter + -- (see list_for_user for the non-unique-number rationale). 0 + -- when the last-read chapter is unknown. + ( + SELECT count(DISTINCT c2.number) + FROM chapters c2 + WHERE c2.manga_id = rp.manga_id + AND c.number IS NOT NULL + AND c2.number > c.number + ) AS new_chapters_count FROM read_progress rp LEFT JOIN chapters c ON c.id = rp.chapter_id WHERE rp.user_id = $1 AND rp.manga_id = $2 @@ -133,20 +143,20 @@ pub async fn list_for_user( c.number AS chapter_number, rp.page, rp.updated_at, - -- Personal "new since last read": chapters numbered past the - -- reader's last-read chapter. `c.number` is NULL for - -- manga-level progress or a deleted chapter, in which case - -- the guard yields 0 rather than counting every chapter. - -- (manga_id, number) is non-unique — translations share a - -- number — so this counts positions past the reader, not - -- distinct files. - COALESCE(( - SELECT count(*) + -- Personal "new since last read": how many distinct chapter + -- numbers sit past the reader's last-read chapter. + -- COUNT(DISTINCT number) — not COUNT(*) — because + -- (manga_id, number) is non-unique (scanlations share a + -- number, migration 0013), so raw rows would over-count. When + -- `c.number` is NULL (manga-level progress or a deleted + -- chapter) the predicate matches no rows, yielding 0. + ( + SELECT count(DISTINCT c2.number) FROM chapters c2 WHERE c2.manga_id = rp.manga_id AND c.number IS NOT NULL AND c2.number > c.number - ), 0) AS new_chapters_count + ) AS new_chapters_count FROM read_progress rp JOIN mangas m ON m.id = rp.manga_id LEFT JOIN chapters c ON c.id = rp.chapter_id diff --git a/backend/tests/api_history.rs b/backend/tests/api_history.rs index 3c36a14..8d5a78f 100644 --- a/backend/tests/api_history.rs +++ b/backend/tests/api_history.rs @@ -242,6 +242,95 @@ async fn list_new_chapters_count_is_zero_without_a_read_chapter(pool: PgPool) { assert_eq!(body["items"][0]["new_chapters_count"], 0); } +#[sqlx::test(migrations = "./migrations")] +async fn list_new_chapters_count_dedupes_same_numbered_scanlations(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; + // (manga_id, number) is non-unique — two scanlations of chapter 2. + // "New since last read" should count distinct chapter numbers past the + // reader (2, 3 → 2), not raw rows (2, 2, 3 → 3). + let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await; + let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await; + let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await; + let _ = seed_chapter(&h.app, &cookie, manga_id, 3).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; + assert_eq!(body["items"][0]["new_chapters_count"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_single_manga_reports_new_chapters_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; + let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await; + let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await; + // Duplicate scanlation of chapter 3 must not inflate the count. + let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await; + let _ = seed_chapter(&h.app, &cookie, manga_id, 3).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( + &format!("/api/v1/me/read-progress/{manga_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + // Distinct numbers past chapter 1: {2, 3} → 2. + assert_eq!(body["new_chapters_count"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_single_manga_new_chapters_count_zero_without_read_chapter(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; + let _ = seed_chapter(&h.app, &cookie, manga_id, 1).await; + let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await; + // Manga-level progress (no chapter) → position unknown → 0. + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "page": 1 }), + ) + .await; + + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie( + &format!("/api/v1/me/read-progress/{manga_id}"), + &cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["new_chapters_count"], 0); +} + #[sqlx::test(migrations = "./migrations")] async fn get_single_manga_returns_404_when_unread(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/e2e/detail-read-markers.spec.ts b/frontend/e2e/detail-read-markers.spec.ts index 444ccc1..a247873 100644 --- a/frontend/e2e/detail-read-markers.spec.ts +++ b/frontend/e2e/detail-read-markers.spec.ts @@ -9,16 +9,21 @@ const ch1 = 'c1111111-1111-1111-1111-111111111111'; const ch2 = 'c2222222-2222-2222-2222-222222222222'; const ch3 = 'c3333333-3333-3333-3333-333333333333'; -// Newest-first, as the API returns them. +// Oldest-first, as repo::chapter::list_for_manga returns them. const chapters = [ - { id: ch3, manga_id: mangaId, number: 3, title: null, page_count: 10, created_at: '2026-03-01T00:00:00Z' }, + { id: ch1, manga_id: mangaId, number: 1, title: null, page_count: 8, created_at: '2026-01-01T00:00:00Z' }, { id: ch2, manga_id: mangaId, number: 2, title: null, page_count: 10, created_at: '2026-02-01T00:00:00Z' }, - { id: ch1, manga_id: mangaId, number: 1, title: null, page_count: 8, created_at: '2026-01-01T00:00:00Z' } + { id: ch3, manga_id: mangaId, number: 3, title: null, page_count: 10, created_at: '2026-03-01T00:00:00Z' } ]; async function mockDetail( page: Page, - readProgress: { chapter_id: string; chapter_number: number; page: number } | null + readProgress: { + chapter_id: string; + chapter_number: number; + page: number; + new_chapters_count: number; + } | null ) { await page.route('**/api/v1/auth/config', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }) @@ -60,7 +65,8 @@ async function mockDetail( } test('marks read chapters and counts new ones when mid-series', async ({ page }) => { - await mockDetail(page, { chapter_id: ch1, chapter_number: 1, page: 1 }); + // Backend reports 2 distinct new chapters (2 and 3) past chapter 1. + await mockDetail(page, { chapter_id: ch1, chapter_number: 1, page: 1, new_chapters_count: 2 }); await page.goto(`/manga/${mangaId}`); // Chapter 1 is read; 2 and 3 are not. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3eeb0dc..64a3f1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.102.0", + "version": "0.103.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.102.0", + "version": "0.103.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 58102a6..4d4eab1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.102.0", + "version": "0.103.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 ce4c295..70909ec 100644 --- a/frontend/src/lib/api/read_progress.ts +++ b/frontend/src/lib/api/read_progress.ts @@ -79,6 +79,10 @@ export type ReadProgressForManga = { chapter_number: number | null; page: number; updated_at: string; + /** Distinct chapter numbers past the last-read chapter — the + * authoritative "new since last read" count (over all chapters, not the + * detail page's paginated list). `0` when caught up / position unknown. */ + new_chapters_count: number; }; /** diff --git a/frontend/src/lib/chapterProgress.test.ts b/frontend/src/lib/chapterProgress.test.ts index a7cf817..be18d42 100644 --- a/frontend/src/lib/chapterProgress.test.ts +++ b/frontend/src/lib/chapterProgress.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isChapterRead, countNewChapters } from './chapterProgress'; +import { isChapterRead } from './chapterProgress'; describe('isChapterRead', () => { it('marks chapters at or before the last-read number as read', () => { @@ -15,19 +15,3 @@ describe('isChapterRead', () => { expect(isChapterRead(1, null)).toBe(false); }); }); - -describe('countNewChapters', () => { - const chapters = [{ number: 1 }, { number: 2 }, { number: 3 }]; - - it('counts chapters past the last-read number', () => { - expect(countNewChapters(chapters, 1)).toBe(2); - }); - - it('is zero when caught up to the latest', () => { - expect(countNewChapters(chapters, 3)).toBe(0); - }); - - it('is zero when the last-read position is unknown', () => { - expect(countNewChapters(chapters, null)).toBe(0); - }); -}); diff --git a/frontend/src/lib/chapterProgress.ts b/frontend/src/lib/chapterProgress.ts index 64e1b3f..658e36b 100644 --- a/frontend/src/lib/chapterProgress.ts +++ b/frontend/src/lib/chapterProgress.ts @@ -1,8 +1,6 @@ -// Pure helpers for reflecting a reader's personal progress on a manga's -// chapter list. Kept UI-free so the read/unread and "new since last read" -// logic is unit-testable without rendering the detail page. - -export type NumberedChapter = { number: number }; +// Pure helper for reflecting a reader's personal progress on a manga's +// chapter list. Kept UI-free so the read/unread logic is unit-testable +// without rendering the detail page. /** * A chapter counts as read once its number is at or before the reader's @@ -13,16 +11,7 @@ export function isChapterRead(chapterNumber: number, lastReadNumber: number | nu return lastReadNumber != null && chapterNumber <= lastReadNumber; } -/** - * How many of the given chapters sit past the reader's last-read chapter - * (by number) — the personal "new since you last read" count. `0` when the - * last-read position is unknown. Counts over the provided (loaded) list, so - * for a paginated chapter list it reflects what's currently shown. - */ -export function countNewChapters( - chapters: NumberedChapter[], - lastReadNumber: number | null -): number { - if (lastReadNumber == null) return 0; - return chapters.reduce((n, c) => (c.number > lastReadNumber ? n + 1 : n), 0); -} +// Note: the "new since last read" *count* is computed server-side +// (distinct chapter numbers over ALL chapters — see +// repo::read_progress) and delivered on the read-progress payloads, so it +// isn't recomputed here over the detail page's paginated chapter list. diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index fa92a81..06534f8 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -13,7 +13,7 @@ } from '$lib/api/mangas'; import { resyncManga } from '$lib/api/admin'; import { chapterLabel } from '$lib/api/chapters'; - import { isChapterRead, countNewChapters } from '$lib/chapterProgress'; + import { isChapterRead } from '$lib/chapterProgress'; import { formatBytes } from '$lib/upload-validation'; import { listTags, type Tag } from '$lib/api/tags'; import type { ContentWarning } from '$lib/api/page_tags'; @@ -70,11 +70,16 @@ : null ); - /** The reader's last-read chapter number, used to mark chapters as read - * and count what's landed since. `null` for guests / never-read. */ + /** The reader's last-read chapter number, used to mark chapters read. + * `null` for guests / never-read. Read-state is by chapter *number* + * (all we persist), so re-reading one scanlation marks same-numbered + * scanlations read too — intended, since "chapter N" is read. */ const lastReadNumber = $derived(readProgress?.chapter_number ?? null); - /** New chapters since last read, over the loaded chapter list. */ - const newChapterCount = $derived(countNewChapters(chapters, lastReadNumber)); + /** New chapters since last read. Authoritative backend count over ALL + * chapters (distinct numbers) — not recomputed over the paginated + * `chapters` list, which would under-count long series and disagree + * with the homepage shelf. `0` for guests / never-read. */ + const newChapterCount = $derived(readProgress?.new_chapters_count ?? 0); const authors = $derived(manga.authors); const genres = $derived(manga.genres);