fix(history): count distinct new chapter numbers, authoritative on detail

new_chapters_count over-counted when scanlations share a chapter number
((manga_id, number) is non-unique, migration 0013): COUNT(*) tallied rows,
so duplicate-numbered chapters inflated the badge. Switch both the list and
per-manga read-progress queries to COUNT(DISTINCT number), and drop the dead
COALESCE (an empty set counts as 0, and the NULL-number case is handled by
the predicate). Fix the misleading comment.

Expose new_chapters_count on GET /me/read-progress/:manga_id and drive the
detail page's "N new" badge from it, instead of recomputing over the
paginated chapter list — which under-counted series past 50 chapters and
disagreed with the homepage shelf. Read markers stay client-side over the
loaded chapters and are documented as by-number (all we persist).

Adds duplicate-scanlation tests at both endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 21:48:36 +02:00
parent f1349100d7
commit 3824eaafb2
12 changed files with 153 additions and 61 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.102.0",
"version": "0.103.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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;
};
/**

View File

@@ -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);
});
});

View File

@@ -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.

View File

@@ -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<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres);