feat(frontend): reading stats store (TDD) + session heartbeat hook (B0)
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -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<string, number>;
|
||||
|
||||
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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<string, string> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user