Pinning workflow
/pin is a one-time bootstrap UI for assigning map coordinates to every entry from the Alaria wiki. It is not an optional curation step — every wiki entry must end up pinned so the public map (/) can show it at the right zoom.
What it produces
world-wikis/.../*.md (4500+ headers across 8 continent files)
│
│ scripts/extract-locations.ts (one-time parse: headers → entries with IDs)
▼
data/work-queue.json (LoreEntry[]: id, name, lineNumber, headerLevel, parentEntryId, status)
│
│ /pin admin UI + /api/pin (human assigns coords, type, zoom, then pin or skip)
▼
data/pinned.json (PinnedData: { [id]: { coordinates, zoomLevel, type, pinnedAt } })
│
│ data/manual-pins.json (ManualPin[]: free-form pins, ids prefixed `manual-`)
│ │ ▲ /pin Quick Label mode + /api/manual-pin
│ │
│ scripts/finalize-locations.ts (merges work-queue + pinned + descriptions + manual-pins → final shape)
▼
data/locations.json (consumed by the public map at /)
Two modes in the /pin UI
- Queue — the original walk-through: steps every wiki
LoreEntry, human assigns coords/type/zoom, writes pinned.json. This is the bootstrap pass and is now effectively done.
- Quick Label — free-form: click the map, type a name, Enter. Saves to
data/manual-pins.json (not the queue), default type uncategorized. For places the wiki never had a header for. After Enter the new pin is selected; type keys (1-5 q w e r) retype the selected pin and N/P step through placed pins (map pans to follow). Default mode is Quick Label; the heavy queue layer loads as a read-only reference layer so you can see labeled gaps.
What "pinning" is buying
- Stable IDs. The extraction script assigns an incrementing
id per header. Pins are keyed by that id forever — line numbers and names can change in the wiki, but the pin survives.
- A zoom-stratified placement. The pinner sets
zoomLevel (1-5). That number controls when the marker appears on the public map: 1 = continent-scale, 5 = building-scale. The depth model lives in CLAUDE.md and src/types/location.ts.
- Coordinates in the Leaflet
CRS.Simple pixel space of the source Alaria.png (not lat/lon). Y is flipped at marker placement.
What comes after pinning is done
Two follow-up passes the user already plans to run:
- Merge duplicates. The wiki has ~4500 headers but fewer real places — many locations appear both as a sub-bullet under a region (
### Salty Hills stub under "Geographic Features") and as a fuller standalone entry (#### The Salty Hills under Thespia). Both should resolve to the same pinned point.
- Fill missing lore. Many entries' source markdown is literally
TODO. Those get written by hand in the wiki, not in this app.
The /pin UI surfaces related entries (same normalized name) so the pinner can spot duplicates while pinning, but does not consolidate them automatically.
Critical invariants — read before changing anything
- Never re-run
scripts/extract-locations.ts over data/work-queue.json without a migration. It re-assigns IDs from a counter, which would silently invalidate every existing pin in data/pinned.json. If extraction logic must change (e.g. to detect more headers), write a script that preserves existing IDs by name+sourceFile+lineNumber lookup.
- Pins are append-only from the human's perspective. The user has invested manual work into hundreds of pins. Any change to data shape, ID scheme, or extraction must keep
pinned.json valid or include a one-shot migration run by hand.
/api/pin and /api/manual-pin write the filesystem. They use fs.writeFileSync against data/work-queue.json + data/pinned.json (pin) and data/manual-pins.json (manual-pin: POST create, PATCH retype, DELETE). This is by design — pinning is local-only and doesn't deploy to Vercel. Do not refactor to a database or read-only without a plan for preserving the existing files.
- Manual pins live entirely outside the queue. They have no
LoreEntry, no parentEntryId, no wiki content. Ids are prefixed manual- so they never collide with the numeric extraction ids. Keep them in manual-pins.json; do not inject synthetic entries into work-queue.json/pinned.json.
finalize-locations.ts is the only thing that merges manual pins into the public map. A new manual pin won't appear at / until you re-run finalize (with ALARIA_LORE_DIR set, or it strips wiki lore). Manual pins arrive with parentId: null, relatedIds: [], no content.
- Status is on the entry, not derived.
work-queue.json entries have status: 'pending' | 'pinned' | 'skipped'. The progress counters and "next entry" cursor read from this. Don't compute status from pinned.json membership — skipped entries have no pin but are not pending.
parentEntryId references another entry's id. Don't sort or renumber entries.
Common mistakes
- "Let me regenerate work-queue.json to pick up new wiki entries" — see invariant #1. Write an additive migration instead.
- "I'll consolidate duplicates by deleting one entry" — that breaks
parentEntryId references and the pin (if it has one) on that entry. Consolidation needs a deliberate scheme: a canonical entry, redirects from the others, and care about which pins survive.
- "The skipped entries should be re-shown" — they were intentionally skipped; the human will revisit them in a separate sweep, not by resetting status.
Where things live
- UI:
src/app/pin/page.tsx (both modes; QuickLabelPanel is in this file), src/components/PinningMap.tsx (two marker layers: stable wiki + light manual)
- API:
src/app/api/pin/route.ts (GET current + pinnedNames, POST pin/skip, PUT jump/back); src/app/api/manual-pin/route.ts (GET list, POST create, PATCH retype, DELETE)
- Types:
src/types/pinning.ts (LoreEntry, PinnedData, ManualPin, ManualPinsFile); uncategorized is in LocationType (src/types/location.ts), icons in src/lib/icons.ts
- Extraction:
scripts/extract-locations.ts
- Finalization:
scripts/finalize-locations.ts
- Source wiki:
$ALARIA_LORE_DIR or ../heart-rush-tools/world-wikis/alaria/all_sections_formatted/
- Data:
data/work-queue.json (queue + status), data/pinned.json (id → wiki pin), data/manual-pins.json (free-form pins), data/locations.json (final, public-facing)
1---2name: pinning3description: Context for the /pin admin workflow — what location pinning is for, how the data flows, and what invariants must not break. Use when working in src/app/pin/, src/app/api/pin/, src/app/api/manual-pin/, scripts/extract-locations.ts, scripts/finalize-locations.ts, data/work-queue.json, data/pinned.json, data/manual-pins.json, or when planning changes that touch lore data, the work queue, pinned coordinates, or free-form manual pins.4---56# Pinning workflow78`/pin` is a one-time bootstrap UI for assigning map coordinates to every entry from the Alaria wiki. It is **not** an optional curation step — every wiki entry must end up pinned so the public map (`/`) can show it at the right zoom.910## What it produces1112```13world-wikis/.../*.md (4500+ headers across 8 continent files)14 │15 │ scripts/extract-locations.ts (one-time parse: headers → entries with IDs)16 ▼17data/work-queue.json (LoreEntry[]: id, name, lineNumber, headerLevel, parentEntryId, status)18 │19 │ /pin admin UI + /api/pin (human assigns coords, type, zoom, then pin or skip)20 ▼21data/pinned.json (PinnedData: { [id]: { coordinates, zoomLevel, type, pinnedAt } })22 │23 │ data/manual-pins.json (ManualPin[]: free-form pins, ids prefixed `manual-`)24 │ │ ▲ /pin Quick Label mode + /api/manual-pin25 │ │26 │ scripts/finalize-locations.ts (merges work-queue + pinned + descriptions + manual-pins → final shape)27 ▼28data/locations.json (consumed by the public map at /)29```3031## Two modes in the `/pin` UI3233- **Queue** — the original walk-through: steps every wiki `LoreEntry`, human assigns coords/type/zoom, writes `pinned.json`. This is the bootstrap pass and is now effectively done.34- **Quick Label** — free-form: click the map, type a name, Enter. Saves to `data/manual-pins.json` (not the queue), default type `uncategorized`. For places the wiki never had a header for. After Enter the new pin is *selected*; type keys (`1-5 q w e r`) retype the **selected** pin and `N`/`P` step through placed pins (map pans to follow). Default mode is Quick Label; the heavy queue layer loads as a read-only reference layer so you can see labeled gaps.3536## What "pinning" is buying3738- **Stable IDs.** The extraction script assigns an incrementing `id` per header. Pins are keyed by that id forever — line numbers and names can change in the wiki, but the pin survives.39- **A zoom-stratified placement.** The pinner sets `zoomLevel` (1-5). That number controls *when the marker appears* on the public map: 1 = continent-scale, 5 = building-scale. The depth model lives in `CLAUDE.md` and `src/types/location.ts`.40- **Coordinates in the Leaflet `CRS.Simple` pixel space** of the source `Alaria.png` (not lat/lon). Y is flipped at marker placement.4142## What comes after pinning is done4344Two follow-up passes the user already plans to run:451. **Merge duplicates.** The wiki has ~4500 headers but fewer real places — many locations appear both as a sub-bullet under a region (`### Salty Hills` stub under "Geographic Features") *and* as a fuller standalone entry (`#### The Salty Hills` under Thespia). Both should resolve to the same pinned point.462. **Fill missing lore.** Many entries' source markdown is literally `TODO`. Those get written by hand in the wiki, not in this app.4748The `/pin` UI surfaces related entries (same normalized name) so the pinner can spot duplicates while pinning, but does not consolidate them automatically.4950## Critical invariants — read before changing anything5152- **Never re-run `scripts/extract-locations.ts` over `data/work-queue.json` without a migration.** It re-assigns IDs from a counter, which would silently invalidate every existing pin in `data/pinned.json`. If extraction logic must change (e.g. to detect more headers), write a script that preserves existing IDs by name+sourceFile+lineNumber lookup.53- **Pins are append-only from the human's perspective.** The user has invested manual work into hundreds of pins. Any change to data shape, ID scheme, or extraction must keep `pinned.json` valid or include a one-shot migration run by hand.54- **`/api/pin` and `/api/manual-pin` write the filesystem.** They use `fs.writeFileSync` against `data/work-queue.json` + `data/pinned.json` (pin) and `data/manual-pins.json` (manual-pin: POST create, PATCH retype, DELETE). This is by design — pinning is local-only and doesn't deploy to Vercel. Do not refactor to a database or read-only without a plan for preserving the existing files.55- **Manual pins live entirely outside the queue.** They have no `LoreEntry`, no `parentEntryId`, no wiki content. Ids are prefixed `manual-` so they never collide with the numeric extraction ids. Keep them in `manual-pins.json`; do not inject synthetic entries into `work-queue.json`/`pinned.json`.56- **`finalize-locations.ts` is the only thing that merges manual pins into the public map.** A new manual pin won't appear at `/` until you re-run finalize (with `ALARIA_LORE_DIR` set, or it strips wiki lore). Manual pins arrive with `parentId: null`, `relatedIds: []`, no content.57- **Status is on the entry, not derived.** `work-queue.json` entries have `status: 'pending' | 'pinned' | 'skipped'`. The progress counters and "next entry" cursor read from this. Don't compute status from `pinned.json` membership — skipped entries have no pin but are not pending.58- **`parentEntryId` references another entry's `id`.** Don't sort or renumber entries.5960## Common mistakes6162- "Let me regenerate work-queue.json to pick up new wiki entries" — see invariant #1. Write an additive migration instead.63- "I'll consolidate duplicates by deleting one entry" — that breaks `parentEntryId` references and the pin (if it has one) on that entry. Consolidation needs a deliberate scheme: a canonical entry, redirects from the others, and care about which pins survive.64- "The skipped entries should be re-shown" — they were intentionally skipped; the human will revisit them in a separate sweep, not by resetting status.6566## Where things live6768- UI: `src/app/pin/page.tsx` (both modes; `QuickLabelPanel` is in this file), `src/components/PinningMap.tsx` (two marker layers: stable wiki + light manual)69- API: `src/app/api/pin/route.ts` (GET current + `pinnedNames`, POST pin/skip, PUT jump/back); `src/app/api/manual-pin/route.ts` (GET list, POST create, PATCH retype, DELETE)70- Types: `src/types/pinning.ts` (`LoreEntry`, `PinnedData`, `ManualPin`, `ManualPinsFile`); `uncategorized` is in `LocationType` (`src/types/location.ts`), icons in `src/lib/icons.ts`71- Extraction: `scripts/extract-locations.ts`72- Finalization: `scripts/finalize-locations.ts`73- Source wiki: `$ALARIA_LORE_DIR` or `../heart-rush-tools/world-wikis/alaria/all_sections_formatted/`74- Data: `data/work-queue.json` (queue + status), `data/pinned.json` (id → wiki pin), `data/manual-pins.json` (free-form pins), `data/locations.json` (final, public-facing)