Skip to content

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 — PoiTypeDefinition type, PoiType becomes string (a slug), DEFAULT_POI_TYPES, POI_TYPE_COLORS, POI_TYPE_ICONS, and the "Unknown" fallback style constant
  • app/lib/projects.server.ts — poiTypes on MapProject; createProject seeds defaults; readProjectsFile backfills missing poiTypes; new addPoiType / updatePoiType / removePoiType exports
  • 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 Select sources project.poiTypes; action validates type against 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 hardcoded POI_STYLES/POI_ICONS, with an "Unknown" fallback
  • data/projects.json — existing entries pick up the default poiTypes list on next read, no manual edits needed

Out:

  • Reordering types or grouping the map-edit sidebar by the group field (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
  • addPoiType derives a unique slug from name and returns { error: "..." } when the derived slug already exists on the project (the only validation that can't be expressed as a simple not-found undefined, since the project itself does resolve).
  • updatePoiType/removePoiType follow the existing not-found convention (undefined/false) for a non-resolving projectId/slug.
  • readProjectsFile backfills project.poiTypes to DEFAULT_POI_TYPES in-memory when absent, mirroring the existing map.pois ?? [] backfill a few lines above it — no forced writeProjectsFile call, no migration script.
  • createProject initializes every new project with poiTypes: DEFAULT_POI_TYPES.
  • Deleting a type never touches existing POIs — removePointOfInterest and POI storage are unchanged; a POI keeps whatever type slug 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.
  • useFetcher per row reuses the exact transport already chosen for POI add/update/remove in project-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 removePointOfInterest delete 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.ts exports PoiTypeDefinition, POI_TYPE_COLORS (10 preset hex values), POI_TYPE_ICONS (12 preset lucide icon names), UNKNOWN_POI_TYPE, and DEFAULT_POI_TYPES (the 3 defaults from the Decisions table), per the shapes above. PoiType becomes string.
  • app/lib/projects.server.ts's MapProject gains poiTypes: PoiTypeDefinition[].
  • createProject sets poiTypes: DEFAULT_POI_TYPES on every new project.
  • readProjectsFile backfills project.poiTypes to DEFAULT_POI_TYPES in-memory whenever the field is absent on read, so every caller can assume MapProject["poiTypes"] is always populated.
  • addPoiType, updatePoiType, removePoiType are exported with the signatures above; addPoiType rejects 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 populated poiTypes list, with no manual edits to the file.
  • pnpm typecheck passes.

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 an intent field: the existing (default/no-intent) branch keeps saving name/description/image unchanged; new "add-poi-type", "update-poi-type", and "delete-poi-type" branches call addPoiType / updatePoiType / removePoiType and return a fetcher-shaped { ok: true } or { error } (no redirect() 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's pois in loaderData.project.maps.
  • Each row has "Edit" (reveals an inline form: name, a swatch picker over POI_TYPE_COLORS, an icon picker over POI_TYPE_ICONS, optional group text input, "Save"/"Cancel") and "Delete" (submits immediately via useFetcher, 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 own useFetcher; a duplicate-name submission shows the returned error inline without clearing the form.
  • pnpm typecheck passes.
  • 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's PoiForm type Select is populated from project.poiTypes (label: name, value: slug) instead of the removed static POI_TYPES import; the default selection for a new POI is the first entry in project.poiTypes.
  • The action validates the submitted type against project.poiTypes.map((t) => t.slug) (loaded via getProject) instead of the static POI_TYPES array, 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 typecheck passes.
  • 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.tsx passes project.poiTypes down to <Map> as a new prop.
  • map.tsx resolves each marker's color/icon by looking up the POI's type slug in the poiTypes prop, rendering the matching icon (via renderToStaticMarkup) inside the existing L.divIcon in that type's color, replacing the current hardcoded POI_STYLES/POI_ICONS lookup.
  • A POI whose type slug isn't found in poiTypes renders using UNKNOWN_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-resolving type, and the real type name otherwise (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 typecheck passes.
  • 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.