From 01d18e7ba22bbe80abb8e8f7db8f397dd02b58ff Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 21:01:46 +0200 Subject: [PATCH] feat(detail): mark read chapters and count new ones on the manga page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the manga detail page, dim chapters at or before the reader's last-read chapter (with an accent check + screen-reader "Read." label) and show a "N new since last read" badge counting chapters that have landed since. All client-side from the already-loaded chapter list and per-manga read progress — no extra requests. The count reflects the loaded (paginated) chapter list. Logic lives in a pure, unit-tested chapterProgress helper; e2e covers the mid-series and never-opened cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/detail-read-markers.spec.ts | 82 +++++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/chapterProgress.test.ts | 33 +++++++++ frontend/src/lib/chapterProgress.ts | 28 +++++++ frontend/src/routes/manga/[id]/+page.svelte | 65 +++++++++++++++- 8 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 frontend/e2e/detail-read-markers.spec.ts create mode 100644 frontend/src/lib/chapterProgress.test.ts create mode 100644 frontend/src/lib/chapterProgress.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 369de5e..af21620 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.96.0" +version = "0.97.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 790db91..ea55d71 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.96.0" +version = "0.97.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/detail-read-markers.spec.ts b/frontend/e2e/detail-read-markers.spec.ts new file mode 100644 index 0000000..444ccc1 --- /dev/null +++ b/frontend/e2e/detail-read-markers.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, type Page } from './fixtures'; + +// Detail page reflects the reader's personal progress: chapters at or before +// the last-read chapter are marked read, and a "N new since last read" badge +// counts chapters that have landed since. + +const mangaId = 'a1111111-1111-1111-1111-111111111111'; +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. +const chapters = [ + { id: ch3, manga_id: mangaId, number: 3, title: null, page_count: 10, created_at: '2026-03-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' } +]; + +async function mockDetail( + page: Page, + readProgress: { chapter_id: string; chapter_number: number; page: 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 }) }) + ); + await page.route('**/api/v1/auth/me', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } }) }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/me/bookmarks*', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chapters, page: { limit: 50, offset: 0, total: 3 } }) }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) }) + ); + await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) => + r.fulfill({ + status: readProgress ? 200 : 404, + contentType: 'application/json', + body: readProgress ? JSON.stringify(readProgress) : JSON.stringify({ error: { code: 'not_found', message: 'no' } }) + }) + ); + // getManga — registered last so it wins over the chapters glob above. + await page.route(`**/api/v1/mangas/${mangaId}`, (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, + cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 + }) + }) + ); +} + +test('marks read chapters and counts new ones when mid-series', async ({ page }) => { + await mockDetail(page, { chapter_id: ch1, chapter_number: 1, page: 1 }); + await page.goto(`/manga/${mangaId}`); + + // Chapter 1 is read; 2 and 3 are not. + await expect(page.getByTestId(`chapter-read-${ch1}`)).toBeVisible(); + await expect(page.getByTestId(`chapter-read-${ch2}`)).toHaveCount(0); + await expect(page.getByTestId(`chapter-read-${ch3}`)).toHaveCount(0); + + // Two chapters landed since last read. + await expect(page.getByTestId('new-chapters-summary')).toContainText('2 new'); +}); + +test('no markers or new-badge when the manga was never opened', async ({ page }) => { + await mockDetail(page, null); + await page.goto(`/manga/${mangaId}`); + + await expect(page.getByTestId('chapter-list')).toBeVisible(); + await expect(page.getByTestId(`chapter-read-${ch1}`)).toHaveCount(0); + await expect(page.getByTestId('new-chapters-summary')).toHaveCount(0); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c963903..58d2ae0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.96.0", + "version": "0.97.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.96.0", + "version": "0.97.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 6fb1002..440bbc6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.96.0", + "version": "0.97.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/chapterProgress.test.ts b/frontend/src/lib/chapterProgress.test.ts new file mode 100644 index 0000000..a7cf817 --- /dev/null +++ b/frontend/src/lib/chapterProgress.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { isChapterRead, countNewChapters } from './chapterProgress'; + +describe('isChapterRead', () => { + it('marks chapters at or before the last-read number as read', () => { + expect(isChapterRead(1, 3)).toBe(true); + expect(isChapterRead(3, 3)).toBe(true); + }); + + it('leaves later chapters unread', () => { + expect(isChapterRead(4, 3)).toBe(false); + }); + + it('marks nothing when the last-read position is unknown', () => { + 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 new file mode 100644 index 0000000..64e1b3f --- /dev/null +++ b/frontend/src/lib/chapterProgress.ts @@ -0,0 +1,28 @@ +// 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 }; + +/** + * A chapter counts as read once its number is at or before the reader's + * last-read chapter number. An unknown last-read position (guest, never + * opened, or a deleted chapter) marks nothing — we never guess. + */ +export function isChapterRead(chapterNumber: number, lastReadNumber: number | null): boolean { + 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); +} diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 25339b8..76fec67 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -13,6 +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 { formatBytes } from '$lib/upload-validation'; import { listTags, type Tag } from '$lib/api/tags'; import type { ContentWarning } from '$lib/api/page_tags'; @@ -27,6 +28,7 @@ import UploadCloud from '@lucide/svelte/icons/upload-cloud'; import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import ChevronLeft from '@lucide/svelte/icons/chevron-left'; + import Check from '@lucide/svelte/icons/check'; import MoreHorizontal from '@lucide/svelte/icons/more-horizontal'; import BookmarkIcon from '@lucide/svelte/icons/bookmark'; import BookmarkCheck from '@lucide/svelte/icons/bookmark-check'; @@ -68,6 +70,12 @@ : 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. */ + const lastReadNumber = $derived(readProgress?.chapter_number ?? null); + /** New chapters since last read, over the loaded chapter list. */ + const newChapterCount = $derived(countNewChapters(chapters, lastReadNumber)); + const authors = $derived(manga.authors); const genres = $derived(manga.genres); /** Deduped content warnings across the manga's pages (analysis worker). */ @@ -597,7 +605,14 @@
-

Chapters

+
+

Chapters

+ {#if newChapterCount > 0} + + {newChapterCount} new since last read + + {/if} +
{#if contentBytes === null || contentBytes > 0} Content: {contentBytes === null ? '—' : formatBytes(contentBytes)} @@ -624,7 +639,12 @@ {:else}
    {#each chapters as c (c.id)} -
  1. + {@const read = isChapterRead(c.number, lastReadNumber)} +
  2. + {#if read} +