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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.102.0"
|
version = "0.103.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.102.0"
|
version = "0.103.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub struct ReadProgressForManga {
|
|||||||
pub chapter_number: Option<i32>,
|
pub chapter_number: Option<i32>,
|
||||||
pub page: i32,
|
pub page: i32,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
@@ -80,7 +80,17 @@ pub async fn get_for_manga(
|
|||||||
rp.chapter_id,
|
rp.chapter_id,
|
||||||
c.number AS chapter_number,
|
c.number AS chapter_number,
|
||||||
rp.page,
|
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
|
FROM read_progress rp
|
||||||
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
||||||
WHERE rp.user_id = $1 AND rp.manga_id = $2
|
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,
|
c.number AS chapter_number,
|
||||||
rp.page,
|
rp.page,
|
||||||
rp.updated_at,
|
rp.updated_at,
|
||||||
-- Personal "new since last read": chapters numbered past the
|
-- Personal "new since last read": how many distinct chapter
|
||||||
-- reader's last-read chapter. `c.number` is NULL for
|
-- numbers sit past the reader's last-read chapter.
|
||||||
-- manga-level progress or a deleted chapter, in which case
|
-- COUNT(DISTINCT number) — not COUNT(*) — because
|
||||||
-- the guard yields 0 rather than counting every chapter.
|
-- (manga_id, number) is non-unique (scanlations share a
|
||||||
-- (manga_id, number) is non-unique — translations share a
|
-- number, migration 0013), so raw rows would over-count. When
|
||||||
-- number — so this counts positions past the reader, not
|
-- `c.number` is NULL (manga-level progress or a deleted
|
||||||
-- distinct files.
|
-- chapter) the predicate matches no rows, yielding 0.
|
||||||
COALESCE((
|
(
|
||||||
SELECT count(*)
|
SELECT count(DISTINCT c2.number)
|
||||||
FROM chapters c2
|
FROM chapters c2
|
||||||
WHERE c2.manga_id = rp.manga_id
|
WHERE c2.manga_id = rp.manga_id
|
||||||
AND c.number IS NOT NULL
|
AND c.number IS NOT NULL
|
||||||
AND c2.number > c.number
|
AND c2.number > c.number
|
||||||
), 0) AS new_chapters_count
|
) AS new_chapters_count
|
||||||
FROM read_progress rp
|
FROM read_progress rp
|
||||||
JOIN mangas m ON m.id = rp.manga_id
|
JOIN mangas m ON m.id = rp.manga_id
|
||||||
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
||||||
|
|||||||
@@ -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);
|
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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_single_manga_returns_404_when_unread(pool: PgPool) {
|
async fn get_single_manga_returns_404_when_unread(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
|
|||||||
@@ -9,16 +9,21 @@ const ch1 = 'c1111111-1111-1111-1111-111111111111';
|
|||||||
const ch2 = 'c2222222-2222-2222-2222-222222222222';
|
const ch2 = 'c2222222-2222-2222-2222-222222222222';
|
||||||
const ch3 = 'c3333333-3333-3333-3333-333333333333';
|
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 = [
|
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: 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(
|
async function mockDetail(
|
||||||
page: Page,
|
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) =>
|
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 }) })
|
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 }) => {
|
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}`);
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
|
||||||
// Chapter 1 is read; 2 and 3 are not.
|
// Chapter 1 is read; 2 and 3 are not.
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.102.0",
|
"version": "0.103.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.102.0",
|
"version": "0.103.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.102.0",
|
"version": "0.103.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export type ReadProgressForManga = {
|
|||||||
chapter_number: number | null;
|
chapter_number: number | null;
|
||||||
page: number;
|
page: number;
|
||||||
updated_at: string;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { isChapterRead, countNewChapters } from './chapterProgress';
|
import { isChapterRead } from './chapterProgress';
|
||||||
|
|
||||||
describe('isChapterRead', () => {
|
describe('isChapterRead', () => {
|
||||||
it('marks chapters at or before the last-read number as read', () => {
|
it('marks chapters at or before the last-read number as read', () => {
|
||||||
@@ -15,19 +15,3 @@ describe('isChapterRead', () => {
|
|||||||
expect(isChapterRead(1, null)).toBe(false);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Pure helpers for reflecting a reader's personal progress on a manga's
|
// Pure helper 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"
|
// chapter list. Kept UI-free so the read/unread logic is unit-testable
|
||||||
// logic is unit-testable without rendering the detail page.
|
// without rendering the detail page.
|
||||||
|
|
||||||
export type NumberedChapter = { number: number };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A chapter counts as read once its number is at or before the reader's
|
* 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;
|
return lastReadNumber != null && chapterNumber <= lastReadNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Note: the "new since last read" *count* is computed server-side
|
||||||
* How many of the given chapters sit past the reader's last-read chapter
|
// (distinct chapter numbers over ALL chapters — see
|
||||||
* (by number) — the personal "new since you last read" count. `0` when the
|
// repo::read_progress) and delivered on the read-progress payloads, so it
|
||||||
* last-read position is unknown. Counts over the provided (loaded) list, so
|
// isn't recomputed here over the detail page's paginated chapter list.
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
} from '$lib/api/mangas';
|
} from '$lib/api/mangas';
|
||||||
import { resyncManga } from '$lib/api/admin';
|
import { resyncManga } from '$lib/api/admin';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { isChapterRead, countNewChapters } from '$lib/chapterProgress';
|
import { isChapterRead } from '$lib/chapterProgress';
|
||||||
import { formatBytes } from '$lib/upload-validation';
|
import { formatBytes } from '$lib/upload-validation';
|
||||||
import { listTags, type Tag } from '$lib/api/tags';
|
import { listTags, type Tag } from '$lib/api/tags';
|
||||||
import type { ContentWarning } from '$lib/api/page_tags';
|
import type { ContentWarning } from '$lib/api/page_tags';
|
||||||
@@ -70,11 +70,16 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
/** The reader's last-read chapter number, used to mark chapters as read
|
/** The reader's last-read chapter number, used to mark chapters read.
|
||||||
* and count what's landed since. `null` for guests / never-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);
|
const lastReadNumber = $derived(readProgress?.chapter_number ?? null);
|
||||||
/** New chapters since last read, over the loaded chapter list. */
|
/** New chapters since last read. Authoritative backend count over ALL
|
||||||
const newChapterCount = $derived(countNewChapters(chapters, lastReadNumber));
|
* 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 authors = $derived<AuthorRef[]>(manga.authors);
|
||||||
const genres = $derived<GenreRef[]>(manga.genres);
|
const genres = $derived<GenreRef[]>(manga.genres);
|
||||||
|
|||||||
Reference in New Issue
Block a user