refactor(web): enforce content-free Umami fields at compile time
Address the Copilot review on #26. #1 The game_start / art_style_select payload fields were typed as bare `string`, so free text could still slip through despite the "content-free by construction" claim. Add lib/options.ts as the single source of truth for the selector option sets (`as const` → literal-union types), have the home OPTS render from those arrays, and type the analytics fields from the derived unions (gender/art_style/plot_style/pacing/style) plus a template type for `card`. Free text now fails to compile; no casts at call sites. #2 The /play heartbeat scheduled its 30s interval unconditionally. Gate the effect on the same NEXT_PUBLIC_UMAMI_* env used for script injection, so nothing is scheduled when the tracker is off (visibility check kept — a hidden tab still never emits). #3 choice_select no longer emits a -1 choice_index: skip the event when the index can't be resolved instead of polluting the index distribution. Verified with tsc (exit 0) and a throwaway negative test: free text in any of the six fields raises TS2322, valid enum/template values compile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+11
-8
@@ -10,6 +10,8 @@
|
||||
// indices, counts and booleans — that is what keeps these events as
|
||||
// privacy-friendly as the cookieless page-view baseline.
|
||||
|
||||
import type { ArtStyle, Gender, Pacing, PlotStyle } from "./options";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
@@ -21,23 +23,24 @@ declare global {
|
||||
// Per-event payload schema. Fixing each event's allowed fields turns the RULE
|
||||
// above into a compile-time guarantee: an event simply has no slot for a prompt,
|
||||
// world/style guide or vision string, so free text can't be attached by mistake
|
||||
// (a bare `Record<string, string>` would happily accept it). Keep every field an
|
||||
// enum, index, count or boolean. `never` marks events that carry no payload.
|
||||
// (a bare `Record<string, string>` would happily accept it). Every field is a
|
||||
// literal union (shared with the selector UI via ./options), index, count or
|
||||
// boolean — never a bare `string`. `never` marks events that carry no payload.
|
||||
type AnalyticsEventData = {
|
||||
game_start:
|
||||
| {
|
||||
source: "prompt";
|
||||
gender: string;
|
||||
art_style: string;
|
||||
plot_style: string;
|
||||
pacing: string;
|
||||
gender: Gender;
|
||||
art_style: ArtStyle;
|
||||
plot_style: PlotStyle;
|
||||
pacing: Pacing;
|
||||
tts: boolean;
|
||||
has_prompt: boolean;
|
||||
has_style_ref: boolean;
|
||||
}
|
||||
| { source: "curated"; gender: string; tts: boolean; card: string }
|
||||
| { source: "curated"; gender: Gender; tts: boolean; card: `${"m" | "f"}${number}` }
|
||||
| { source: "custom" };
|
||||
art_style_select: { style: string };
|
||||
art_style_select: { style: ArtStyle };
|
||||
style_image_upload: { ok: boolean };
|
||||
scene_reached: { scene_index: number };
|
||||
choice_select: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Single source of truth for the home-page selector option sets. Kept as
|
||||
// `as const` so each list also yields a literal-union type: the play-start
|
||||
// UI (app/page.tsx) renders from the arrays, and the analytics schema
|
||||
// (lib/analytics.ts) types its payload fields from the unions. That shared
|
||||
// origin is what keeps the "content-free" events honest — an event field can
|
||||
// only ever be one of these fixed labels, never free-form player text.
|
||||
|
||||
export const GENDERS = ["男性向", "女性向"] as const;
|
||||
|
||||
export const ART_STYLES = [
|
||||
"自动",
|
||||
"自定义",
|
||||
"京阿尼细腻日常",
|
||||
"新海诚唯美光影",
|
||||
"Galgame CG",
|
||||
"3D 动漫电影",
|
||||
"赛博朋克",
|
||||
"蒸汽波",
|
||||
"吉卜力治愈手绘",
|
||||
"哥特庄园",
|
||||
"废土科幻",
|
||||
// 以下为小众/区域性画风,留作长尾选项
|
||||
"古典厚涂油画",
|
||||
"极简中国水墨",
|
||||
"浮世绘木刻",
|
||||
"莫高窟壁画",
|
||||
"波斯细密画",
|
||||
] as const;
|
||||
|
||||
export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const;
|
||||
|
||||
export const PACINGS = ["慢热细腻", "紧凑爽快"] as const;
|
||||
|
||||
export type Gender = (typeof GENDERS)[number];
|
||||
export type ArtStyle = (typeof ART_STYLES)[number];
|
||||
export type PlotStyle = (typeof PLOT_STYLES)[number];
|
||||
export type Pacing = (typeof PACINGS)[number];
|
||||
Reference in New Issue
Block a user