feat(web): add privacy-friendly Umami custom events

Instrument the play flow with 9 content-free custom events (game_start,
art_style_select, style_image_upload, scene_reached, choice_select,
vision_click, tts_toggle, fullscreen_toggle, play_heartbeat) to measure
retention, engagement depth and session duration.

Privacy is enforced by construction, not convention:
- lib/analytics.ts types each event with a discriminated union, so a
  payload has no slot for free text — prompts, world guides, uploaded
  images and vision output can never reach analytics (compile-time
  guarantee, not a comment).
- track() no-ops without window.umami and never throws into the app.
- coarse 30s heartbeat fires only while the tab is visible.
- script stays gated on NEXT_PUBLIC_UMAMI_* env (blank → no script),
  honours Do-Not-Track, and locks to an exact data-domains allowlist.
- one-line on-site disclosure with a link, shown only when tracking is on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-04 10:14:08 +08:00
parent 9f4dcc097b
commit 4bf05f6784
6 changed files with 163 additions and 7 deletions
+28
View File
@@ -26,6 +26,7 @@ import type {
StartResponse,
VisionResponse,
} from "@infiplot/types";
import { track } from "@/lib/analytics";
const MUTED_STORAGE_KEY = "infiplot:muted";
@@ -373,6 +374,17 @@ function PlayInner() {
mutedRef.current = muted;
}, [muted]);
// Coarse liveness ping for active-time analytics. /play is a single SPA
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
// while the tab is visible) gives Umami the timestamps to derive real
// engaged time. Content-free — no payload, and a no-op without the tracker.
useEffect(() => {
const id = window.setInterval(() => {
if (document.visibilityState === "visible") track("play_heartbeat");
}, 30_000);
return () => window.clearInterval(id);
}, []);
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
useEffect(() => {
if (!currentBeatId) return;
@@ -472,6 +484,7 @@ function PlayInner() {
// ── Mute persistence (read is via the useState lazy initializer above) ─
const toggleMuted = useCallback(() => {
track("tts_toggle", { muted: !mutedRef.current });
setMuted((prev) => {
const next = !prev;
try {
@@ -507,6 +520,7 @@ function PlayInner() {
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
const entering = !presentation;
track("fullscreen_toggle", { on: entering });
if (entering) {
try {
if (!document.fullscreenElement) {
@@ -671,6 +685,7 @@ function PlayInner() {
// beatAudioMap is populated lazily by the per-beat fetch effect once
// currentScene becomes non-null (see fetchBeatAudio).
setPhase("ready");
track("scene_reached", { scene_index: initial.history.length });
})
.catch((e) => setError(String(e)));
}, [params, router]);
@@ -782,6 +797,7 @@ function PlayInner() {
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
setLastExitLabel(exitLabel);
setPhase("ready");
track("scene_reached", { scene_index: newSession.history.length });
} catch (e) {
if ((e as { name?: string }).name === "AbortError") {
setPhase("ready");
@@ -795,6 +811,17 @@ function PlayInner() {
function onSelectChoice(choice: BeatChoice) {
if (phase !== "ready" || !session || !currentScene) return;
const beatNext = currentBeatRef.current?.next;
const choiceIndex =
beatNext?.type === "choice"
? beatNext.choices.findIndex((c) => c.id === choice.id)
: -1;
track("choice_select", {
scene_index: session.history.length,
choice_index: choiceIndex,
kind: choice.effect.kind,
});
if (choice.effect.kind === "advance-beat") {
// Pure local jump. No network. No pool changes.
setCurrentBeatId(choice.effect.targetBeatId);
@@ -863,6 +890,7 @@ function PlayInner() {
throw new Error(j.error ?? visionRes.statusText);
}
const decision = (await visionRes.json()) as VisionResponse;
track("vision_click", { result: decision.classify });
if (decision.classify === "insert-beat") {
setPhase("inserting-beat");