feat(frontend): reading stats store (TDD) + session heartbeat hook (B0)

This commit is contained in:
2026-09-16 15:22:38 +08:00
parent 6aefe62a9f
commit 996f492083
4 changed files with 176 additions and 0 deletions
+65
View File
@@ -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());
},
};
}
+32
View File
@@ -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]);
}