Point of Interest Types¶
Goal¶
Let a GM define their own point-of-interest types (name, slug, color, icon, group) per project in project settings — with sensible defaults for new projects — and have those types drive POI type selection in the map edit view, gracefully labeling any POI whose type has since been deleted as "Unknown".
Decisions¶
| Decision | Choice | Why |
|---|---|---|
| Plan file location | docs/v0.0.2/poi-types.md |
Follows the existing per-feature file precedent in this folder (e.g. map-points-of-interest.md) |
| Stable reference | PointOfInterest.type stores the type's slug (auto-derived from name, unique per project) |
Confirmed with user — the slug is the stable identifier; renaming a type's display name doesn't orphan existing POIs |
| CRUD surface | A new card on the existing project settings page (project.tsx), not a dialog or separate route | Confirmed with user — "same like settings, keep it lean"; matches the read/edit toggle already used for name/description/image on the same page |
| Mutation transport for type CRUD | useFetcher per row/add-form (add / update / delete), dispatched via an intent field on the existing action |
Mirrors the exact pattern already used for POI add/update/remove in project-map.tsx; avoids a full-page redirect resetting the rest of the settings page |
| Deletion of an in-use type | Allowed unconditionally; each row always shows a live "used by N POIs" count computed from the project's maps | Confirmed with user — "warn but allow"; a persistent count is the warning, no confirm dialog/modal needed to stay lean |
| Color input | Fixed preset palette (swatches), not a free hex input | Confirmed with user — start with sensible defaults |
| Icon input | Fixed preset list of lucide-react icon names, picked from a select |
Confirmed with user; the app already depends on lucide-react elsewhere, so no new dependency |
| Default palette | #ef4444 red, #f97316 orange, #eab308 yellow, #22c55e green, #14b8a6 teal, #3b82f6 blue, #8b5cf6 violet, #ec4899 pink, #64748b slate, #78716c stone |
A small, visually distinct 10-swatch set; reuses the 3 hex values already hardcoded in today's POI_STYLES |
| Default icon set | MapPin, Landmark, Castle, Home, Sword, Shield, Flag, Skull, Mountain, Anchor, Star, Users |
Common RPG/map iconography already expressible with lucide-react, no custom SVGs |
| Default types seeded for new projects | City (slug city, yellow #eab308, Landmark), Army (slug army, red #ef4444, Sword), Character (slug character, blue #3b82f6, Users) |
Confirmed with user — same 3 types/colors as today's hardcoded POI_STYLES, so existing POIs keep their current look |
| Migration of existing data | readProjectsFile backfills poiTypes to the same default list in-memory when absent, the same way it already backfills a map's missing pois to [] |
Confirmed with user; matches the existing normalize-on-read pattern, no forced disk write, no manual migration script |
| "Unknown" fallback style | A fixed, non-deletable fallback color/icon pair (#94a3b8 slate, HelpCircle) defined alongside the presets in poi.ts |
Needed once a POI's type slug matches nothing in project.poiTypes (T4) |
| Marker icon rendering | Lucide icon rendered to a static HTML string via renderToStaticMarkup (from react-dom/server) for use inside Leaflet's L.divIcon |
L.divIcon needs an HTML string, not a live React element; react-dom/server is already available in this SSR framework, no new dependency |
group field |
Free-text, optional, stored only — no consuming UI in this task | Confirmed with user; sidebar grouping is a separate backlog line ("Map Edit View Sidebar") |
Scope¶
In:
- app/lib/poi.ts —
PoiTypeDefinitiontype,PoiTypebecomesstring(a slug),DEFAULT_POI_TYPES,POI_TYPE_COLORS,POI_TYPE_ICONS, and the "Unknown" fallback style constant - app/lib/projects.server.ts —
poiTypesonMapProject;createProjectseeds defaults;readProjectsFilebackfills missingpoiTypes; newaddPoiType/updatePoiType/removePoiTypeexports - app/routes/project.tsx — new "Point of Interest Types" card (list + add/edit/delete), action gains
intent-based dispatch - app/routes/project-map.tsx — POI form's type
Selectsourcesproject.poiTypes; action validatestypeagainst the project's configured slugs; selected-POI detail view falls back to "Unknown" - app/components/map.tsx — marker color/icon resolved from
poiTypes(passed down as a prop) instead of the hardcodedPOI_STYLES/POI_ICONS, with an "Unknown" fallback - data/projects.json — existing entries pick up the default
poiTypeslist on next read, no manual edits needed
Out:
- Reordering types or grouping the map-edit sidebar by the
groupfield (separate backlog line, "Map Edit View Sidebar") - Dragging/selecting POIs on the map (separate backlog line)
- Free hex color input or custom icon uploads
- A confirm dialog/modal on type deletion
- Any Directus/remote persistence
Data model¶
// app/lib/poi.ts
export type PoiType = string // a PoiTypeDefinition's slug
export type PoiTypeDefinition = {
slug: string
name: string
color: string // one of POI_TYPE_COLORS
icon: string // one of POI_TYPE_ICONS (a lucide-react icon name)
group?: string
}
export const POI_TYPE_COLORS: string[] // the 10-swatch preset palette
export const POI_TYPE_ICONS: string[] // the 12-icon preset list
export const UNKNOWN_POI_TYPE: { color: string; icon: string } // fallback style
export const DEFAULT_POI_TYPES: PoiTypeDefinition[] // city / army / character
export type PointOfInterest = {
id: string
name: string
description?: string
type: PoiType // stores a PoiTypeDefinition["slug"]
position: { lat: number; lng: number }
createdAt: string
}
// app/lib/projects.server.ts — added to the existing MapProject type
export type MapProject = {
id: string
name: string
description?: string
previewImage?: string
createdAt: string
poiTypes: PoiTypeDefinition[]
maps: MapRef[]
}
export function addPoiType(
projectId: string,
input: { name: string; color: string; icon: string; group?: string }
): PoiTypeDefinition | { error: string } | undefined
export function updatePoiType(
projectId: string,
slug: string,
input: { name: string; color: string; icon: string; group?: string }
): PoiTypeDefinition | undefined
export function removePoiType(projectId: string, slug: string): boolean
addPoiTypederives a unique slug fromnameand returns{ error: "..." }when the derived slug already exists on the project (the only validation that can't be expressed as a simple not-foundundefined, since the project itself does resolve).updatePoiType/removePoiTypefollow the existing not-found convention (undefined/false) for a non-resolvingprojectId/slug.readProjectsFilebackfillsproject.poiTypestoDEFAULT_POI_TYPESin-memory when absent, mirroring the existingmap.pois ?? []backfill a few lines above it — no forcedwriteProjectsFilecall, no migration script.createProjectinitializes every new project withpoiTypes: DEFAULT_POI_TYPES.- Deleting a type never touches existing POIs —
removePointOfInterestand POI storage are unchanged; a POI keeps whatevertypeslug it had.
Why this shape¶
- The slug (not a separate generated id) is the stable reference because it's the one part of a type that's meaningful to reuse across projects' worth of defaults and because renames must not orphan existing POIs — a generated id would work too, but would need showing somewhere for users to reason about, whereas the slug is already visible as "the type the POI has."
- Type CRUD lives on the existing project settings page instead of a new route/dialog because the page already has an established read/edit toggle convention for project fields; a whole new page for "5 small entities with 4 fields each" would be more surface area than the data size warrants.
useFetcherper row reuses the exact transport already chosen for POI add/update/remove inproject-map.tsx— same shape of problem (CRUD on a small list within a bigger page), same solution, no new pattern to learn.- A persistent "used by N POIs" count (rather than a confirm dialog) keeps
the delete action a single click, consistent with the existing
no-confirm
removePointOfInterestdelete button, while still surfacing the warning the user asked for. - Presets (color swatches, icon names) instead of free-form hex/icon inputs avoid building a color picker or icon browser for a task that asked for "sensible defaults" — a fixed, small list is both simpler to build and harder for a user to produce an ugly/illegible marker with.
Tasks¶
Ordered so each task type-checks and is shippable on its own; later tasks depend on earlier ones.
T1 — Data model: per-project POI type definitions¶
Goal: Projects can store a list of user-defined POI types, with sensible defaults seeded for new projects and backfilled for existing ones.
Acceptance criteria:
app/lib/poi.tsexportsPoiTypeDefinition,POI_TYPE_COLORS(10 preset hex values),POI_TYPE_ICONS(12 preset lucide icon names),UNKNOWN_POI_TYPE, andDEFAULT_POI_TYPES(the 3 defaults from the Decisions table), per the shapes above.PoiTypebecomesstring.app/lib/projects.server.ts'sMapProjectgainspoiTypes: PoiTypeDefinition[].createProjectsetspoiTypes: DEFAULT_POI_TYPESon every new project.readProjectsFilebackfillsproject.poiTypestoDEFAULT_POI_TYPESin-memory whenever the field is absent on read, so every caller can assumeMapProject["poiTypes"]is always populated.addPoiType,updatePoiType,removePoiTypeare exported with the signatures above;addPoiTyperejects a name whose derived slug collides with an existing type on the same project via{ error: string }, without writing.data/projects.json's existing entries remain valid and load with a populatedpoiTypeslist, with no manual edits to the file.pnpm typecheckpasses.
T2 — Project settings: add/edit/delete POI types¶
Goal: A GM can view, add, edit, and delete a project's POI types from the project settings page.
Acceptance criteria:
project.tsx's action dispatches on anintentfield: the existing (default/no-intent) branch keeps saving name/description/image unchanged; new"add-poi-type","update-poi-type", and"delete-poi-type"branches calladdPoiType/updatePoiType/removePoiTypeand return a fetcher-shaped{ ok: true }or{ error }(noredirect()for these three).- A new "Point of Interest Types" card lists every entry in
project.poiTypes: color swatch, icon, name, group (or none), and a live "used by N POIs" count computed by summing matches of that slug across every map'spoisinloaderData.project.maps. - Each row has "Edit" (reveals an inline form: name, a swatch picker over
POI_TYPE_COLORS, an icon picker overPOI_TYPE_ICONS, optional group text input, "Save"/"Cancel") and "Delete" (submits immediately viauseFetcher, no confirmation dialog, regardless of the row's usage count). - An "Add type" row/form at the bottom of the list submits
intent: "add-poi-type"via its ownuseFetcher; a duplicate-name submission shows the returned error inline without clearing the form. pnpm typecheckpasses.- Manual check:
pnpm dev, open a project, add a type, edit its color and icon, confirm a POI using it (added in T3) reflects the change, and delete a type that's in use, confirming the count was visible beforehand and the row disappears after deleting.
T3 — Map edit view: select from the project's configured types¶
Goal: The POI add/edit form in the map view offers the project's own configured types instead of a hardcoded list.
Acceptance criteria:
project-map.tsx'sPoiFormtypeSelectis populated fromproject.poiTypes(label:name, value:slug) instead of the removed staticPOI_TYPESimport; the default selection for a new POI is the first entry inproject.poiTypes.- The action validates the submitted
typeagainstproject.poiTypes.map((t) => t.slug)(loaded viagetProject) instead of the staticPOI_TYPESarray, still returning{ error: "Invalid type." }for anything else. - Adding a type in project settings (T2) makes it selectable on this page after a reload, with no other code changes.
pnpm typecheckpasses.- Manual check:
pnpm dev, add a new type in project settings, open a map, confirm it appears in the "Type" dropdown when adding/editing a POI.
T4 — Marker styling from configured types, with an "Unknown" fallback¶
Goal: Map markers render each POI's configured color/icon, and a POI whose type has been deleted still renders — labeled "Unknown" — instead of erroring.
Acceptance criteria:
project-map.tsxpassesproject.poiTypesdown to<Map>as a new prop.map.tsxresolves each marker's color/icon by looking up the POI'stypeslug in thepoiTypesprop, rendering the matching icon (viarenderToStaticMarkup) inside the existingL.divIconin that type'scolor, replacing the current hardcodedPOI_STYLES/POI_ICONSlookup.- A POI whose
typeslug isn't found inpoiTypesrenders usingUNKNOWN_POI_TYPE's color/icon instead of throwing or rendering nothing. - The selected-POI detail view and edit form in
project-map.tsx(where the POI's type is currently shown as raw text) display "Unknown" for a non-resolvingtype, and the real typenameotherwise (not the raw slug). - The 3 default types (
city/army/character) render with the same colors as today's markers — no visual regression for existing data. pnpm typecheckpasses.- Manual check:
pnpm dev, delete a type that's in use (T2), reload the map, confirm its POI still renders on the map and reads "Unknown" rather than crashing the page.