Skip to content

Feature/meta build aggregator#20

Open
314159DD wants to merge 40 commits into
coleestrin:mainfrom
314159DD:feature/meta-build-aggregator
Open

Feature/meta build aggregator#20
314159DD wants to merge 40 commits into
coleestrin:mainfrom
314159DD:feature/meta-build-aggregator

Conversation

@314159DD

Copy link
Copy Markdown

Add /meta build aggregator page

Adds a new top-level page at /meta that aggregates Project Diablo 2
ladder build data: top items per slot, affix mod patterns,
skill usage with prereq classification, mercenary, and level
distribution, plus a "diff my character" mode.

This implementation is a port of https://pd2-aggregator.vercel.app
discussed in Discord. All public-API code paths have been replaced
with direct Postgres aggregations via a new Express route + autoCache.

What's new

Backend

  • api/src/routes/meta.ts — single GET endpoint with strict query-param parsing
  • api/src/database/postgres/meta.ts — 7 aggregation methods on MetaDB_Postgres:
    • findCohort — class/level/gameMode/skill filter
    • aggregateItemUsage — equipped Uniques/Sets/Runewords
    • aggregateSkillUsage — base counts per skill (kept as private helper)
    • aggregateSkillUsageClassified — adds prereq classification (1pt skills required by a higher skill are bucketed as "prereq" not "build")
    • aggregateMercType + aggregateMercItems — mercenary data
    • aggregateLevelDistribution — character count per level
    • aggregateAffixMods — Rare/Magic/Crafted modifier patterns with avg/median/p75 per (slot, modName)
  • api/src/types/meta.ts — request + response types
  • api/src/routes/meta.test.ts — 67 Jest+supertest tests (validation paths + parity assertions across 7 canonical builds)
  • All routes use autoCache(900) + validateSeason matching the rest of the API

