From 0ef38023f53b3e8d145ea7b854c61f1ae3306ddb Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Mon, 14 Sep 2026 10:58:49 -0700 Subject: [PATCH 1/7] fix(02): CR-01 gate healer suggestions on hasHealing to avoid false advice on zero-healing data Add regression test asserting a healer with hasHealing: false yields no healer-category suggestions. Co-Authored-By: Claude Fable 5.1 --- lib/analysis-engine.test.ts | 22 ++++++++++++++++++++++ lib/analysis-engine.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/analysis-engine.test.ts b/lib/analysis-engine.test.ts index 8e7e014..8471901 100644 --- a/lib/analysis-engine.test.ts +++ b/lib/analysis-engine.test.ts @@ -276,4 +276,26 @@ describe("generateSuggestions — healer rules (D-07)", () => { } expect(result.some((s) => s.category === "healing" && s.priority === "high")).toBe(true); }); + + it("Test 10: no healer rule fires when the player recorded no healing this fight (hasHealing: false)", () => { + // Mirrors a healer who died pre-pull or was off-role: every other field on + // this fixture is "meaningless, not measured" per hasHealing's contract + // (lib/wcl-types.ts), so zero activity/overheal must not read as a real + // uptime or overheal gap (CR-01). + const healer = healerFixture({ + hasHealing: false, + overhealPercent: 0, + activityPercent: 0, + }); + const dps = dpsFixture({ gapToTop: 50, gapToMedian: 50 }); + const result = generateSuggestions( + dps, + gearFixture(), + consumablesFixture(), + healthyCasts, + "healer", + healer + ); + expect(healingOnly(result)).toHaveLength(0); + }); }); diff --git a/lib/analysis-engine.ts b/lib/analysis-engine.ts index bfd0a7e..d7bbb2d 100644 --- a/lib/analysis-engine.ts +++ b/lib/analysis-engine.ts @@ -625,7 +625,7 @@ export function generateSuggestions( // this player's own spec rather than a fixed constant. No comparison // population means no relative advice — advice relative to zero is worse // than no advice at all (T-02-21). - if (playerRole === "healer" && healer && healer.topSampleCount > 0) { + if (playerRole === "healer" && healer && healer.hasHealing && healer.topSampleCount > 0) { const overhealGapPP = healer.overhealPercent - healer.topOverhealPercent; const overhealRatio = healer.topOverhealPercent > 0 From af1442b4a3f52b4b8f0e0adb3afb80a19c8444b7 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Mon, 14 Sep 2026 11:00:06 -0700 Subject: [PATCH 2/7] fix(02): WR-01 make /api/timeline's player-in-fight check actually fight-scoped masterData.actors is the report-wide roster; a sourceId from a different fight in the same report passed the old check. Fetch playerDetails(fightIDs: [fightId]) (as app/api/analyze/route.ts already does) and check membership there instead. Co-Authored-By: Claude Fable 5.1 --- app/api/timeline/route.ts | 18 +++++++++++++++--- lib/wcl-queries.ts | 1 + 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/api/timeline/route.ts b/app/api/timeline/route.ts index 707cc60..98d8a4a 100644 --- a/app/api/timeline/route.ts +++ b/app/api/timeline/route.ts @@ -5,7 +5,8 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { TIMELINE_CASTS_QUERY, TIMELINE_CASTS_PAGE_QUERY } from "@/lib/wcl-queries"; import { buildCastTimeline } from "@/lib/timeline-engine"; import type { TimelineDeathEvent } from "@/lib/timeline-engine"; -import type { WCLCastEvent, WCLCastEntry, TimelineRequest } from "@/lib/wcl-types"; +import type { WCLCastEvent, WCLCastEntry, TimelineRequest, WCLPlayerDetails } from "@/lib/wcl-types"; +import { flattenPlayerDetails } from "@/lib/wcl-helpers"; interface TimelineActor { id: number; @@ -24,6 +25,11 @@ interface TimelineFight { interface TimelineCastsResponse { reportData: { report: { + playerDetails: { + data: { + playerDetails: Record; + }; + }; castEvents: { data: WCLCastEvent[]; nextPageTimestamp: number | null }; castTable: { data: { entries: WCLCastEntry[] } }; deathEvents: { data: TimelineDeathEvent[] }; @@ -82,8 +88,14 @@ export async function POST(request: NextRequest) { } const actors = report.masterData?.actors ?? []; - const sourceExists = actors.some((a) => a.id === sourceId); - if (!sourceExists) { + + // playerDetails is fetched with fightIDs: [fightId], so — unlike the + // report-wide actor list above — this genuinely confirms sourceId played + // in *this* fight, not just somewhere in the report (WR-01). Mirrors the + // fight-scoped check in app/api/analyze/route.ts. + const fightPlayers = flattenPlayerDetails(report.playerDetails); + const sourceInFight = fightPlayers.some((p) => p.id === sourceId); + if (!sourceInFight) { return NextResponse.json({ error: "Player not found in fight" }, { status: 404 }); } diff --git a/lib/wcl-queries.ts b/lib/wcl-queries.ts index c7bf6c2..16e297d 100644 --- a/lib/wcl-queries.ts +++ b/lib/wcl-queries.ts @@ -247,6 +247,7 @@ export const TIMELINE_CASTS_QUERY = ` query TimelineCasts($code: String!, $fightIDs: [Int!]!, $sourceID: Int!, $startTime: Float) { reportData { report(code: $code) { + playerDetails(fightIDs: $fightIDs) castEvents: events( fightIDs: $fightIDs sourceID: $sourceID From 34488eb4d9f70efbddb5a555b2e8a700d8555a31 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Mon, 14 Sep 2026 11:00:38 -0700 Subject: [PATCH 3/7] fix(02): WR-02 move PostHog capture out of setState updater in CastTimeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toggleAbility fired onFilterToggle (timeline_filter_used) from inside the setHiddenAbilityIds functional updater, which React Strict Mode invokes twice — double-firing the event on every ability-chip click in dev. Compute the toggled set up front and call setHiddenAbilityIds with the concrete value instead, mirroring resetAbilities. Co-Authored-By: Claude Fable 5.1 --- app/components/CastTimeline.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/components/CastTimeline.tsx b/app/components/CastTimeline.tsx index 9f626e1..38b2cb0 100644 --- a/app/components/CastTimeline.tsx +++ b/app/components/CastTimeline.tsx @@ -128,15 +128,19 @@ export default function CastTimeline({ const toggleAbility = useCallback( (id: number) => { - setHiddenAbilityIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - onFilterToggle?.(result?.abilityCounts.length ?? 0, next.size); - return next; - }); + // The onFilterToggle side effect (fires the timeline_filter_used + // PostHog event) must not live inside the setState updater — React + // Strict Mode invokes updater functions twice specifically to surface + // impurities like this, which would double-fire the event on every + // click (WR-02). Compute `next` from current state up front instead, + // mirroring resetAbilities below. + const next = new Set(hiddenAbilityIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setHiddenAbilityIds(next); + onFilterToggle?.(result?.abilityCounts.length ?? 0, next.size); }, - [onFilterToggle, result] + [hiddenAbilityIds, onFilterToggle, result] ); const resetAbilities = useCallback(() => { From 1dfbdff923635eb300254ca25a57ffdd5c5165f9 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Mon, 14 Sep 2026 11:01:26 -0700 Subject: [PATCH 4/7] fix(02): WR-03 correct stale era-precedence comment in game-data.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local composeEras() helper's comment claimed later-era-wins 'matches current behaviour', but lib/generated/index.ts's composeEraPriority implements the opposite (first-resolved-era-wins). Correct the comment and add Test 9, asserting the real production ENCHANT_NAME_DB resolves the known id 3003 collision to the Classic/TBC fact, not WotLK's — closing the coverage gap the stale comment papered over. Co-Authored-By: Claude Fable 5.1 --- lib/generated/game-data.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/generated/game-data.test.ts b/lib/generated/game-data.test.ts index 9090fbb..5747190 100644 --- a/lib/generated/game-data.test.ts +++ b/lib/generated/game-data.test.ts @@ -3,6 +3,7 @@ import { ENCHANT_NAME_CLASSIC_TBC, GEM_NAME_CLASSIC_TBC, GEM_STAT_CLASSIC_TBC } import { ENCHANT_NAME_WOTLK, GEM_NAME_WOTLK, GEM_STAT_WOTLK } from "./game-data.wotlk"; import { ENCHANT_NAME_CATA, GEM_NAME_CATA, GEM_STAT_CATA } from "./game-data.cata"; import { ENCHANT_NAME_OVERRIDES, GEM_NAME_OVERRIDES, UNVERIFIED_OVERRIDE_IDS } from "./game-data-overrides"; +import { ENCHANT_NAME_DB } from "./index"; // Proves the wago.tools-generated data (scripts/regen-game-data.mjs) reproduces // every ID-to-name pair lib/cla-constants.test.ts pins against the @@ -14,8 +15,16 @@ import { ENCHANT_NAME_OVERRIDES, GEM_NAME_OVERRIDES, UNVERIFIED_OVERRIDE_IDS } f /** * Composes era maps in the same order lib/cla-constants.ts's sections list - * today — Classic and TBC, then WotLK, then Cata — so a later era wins for a - * colliding id, matching current behaviour. + * today — Classic and TBC, then WotLK, then Cata — with a later era winning + * for a colliding id (`result.set` unconditionally overwrites). This is the + * OPPOSITE of production: lib/generated/index.ts's `composeEraPriority` + * implements first-resolved-era-wins (Classic/TBC beats WotLK/Cata on a + * collision) — see that file's header for why a later-wins merge would + * silently replace a real TBC fact with an unrelated later-era item reusing + * the same numeric id (e.g. enchant 3003). This local helper exists only to + * verify internal self-consistency of Tests 7-8 below, not to reproduce + * production precedence — Test 9 exercises the real precedence via the + * production `ENCHANT_NAME_DB`. */ function composeEras(maps: Map[]): Map { const result = new Map(); @@ -124,4 +133,16 @@ describe("generated game data reproduces the pinned ID-to-name pairs", () => { expect(composedEnchantNames.get(id)).toBe(value); } }); + + it("Test 9: production's first-resolved-era-wins composition resolves the id 3003 collision to the Classic/TBC fact, not WotLK's", () => { + // Enchant 3003 means "Glyph of Ferocity" in Classic/TBC and "Arcanum of + // Ferocity" in WotLK (WR-03) — the exact collision this test's local + // composeEras() (later-wins, opposite direction) would get backwards. + // Assert against the real production map (lib/generated/index.ts's + // ENCHANT_NAME_DB), not the local helper, so this closes the coverage + // gap the stale comment above used to paper over. + expect(ENCHANT_NAME_CLASSIC_TBC.get(3003)).toContain("Glyph of Ferocity"); + expect(ENCHANT_NAME_WOTLK.get(3003)).toContain("Arcanum of Ferocity"); + expect(ENCHANT_NAME_DB.get(3003)).toBe(ENCHANT_NAME_CLASSIC_TBC.get(3003)); + }); }); From 53db5a0f5d1c0c1ccefad272df2e9abe2e6345ec Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Mon, 14 Sep 2026 11:04:50 -0700 Subject: [PATCH 5/7] docs(02): add code review fix report; capture WoW Forever todo Co-Authored-By: Claude Fable 5.1 --- .planning/STATE.md | 4 +- .planning/config.json | 5 +- .../02-REVIEW-FIX.md | 90 +++++++++++++++++++ .../todos/pending/wow-forever-support.md | 41 +++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/02-accuracy-analysis-depth/02-REVIEW-FIX.md create mode 100644 .planning/todos/pending/wow-forever-support.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 62dbccc..992e737 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -101,7 +101,9 @@ Recent decisions affecting current work: [From .planning/todos/pending/ — ideas captured during sessions] -None. Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European regulations → message → site settings: paste https://parseforge.gg/privacy (still open since 01-09); (2) PostHog MCP connection returns `INVALID_API_KEY` and its active project is "LootList+ App" — re-wire it to the ParseForge project (337485) before the Phase 3 gate so event definitions can be confirmed by tool rather than by hand; (3) GSC: `/` and `/analyze/*` show "Crawled – currently not indexed" with crawl dates predating the Phase 2 deploy — request recrawl and watch `/` specifically; (4) merge PR #15 (or `git push origin main`) — `origin/main` is at `55d2010`, local `main` carries Phase 2 + close-out commits; parallel worktree execution returns once they match; (5) game-data ids 96264 / 96294 flagged for human review in `.planning/WINDOWS.md`; (6) code-review findings in `02-REVIEW.md` — CR-01 (healer suggestions fire with `hasHealing: false`) is live and should be fixed via `/gsd-code-review 02 --fix` + a follow-up deploy. +- `wow-forever-support.md` (2026-09-13) — add World of Warcraft Forever support once WCL exposes Forever logs; era module via wago regen, partition-aware rankings, fixtures. Not actionable until upstream data exists. + +Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European regulations → message → site settings: paste https://parseforge.gg/privacy (still open since 01-09); (2) PostHog MCP connection returns `INVALID_API_KEY` and its active project is "LootList+ App" — re-wire it to the ParseForge project (337485) before the Phase 3 gate so event definitions can be confirmed by tool rather than by hand; (3) GSC: `/` and `/analyze/*` show "Crawled – currently not indexed" with crawl dates predating the Phase 2 deploy — request recrawl and watch `/` specifically; (4) merge PR #15 (or `git push origin main`) — `origin/main` is at `55d2010`, local `main` carries Phase 2 + close-out commits; parallel worktree execution returns once they match; (5) game-data ids 96264 / 96294 flagged for human review in `.planning/WINDOWS.md`; (6) code-review findings in `02-REVIEW.md` — CR-01 (healer suggestions fire with `hasHealing: false`) is live and should be fixed via `/gsd-code-review 02 --fix` + a follow-up deploy. ### Blockers/Concerns diff --git a/.planning/config.json b/.planning/config.json index 6d25dc2..5ab03f3 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -46,7 +46,10 @@ "post_planning_gaps": true, "security_enforcement": true, "security_asvs_level": 1, - "security_block_on": "high" + "security_block_on": "high", + "_auto_chain_active": false, + "build_command": "npx tsc --noEmit", + "test_command": "npm test" }, "ship": { "pr_body_sections": [ diff --git a/.planning/phases/02-accuracy-analysis-depth/02-REVIEW-FIX.md b/.planning/phases/02-accuracy-analysis-depth/02-REVIEW-FIX.md new file mode 100644 index 0000000..9b3ef20 --- /dev/null +++ b/.planning/phases/02-accuracy-analysis-depth/02-REVIEW-FIX.md @@ -0,0 +1,90 @@ +--- +phase: 02-accuracy-analysis-depth +fixed_at: 2026-09-14T18:01:29Z +review_path: .planning/phases/02-accuracy-analysis-depth/02-REVIEW.md +iteration: 1 +findings_in_scope: 4 +fixed: 4 +skipped: 0 +status: all_fixed +--- + +# Phase 02: Code Review Fix Report + +**Fixed at:** 2026-09-14T18:01:29Z +**Source review:** .planning/phases/02-accuracy-analysis-depth/02-REVIEW.md +**Iteration:** 1 + +**Summary:** +- Findings in scope: 4 (CR-01 critical; WR-01, WR-02, WR-03 warnings) +- Fixed: 4 +- Skipped: 0 + +**Verification environment:** Main checkout at `/Users/alexander.mayes/Code/personal/parseforge` +on branch `growth/phase-2-review-fixes` (no isolated worktree — this run operated directly in the +working tree per explicit orchestrator instruction for this invocation). `npx tsc --noEmit` and +`npx vitest run` were both run from this same checkout after each fix, so the pass/fail numbers +below are reproducible from the tree as it stands now (all fix commits are present on +`growth/phase-2-review-fixes`). + +## Fixed Issues + +### CR-01: Healer suggestions fire on zero-healing data instead of respecting `hasHealing` + +**Files modified:** `lib/analysis-engine.ts`, `lib/analysis-engine.test.ts` +**Commit:** `0ef3802` +**Applied fix:** Added `healer.hasHealing` to the guard on `generateSuggestions`' healer-suggestion +block (`if (playerRole === "healer" && healer && healer.hasHealing && healer.topSampleCount > 0)`), +so a healer who recorded zero healing this fight no longer gets a misleading "Low healing uptime: +0%..." recommendation. Added Test 10 to `lib/analysis-engine.test.ts` asserting a +`healerFixture({ hasHealing: false, overhealPercent: 0, activityPercent: 0 })` input yields zero +healer-category suggestions. `npx vitest run lib/analysis-engine.test.ts` — 13/13 passed. Full +suite (`npx vitest run`) — 136/136 passed at time of this commit. `npx tsc --noEmit` — clean. + +### WR-01: `/api/timeline`'s "player not found in fight" check is not actually fight-scoped + +**Files modified:** `app/api/timeline/route.ts`, `lib/wcl-queries.ts` +**Commit:** `af1442b` +**Applied fix:** Added `playerDetails(fightIDs: $fightIDs)` to `TIMELINE_CASTS_QUERY` (mirroring +the field `app/api/analyze/route.ts` already fetches) and replaced the report-wide +`masterData.actors` membership check with `flattenPlayerDetails(report.playerDetails).some((p) => +p.id === sourceId)`, which is genuinely scoped to the requested fight. No fixture-based test +constructs the GraphQL query string directly, so this is a safe query-shape change. `npx tsc +--noEmit` — clean. Full suite — 136/136 passed. + +### WR-02: PostHog capture invoked from inside a `setState` functional updater + +**Files modified:** `app/components/CastTimeline.tsx` +**Commit:** `34488eb` +**Applied fix:** `toggleAbility` now computes the toggled `Set` from the current `hiddenAbilityIds` +state up front, calls `setHiddenAbilityIds(next)` with the concrete value, and fires +`onFilterToggle?.(...)` (the `timeline_filter_used` PostHog capture) after — outside the updater — +mirroring how `resetAbilities` already did this two lines below. This removes the impurity Strict +Mode's double-invocation of updater functions was exploiting to double-fire the event. +`hiddenAbilityIds` was added to the `useCallback` dependency array. `npx tsc --noEmit` — clean. +Full suite — 136/136 passed (no dedicated component test exists for `CastTimeline`). + +### WR-03: Stale/incorrect comment in `game-data.test.ts` misdescribes production era-precedence + +**Files modified:** `lib/generated/game-data.test.ts` +**Commit:** `1dfbdff` +**Applied fix:** Corrected the doc comment on the test's local `composeEras()` helper to state the +true relationship — this helper is later-era-wins, the OPPOSITE of production's +first-resolved-era-wins `composeEraPriority` in `lib/generated/index.ts` — and clarified the helper +exists only to verify Tests 7-8's internal self-consistency, not to reproduce production +precedence. Added Test 9, importing the real production `ENCHANT_NAME_DB` from +`lib/generated/index.ts` and asserting the known id 3003 collision (Classic/TBC: "Glyph of +Ferocity" vs. WotLK: "Arcanum of Ferocity") resolves to the Classic/TBC value in production, +closing the coverage gap the stale comment had papered over. `npx vitest run +lib/generated/game-data.test.ts` — 9/9 passed. `npx tsc --noEmit` — clean. Full suite — 137/137 +passed (135 baseline + Test 10 from CR-01 + Test 9 from this fix). + +## Skipped Issues + +None — all four in-scope findings were fixed. + +--- + +_Fixed: 2026-09-14T18:01:29Z_ +_Fixer: Claude (gsd-code-fixer)_ +_Iteration: 1_ diff --git a/.planning/todos/pending/wow-forever-support.md b/.planning/todos/pending/wow-forever-support.md new file mode 100644 index 0000000..d19b6ff --- /dev/null +++ b/.planning/todos/pending/wow-forever-support.md @@ -0,0 +1,41 @@ +--- +created: 2026-09-13 +source: developer request (session 2026-09-13), following Blizzard's "World of Warcraft Forever" announcement +resolves_phase: +resolved: +--- + +# Add World of Warcraft Forever support when logs become available + +Blizzard has announced **World of Warcraft Forever**. ParseForge currently +analyzes Classic / TBC-era Warcraft Logs reports; when WCL begins accepting +Forever logs, the site should support them too. + +Not actionable until upstream data exists. Trigger to start work: WCL exposes +Forever reports/zones/partitions through the GraphQL API (check `worldData` +zones + `reportData` for a new game version / partition set). + +When triggered, scope will likely include: +1. **Era/game-version detection** — `lib/wcl-client.ts` / `wcl-queries.ts` / + `wcl-helpers.ts`: recognise the Forever game version, partition scoping for + rankings (rankings queries must stay partition-aware — see ROADMAP + anti-patterns), and any new zone/encounter IDs in `lib/constants.ts`. +2. **Game data** — extend the wago.tools regeneration pipeline + (`npm run regen-game-data`, `scripts/regen-game-data.mjs`, `lib/generated/*`) + with a Forever era module. Respect the Classic/TBC-first era-precedence rule + (ID reuse across client builds) and go through `docs/GAME-DATA-AUDIT.md`. + **No hand-typed ID maps.** +3. **Analysis engines** — `cla-engine`, `raid-overview-engine`, + `analysis-engine`, `healer-metrics`: confirm consumable/enchant/gem/talent + rules hold for Forever; add fixtures (`lib/__fixtures__`, recorded via + `record-wcl-fixtures.mjs`) so the regression net covers the new era. +4. **URL parsing + UI copy** — `lib/url-parser.ts` if WCL URL shape differs; + landing hero / guides / metadata mention "Classic / TBC" in places and + should be widened. +5. **SEO/content** — landing FAQ + a guide page targeting Forever tool-intent + queries (fold into Phase 6 if it is still open at the time); OPS-01 gate + (PostHog events + GSC pass) as usual. + +Sequencing: fits naturally as an insertion after whichever phase is active when +WCL support lands (decimal phase, e.g. 3.1 / 4.1), or as part of Phase 6 +Discoverability if timing aligns. Decide at that point via `/gsd-phase`. From 387e1d830087d7cae880700cf311c0f9022d8027 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Mon, 14 Sep 2026 12:21:00 -0700 Subject: [PATCH 6/7] =?UTF-8?q?docs(phase-2.1):=20insert=20urgent=20PostHo?= =?UTF-8?q?g=20consent-gate=20hotfix=20phase=20=E2=80=94=20diagnosis,=20co?= =?UTF-8?q?ntext,=20research,=20validation=20strategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog capture has been ~99.9% down since the Phase 1 deploy (2026-09-06): cookieless_mode "on_reject" drops all PENDING-consent events and opt-in depended on __tcfapi calling back, which never happens for fresh visitors. Phase 2.1 (INSERTED after Phase 2) fixes it with server-side geo and hardens the OPS-01 gate to require post-deploy live-traffic evidence. Co-Authored-By: Claude Fable 5.1 --- .planning/ROADMAP.md | 22 +- .planning/STATE.md | 30 +- .../02.1-CONTEXT.md | 122 +++++ .../02.1-DIAGNOSIS.md | 67 +++ .../02.1-RESEARCH.md | 466 ++++++++++++++++++ .../02.1-VALIDATION.md | 85 ++++ 6 files changed, 779 insertions(+), 13 deletions(-) create mode 100644 .planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md create mode 100644 .planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md create mode 100644 .planning/phases/02.1-posthog-consent-gate-hotfix/02.1-RESEARCH.md create mode 100644 .planning/phases/02.1-posthog-consent-gate-hotfix/02.1-VALIDATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2dfa073..07567d9 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -21,6 +21,7 @@ Decimal phases appear between their surrounding integers in numeric order. - [x] **Phase 1: Foundation — Themes & Consent** - Token audit unlocks a dark/light toggle; consent layer gates ads and PostHog replay (completed 2026-09-06) - [x] **Phase 2: Accuracy & Analysis Depth** - Verified game data, tested engines, cast timeline, healer metrics (completed 2026-09-08) +- [ ] **Phase 2.1: PostHog Consent Gate Hotfix** (INSERTED) - Server-side geo opt-in for non-EEA; live-traffic OPS-01 re-verification - [ ] **Phase 3: Share Loop** - Roast/award cards, per-player permalinks, share CTA that survives later phases - [ ] **Phase 4: Ads Live** - AdSense in reserved, consent-gated slots that never crowd the core flow - [ ] **Phase 5: Community & Cross-Promotion** - Dedicated Discord, LootList+ two-way links, auto-posted reports @@ -122,6 +123,24 @@ Plans: **Notes**: Accuracy is the project's stated core value ("a wrong recommendation is worse than no recommendation"), so it precedes monetization and redesign. The engine tests here are what make Phase 7's redesign safe to attempt. +### Phase 2.1: PostHog Consent Gate Hotfix (INSERTED) + +**Goal**: Every visitor is measured again — non-EEA/UK visitors are captured without depending on the Google CMP, EEA/UK visitors keep the exact TCF gate, and the OPS-01 gate can never again pass on pre-deploy data +**Mode:** mvp +**Depends on**: Phase 2 +**Requirements**: OPS-01 (re-verification), MONY-01 (consent semantics preserved) +**Success Criteria** (what must be TRUE): + + 1. A fresh non-EEA/UK visitor's first `$pageview` reaches PostHog within seconds of load, with no `__tcfapi` involvement — decided server-side from Vercel's `x-vercel-ip-country` header. + 2. An EEA/UK visitor's behaviour is unchanged: no capture before the TCF dialog resolves, cookieless on reject, replay only on full opt-in. + 3. `consent_resolved` / `consent_unavailable` / `theme_changed` / `timeline_*` events appear in project 337485 within one hour of the prod deploy, with a `consent_gate_path` property distinguishing geo / TCF / timeout. + 4. `docs/OPS-01-SHIP-GATE.md` requires a post-deploy live-traffic check (≥ N `$pageview` from ≥ 2 non-EEA countries within 60 min) and this phase passes it for real; Phase 1 and 2 VERIFICATION docs carry an addendum noting their PostHog criterion was only met here. + +**Plans**: TBD +**UI hint**: no + +**Notes**: Diagnosis in `02.1-DIAGNOSIS.md`. Root cause: `cookieless_mode: "on_reject"` makes PENDING consent *drop* events in posthog-js 1.360, and opt-in depended on `__tcfapi` calling back — which it never does for fresh visitors. Capture fell from ~4–10k events/day to 3–7/day on 2026-09-06. Not a traffic collapse. + ### Phase 3: Share Loop **Goal**: A raider who just analyzed a log wants to post it, and the artifact they post pulls new players back to ParseForge @@ -219,12 +238,13 @@ Plans: ## Progress **Execution Order:** -Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 +Phases execute in numeric order: 1 → 2 → 2.1 → 3 → 4 → 5 → 6 → 7 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation — Themes & Consent | 9/9 | Complete | 2026-09-06 | | 2. Accuracy & Analysis Depth | 9/9 | Complete | 2026-09-08 | +| 2.1. PostHog Consent Gate Hotfix (INSERTED) | 0/TBD | Not started | - | | 3. Share Loop | 0/TBD | Not started | - | | 4. Ads Live | 0/TBD | Not started | - | | 5. Community & Cross-Promotion | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 992e737..2313e55 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,19 +1,19 @@ --- gsd_state_version: 1.0 -current_phase: 3 -current_phase_name: Share Loop +current_phase: "2.1" +current_phase_name: PostHog Consent Gate Hotfix status: planning -stopped_at: Phase 02 complete, ready to plan Phase 3 -last_updated: "2026-09-08T21:21:51.076Z" -last_activity: 2026-09-08 -last_activity_desc: Phase 02 complete, transitioned to Phase 3 -state_head: b5058ce0f4fe241d967d8634e4837cbfa33adb01 +stopped_at: Phase 2.1 inserted (urgent) — PostHog capture regression diagnosed, ready to plan +last_updated: "2026-09-14T19:00:48.962Z" +last_activity: 2026-09-14 +last_activity_desc: Phase 2 review fixes (PR #16); PostHog capture regression found, Phase 2.1 inserted +state_head: 53db5a0f5d1c0c1ccefad272df2e9abe2e6345ec progress: - total_phases: 7 + total_phases: 8 completed_phases: 2 total_plans: 18 completed_plans: 18 - percent: 29 + percent: 25 --- # Project State @@ -23,13 +23,13 @@ progress: See: .planning/PROJECT.md (updated 2026-09-08 after Phase 2) **Core value:** A player pastes a Warcraft Logs URL and instantly gets accurate, actionable answers to "why is my parse low" — accuracy is non-negotiable. -**Current focus:** Phase 3 — Share Loop (roast/award cards, per-player permalinks, a share CTA that survives later phases) +**Current focus:** Phase 2.1 — PostHog Consent Gate Hotfix (URGENT: capture ~99.9% down since 2026-09-06; see `02.1-DIAGNOSIS.md`). Phase 3 Share Loop follows. ## Current Position Phase: 3 — Share Loop Plan: Not started -Status: Ready to plan +Status: planning Last activity: 2026-09-08 — Phase 02 complete, transitioned to Phase 3 Progress: [███░░░░░░░] 29% (2/7 phases; 18/18 planned plans complete) @@ -103,10 +103,12 @@ Recent decisions affecting current work: - `wow-forever-support.md` (2026-09-13) — add World of Warcraft Forever support once WCL exposes Forever logs; era module via wago regen, partition-aware rankings, fixtures. Not actionable until upstream data exists. -Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European regulations → message → site settings: paste https://parseforge.gg/privacy (still open since 01-09); (2) PostHog MCP connection returns `INVALID_API_KEY` and its active project is "LootList+ App" — re-wire it to the ParseForge project (337485) before the Phase 3 gate so event definitions can be confirmed by tool rather than by hand; (3) GSC: `/` and `/analyze/*` show "Crawled – currently not indexed" with crawl dates predating the Phase 2 deploy — request recrawl and watch `/` specifically; (4) merge PR #15 (or `git push origin main`) — `origin/main` is at `55d2010`, local `main` carries Phase 2 + close-out commits; parallel worktree execution returns once they match; (5) game-data ids 96264 / 96294 flagged for human review in `.planning/WINDOWS.md`; (6) code-review findings in `02-REVIEW.md` — CR-01 (healer suggestions fire with `hasHealing: false`) is live and should be fixed via `/gsd-code-review 02 --fix` + a follow-up deploy. +Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European regulations → message → site settings: paste https://parseforge.gg/privacy (still open since 01-09); (2) PostHog MCP now authenticates; its *default* project is still "LootList+ App" — `switch-project 337485` per session works, but fix the connector default; (3) GSC: `/` and `/analyze/*` show "Crawled – currently not indexed" with crawl dates predating the Phase 2 deploy — request recrawl and watch `/` specifically; (4) ~~merge PR #15~~ done 2026-09-14 (`origin/main` = `f1d74f4`); PR #16 (Phase 2 review fixes) awaits preview + prod deploy; (5) game-data ids 96264 / 96294 flagged for human review in `.planning/WINDOWS.md`; (6) ~~code-review findings in `02-REVIEW.md`~~ fixed on `growth/phase-2-review-fixes` (PR #16, 4/4) — still needs deploy. ### Blockers/Concerns +- **[URGENT — Phase 2.1] PostHog capture has been ~99.9% down since the Phase 1 prod deploy (2026-09-06).** `cookieless_mode: "on_reject"` drops all PENDING-consent events; opt-in depended on `__tcfapi` calling back, which never happens for fresh visitors. Phase 1 & 2 OPS-01 PostHog criteria were not actually met. Fix: server-side geo opt-in (decided 2026-09-14). Evidence: `02.1-DIAGNOSIS.md`. +- Vercel CLI on this machine is logged into the `beast-app` team, not `loot-list-plus` — preview/prod deploys blocked until `vercel login` with the owning account. - Live brownfield product — deploys are manual Vercel CLI and require explicit user confirmation each time; the Claude Code auto-mode classifier also blocks `vercel deploy --prod` unless `Bash(vercel deploy:*)` is allowed (granted 2026-09-08 in `.claude/settings.local.json`). Preview-before-prod is the established pattern. - [Phase 1+2 carry-forward] Security ASVS review deferred for both phases (tooling not installed — see Deferred Items); CSP still report-only. - [Phase 2 carry-forward] `02-REVIEW.md` CR-01: healer overheal/uptime suggestions fire when `hasHealing` is false — a wrong recommendation live on parseforge.gg; plus WR-01..03. Fix before Phase 3 bakes healer numbers into share images. @@ -124,6 +126,10 @@ Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European |---|-------------|------|--------|-----------| | 260906-kzw | Add /privacy and /terms pages, footer links, sitemap entries | 2026-09-06 | 8dc7673 | [260906-kzw-add-privacy-and-terms-pages-footer-links](./quick/260906-kzw-add-privacy-and-terms-pages-footer-links/) | +### Roadmap Evolution + +- Phase 2.1 inserted after Phase 2: PostHog Consent Gate Hotfix — cookieless_mode on_reject dropped all PENDING-consent capture since the Phase 1 deploy (2026-09-06); server-side geo opt-in for non-EEA, live-traffic OPS-01 re-verification (URGENT) + ## Deferred Items Items acknowledged and deferred at milestone close, most recent first: diff --git a/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md new file mode 100644 index 0000000..c20b8a5 --- /dev/null +++ b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md @@ -0,0 +1,122 @@ +# Phase 2.1: PostHog Consent Gate Hotfix - Context + +**Gathered:** 2026-09-14 +**Status:** Ready for planning +**Origin:** Urgent insertion (INSERTED after Phase 2). Decisions below were made by the developer in-session on 2026-09-14 after the diagnosis in `02.1-DIAGNOSIS.md`; there was no separate discuss-phase. + + +## Phase Boundary + +PostHog capture on parseforge.gg has been ~99.9% down since the Phase 1 prod deploy (2026-09-06). Root cause (confirmed three ways — see `02.1-DIAGNOSIS.md`): `PostHogProvider.tsx` sets `cookieless_mode: "on_reject"`, and in posthog-js 1.360 that makes consent **PENDING ⇒ every `capture()` dropped**; capture only resumes after `opt_in_capturing()`, which the provider calls solely from the `__tcfapi` callback — and Google's CMP never calls back for fresh visitors. This phase delivers: (1) **server-side geo classification** so non-consent-region visitors are opted in immediately with no CMP dependency; (2) **unchanged EEA/UK consent semantics** (no capture before the TCF dialog resolves, cookieless on explicit reject, replay only on full opt-in); (3) **measurability of the gate itself** — every event carries which consent path admitted it; (4) **OPS-01 hardened** so the ship gate requires a post-deploy live-traffic check and can never again pass on pre-deploy data; (5) honest retro addenda to the Phase 1 and Phase 2 verification records. + +Out of scope: any ad code (Phase 4), changing the CMP vendor or its message config, CSP enforcing (Phase 7), the analysis engines, the PostHog MCP connector's default project (manual follow-up), the pre-existing GSC "Crawled – currently not indexed" investigation (Phase 3 gate), and the Vercel CLI account mismatch on the developer's machine (manual; blocks deploys, not code). + + + + +## Implementation Decisions + +### Where the region decision is made (SC 1) +- **D-01:** The consent-region decision is made **server-side from Vercel's geo request headers** (`x-vercel-ip-country`, ISO 3166-1 alpha-2), never from the client, never from `__tcfapi`. The client receives a single boolean, `isConsentRegion`, before `posthog.init` runs. — **Reversibility:** reversible. +- **D-02:** **Hard constraint on transport:** delivering that boolean must **not change the rendering mode of any route** — `/` stays ISR (`revalidate = 3600`), `/guides/*`, `/tbc-audit`, `/privacy`, `/terms` stay static, and no route gains a per-request server render it did not have. Calling `headers()` in `app/layout.tsx` is therefore **not acceptable** (it would make the whole tree dynamic). The transport is a research question (R-01) — the two candidates are a Next `proxy.ts`/middleware that classifies at the edge, or a minimal `/api/geo` endpoint the client calls once before init. Whatever is chosen must add at most one small request before the first capture and must **not set any cookie for a consent-region visitor before they consent**. — **Reversibility:** costly — the transport shape is what Phase 4's ad loader will read too. +- **D-03:** The consent-region set is **one exported constant in `lib/`** (e.g. `lib/geo.ts`) with a unit test, defined as **exactly the region set Google Privacy & Messaging's GDPR message is configured to target** (EEA + UK, and Switzerland if Google includes it — research R-04 verifies). A mismatch in either direction is a bug: a country in the list without a CMP dialog would be dropped forever (today's bug in miniature); a country with a dialog but outside the list would be captured before consent. **Unknown or missing header ⇒ consent region** (fail closed), and when running on Vercel (`process.env.VERCEL` set) that case emits one `logEvent("geo_header_missing", …)` via `lib/observability.ts` so it is countable in Vercel logs. + +### Client behaviour by region (SC 1, SC 2) +- **D-04:** **Non-consent-region visitors:** inside `posthog.init`'s `loaded` callback, call `ph.opt_in_capturing({ captureEventName: false })` (or the equivalent that suppresses the default `$opt_in` event) and `ph.startSessionRecording()` immediately — Phase 1 D-07 behaviour — with **no `startConsentListener` and no `__tcfapi` involvement at all**. The first `$pageview` must not be lost to ordering: research R-03 settles whether `PostHogPageView`'s capture can fire before `loaded` and, if so, the provider defers page-view capture until the consent path is resolved. +- **D-05:** **Consent-region visitors:** the existing TCF flow in `PostHogProvider.tsx` / `lib/consent.ts` is **kept exactly as-is** — `opt-in-full` → opt in + start replay; `cookieless` → nothing further (explicit reject captures cookieless via the SDK's `isExplicitlyOptedOut()` path); `pending` after `CMP_TIMEOUT_MS` → no capture. This hotfix does **not** try to make pre-choice EEA visitors capture cookieless (Phase 1 D-06's stated intent, which the SDK never honoured) — that is deferred, see ``. +- **D-06:** The decision logic that maps `(isConsentRegion, ConsentAction)` → SDK calls is **extracted into a pure, unit-tested function in `lib/consent.ts`** (same style as `deriveConsentAction`), so the provider's `loaded` callback becomes a thin switch over its result. Tests live in `lib/consent.test.ts` (vitest, node env — the project has no React Testing Library and this phase does not add one). + +### Measurability of the gate (SC 3) +- **D-07:** Every event carries a **`consent_gate_path` super property** registered on the client before/at opt-in, with values `geo-non-consent-region` | `tcf-accept` | `tcf-reject` | `tcf-timeout`. `consent_resolved` keeps `accepted` and `gdpr_applies`; `consent_unavailable` keeps `reason`. It is accepted that `consent_unavailable` and `tcf-timeout` remain **unobservable from PostHog** for a pending consent-region visitor (the SDK drops them) — the server-side `geo_header_missing` log and the Vercel-Analytics-vs-PostHog ratio (D-08) are the observability for that gap. + +### OPS-01 hardening (SC 4) +- **D-08:** `docs/OPS-01-SHIP-GATE.md` gains a **mandatory post-deploy live-traffic check** that must be run and pasted as evidence before a gate is signed: within **60 minutes** of the prod deploy, PostHog project 337485 shows **≥ 20 `$pageview` events from ≥ 2 distinct non-consent-region `$geoip_country_code` values**, and the PostHog `$pageview` count is **≥ 50 % of Vercel Web Analytics page views** for the same window (Vercel Analytics is already mounted in `app/layout.tsx` and is independent of PostHog — it is the reference signal that would have exposed this outage on day one). A phase whose events cannot be observed (`no-data`) **fails** the gate; it may no longer be recorded as "recorded, not assumed". The check is expressed as an exact HogQL query in the doc (and, at Claude's discretion, a `scripts/posthog-live-check.mjs` that runs it with a personal API key from the environment — never committed). +- **D-09:** Append-only **addenda** to `.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md` and `.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md` (dated 2026-09-14) stating that their OPS-01 PostHog criterion was not met at sign-off and was met by Phase 2.1; `REQUIREMENTS.md`'s OPS-01 row gets the same note. Existing text is not rewritten. + +### Verification and deploy (SC 1–4) +- **D-10:** The end-to-end proof for the tracer slice is the **same headless-Chrome netlog technique that found the bug**: a fresh profile loading the preview URL from a non-consent-region IP must produce a capture request to `/ingest/e/*` or `/ingest/i/v0/e/*` within ~5 s of load, whereas today it produces none. Record the exact command (with `--virtual-time-budget` and a hard timeout/kill — the bare command does not exit on its own) in the plan's verify block and in the OPS-01 doc. +- **D-11:** Ship **preview first** (`vercel deploy --scope loot-list-plus`), run D-10 against the preview, then **prod only after the developer's explicit confirmation** (CLAUDE.md). Note: the developer's machine is currently logged into the wrong Vercel team (`beast-app`); `vercel login` with the owning account is a prerequisite the plan must surface as a human checkpoint, not silently retry. + +### Claude's Discretion +- Exact file layout (`lib/geo.ts` vs folding into `lib/consent.ts`), the name of the boolean prop, and whether the region constant is a `Set` or a frozen array. +- The transport implementation details once R-01 picks proxy/middleware vs `/api/geo` (matcher scope, caching headers, response shape), within D-02's constraints. +- Whether to add `scripts/posthog-live-check.mjs` or keep the live check as a documented HogQL query run through the PostHog MCP. +- Wording of the OPS-01 doc changes and the verification addenda. +- Whether `consent_gate_path` is set with `posthog.register` or passed per-event — but it must be on every event, including `$pageview`. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### This phase +- `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md` — evidence tables, headless-Chrome netlog result, the exact posthog-js `isOptedOut()` / `is_capturing()` logic, and the scope notes +- `.planning/ROADMAP.md` — Phase 2.1 entry: goal + 4 success criteria +- `.planning/REQUIREMENTS.md` — OPS-01 (standing gate — this phase re-verifies it for real), MONY-01 (consent semantics that must be preserved) + +### Code under change +- `app/components/PostHogProvider.tsx` — `posthog.init` config (`cookieless_mode: "on_reject"`, `disable_session_recording: true`, `capture_pageview: false`), the `loaded` callback's `switch` over `ConsentAction`, and `PostHogPageView` +- `lib/consent.ts` + `lib/consent.test.ts` — `deriveConsentAction`, `startConsentListener`, `CMP_TIMEOUT_MS = 3000`, `getConsentState`; 11 existing tests must keep passing +- `app/layout.tsx` — sync server `RootLayout`; mounts `…`, the Google CMP ` +