diff --git a/web/src/lib/mangaChapters.test.ts b/web/src/lib/mangaChapters.test.ts index 918ef06e..24ad58fc 100644 --- a/web/src/lib/mangaChapters.test.ts +++ b/web/src/lib/mangaChapters.test.ts @@ -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 { return { @@ -11,6 +11,8 @@ function chapter(partial: Partial): 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); + }); +}); diff --git a/web/src/lib/mangaChapters.ts b/web/src/lib/mangaChapters.ts index 1cafc1b2..ea03015d 100644 --- a/web/src/lib/mangaChapters.ts +++ b/web/src/lib/mangaChapters.ts @@ -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; +} diff --git a/web/src/pages/ItemDetail/MangaContent.test.tsx b/web/src/pages/ItemDetail/MangaContent.test.tsx index f6870c14..55644511 100644 --- a/web/src/pages/ItemDetail/MangaContent.test.tsx +++ b/web/src/pages/ItemDetail/MangaContent.test.tsx @@ -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( + + + , + ); + + // 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( + + + , + ); + + // 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( + + + , + ); + + // 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( diff --git a/web/src/pages/ItemDetail/MangaContent.tsx b/web/src/pages/ItemDetail/MangaContent.tsx index eac18f62..1c706a6e 100644 --- a/web/src/pages/ItemDetail/MangaContent.tsx +++ b/web/src/pages/ItemDetail/MangaContent.tsx @@ -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" }); }} > - Jump to {resume.label} + Jump to {resume.target.label} )}