fix(web): show "Resume Reading" CTA for a part-read manga volume

The series hero CTA chose its verb from a whole-list `anyRead` check, so a
volume partially read (e.g. 18%) — whose read flag is still false — fell
through to "Start Reading" even though the resume target already had
reading progress. A later volume read out of order would also mislabel an
in-progress earlier volume as "Continue".

Add resumeState() to the manga chapter library: it walks the flat reading
order once to resolve the resume target, derives "earlier read" only from
chapters before the target (so skip-ahead / manual mark-read doesn't flip
the verb), and flags the target as in-progress only for a reading position
strictly inside (0, 1) — the server marks a chapter read past its finished
threshold, so a full-progress target with a stale read flag must not show
"Resume Reading". The CTA verb is now hasEarlierRead ? "Continue"
: targetInProgress ? "Resume Reading" : "Start Reading"; a pristine series
still shows "Start Reading" and a fully read one "Read Again".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-26 18:44:05 -04:00
co-authored by Claude Opus 4.8
parent 4d2a990de1
commit b719292b3c
4 changed files with 217 additions and 11 deletions
+74 -1
View File
@@ -3,7 +3,7 @@
import { describe, expect, it } from "vitest";
import type { MangaChapter } from "@/api/types";
import { buildMangaList, prettifyVolumeLabel } from "./mangaChapters";
import { buildMangaList, prettifyVolumeLabel, resumeState } from "./mangaChapters";
function chapter(partial: Partial<MangaChapter>): MangaChapter {
return {
@@ -11,6 +11,8 @@ function chapter(partial: Partial<MangaChapter>): MangaChapter {
title: partial.title ?? "Chapter",
chapter_index: partial.chapter_index,
volume: partial.volume,
read: partial.read,
progress: partial.progress,
};
}
@@ -155,3 +157,74 @@ describe("volume token normalization", () => {
expect(entries).toHaveLength(2);
});
});
describe("resumeState", () => {
it("returns null for an empty list", () => {
expect(resumeState(buildMangaList([]))).toBeNull();
});
it("returns null once every chapter is read (caller restarts)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", read: true }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02", read: true }),
]);
expect(resumeState(entries)).toBeNull();
});
it("targets the first volume with no earlier-read and no progress (Start Reading)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01" }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
expect(resumeState(entries)).toEqual({
target: expect.objectContaining({ chapter: expect.objectContaining({ content_id: "v01" }) }),
hasEarlierRead: false,
targetInProgress: false,
});
});
it("flags the target in progress when it is part-read but unfinished (Resume Reading)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", progress: 0.18 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v01");
expect(state?.hasEarlierRead).toBe(false);
expect(state?.targetInProgress).toBe(true);
});
it("does not treat a fully read (progress 1, read true) earlier chapter as in-progress (Continue)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", read: true, progress: 1 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v02");
expect(state?.hasEarlierRead).toBe(true);
expect(state?.targetInProgress).toBe(false);
});
it("scopes hasEarlierRead to chapters before the target: a later read volume does not flip an in-progress target to Continue", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", progress: 0.18 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02", read: true }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v01");
expect(state?.hasEarlierRead).toBe(false);
expect(state?.targetInProgress).toBe(true);
});
it("does not flag a full-progress (1.0) target with a stale/absent read flag as in-progress", () => {
// The server marks a chapter read past its finished threshold, so a target
// at full progress with read still unset is finished, not "Resume Reading".
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", progress: 1 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v01");
expect(state?.targetInProgress).toBe(false);
});
});
+35
View File
@@ -151,3 +151,38 @@ export function firstUnreadChapter(entries: MangaListEntry[]): FlatMangaChapter
}
return null;
}
// A MangaResumeState describes where the viewer is in a series and how to label
// the resume CTA. `target` is the first unfinished chapter (the resume point);
// `hasEarlierRead` is true when any chapter *before* the target is finished
// (the viewer is genuinely mid-series → "Continue"); `targetInProgress` is true
// when the target itself is part-read but not finished (→ "Resume Reading").
// Null means every chapter is read or the list is empty, so the caller offers a
// restart.
export interface MangaResumeState {
target: FlatMangaChapter;
hasEarlierRead: boolean;
targetInProgress: boolean;
}
// resumeState walks the flat reading order once to resolve the resume target and
// classify it. `hasEarlierRead` is derived only from chapters before the target,
// so a chapter read out of order *after* an unfinished one (skip-ahead or a
// manual mark-read) does not mislabel an in-progress target as "Continue".
//
// `targetInProgress` requires a reading position strictly inside (0, 1): the
// server marks a chapter read once progress crosses its finished threshold, so a
// finished position (>= 1, or a stale/absent read flag at full progress) must
// not be surfaced as "Resume Reading".
export function resumeState(entries: MangaListEntry[]): MangaResumeState | null {
let hasEarlierRead = false;
for (const flat of flattenMangaList(entries)) {
if (flat.chapter.read !== true) {
const { progress } = flat.chapter;
const targetInProgress = typeof progress === "number" && progress > 0 && progress < 1;
return { target: flat, hasEarlierRead, targetInProgress };
}
hasEarlierRead = true;
}
return null;
}
@@ -139,6 +139,92 @@ describe("MangaContent", () => {
expect(cta).toHaveAttribute("href", "/reader/ebook/v01?libraryId=7" + seriesBackTo);
});
it("shows a Resume Reading hero CTA when the first volume is part-read but unfinished", () => {
render(
<MemoryRouter>
<MangaContent
item={mangaItem([
{
content_id: "v01",
title: "Railgun v01",
chapter_index: 1,
volume: "v01",
progress: 0.18,
},
{ content_id: "v02", title: "Railgun v02", chapter_index: 2, volume: "v02" },
])}
libraryId={7}
/>
</MemoryRouter>,
);
// 18% into Volume 1 with read=false: the CTA must resume the in-progress
// volume rather than offer a fresh "Start Reading".
const cta = screen.getByRole("link", { name: /Resume Reading/i });
expect(cta).toHaveTextContent("Volume 1");
expect(cta).toHaveAttribute("href", "/reader/ebook/v01?libraryId=7" + seriesBackTo);
expect(screen.queryByRole("link", { name: /Start Reading/i })).not.toBeInTheDocument();
});
it("keeps Resume Reading on an in-progress volume even when a later volume was read out of order", () => {
render(
<MemoryRouter>
<MangaContent
item={mangaItem([
{
content_id: "v01",
title: "Railgun v01",
chapter_index: 1,
volume: "v01",
progress: 0.18,
},
{
content_id: "v02",
title: "Railgun v02",
chapter_index: 2,
volume: "v02",
read: true,
},
])}
libraryId={7}
/>
</MemoryRouter>,
);
// Volume 2 read out of order must not relabel the part-read Volume 1 as
// "Continue": the resume target (V1) is in progress, so it stays "Resume
// Reading" and still points at V1.
const cta = screen.getByRole("link", { name: /Resume Reading/i });
expect(cta).toHaveTextContent("Volume 1");
expect(cta).toHaveAttribute("href", "/reader/ebook/v01?libraryId=7" + seriesBackTo);
expect(screen.queryByRole("link", { name: /Continue/i })).not.toBeInTheDocument();
});
it("shows Start Reading (not Resume) for a full-progress target whose read flag is stale", () => {
render(
<MemoryRouter>
<MangaContent
item={mangaItem([
{
content_id: "v01",
title: "Railgun v01",
chapter_index: 1,
volume: "v01",
progress: 1,
},
{ content_id: "v02", title: "Railgun v02", chapter_index: 2, volume: "v02" },
])}
libraryId={7}
/>
</MemoryRouter>,
);
// progress 1 means finished, not part-read: a stale/absent read flag must
// not surface "Resume Reading".
expect(screen.getByRole("link", { name: /Start Reading/i })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Resume Reading/i })).not.toBeInTheDocument();
});
it("shows a Continue hero CTA targeting the first unread chapter mid-series", () => {
render(
<MemoryRouter>
+22 -10
View File
@@ -33,8 +33,8 @@ import { buildItemHref, buildMediaPlayHref } from "@/lib/mediaNavigation";
import {
buildMangaList,
chapterLabel,
firstUnreadChapter,
flattenMangaList,
resumeState,
type MangaListEntry,
} from "@/lib/mangaChapters";
import { cn } from "@/lib/utils";
@@ -252,7 +252,6 @@ export default function MangaContent({
const entries = useMemo(() => buildMangaList(item.manga?.chapters ?? []), [item.manga?.chapters]);
const year = item.year ? String(item.year) : "";
const publisher = item.studios?.[0];
const chapterRows = item.manga?.chapters ?? [];
// Derive the badge counts from the rendered list so they always match the
// rows on screen: a volume/section entry is one volume (buildMangaList
// already canonicalizes v01 ≡ 1), a loose chapter entry is one chapter.
@@ -265,14 +264,27 @@ export default function MangaContent({
[entries],
);
// The resume target is the first unfinished chapter in reading order. Any
// finished chapter before it means the viewer is mid-series ("Continue");
// a fully read series restarts from the beginning.
const anyRead = chapterRows.some((chapter) => chapter.read === true);
const resume = useMemo(() => firstUnreadChapter(entries), [entries]);
// The resume target is the first unfinished chapter in reading order. The CTA
// verb reflects how far along the viewer is:
// - "Continue" when an earlier chapter is fully read (genuinely mid-series).
// - "Resume Reading" when the resume target itself is part-read but not yet
// finished (e.g. 18% into Volume 1) — its read flag is still false, so
// this is detected from reading progress, not the read flag.
// - "Start Reading" on a pristine series with no progress anywhere.
// - "Read Again" from the start once every chapter is read.
// hasEarlierRead is scoped to chapters *before* the target, so a later volume
// read out of order doesn't relabel an in-progress earlier volume "Continue".
const resume = useMemo(() => resumeState(entries), [entries]);
const fallbackStart = entries.length > 0 ? flattenFirst(entries) : null;
const cta = resume
? { ...resume, verb: anyRead ? "Continue" : "Start Reading" }
? {
...resume.target,
verb: resume.hasEarlierRead
? "Continue"
: resume.targetInProgress
? "Resume Reading"
: "Start Reading",
}
: fallbackStart
? { ...fallbackStart, verb: "Read Again" }
: null;
@@ -375,12 +387,12 @@ export default function MangaContent({
className="text-muted-foreground gap-1.5"
onClick={() => {
document
.getElementById(`manga-chapter-${resume.chapter.content_id}`)
.getElementById(`manga-chapter-${resume.target.chapter.content_id}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
>
<CornerDownRight className="size-4" />
Jump to {resume.label}
Jump to {resume.target.label}
</Button>
</div>
)}