diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ad40793..a5fa49a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.94.0" +version = "0.94.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b5f1ef6..5a2f57b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.94.1" +version = "0.95.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/domain/read_progress.rs b/backend/src/domain/read_progress.rs index c36577b..33fa330 100644 --- a/backend/src/domain/read_progress.rs +++ b/backend/src/domain/read_progress.rs @@ -26,6 +26,12 @@ pub struct ReadProgressSummary { pub chapter_number: Option, pub page: i32, pub updated_at: DateTime, + /// How many chapters sit past the reader's last-read chapter (by + /// chapter number) — a personal "new since you last read" count. + /// `0` when the reader is caught up or when `chapter_number` is + /// unknown (manga-level progress or a deleted chapter), so we never + /// claim chapters are new when we can't place the reader. + pub new_chapters_count: i64, } /// Returned by `GET /me/read-progress/:manga_id`. Same shape as diff --git a/backend/src/repo/read_progress.rs b/backend/src/repo/read_progress.rs index 7c92024..5f794ca 100644 --- a/backend/src/repo/read_progress.rs +++ b/backend/src/repo/read_progress.rs @@ -132,7 +132,21 @@ pub async fn list_for_user( rp.chapter_id, c.number AS chapter_number, rp.page, - rp.updated_at + 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(*) + 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 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 680b9c5..3c36a14 100644 --- a/backend/tests/api_history.rs +++ b/backend/tests/api_history.rs @@ -164,6 +164,84 @@ async fn list_is_per_user_only(pool: PgPool) { assert_eq!(body["items"], json!([])); } +#[sqlx::test(migrations = "./migrations")] +async fn list_reports_new_chapters_since_last_read(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; + // Three chapters exist; the reader is on chapter 1, so chapters 2 and + // 3 are "new since last read". + 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, 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 list_new_chapters_count_is_zero_at_latest_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 ch2 = seed_chapter(&h.app, &cookie, manga_id, 2).await; + // Caught up to the last chapter → nothing newer. + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "chapter_id": ch2, "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"], 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_new_chapters_count_is_zero_without_a_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; + // Progress recorded without a chapter (manga-level) — we can't place + // the reader among the chapters, so we don't claim any are "new". + 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("/api/v1/me/read-progress", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["items"][0]["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/package-lock.json b/frontend/package-lock.json index cb6c27e..575d277 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.94.1", + "version": "0.95.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.94.1", + "version": "0.95.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 49466ed..a55800d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.94.1", + "version": "0.95.0", "private": true, "type": "module", "scripts": {