diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 3af75ef..889338b 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 / 新增 +- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card. +- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。 + - Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`. - 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。 diff --git a/frontend/src/lib/readingStats.ts b/frontend/src/lib/readingStats.ts new file mode 100644 index 0000000..80aa7b6 --- /dev/null +++ b/frontend/src/lib/readingStats.ts @@ -0,0 +1,65 @@ +export interface DayBucket { + day: string; + seconds: number; +} +export interface StatsStore { + record(seconds: number): void; + weekly(): { perDay: DayBucket[]; total: number }; + streak(): number; +} + +const KEY = "reading-stats"; +export type Buckets = Record; + +export function dayKey(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} +export function recordInto(b: Buckets, day: string, seconds: number): Buckets { + return { ...b, [day]: Math.max(0, Math.round((b[day] ?? 0) + seconds)) }; +} +export function weeklySum(b: Buckets, today: Date): { perDay: DayBucket[]; total: number } { + const perDay: DayBucket[] = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const day = dayKey(d); + perDay.push({ day, seconds: b[day] ?? 0 }); + } + return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) }; +} +export function streakOf(b: Buckets, today: Date): number { + let n = 0; + const d = new Date(today); + if (!(b[dayKey(d)] > 0)) d.setDate(d.getDate() - 1); // 今天还没读不算断 + while (b[dayKey(d)] > 0) { + n++; + d.setDate(d.getDate() - 1); + } + return n; +} + +function load(): Buckets { + try { + const o: unknown = JSON.parse(localStorage.getItem(KEY) ?? "{}"); + return o && typeof o === "object" ? (o as Buckets) : {}; + } catch { + return {}; + } +} + +/** localStorage 实现;③ 可换服务端实现(spec:可替换接口)。 */ +export function createLocalStats(): StatsStore { + return { + record(seconds) { + if (seconds <= 0) return; + localStorage.setItem(KEY, JSON.stringify(recordInto(load(), dayKey(new Date()), seconds))); + }, + weekly() { + return weeklySum(load(), new Date()); + }, + streak() { + return streakOf(load(), new Date()); + }, + }; +} diff --git a/frontend/src/lib/useReadingSession.ts b/frontend/src/lib/useReadingSession.ts new file mode 100644 index 0000000..c3a6def --- /dev/null +++ b/frontend/src/lib/useReadingSession.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from "react"; +import { createLocalStats, type StatsStore } from "./readingStats"; + +const TICK = 30; // 秒:可见期间心跳粒度 + +/** Reader 页挂一次:可见期间每 30s 落一笔,隐藏/卸载即 flush。 */ +export function useReadingSession(store: StatsStore = createLocalStats()) { + const acc = useRef(0); + const last = useRef(0); + useEffect(() => { + last.current = Date.now(); // 渲染期不取时钟,effect 内初始化起算点 + const flush = () => { + const now = Date.now(); + if (document.visibilityState === "visible") acc.current += (now - last.current) / 1000; + last.current = now; + if (acc.current >= 1) { + store.record(acc.current); + acc.current = 0; + } + }; + const t = setInterval(flush, TICK * 1000); + const onVis = () => flush(); + document.addEventListener("visibilitychange", onVis); + globalThis.addEventListener?.("pagehide", onVis); + return () => { + clearInterval(t); + document.removeEventListener("visibilitychange", onVis); + globalThis.removeEventListener?.("pagehide", onVis); + flush(); + }; + }, [store]); +} diff --git a/frontend/test/readingStats.test.ts b/frontend/test/readingStats.test.ts new file mode 100644 index 0000000..e9d974c --- /dev/null +++ b/frontend/test/readingStats.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { createLocalStats, dayKey, recordInto, streakOf, weeklySum, type Buckets } from "../src/lib/readingStats"; + +describe("dayKey", () => { + it("本地时区 YYYY-MM-DD", () => { + expect(dayKey(new Date(2026, 0, 5))).toBe("2026-01-05"); + expect(dayKey(new Date(2026, 10, 23))).toBe("2026-11-23"); + }); +}); + +describe("recordInto", () => { + it("不可变累加", () => { + const b: Buckets = { "2026-01-01": 60 }; + const nb = recordInto(b, "2026-01-01", 30); + expect(nb["2026-01-01"]).toBe(90); + expect(b["2026-01-01"]).toBe(60); // 原桶不变 + expect(recordInto(nb, "2026-01-02", 5)["2026-01-02"]).toBe(5); + expect(recordInto(nb, "2026-01-03", -5)).toEqual({ "2026-01-01": 90, "2026-01-03": 0 }); // 负值钳 0 + }); +}); + +describe("weeklySum", () => { + it("近 7 天含今天、缺日补 0、合计", () => { + const today = new Date(2026, 8, 16); // 2026-09-16(周三) + const b: Buckets = { "2026-09-16": 120, "2026-09-12": 300 }; + const w = weeklySum(b, today); + expect(w.perDay).toHaveLength(7); + expect(w.perDay[6]).toEqual({ day: "2026-09-16", seconds: 120 }); + expect(w.perDay[2]).toEqual({ day: "2026-09-12", seconds: 300 }); + expect(w.perDay[0].seconds).toBe(0); + expect(w.total).toBe(420); + }); +}); + +describe("streakOf", () => { + it("今天有记录 → 连续天数", () => { + const today = new Date(2026, 8, 16); + const b: Buckets = { "2026-09-16": 10, "2026-09-15": 10, "2026-09-14": 10 }; + expect(streakOf(b, today)).toBe(3); + }); + it("今天无但昨天有 → 连续天数(含昨天)", () => { + const today = new Date(2026, 8, 16); + const b: Buckets = { "2026-09-15": 10, "2026-09-14": 10 }; + expect(streakOf(b, today)).toBe(2); + }); + it("断档即止", () => { + const today = new Date(2026, 8, 16); + const b: Buckets = { "2026-09-15": 10, "2026-09-13": 10 }; + expect(streakOf(b, today)).toBe(1); + }); + it("空 → 0", () => { + const today = new Date(2026, 8, 16); + expect(streakOf({}, today)).toBe(0); + expect(streakOf({ "2026-09-14": 10 }, today)).toBe(0); + }); +}); + +describe("createLocalStats", () => { + it("record→weekly/streak 走 localStorage(内存桩)", () => { + const mem: Record = {}; + globalThis.localStorage ??= { + getItem: (k: string) => (k in mem ? mem[k] : null), + setItem: (k: string, v: string) => (mem[k] = String(v)), + clear: () => Object.keys(mem).forEach((k) => delete mem[k]), + } as unknown as typeof localStorage; + globalThis.localStorage.clear(); + const s = createLocalStats(); + s.record(30); + s.record(20); + const w = s.weekly(); + expect(w.perDay.at(-1)?.seconds).toBe(50); + expect(s.streak()).toBe(1); + s.record(0); // 无时间不落盘 + expect(s.weekly().total).toBe(50); + }); +});