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

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';
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<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres);
/** Deduped content warnings across the manga's pages (analysis worker). */
@@ -597,7 +605,14 @@
<section aria-label="chapters">
<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}
<span class="content-size" data-testid="manga-content-size">
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
@@ -624,7 +639,12 @@
{:else}
<ol class="chapter-list" data-testid="chapter-list">
{#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}">
{chapterLabel(c)}
</a>
@@ -1009,6 +1029,40 @@
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 {
color: var(--text-muted);
margin-left: var(--space-2);
@@ -1022,6 +1076,13 @@
gap: var(--space-3);
}
.chapters-head-left {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.content-size {
color: var(--text-muted);
font-size: var(--font-sm);