feat(detail): mark read chapters and count new ones on the manga page

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 21:01:46 +02:00
parent 2267a83f6c
commit 01d18e7ba2
8 changed files with 211 additions and 7 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.96.0" version = "0.97.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.96.0" version = "0.97.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

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

View File

@@ -1,12 +1,12 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.96.0", "version": "0.97.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.96.0", "version": "0.97.0",
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^1.16.0", "@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0", "@playwright/test": "^1.48.0",

View File

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

View File

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

View File

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

View File

@@ -13,6 +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 { 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';
@@ -27,6 +28,7 @@
import UploadCloud from '@lucide/svelte/icons/upload-cloud'; import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import Check from '@lucide/svelte/icons/check';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal'; import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import BookmarkIcon from '@lucide/svelte/icons/bookmark'; import BookmarkIcon from '@lucide/svelte/icons/bookmark';
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check'; import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
@@ -68,6 +70,12 @@
: null : 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<AuthorRef[]>(manga.authors); const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres); const genres = $derived<GenreRef[]>(manga.genres);
/** Deduped content warnings across the manga's pages (analysis worker). */ /** Deduped content warnings across the manga's pages (analysis worker). */
@@ -597,7 +605,14 @@
<section aria-label="chapters"> <section aria-label="chapters">
<div class="chapters-head"> <div class="chapters-head">
<h2>Chapters</h2> <div class="chapters-head-left">
<h2>Chapters</h2>
{#if newChapterCount > 0}
<span class="new-chapters" data-testid="new-chapters-summary">
{newChapterCount} new since last read
</span>
{/if}
</div>
{#if contentBytes === null || contentBytes > 0} {#if contentBytes === null || contentBytes > 0}
<span class="content-size" data-testid="manga-content-size"> <span class="content-size" data-testid="manga-content-size">
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)} Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
@@ -624,7 +639,12 @@
{:else} {:else}
<ol class="chapter-list" data-testid="chapter-list"> <ol class="chapter-list" data-testid="chapter-list">
{#each chapters as c (c.id)} {#each chapters as c (c.id)}
<li> {@const read = isChapterRead(c.number, lastReadNumber)}
<li class:read data-testid={read ? `chapter-read-${c.id}` : undefined}>
{#if read}
<Check size={15} class="read-check" aria-hidden="true" />
<span class="sr-only">Read.</span>
{/if}
<a href="/manga/{manga.id}/chapter/{c.id}"> <a href="/manga/{manga.id}/chapter/{c.id}">
{chapterLabel(c)} {chapterLabel(c)}
</a> </a>
@@ -1009,6 +1029,40 @@
padding: var(--space-1) 0; padding: var(--space-1) 0;
} }
/* Already-read chapters (at or before the reader's last-read chapter)
dim so the eye is drawn to what's still unread; the check keeps its
accent colour as a positive "done" marker. */
.chapter-list li.read {
color: var(--text-muted);
}
.chapter-list li.read :global(svg) {
vertical-align: -2px;
margin-right: var(--space-1);
color: var(--primary);
}
.new-chapters {
color: var(--primary);
background: var(--primary-soft-bg);
border-radius: var(--radius-pill);
padding: 0 var(--space-2);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.pages { .pages {
color: var(--text-muted); color: var(--text-muted);
margin-left: var(--space-2); margin-left: var(--space-2);
@@ -1022,6 +1076,13 @@
gap: var(--space-3); gap: var(--space-3);
} }
.chapters-head-left {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.content-size { .content-size {
color: var(--text-muted); color: var(--text-muted);
font-size: var(--font-sm); font-size: var(--font-sm);