Frontend

  • web/src/pages/Meta.tsx — top-level page
  • web/src/components/meta/* — 8 components (FilterForm, BuildSheet, ItemFrequencyTable, AffixFrequencyTable, CharmPanel, DiffView, DataFreshness, MatchBanner) — all Mantine v7
  • web/src/api/meta.ts — typed API client
  • web/src/hooks/useMetaData.ts — React Query hook (first one in the codebase — RQ was already wired in App.tsx)
  • web/src/lib/* — pure logic ported from pd2-aggregator (slot map, build presets, URL state, diff function, types)
  • web/src/data/* — skill prereq + synergy data, item-slot map, build presets, affix mod dictionary

Wired in

  • web/src/App.tsx<Route path="/meta">
  • web/src/components/layout/NavBar/index.tsx — "Meta" entry between Builds and Economy
  • web/src/config/api.tsmeta: "/meta" endpoint constant
  • api/src/routes/index.tsrouter.use("/meta", metaRoutes)
  • api/src/database/index.ts — exports metaDB
  • api/src/types/index.ts — re-exports meta types

Verification

  • Backend Jest tests: 67/67 passing (cd api && npm test -- --testPathPattern=meta.test)
  • Frontend tsc + eslint: clean (cd web && npx tsc --noEmit && npm run lint)
  • Manual smoke test: cohort lookup + 7 aggregations return correctly across 7 canonical builds (Hammerdin, Blizz Sorc, WW Barb, Bone Spear Necro, Wind Druid, Trapsin, LF Zon)
  • Tested at mobile width (375px) — tables wrap in ScrollArea, button rows reflow
  • Live click-through validated against the dev stack with a 73-Paladin softcore cohort: FilterForm renders class-only baseline percentages, BuildSheet's Hard % column matches the >= 20 hard points threshold that analyzeSkillUsage in CharacterDB_Postgres uses, affix table shows real magnitudes (avg/median/p75 of the rolled value, not the skill-ID index)

Notes on the iteration

After the initial implementation we ran the live stack and tightened a few things based on what we saw:

  • aggregateSkillUsageClassified now exposes pctAtTwenty alongside the existing pct and pctBuild. FilterForm + BuildSheet's primary column use pctAtTwenty so the numbers match what /builds shows.
  • Affix magnitude bug: was averaging values[0] which is the skill/tab/class ID for two-value mods (item_singleskill, item_addskill_tab, item_addclassskills); now uses values[values.length-1] which is always the magnitude. Bucketing also extended so +3 to Ice Blast and +3 to Meteor no longer collapse into the same row.
  • Build presets fleshed out from 1-skill to 3-skill signatures (Hammerdin = Blessed Hammer + Concentration + Vigor, etc.) so cohorts actually match the build identity instead of just one skill.
  • Smaller UX bits: level distribution chart no longer has a stray scrollbar, selected skill rows use a single solid green (was a two-tone overlap), selected skills float to the top of the picker, table section headers explain "Top items: Unique/Set/Runeword" vs "Affix patterns: Rare/Magic/Crafted only" so the differing tab counts make sense.

Open follow-ups (not in this PR)

  • Charm aggregation — currently a placeholder. The standalone shows charm count/size patterns; we'll add /api/v1/meta/charms in a follow-up since the fork doesn't load raw character JSON FE-side.
  • Frontend test runnerweb/ has no vitest/jest setup currently. We lean on tsc --noEmit + manual testing. Happy to open a follow-up PR if you'd like vitest added.
  • Aggregate Rare/Magic/Crafted items by base — currently only Unique/Set/Runeword are name-aggregable. Aggregating by base item type (e.g., "Phase Blade" as a category) would surface common Crafted weapon bases.
  • Build-name auto-detection — given a cohort, identify which canonical build applies and suggest it.
  • autoCache caches 400 responses — pre-existing across the whole API; a 400 response gets returned as 200 on cache hit. Not introduced by this PR but worth flagging.

Data sources

  • Skill prereq + synergy data scraped from wiki.projectdiablo2.com (CC-BY-SA — re-running the scraper is a one-line script call we'd reuse here)
  • Affix mod dictionary: ported from the standalone, derived from PD2 community sources
  • Item-slot map: regenerated from pd2-tools' own character snapshots + wiki

A note on architecture

The aggregator's "look-and-feel" runs Mantine v7 (your stack). The "engine" is direct SQL queries against your existing Postgres schema. We deliberately did NOT preserve the standalone's public-API HTTP path or its IndexedDB cache — those don't add value in a server-rendered environment with autoCache.

Happy to iterate on review feedback.


🤖 Generated with Claude Code

314159DD and others added 30 commits May 11, 2026 03:01
Placeholder page reachable at /meta. NavBar shows the new entry.
Next: backend SQL aggregations.
Code review flagged em-dash + "PD2 Tools" — fork convention is
"Page - pd2.tools" (hyphen, lowercase, matches About + Builds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary notes file — captures schema + patterns we'll need for
the meta route SQL. Delete at sprint close.
Request shape (IMetaQuery), per-aggregation row types, combined
IMetaResponse. Shapes mirror api.pd2.tools' public /stats/*
responses since /meta replaces those calls.
- Replace open-ended `... | string` itemType union with a named
  `ItemType` so typos surface at compile time
- Rename level-bucket field `count` -> `numOccurrences` for
  consistency with the other row interfaces; introduce
  `ILevelBucket` so the same shape isn't inlined twice

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MetaDB_Postgres class with findCohort(filter). Mirrors the existing
getFilteredCharacters CTE pattern: subquery-resolves game_mode_id
from name lookup, JOIN Classes for className, EXISTS-clause per
required skill against CharacterSkills joined to SkillsDefinitions.

Returns character_db_id[] — downstream aggregation queries (Tasks
7-8) take this list as their starting set.
GameMode type already constrains values to lowercase literals;
the runtime call was redundant. Aligned `unknown[]` -> `any[]`
to match the existing query-param convention in economy.ts
and postgres/index.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Counts equipped Unique/Set/Runeword items across the cohort.
Mirrors analyzeItemUsage's CASE classification for item-type
(promotes is_runeword rows to 'Runeword' label).

Rare/Magic/Crafted items skipped — they have unique random names
and can't be name-aggregated; affix-mod aggregation in Task 17
handles their statistical patterns instead.
Four more methods on MetaDB_Postgres against the cohort:
- aggregateSkillUsage — CharacterSkills JOIN SkillsDefinitions
- aggregateMercType — CharacterMercenaries.description
- aggregateMercItems — MercenaryItems, same CASE/IGNORED pattern
  as aggregateItemUsage (mirrors analyzeMercItemUsage)
- aggregateLevelDistribution — Characters grouped by level, returns
  {hardcore:[], softcore:[]} populated on one side only

Schema mirrors what was documented in .meta-recon-notes.md.
Sanity-tested against the dev Postgres.
Code review noted Task 9's wiring could be confused by the param —
it never reaches the SQL, only picks which side of the return
shape gets populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET endpoint that takes gameMode + className + minLevel + skills +
season query params, fans out to 5 aggregations in parallel via
Promise.all, returns a combined IMetaResponse.

15-minute Redis cache via autoCache(900) — matches every other read
endpoint in the API. validateSeason middleware applied.

Query param parsing is strict — invalid gameMode/className/minLevel/
skills return 400 with a clear message using the error envelope
pattern { error: { message } } used elsewhere.
Code review caught two issues:
- gameMode `as string` cast silently passed `string[]` through on
  duplicate `?gameMode=hardcore&gameMode=softcore` requests. Now an
  explicit typeof guard rejects non-string forms.
- No success log line on the GET handler. Added one with className,
  gameMode, season, minLevel, skillsCount, cohortSize — matches the
  visibility level the other routes maintain in prod.

Also dropped a redundant `as string` cast on seasonRaw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports Sprint 2.2's PD2 parity assertions to the fork as Jest +
supertest integration tests against the live Express app and dev
Postgres.

7 canonical builds × 6 parity sub-tests = 42, plus 4 validation
tests = 46 total.

Tests assert internal consistency (totalSample agreement, math
correctness, no duplicates) and validation paths (missing
className, bad gameMode, out-of-range minLevel, malformed
skills JSON), not absolute population values — so they pass
regardless of ladder state in the dev DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds web/src/api/meta.ts (metaAPI.fetchMeta) and web/src/types/meta.ts
mirroring api/src/types/meta.ts — duplicated for the package boundary
since web/ and api/ are independent npm packages with no shared types
module.

Adds API_ENDPOINTS.meta = "/meta" to web/src/config/api.ts.
Exports metaAPI from api/index.ts and meta types from types/index.ts.

Uses the existing apiClient singleton + encodeURIComponent(JSON.stringify)
pattern for skills, matching how characters.ts serialises requiredSkills.
Thin wrapper over metaAPI.fetchMeta. queryKey includes the full
filter so different builds get distinct cache entries. Inherits
staleTime (5 min) / retry (1) / refetchOnWindowFocus (false) from
the QueryClientProvider defaults in App.tsx.

Also exports useMetaData from the hooks barrel (hooks/index.ts).
Hardcoded softcore Hammerdin filter for now; placeholder JSON
dump of the top 10 items. Validates the full pipeline:
React Query -> APIClient -> Express -> Postgres -> response.

Task 15 replaces the hardcoded filter with the FilterForm.
Files ported (no changes except import-path fixes):
- web/src/lib/shape/buildSheet.ts
- web/src/lib/shape/topItems.ts
- web/src/lib/filter.ts
- web/src/lib/diff.ts
- web/src/lib/slot.ts
- web/src/lib/buildPresets.ts
- web/src/lib/types.ts
- web/src/lib/url-state.ts
- web/src/data/skill-prereqs.json
- web/src/data/item-slots.json
- web/src/data/builds.json
- web/src/data/mod-dictionary.json

Added web/src/lib/api.ts — thin type bridge mapping PD2's lib/api.ts
type names (CommonFilter, GameMode, ItemUsageRow, SkillUsageRow,
MercTypeUsageRow, MercItemUsageRow, LevelDistribution) to the fork's
existing types/meta.ts equivalents. No fetch logic — only type aliases.

AffixMod / AffixModsBySlot types moved inline into diff.ts since
aggregate/affixMods.ts is not ported (aggregation runs server-side).

Skipped (no longer used on FE — backend does the aggregation now):
- aggregate/affixMods.ts, avgStats.ts, charms.ts, skillUsage.ts,
  index.ts, types.ts

These can be ported back if a future feature needs client-side
aggregation.
Mode toggle (guide/diff), game mode pills, class selector, build
preset row (conditional on class), min-level slider, skill picker
with selected chips + scrollable available list. URL-state
hydration on mount, URL update on submit.

Skill names sourced from data/skill-prereqs.json. TODO: future
iteration could fetch class skills from a server endpoint instead
to maintain a single source of truth.
Tabbed table per slot (weapon, offhand, helm, armor, gloves, belt,
boots, amulet, ring). Rarity badges color-coded. Uses
shapeTopItemsBySlot from the ported lib.

ScrollArea wraps each table for mobile responsiveness.
Backend: aggregateAffixMods fetches Rare/Magic/Crafted equipped
items via jsonb_array_elements on full_response_json->'items',
then aggregates in Node. Parsing ported verbatim from PD2's
affixMods.ts: zone gate (Equipped only), slot mapping from
SLOT_BY_EQUIPMENT, skill-tab bucket key collapsing magnitude prefix,
median/p75 using nearest-rank method. Denominator is per-slot item
count (not cohortSize) to match standalone's pct semantics.
Suppresses mods with <3 occurrences.

Frontend: Mantine tab-per-slot table, top 20 mods per slot.
Display labels resolved via mod-dictionary.json; skill-tab keys
strip the "item_addskill_tab|" prefix to show the tab name directly.

Tests: 46 → 60 (added shape + pct-math assertions for affixMods).
All 60 pass. tsc + lint clean both sides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: aggregateSkillUsageClassified — classifies each cohort
member's 1-point skills as prereq-only when another skill at
>1pt requires it. Returns numAsBuild + numAsPrereq + pctBuild
alongside the existing counts. Replaces aggregateSkillUsage in
the route (kept as a private helper for potential future use).
skill-prereqs.json copied to api/src/data/ (clean package boundary).

Frontend (Mantine):
- BuildSheet: top skills with prereq-only toggle + mini level-
  distribution bar chart using Mantine Box bars (no extra lib)
- CharmPanel: placeholder for now — full charm aggregation will
  come in a follow-up PR (significant scope; needs /api/v1/meta/charms)
- DataFreshness: cohort size + 'fetched X ago' badges, ticks every 30s
- MatchBanner: 'N <Class> characters match this filter' header

Meta.tsx wiring: MatchBanner -> DataFreshness -> BuildSheet ->
ItemFrequencyTable -> AffixFrequencyTable -> CharmPanel.
fetchedAt tracked in state, set on data arrival.

Tests: 60 -> 67 (+7 classified-skill row shape assertions, one per
canonical build). All 67 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In diff mode, looks up the named character via charactersAPI,
adapts to the PD2 Character shape, runs diffCharacter against
the current cohort's top items, renders a per-slot comparison
table.

Gracefully handles missing characters with an error alert.
- Loading: replace Loader with Skeleton blocks (more visible during
  long cohort queries) in both hydration guard and data-fetch gate
- Error: Alert with Retry button wired to React Query refetch()
- Empty: friendly Alert when cohortSize=0 with filter suggestions
- FilterForm slider: marks (1/50/99) + tooltip label for level selection
- FilterForm Group: explicit wrap="wrap" on class, preset, game-mode
  button rows so they reflow at 375px
- Tables already wrap in ScrollArea; no change needed
- Mantine Tabs/Table/Slider are semantic-HTML by default; no extra
  ARIA work needed
Recon complete — schema documented in code comments throughout
api/src/database/postgres/meta.ts.
Iterated based on live testing against the dev cohort:

Backend:
- aggregateSkillUsageClassified now also exposes numAtTwenty +
  pctAtTwenty (matches pd2.tools/builds' >= 20 hard-points threshold).
  Tests assert the new fields and subset invariant.

Frontend:
- FilterForm fetches class-only baseline meta data so the skill
  picker can show real cohort percentages next to each skill name.
- Skill rows ported to the pd2.tools /builds SkillCard pattern:
  top-bordered Paper rows form dividers, absolute background fill
  bar shows percentage, hover state.
- Bigger touch targets throughout (32px skill icons, size=md buttons,
  uppercase section labels, fw=600 button text).
- BuildSheet primary column is now Hard% (pctAtTwenty); Any% kept
  as secondary context column.
- useMetaData hook is enabled-gated on className so empty class
  doesn't fire a doomed 400.
- DEFAULT_UI_STATE no longer preselects Paladin; lands on a
  "Pick a class above" prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pre-PR polish fixes:

1. Affix mod magnitude was wrong for two-value mods. PD2 modifier
   shapes are [magnitude] for single-value mods (item_fastercastrate)
   but [id, magnitude] for two-value mods (item_singleskill,
   item_addskill_tab, item_addclassskills). Old code used values[0]
   for all, so "+X to a Skill" rows showed avg=100+ (the skill ID).
   Now uses values[values.length - 1] which is always the magnitude.

2. Affix bucketing extended: item_singleskill and item_addclassskills
   now bucket by label-derived target (skill name / class name) so
   "+3 to Ice Blast" and "+3 to Meteor" stay in separate rows instead
   of collapsing. AffixFrequencyTable.resolveLabel handles any
   "<modKey>|<label>" form generically.

3. Level distribution chart: removed overflowX scroll (was producing
   a stray scrollbar at the chart edge), bumped bars from 6px to
   flex-grow with 28px max width and 96px max height, drops empty
   buckets, labels every level (not just multiples of 10). Reserved
   fixed height so the page doesn't reflow when cohort changes.

4. DataFreshness "FETCHED: -15s AGO" bug: useState lazy-init captured
   a timestamp before the parent's setFetchedAt fired. Added an effect
   that resets `now` whenever fetchedAt changes, plus a Math.max(0)
   clamp as a defensive backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- FilterForm skill list: selected skills float to the top (within
  groups still sorted by pctAtTwenty desc). Gives immediate visual
  feedback when clicking a skill or a build preset.

- ItemFrequencyTable: section header 'Top items' with subtitle
  'Unique, Set, and Runeword items'.

- AffixFrequencyTable: section header 'Affix patterns' with
  subtitle 'Rare, Magic, and Crafted items only' (hover-tooltip
  explains why counts differ from the Top items table).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The selected-row background bar overlapped the percentage-width fill
bar, producing two stacked shades of green. Now selected rows use a
single solid darker green across the full row (no fill bar) and the
percentage stays visible on the right edge. Click the row to deselect
(X icon removed — the whole row is the toggle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-skill presets were too loose — "Hammerdin" matching every
character with Blessed Hammer at 20 hard points doesn't pin down
the build. 3-skill signatures (primary + key synergy/aura + extra)
match the conventions PD2 community uses to describe a build:

  Hammerdin = Blessed Hammer + Concentration + Vigor
  Smiter    = Smite + Holy Shield + Fanaticism
  FoH       = Fist of the Heavens + Holy Bolt + Conviction
  ... etc, across all 7 classes

Verified every preset skill name resolves to a real
SkillsDefinitions row in the DB (caught 4 PD2-renamed skills:
"Raise Skeleton Warrior" -> "Raise Skeleton", "Enchant Fire" ->
"Enchant", and removed "Poison Explosion" / "Poison Dagger" which
PD2 doesn't have).

Production cohorts will be meaningful for every preset. The dev DB
sample happens to lack data for Hammerdin/Smiter specifically (no
characters with maxed Blessed Hammer or Smite among ~114 scraped
Paladins), but FoH/Zealot/Auradin/Charger all return real cohorts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314159DD and others added 8 commits May 11, 2026 06:28
Was only there as a copy-paste source for the GitHub PR body —
not part of the feature, doesn't belong in the merged tree.
Set Group wrap="nowrap" and shrink Button size to xs so all 7 classes
fit on a single row at typical sidebar widths instead of wrapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI polish
- Class buttons stay on one line; shorten to Sorc/Pala/etc. then to
  initials at narrow viewports so the row never overflows
- HC/SC pills shrunk one size step; form widened to 1050px
- Top items and Affix patterns tables use fixed column widths so
  type/count/% columns stay anchored across slot tabs
- Tab rows are center-aligned and wrap to a second centered row on
  narrow viewports
- Page title centered; banner alert constrained to form width

Collapsible sections
- New CollapsibleSection wrapper with rotating chevron and bottom
  border, mirroring the pd2-aggregator Section component
- Core Skills, Level distribution, Top items, Affix patterns, Charms,
  and Average build stats are all collapsible

Average build stats (new section)
- Backend: aggregateAvgStats() reads character.attributes plus
  character.life and character.mana directly. These are the stats-page
  totals (base, gear, leveling already baked in). Returns six fixed
  rows: str, dex, vit, energy, life, mana. Resistances are skipped
  since every end-game build caps at 75
- AvgStatsPanel renders six stat cards
- Affix patterns gained an "All slots" tab; TopAffixAveragesPanel
  reuses that aggregation to show the most common build-defining
  affixes. Resists and skill/proc mods are excluded since they do not
  aggregate meaningfully across the pool

Core Skills synergy filter
- Default Core Skills view hides skills that appear as synergies of
  other displayed core skills, using skill-prereqs receivesBonusesFrom.
  A new "Show synergies (N)" toggle parallel to "Show prerequisites"
  exposes them with a SYNERGY badge

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core Skills now show a per-skill Type bubble (Synergy or Prereq; cores
get no badge to keep visual noise down) and a single popularity-sorted
list. Synergies are hidden by default behind the "Show synergies (N)"
toggle for a compact entry view.

Classification source: scripts/build-skill-classification.py scrapes
21 per-tree pages from wiki.projectdiablo2.com, assigns a default role
per tree (Combat Skills -> core, Defensive Auras -> synergy, etc.), and
falls back to the existing receivesBonusesFrom heuristic for six trees
that mix the two roles (Offensive Auras, Warcries, Shape Shifting,
Cold/Fire/Lightning Spells, Poison and Bone, Druid Summoning). A small
OVERRIDES dict patches the obvious edge cases (Cold Mastery -> synergy,
Teleport -> synergy, Skeleton/Golem/Curse Mastery -> synergy, Maul ->
core, etc.). Output is checked into web/src/data/skill-classification.json
so the FE doesn't need network access at runtime; rerun the script when
PD2 patches change skill rosters.

The "Show synergies" toggle defaults to off so the entry view stays
short. Top-three cores still get the yellow text highlight regardless
of how synergies interleave when the toggle is on.

Also:
- BETA pill on the /meta nav link and the page title (violet light Badge)
- Welcome banner gains a dimmed second line asking for feedback on
  Discord (@tekk0n) since the tool is new

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shrinks the PR diff per the review ask. Net -264 lines.

- Remove the Python wiki scraper from the repo. Generated JSON stays.
- Remove the CharmPanel placeholder card; charms ship in a follow-up
  when the backend route is built.
- Remove the "Resistances aren't shown" note and the "Prereq detection
  uses skill-tree data" citation. Both were noise.
- Level slider now spans 80-99 to match the backend's indexed range.
- SkillIcon switches from the wiki Special:FilePath URL to /icons/{name}.png
  so Prayer (and every other skill) renders, and icons have uniform
  20px sizing instead of variable wiki dimensions. Fixes the missing
  Prayer image and the spacing drift Cole called out.
- Top items table wraps each row name with the existing ItemTooltip
  from components/builds/shared/ItemHelpers so hover gets the same
  item-card behavior as the /builds page.
- Comment cleanup pass across meta.ts (backend + types), BuildSheet,
  AvgStatsPanel, TopAffixAveragesPanel, CollapsibleSection,
  AffixFrequencyTable. Kept the WHY-comments, trimmed paragraphs that
  a reviewer can derive from the code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trims ~2k lines off the PR diff.

- web/src/data/skill-prereqs.json: web only needed Object.keys() of the
  skill list, so it now reads skill-classification.json (same 220 skill
  names across the 7 classes). API keeps its copy since it still uses
  the prereqs graph for the 1-pt-prereq classifier.
- web/src/lib/filter.ts: not imported anywhere. Was ported from the
  standalone for client-side cohort filtering; with backend aggregation
  the FE doesn't filter raw characters.
- web/src/lib/shape/buildSheet.ts: not imported anywhere. The BuildSheet
  component now reads the API's IClassifiedSkillRow shape directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net -191 lines. No behavior changes.

- types.ts: drop the 150-odd "// CONFIRMED: ..." snapshot-inspection
  annotations. The types themselves document the shape.
- Strip "Sprint X" / "Task X" / "PD2 standalone" / "mirrors the
  standalone" / "ported from PD2/..." references across backend and
  frontend comments.
- Replace em dashes in comments and user-facing empty-state JSX
  ("No skill data" instead of "- no skill data -" style).
- Consolidate api.ts comment block and diff-adapter.ts docstring.
- Trim verbose JSDoc on aggregateItemUsage / aggregateAffixMods.
- Hoist BuildSheet imports above the top-level const.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314159DD added a commit to 314159DD/pd2-aggregator that referenced this pull request May 12, 2026
Sprint 2.3 shipped to coleestrin/pd2-tools#20 on 2026-05-11. This commit
captures the doc-side closeout (status line, roadmap entry, archived sprint
file) plus the build tooling + classification data that the PR ports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314159DD added a commit to 314159DD/pd2-aggregator that referenced this pull request May 12, 2026
Brings pd2-aggregator.vercel.app to feature parity with coleestrin/pd2-tools#20
while Cole reviews the upstream port. Same data layer, adapted to the
standalone's Tailwind/shadcn UI.

- Min-level slider clamped to 80-99 with marks (and URL parser clamps too)
- "All slots combined" affix view now hides resists + skill/proc procs;
  avgStats does the same (resists cap at 75, averaging is misleading)
- New "Average build stats" section above Top items:
    - AvgStatsPanel: Str/Dex/Vit/Energy/Life/Mana from character.attributes
    - TopAffixAveragesPanel: cross-slot mod averages, resist-filtered
- Level distribution rendered as a vertical bar chart with empty buckets
  dropped and per-bar title tooltips, replacing the flat pill row
- Skill table gains Core/Synergy/Prereq classification via
  data/skill-classification.json, plus "Chars 20+" and "Hard %" columns
  with explanatory tooltips. Synergies and prereqs hidden behind toggles
- Item names get hover tooltips with image + req level + attribute lines
  via a mirrored public/items.json (640 KB; refresh on PD2 patches)

All 180 tests still pass; tsc + next build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314159DD added 2 commits May 13, 2026 16:51
Bundles the work for LAMP's second review round into a single commit
since the changes are interlocking. Net -2225 LoC across 26 files
(6 deletions).

Architectural changes
- Drop `web/src/types/meta.ts`: the duplicates of `ItemUsageStats` /
  `SkillUsageStats` / `MercTypeStats` / `LevelDistribution` already exist
  in `web/src/types/character.ts`. Reuse those; the genuinely-new meta
  types (`IMetaResponse`, `IMetaQuery`, `IClassifiedSkillRow`,
  `IAffixModRow`, `IAvgStatRow`, `ISkillRequirement`, `Slot`) move into
  `types/character.ts`. `ItemUsageStats.itemType` widened to include
  Rare / Magic / Crafted. `ILevelBucket` field renamed `numOccurrences
  → count` to match existing `LevelDistribution`.
- Drop `web/src/lib/diff-adapter.ts`, `web/src/lib/types.ts` (the PD2-
  shape Character / Item universe), and `web/src/lib/api.ts` (the type
  shim). `lib/diff.ts` is rewritten to consume `FullCharacterResponse`
  / `IItem` directly. The "userHas affix" check shifts from
  `modifiers[].name` (not present on `IItem`) to `properties[]` string
  match against the pool affix display label — same source the /builds
  UI uses.
- Drop `web/src/data/item-slots.json` (1591 entries) and the
  `slotFromItemName` lookup. Backend now derives slot from
  `item.base.type` in each character's JSON, builds a lazy
  `Map<itemName, slot>` cache on first call, and returns `slot` on
  every `IItemUsageRow`. FE `shape/topItems` reads `row.slot` directly.

UI changes
- `/meta` link moves into the Tools dropdown (next to Character Exporter
  and Corrupted Zones). BETA badge dropped from nav; page header keeps
  it.
- DiffView pool-top and user-equipped item names get hover tooltips,
  reusing the `/builds` page's `ItemTooltip` + `/items.json` mechanism.
  Pool item type now flows through `SlotDiff.poolTopItemType` for
  correct tooltip border colors.
- FilterForm class-button responsive shrink range extended from
  ≤768px to ≤992px to fix overflow on widths between mobile and
  full-tablet (LAMP's responsive observation).

Cleanup
- Header block comments removed from `api/src/types/meta.ts`,
  `api/src/routes/meta.test.ts`, `web/src/components/meta/FilterForm.tsx`
  and other meta components. JSDoc retained where field semantics are
  non-obvious (modKey suffix bucketing, pctAtTwenty threshold, the
  zone-gate behavior on `location.equipment`).

Verification
- API tsc + jest meta.test (67 tests, all green)
- Web tsc + eslint clean
Follow-up to e1eb0f4 addressing issues caught in self-review.

Bugs fixed
- `DiffView.tsx` was multiplying `m.pct` by 100 a second time
  (`(m.pct * 100).toFixed(0)`) — every affix row would have rendered
  "10000% of pool". Other consumers in the codebase print `pct.toFixed(N)`
  directly; `m.pct` is already 0-100.
- `lib/diff.ts` "userHas affix" check resolved non-piped modKeys (the
  common case: `item_fastercastrate`, `item_armorpercent`, …) to the
  raw mod name, which never appears in a `properties[]` string. Now
  resolves through `mod-dictionary.json` first (same dict
  `AffixFrequencyTable` uses) and falls back to the raw key. Matching
  also tightened from substring to word-boundary regex so a pool top
  of "Resist" no longer false-positives on "Cold Resist 10%".
- `lib/diff.ts` `SLOT_BY_EQUIPMENT` was mapping both
  `Right Hand`/`Right Hand Switch` to "weapon" (and same for offhand),
  so `items.find()` could return a weapon-swap item instead of the
  active-set weapon. Removed Switch positions — diff considers the
  active loadout only.

Type tightening
- Narrowed `ItemType` to `Unique | Set | Runeword` (was 6 values).
  SQL never emits the other 3; the wider union was a lie. Dead branches
  in `ItemFrequencyTable.rarityColor` dropped.
- `IAffixModRow.slot` is now `Slot` instead of `string` on both sides.
  Backend `inferSlot` and `SLOT_BY_EQUIPMENT` typed accordingly. Drops
  the `as Slot` cast in `bucketAffixModsBySlot`.
- `ItemUsageStats.slot` widened to `Slot | null` to match the actual
  wire format (backend writes explicit `null` for non-equippable items
  like charms). Docs updated.
- Added `zone?: string` to `IItem.location`. The previous
  `(loc as { zone?: string }).zone` cast was a workaround for the
  type being wrong, exactly the kind of accommodation-smell the
  bridge-deletion was supposed to remove.

Backend
- Slot cache wrapped in `Promise<Map>` so concurrent first-callers
  share a single in-flight JSONB scan instead of each issuing one.

Comments
- Dropped remaining AI-prose: 9× `// Test N:` comments in
  `meta.test.ts`, 9× `{/* Section name */}` JSX dividers in
  `FilterForm.tsx`, 3× `// ----` block separators in `meta.ts` and
  `meta.test.ts`, and the dangling "Mirrors X" / "Ported verbatim" /
  "(Tasks 7-8)" references in `meta.ts` (which referred to a
  standalone codebase the fork's readers have no context for).
- Updated stale `modKey` docstrings to mention all three pipe-bucketed
  mods (`item_addskill_tab`, `item_singleskill`, `item_addclassskills`),
  not just the first one.

Frontend
- `.catch(() => {})` on `/items.json` fetches in `DiffView` and
  `ItemFrequencyTable` replaced with `console.error` to match the
  `UniqueCard` / `MercItemCard` convention.

Logging
- Switched the items.json catch handlers to console.error.

Verification
- API tsc + jest meta.test (67/67 green)
- Web tsc + eslint clean
- Live API spot-check: `slot` populated correctly per item, level
  distribution emits `{ level, count }`, charms map to `slot: null`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant