From 28d8587e71b36796ea05c04d79a626d9b743e668 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Wed, 16 Sep 2026 17:49:12 +0800 Subject: [PATCH] feat(frontend): shelf reading stats card + session tracking (B4) --- docs/CHANGELOG_web.md | 3 + frontend/e2e/auth-shelf.spec.ts | 1 + .../components/reading-stats-card.test.tsx | 64 +++++++++++++++++++ .../src/components/reading-stats-card.tsx | 58 +++++++++++++++++ frontend/src/pages/Reader.tsx | 4 ++ frontend/src/pages/Shelf.tsx | 3 + 6 files changed, 133 insertions(+) create mode 100644 frontend/src/components/reading-stats-card.test.tsx create mode 100644 frontend/src/components/reading-stats-card.tsx diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 0679daf..4f8054b 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 +- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open. +- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。 + - EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar. - EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。 diff --git a/frontend/e2e/auth-shelf.spec.ts b/frontend/e2e/auth-shelf.spec.ts index 71cd9e8..915efa5 100644 --- a/frontend/e2e/auth-shelf.spec.ts +++ b/frontend/e2e/auth-shelf.spec.ts @@ -32,6 +32,7 @@ test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ pa // 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。 const card = page.locator(".group > button", { hasText: title }); await expect(card).toBeVisible(); + await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible(); await scanA11y(page, "shelf"); await card.click(); await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`)); diff --git a/frontend/src/components/reading-stats-card.test.tsx b/frontend/src/components/reading-stats-card.test.tsx new file mode 100644 index 0000000..53a1b9b --- /dev/null +++ b/frontend/src/components/reading-stats-card.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect } from "vitest"; +import { beforeEach, describe, it } from "vitest"; +import { ReadingStatsCard } from "@/components/reading-stats-card"; +import type { StatsStore } from "@/lib/readingStats"; + +function fakeStore(): StatsStore { + const today = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + const key = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; + const y = new Date(today); + y.setDate(y.getDate() - 1); + const buckets: Record = { [key(today)]: 900, [key(y)]: 300 }; + return { + record() {}, + weekly() { + const perDay = Array.from({ length: 7 }, (_, i) => { + const d = new Date(today); + d.setDate(d.getDate() - (6 - i)); + const k = key(d); + return { day: k, seconds: buckets[k] ?? 0 }; + }); + return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) }; + }, + streak() { + return 2; + }, + }; +} + +beforeEach(() => localStorage.clear()); + +describe("ReadingStatsCard", () => { + it("本周文案含时长与连续天数", () => { + render(); + expect(screen.getByText(/本周阅读/)).toBeInTheDocument(); + expect(screen.getByText(/20 分钟/)).toBeInTheDocument(); + expect(screen.getByText(/连续 2 天/)).toBeInTheDocument(); + }); + + it("展开/收起:aria-label 翻转 + 柱状图可见性 + localStorage 记忆", () => { + render(); + expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument(); + const btn = screen.getByRole("button", { name: "折叠统计" }); + expect(screen.queryAllByRole("img").length).toBeGreaterThan(0); + fireEvent.click(btn); + expect(screen.getByRole("button", { name: "展开统计" })).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "近 7 日阅读时长柱状图" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "展开统计" })); + expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument(); + }); + + it("注入的 store 无连续记录时不显示连续段", () => { + const s = fakeStore(); + render( 0 }} />); + expect(screen.queryByText(/连续/)).not.toBeInTheDocument(); + }); + + it("7 根柱(含 0 值日)", () => { + render(); + const chart = screen.getByRole("img", { name: "近 7 日阅读时长柱状图" }); + expect(chart.children.length).toBe(7); + }); +}); diff --git a/frontend/src/components/reading-stats-card.tsx b/frontend/src/components/reading-stats-card.tsx new file mode 100644 index 0000000..0aa6c8e --- /dev/null +++ b/frontend/src/components/reading-stats-card.tsx @@ -0,0 +1,58 @@ +import { useMemo, useState } from "react"; +import { BarChart3, ChevronDown, ChevronUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { createLocalStats, type StatsStore } from "@/lib/readingStats"; + +const FOLD_KEY = "stats-card-folded"; + +function fmtDur(s: number): string { + const h = Math.floor(s / 3600); + const m = Math.round((s % 3600) / 60); + return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`; +} + +/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */ +export function ReadingStatsCard({ store }: { store?: StatsStore }) { + const stats = useMemo(() => store ?? createLocalStats(), [store]); + const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1"); + const { perDay, total } = stats.weekly(); + const streak = stats.streak(); + const max = Math.max(1, ...perDay.map((d) => d.seconds)); + return ( +
+
+ + + 本周阅读 {fmtDur(total)} + {streak > 0 && · 连续 {streak} 天} + + +
+ {!folded && ( +
+ {perDay.map((d) => ( +
+
+ {d.day.slice(8)} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 6e3a143..5ba1ef7 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -7,6 +7,8 @@ import type { Format } from "../api/types"; import { FormatBadge } from "../components/format-badge"; import { Button } from "../components/ui/button"; import type { ReaderProps } from "../lib/useProgress"; +import { createLocalStats } from "../lib/readingStats"; +import { useReadingSession } from "../lib/useReadingSession"; const READERS: Partial>>> = { cbz: lazy(() => import("../readers/CbzReader")), @@ -27,6 +29,8 @@ export default function Reader() { const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); + const stats = useMemo(() => createLocalStats(), []); + useReadingSession(stats); const book = bookQ.data; const body = (() => { diff --git a/frontend/src/pages/Shelf.tsx b/frontend/src/pages/Shelf.tsx index 311156a..da300a9 100644 --- a/frontend/src/pages/Shelf.tsx +++ b/frontend/src/pages/Shelf.tsx @@ -17,6 +17,7 @@ import { useAuth } from "@/auth/AuthContext"; import { Cover } from "@/components/Cover"; import { toast } from "@/components/Toaster"; import { UploadButton } from "@/components/upload-button"; +import { ReadingStatsCard } from "@/components/reading-stats-card"; import { formatBadge } from "@/components/ui"; import { AlertDialog, @@ -280,6 +281,8 @@ export default function Shelf() { {lib === LIB0 &&

{`当前筛选:${libName}`}

} + + {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (