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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user