Skip to main content

Investigate: Frontend display of NGO supply data

IMPLEMENTATION RULES: Before implementing any plan from this investigation, read and follow:

Status: Backlog

Goal: Decide how Atlas's Next.js frontend should surface NGO supply data — chapters, activities, service categories — both as an immediate sanity check that the Red Cross ingest worked, and as the foundation for organisation-neutral views the public personas (Kari, Amira, Lars, Tone, Jonas, Ola) and internal personas (Inger, Arne) will use as more NGOs land.

Last Updated: 2026-04-24 (revised after stack decision and gap audit)


Status update (2026-04-27): dogfood pivot

The data-access posture in this plan was written before the dogfood decision: Atlas's Next.js frontend will be migrated to consume the same public HTTP API external consumers use (PostgREST), not direct marts.* reads. See INVESTIGATE-public-api-surface.md for the full plan and per-route audit.

What this changes about Section H (Technical approach):

  • H.1 Component model — Server Components still default, but they fetch() from the API (api.atlas.helpers.no) instead of executing SQL via db(). The "no internal API routes" point still holds — there's an external API service (PostgREST) that Next.js calls via plain fetch(). No internal /api/* routes needed.
  • H.4 Caching — moves from "server fetch on every request" to leveraging HTTP cache headers from PostgREST (Cache-Control). Next.js's fetch() deduplicates and caches per-render; gateway-side caching arrives later via Gravitee/APIM in v1.5+.
  • Data access referenceatlas-contributor-frontend/src/lib/db.ts (the native postgres client mentioned below). Note: the original atlas-frontend/ was renamed to atlas-contributor-frontend/ in PLAN-005 Phase 1; today's contributor frontend keeps direct Postgres access for diagnostic work, while a separately-built customer atlas-frontend/ consumes PostgREST.

What stays the same:

  • Three viewing layers (Sections B, C, D).
  • Visualisation choices, visual identity, empty states, URL state for filters, pagination posture.
  • Server Components by default.
  • Norwegian-first localisation.
  • Per-NGO vs cross-NGO display vocabulary.

What new supply pages need from the data side:

The per-route audit in INVESTIGATE-public-api-surface.md identifies the mart_<feature> views needed for the existing 15 routes. New supply pages from this plan (e.g. /aktivitet/<code>, /aktivitet/<code>/kommune/<kommune_nr>, per-NGO chapters tables, distrikter pages for non-Red-Cross NGOs) will each likely need their own mart_* view added before the page can ship via PostgREST.

The data-access lines in Existing context below and the SQL examples throughout this plan reflect the pre-dogfood pattern. They remain useful as the current code shape — they just describe what PLAN-E will migrate away from, not the long-term posture.


Scope

In scope:

  • Information architecture (URL structure, page types) for supply display.
  • The three viewing layers: engineering-validation, public org-neutral, per-NGO landing pages.
  • Per-NGO vs cross-NGO display vocabulary (canonical_name vs service_category_code).
  • Visualisation choices (table / list / map) per page type.
  • Visual identity — Atlas-neutral as primary, NGO branding as secondary.
  • Empty states and data-quality signals (Brreg-only chapters, dormant chapters, ingest staleness).
  • Technical approach — extending the existing postgres + Server Components + inline-CSS pattern; when to graduate to Digdir Designsystemet (already imported, currently dormant).
  • Phased rollout: v1 (Red Cross only, validation + per-kommune supply), v1.5 (Folkehjelp added — first true cross-NGO finder), v2 (Coverage-gap supply×demand mart consumption).

Out of scope:

  • The Coverage-gap mart itself (its own future investigation; this file consumes it once it exists).
  • Authentication, accounts, "save my preferences" — Atlas is anonymous read-only in v1.
  • Mobile-app shell / PWA installability — separate concern.
  • The compare-NGOs deep-comparison page (covered by compare-ngos-spec.md — supply-display feeds it but doesn't replicate it).
  • Funding-side display (Lottstift, Innsamlingskontrollen) — different fact tables, different views.
  • Indicator-side display — already shipped under /data/... and /coverage-gap/barnefattigdom.

Why this exists

Atlas's mission per goal.md is organisation-neutral: it aggregates and compares NGO supply across the Norwegian sector. The default user (Kari) doesn't pick an NGO first — she picks a need ("visiting lonely elderly") and Atlas shows providers across all relevant NGOs in her area.

The data side of this is well-advanced: Red Cross is ingested (391 chapters, 35 activities, 1941 fact rows), and PLANs for Folkehjelp, the scraping infrastructure, and the multi-NGO model extensions are queued. The frontend side has zero supply views today — five existing routes, all demand-side (/data/..., /coverage-gap/barnefattigdom, /kommuner/[kommune_nr]).

Without this investigation:

  • We'd build a Red Cross-specific page first (because that's the data we have) and then have to retrofit it as cross-NGO when Folkehjelp lands. Sunk cost.
  • We'd duplicate vocabulary decisions per page (when to use service_category_code vs canonical_name) instead of choosing once.
  • We'd miss the chance to design the supply side as the org-neutral counterpart of the existing demand side, with a clean integration into /kommuner/[kommune_nr].

This investigation produces 1–3 PLANs covering the v1 supply views, with an explicit upgrade path as more NGOs ingest.


Existing context

Frontend stack (verified 2026-04-24):

  • Next.js App Router, Server Components by default. Repo root contains app/, src/.
  • Data access: src/lib/db.ts — native postgres v3 client, reads DATABASE_URL, role is SELECT-only on marts.*. Connection pool cached across hot-reloads. Inline SQL template literals in route files.
  • UI: no Tailwind, no shadcn/ui. Inline CSS-in-JS via style= props. @digdir/designsystemet-react ^1.13.2 imported in package.json but currently unused in any page.
  • Maps: maplibre-gl ^5.23.0, used by app/coverage-gap/barnefattigdom/Map.tsx. Boundary GeoJSON served from /public/boundaries/kommuner-2024.geojson.
  • No charting library, no data grid (plain HTML <table>).

Supply tables ready to query (verified):

  • dim_chapter — 391 rows, Red Cross only. Carries chapter_id, ngo_orgnr, chapter_level, parent_chapter_id, name, kommune_nr, is_active. Will gain source_url and chapter_subtype per multi-ngo extensions.
  • dim_activity — 35 rows. activity_id, ngo_orgnr, canonical_name, service_category_code, is_active.
  • fact_chapter_activities — 1941 rows. (chapter_id, activity_id) join with denormalised ngo_orgnr, kommune_nr, local_activity_name, canonical_name, is_active, source_id.
  • dim_ngo — 11 rows. orgnr, slug, name, brand_name, website_url, tier, chapter_data_shape, primary_focus. 10 of 11 NGOs are not yet ingested — they're seeded in dim_ngo but have zero dim_chapter rows.
  • ref_atlas_service_category — 22 rows with code, label_no, label_en, description, sort_order. Cross-NGO vocabulary.
  • dim_kommune, dim_fylke, dim_postnummer — already used by existing demand-side routes.

Personas we're serving (from personas.md):

  • Primary public: Kari (find a way to help), Jonas (donate meaningfully), Amira (need a service now), Lars (who responds in my area), Tone (compare orgs before committing), Ola (data-curious observer).
  • Secondary internal: Inger (chapter leader, sees her chapter from outside), Arne (district coordinator).

The supply-display work serves all primary personas (every primary persona reaches a chapter at some point) plus the two internal ones explicitly.


Section A — Personas and the supply-side questions

Mapping each persona to the supply-side question they ask, and the page type that answers it. Drives the IA in Section B.

PersonaQuestionBest-fit page type
Kari"Who offers visiting-lonely-elderly near me?"Activity-first finder: pick category → see chapters across NGOs in chosen kommune. Cross-NGO, Atlas-neutral vocabulary.
Amira"Where's the nearest språkkafé that's running this Tuesday?"Same finder, but with a language_practice filter pre-applied; eventually with event-frequency signals from INVESTIGATE-ngo-events-and-minisites.md.
Lars"Who responds to weather warnings in my parents' kommune?"Per-kommune view + filter to rescue_corps + first_aid_standby.
Tone"What does each NGO actually do — comparison view."Per-NGO landing pages (one per NGO) + the dedicated compare-NGOs page (out of scope here, see compare-ngos-spec.md).
Jonas"Where does my donation go — what does this org actually fund?"Per-NGO landing page + activities catalog + future funding overlay.
Ola"Cross-sector data: how many providers per kommune per service?"Cross-NGO browse pages, raw counts, links to data download (future).
Inger (chapter leader)"How does my chapter look from outside? Who are my peers?"Per-NGO chapter detail + parent/sibling navigation.
Arne (district coordinator)"What's the supply across my distrikt — gaps, overlaps?"Per-NGO regional view + per-kommune drilldown. Pre-Coverage-gap.

Two cross-cutting observations:

  • Kari/Amira/Lars want org-neutral views first; they may reach a per-NGO page only after seeing the comparison.
  • Inger/Arne want per-NGO views first; they're already inside one NGO's mental model.
  • Tone and Jonas want both — they pivot between cross-NGO comparison and per-NGO detail.

This is why the IA below has both layers, with cross-NGO as the default entry point.


Section B — Three viewing layers — [Q1]

Splitting supply display into three layers clarifies what to build now vs later, and what audience each serves:

Layer 1 — Engineering-validation views (build now, alongside each ingest)

Purpose: sanity-check that the data we imported looks right. Audience: us. Not linked from public navigation.

  • Plain tables, no styling polish.
  • Show the raw structure (counts, coverage, joins) so import bugs are visible.
  • Live under /admin/... or /internal/... (see [Q2]).

Layer 2 — Public org-neutral views (mature over multiple ingests)

Purpose: Atlas's main public flow per goal.md. Audience: Kari, Amira, Lars, Tone, Jonas, Ola.

  • Activity-first finder ("I want to do X near Y → list providers across NGOs").
  • Per-kommune view extended with "tilbud i denne kommunen" (org-neutral list).
  • Designed-quality, eventually adopting Digdir Designsystemet.

These views are shape-correct from day one but only useful at N≥2 NGOs — with Red Cross alone they look like a single-NGO list with weird empty NGO columns. Real launch is when Folkehjelp lands.

Layer 3 — Per-NGO landing pages (secondary, for Inger/Arne and Tone/Jonas drilldown)

Purpose: per-organisation detail, useful even pre-launch as deep-link targets and validation tools. Audience: Inger ("my chapter from outside"), Arne ("my distrikt"), Tone/Jonas (drilldown from compare).

  • One landing page per NGO under /ngo/<slug> (see [Q3]).
  • Sub-views: chapters list, activity catalog, distrikt structure.
  • Atlas-styled but with NGO brand accents (see [Q10]).

Decision rule: build Layer 1 for Red Cross now (cheap, validates the import). Build Layer 3 for Red Cross now (modest effort, gives Inger something useful day-one, becomes per-NGO-replicable when Folkehjelp lands). Defer Layer 2 until Folkehjelp lands so the cross-NGO design is grounded in actual cross-NGO data.


Section C — Information architecture

C.1 URL structure — [Q2]/[Q3]/[Q4]/[Q5]

URL patternLayerWhat it showsBuild when?
/admin/supply/<source-slug>1Per-source raw row counts, coverage, sample joins. Not linked publicly.v1 (Red Cross), then per ingest
/admin/ingest-health1mart_ingest_health view, last-run status per source.When raw.ingest_runs ships (infra PLAN-001)
/ngo/<slug>3NGO landing page — overview, chapter count, activity count, distrikt structure.v1 (Red Cross), then per ingest
/ngo/<slug>/chapters3Filterable list of all the NGO's chapters.v1 (Red Cross)
/ngo/<slug>/chapters/<chapter_id>3Single chapter detail — activities, parent, kommune, deep-link to NGO's own page (source_url).v1 (Red Cross)
/ngo/<slug>/aktiviteter3Per-NGO activity catalog — canonical_name vocabulary, count of chapters offering each.v1 (Red Cross)
/aktivitet/<service-category-code>2Cross-NGO finder for one Atlas service category — list providers, optional kommune filter.v1.5 (after Folkehjelp)
/kommuner/<kommune_nr> (existing)2Extended with "Tilbud i kommunen" section listing local chapters across NGOs.v1 (Red Cross only initially; the section grows as more NGOs land)
/coverage-gap/<service-category-code>/<kommune_nr>2Future: supply×demand mart for one (service, kommune) — pre-computed by the Coverage-gap PLAN.v2

Rationale:

  • /admin/... keeps engineering pages off the public sitemap (and out of search results); a single noindex meta + a robots.txt rule covers the prefix.
  • /ngo/<slug> (singular) follows REST-ish convention (/kommuner/<nr> already singular-resource-style; matches).
  • /aktivitet/<code> (singular) matches /ngo/....
  • The existing /data/<source_id>/<contents_code> pattern is preserved for indicators — supply doesn't reuse it because supply concepts (chapter, activity, service category) don't map cleanly to (source, contents).

C.2 Navigation surface — [Q6]

The home page at app/page.tsx currently links to /data and /coverage-gap/barnefattigdom. Proposed v1 additions:

  • "Tilbud per organisasjon" — links to a v1 NGO index /ngo (lists all 11 dim_ngo rows; clickable for the ingested ones, "ikke ingestet" badge for the others).
  • "Tilbud i din kommune" — same /kommuner index that exists today, but the kommune detail pages now also include supply.
  • (v1.5) "Finn en aktivitet" — /aktivitet index page listing the 22 service categories.

Layer 1 (/admin/...) does not appear in the public nav.

C.3 Pre-launch display of unfinished NGOs — [Q15]

dim_ngo has 11 rows; only Red Cross has supply data. The /ngo index page shows all 11 with status badges:

  • ✅ "Tilbud importert" — has dim_chapter rows. Card is clickable.
  • ⏳ "Under arbeid" — seeded in dim_ngo but no supply rows yet. Card is greyed out, links to a brief "we're working on this org" stub page (so deep-linking from external sources doesn't 404 indefinitely).

This is honest about the project's state and avoids the awkward "where are the others?" question.


Section D — Per-NGO vs cross-NGO display

D.1 Vocabulary toggle — [Q7]

The same activity has two names:

  • dim_activity.canonical_name — the NGO's own term ("Modum Røde Kors Hjelpekorps Beredskap", "Norsk Folkehjelp Førstehjelp og redningstjeneste").
  • ref_atlas_service_category.label_no — Atlas's neutral term ("Førstehjelpsberedskap").

When to use which:

Page typeVocabularyWhy
Per-NGO views (/ngo/<slug>/...)canonical_name primary, service_category_code shown as small labelThe user is in the NGO's mental model; speak their language.
Cross-NGO views (/aktivitet/..., /kommuner/... supply section)service_category_code primary, canonical_name shown on hover/expandThe user is comparing across NGOs; need a common vocabulary.
Engineering views (/admin/...)Both shown verbatimValidate the mapping is correct.

D.2 ICNPO codes display — [Q8]

dim_ngo carries up to 3 ICNPO codes per NGO (per icnpo_code_1/2/3). v1 surfaces them only on the /ngo/<slug> overview page as a small "klassifisert som" footer item — Ola (data-curious) cares; primary personas don't. Don't promote to navigation or filtering until a real persona need surfaces.

D.3 NGO regions vs SSB fylker

NGO-defined regions (Red Cross's 19 distrikter) live in dim_chapter with chapter_level='regional'. They are NGO-internal navigation/ngo/redcross/distrikter lists them; /ngo/redcross/distrikt/<chapter_id> shows the distrikt's children. They do not appear in cross-NGO views, which use SSB dim_fylke exclusively (Kari doesn't know what a Røde Kors-distrikt is).

The chapter_kommune_coverage link table (per multi-ngo extensions) lets per-NGO regional pages show "kommuner served" and lets cross-NGO views show regional-level supply when local-level data is sparse.


Section E — Visualisation choices

E.1 Default representation per page type — [Q9]/[Q10]

PageDefaultWhyMap adds value?
/admin/supply/<source>Plain HTML tableValidation; tables are scannableNo
/ngo/<slug> overviewStat blocks + small distrikt diagramSingle-screen at-a-glanceOptional kommune choropleth ("Røde Kors er til stede i Y kommuner") — defer to v1.5
/ngo/<slug>/chaptersFilterable table391 rows is manageable; filters are essentialToggle to map view (defer; not v1)
/ngo/<slug>/aktiviteterList with counts35 items; smallNo
/aktivitet/<code> (v1.5)Two-column: list of providers + map showing kommuner with coverageGeographic distribution is the answerYes — choropleth of "providers per kommune for this service"
/kommuner/<kommune_nr> supply sectionList of local chapters grouped by service categoryDense info at the bottom of an existing pageNo (page already has demand maps)

E.2 When not to use a map

  • Sparse data — Red Cross alone, one service category often covers <50 kommuner. Map looks empty; table communicates the same data more honestly.
  • Within-kommune detail — there's only ever one kommune to show.
  • Engineering validation — tables show schema correctness; maps don't.

E.3 Reuse existing map pattern

The MapLibre setup in app/coverage-gap/barnefattigdom/Map.tsx is the template. v1.5 supply choropleths use the same kommuner-2024.geojson, the same MapLibre styling, swapping only the feature.properties.kommune_nr → value lookup.


Section F — Visual identity — [Q11]/[Q12]

F.1 Atlas-neutral as primary

Atlas is org-neutral. The default visual identity is:

  • A small Atlas wordmark / colour palette (TBD — not yet defined; the project lacks brand assets).
  • No NGO logos in cross-NGO contexts (one logo would look like a recommendation).
  • NGO names rendered as text only.

F.2 Per-NGO branding as secondary

On /ngo/<slug>/... pages it's appropriate to show:

  • The NGO's logo (small, header-corner, not page-dominant).
  • A subtle accent colour from the NGO's palette (border-left on chapter cards, maybe).
  • Link out to source_url when present (per multi-ngo extensions).

This signals "you are looking at one specific NGO" without making Atlas feel like that NGO's site.

F.3 Stack decision — [Q13] Resolved 2026-04-24 — Tailwind v4 + shadcn/ui + Designsystemet tokens

Adopt the new stack as Phase 1 of the v1 PLAN, not as a deferred migration. Rationale: the existing inline-CSS pattern across the 5 demand-side routes is small enough that we don't gain by extending it for another 7 supply routes; we'd just have more to migrate later. Adopting the stack alongside v1 also unlocks the data-heavy components (DataTable, sortable columns, Command palette, charts) that v1.5 cross-NGO views and the eventual Compare-NGOs page will need.

The combination:

  • shadcn/ui as the component library — copy-paste source we own, built on Radix primitives, Tailwind-styled.
  • Tailwind v4 as the styling layer — required by Designsystemet's bridge file (uses @theme {}).
  • Designsystemet tokens as the visual identity — colour, typography, spacing, border-radius. Generated via npx @digdir/designsystemet tokens create, output as a Tailwind v4-native CSS bridge (designsystemet.tailwind.css) that exposes --ds-* tokens as Tailwind-recognised --color-* / --font-* / --radius-* names.

The integration recipe (~30 lines of CSS in app/globals.css):

@import "tailwindcss";
@import "@digdir/designsystemet-css/theme/designsystemet.css";
@import "@digdir/designsystemet-css/theme/designsystemet.tailwind.css";

@theme {
/* shadcn semantic names → Designsystemet role-based tokens */
--color-background: var(--ds-color-background-default);
--color-foreground: var(--ds-color-neutral-text-default);
--color-primary: var(--ds-color-accent-base-default);
--color-primary-foreground: var(--ds-color-accent-base-contrast-default);
--color-secondary: var(--ds-color-neutral-surface-default);
--color-secondary-foreground: var(--ds-color-neutral-text-default);
--color-muted: var(--ds-color-neutral-surface-tinted);
--color-muted-foreground: var(--ds-color-neutral-text-subtle);
--color-accent: var(--ds-color-accent-surface-default);
--color-accent-foreground: var(--ds-color-accent-text-default);
--color-destructive: var(--ds-color-danger-base-default);
--color-destructive-foreground: var(--ds-color-danger-base-contrast-default);
--color-border: var(--ds-color-neutral-border-default);
--color-input: var(--ds-color-neutral-border-subtle);
--color-ring: var(--ds-color-accent-border-strong);
--radius: var(--ds-border-radius-md);
}

shadcn components reach for var(--color-primary) etc., which resolves through to Designsystemet's tokens. Theme regeneration via npx @digdir/designsystemet tokens create --main-colors "accent:#XXXXXX" ... swaps the brand colour without touching shadcn.

Trade-offs noted:

  • Tailwind v4 required. Atlas has no Tailwind today — adopt v4 fresh, no migration.
  • Designsystemet's vocabulary is richer than shadcn's (accent, brand1/2/3, neutral, danger, info, warning, success, base). Aliases above collapse it to shadcn's smaller set; surfaces that want richer semantics (Compare-NGOs focus-area colours per compare-ngos-spec.md) use --ds-* directly via Tailwind arbitrary values like bg-[--ds-color-success-base-default].
  • No component conflicts — we use Designsystemet's tokens, not its components. shadcn is the only Button source.
  • @digdir/designsystemet-react dropped from package.json (already imported but unused). @digdir/designsystemet-css kept for the token CSS.
  • shadcn neutral defaults remain for variables Designsystemet doesn't cover (chart colours, sidebar variants).

Atlas brand colour — [Q26] open. Default for v1: #0062BA (Designsystemet's own default accent). Easily changed by re-running the token generator. Final colour is a brand decision deferred until Atlas has a brand identity.


Section G — Empty states and data-quality signals

G.1 Brreg-only / dormant chapters — [Q14]

Per INVESTIGATE-folkehjelp-supply.md Q7, Brreg-only chapters land in dim_chapter with is_active=false. Display rules:

  • Default chapter lists filter to is_active=true.
  • A "Vis også inaktive" toggle reveals them, rendered greyed-out with a "Registrert i Brreg, ikke aktiv på nettsiden" badge.
  • Coverage queries (kommune view, finder) only count active chapters.

G.2 No-supply kommuner

Some kommuner have no chapters from any ingested NGO. On the kommune view, the supply section explicitly says "Ingen ingestede organisasjoner har lokale tilbud i denne kommunen." (Not "no supply" — that overclaims.)

G.3 Ingest staleness

mart_ingest_health (per scraping infra E.3) tracks last-run-at per source. Display rule:

  • /admin/ingest-health shows full table.
  • Per-NGO landing pages show a small footer "Sist oppdatert: ".
  • If staleness exceeds N days (TBD per source — Red Cross monthly, Folkehjelp monthly, see source READMEs), surface a "Data kan være utdatert" warning. Threshold open per [Q16].

G.4 Activity vocabulary drift

When parsing surfaces a new activity label not in the per-source CASE mapping (scraping infra E.1), the row is loaded but unmapped. Display:

  • /admin/supply/<source> lists "Unmapped labels: N" with the labels.
  • Cross-NGO views silently skip these rows (they don't have a service_category_code).

Section H — Technical approach

H.1 Component model — [Q17]

  • Server Components by default — every page that's a read-only table or list is a Server Component fetching from db() at request time, mirroring the existing /kommuner/<kommune_nr> pattern.
  • Client Components only when interactive — filter inputs on the chapters table, the MapLibre map, sortable column headers. Hydrated boundaries only where needed.
  • Data fetching at the page level, not in a client fetch('/api/...'). No internal API routes for supply data in v1 — the SQL is right there.
  • Visual layer: Tailwind v4 utilities + shadcn/ui components themed with Designsystemet tokens (see F.3). Inline style= props are the previous pattern; supply pages use the new stack from day one. Existing 5 demand-side routes are not touched in v1 — they migrate later as a small follow-up PLAN.

H.2 URL state for filters — [Q18]

Filters on the chapters table (kommune, fylke, level, activity) live in URL query params (?kommune=0301&category=rescue_corps). Reasons:

  • Bookmarkable / shareable. Inger can email Arne a link that reproduces what she's looking at.
  • Server Components can read searchParams and execute the right SQL — no client-side filtering needed for the typical 391-row table.
  • The same filter pattern reused on /aktivitet/<code>?kommune=... later.

H.3 Pagination — [Q19]

Skip pagination in v1. The biggest table is 391 rows (Red Cross all chapters). With Folkehjelp adding ~108 it'll be ~500. With all 11 ingested NGOs realistically ~3000–4000. At that point introduce TanStack Table with virtualisation. Until then plain HTML tables are fine.

H.4 Caching — [Q20]

Default: server fetch on every request. dbt rebuilds run nightly at most; no need for fancier ISR/SSG in v1. Revisit if a page shows up in a perf budget.

H.5 Types — [Q21]

Continue the manual TypeScript interface pattern from src/lib/types.ts. Types per supply table go alongside (e.g. DimChapter, DimActivity, FactChapterActivity). Code generation from dbt schema is a future investigation if the manual pattern decays.


Section I — Internationalisation

I.1 Norwegian first — [Q22]

All UI copy in Norwegian (bokmål) for v1. The personas are Norway-resident; even Amira, who arrived recently, is more likely to be served by simple Norwegian + the NGO's own language support than by a half-baked English layer.

ref_atlas_service_category already carries label_no and label_en; the column is there for future use but v1 surfaces only label_no.

I.2 Sami / Kven names

dim_kommune carries kommune_name_sami for kommuner in Sami areas. v1 displays the bokmål name primary, with the Sami name in parentheses where present. Already the convention in the existing /kommuner/<kommune_nr> page; supply views inherit it.


Section J — Phased rollout — [Q23]

v1 — ships alongside the Red Cross display PLAN

Audience: us (validation), Inger (sees Red Cross from outside), Arne (sees his distrikt).

Pages:

  • /admin/supply/redcross-branches (validation)
  • /ngo (NGO index with badges)
  • /ngo/redcross (overview)
  • /ngo/redcross/chapters + /ngo/redcross/chapters/<chapter_id>
  • /ngo/redcross/aktiviteter
  • /ngo/redcross/distrikter (the 19 distrikter)
  • /kommuner/<kommune_nr> extended with a "Lokale tilbud" section (Red Cross only initially)

What's not built: cross-NGO finder, maps for supply, Designsystemet migration.

v1.5 — triggers when Folkehjelp ships

The cross-NGO finder becomes useful only when there's something to compare. Triggered by Folkehjelp landing.

Pages added:

  • /ngo/folkehjelp + sub-views (mirrors Red Cross structure).
  • /aktivitet index + /aktivitet/<code> cross-NGO finder pages.
  • /kommuner/<kommune_nr> supply section grows to include both NGOs.
  • First supply choropleth — providers per kommune for one selected service category.

v2 — triggers when the Coverage-gap mart lands

Pages added:

  • /coverage-gap/<service-category-code>/<kommune_nr> — supply×demand for one (service, kommune).
  • An "Aktivitetsforslag basert på behov" surface on the kommune view.

Always-on — /admin/supply/<source-slug> per ingest

Each new ingest PLAN includes the small validation page as part of its acceptance criteria, so we never ingest data we can't see.


Decisions resolved during planning

These are the recommendations baked into the sections above; recording them here as the canonical resolved set:

  1. [Q1] Three viewing layers (engineering / public org-neutral / per-NGO). Codified.
  2. [Q2] Engineering-validation pages live under /admin/... (noindex, off public nav).
  3. [Q3] Per-NGO pages live under /ngo/<slug>/... (singular).
  4. [Q4] /kommuner/<kommune_nr> is extended with a "Lokale tilbud" section, not duplicated.
  5. [Q5] Cross-NGO finder is /aktivitet/<service-category-code> (singular).
  6. [Q6] Home-page nav adds "Tilbud per organisasjon" in v1; "Finn en aktivitet" in v1.5.
  7. [Q7] Per-NGO views use canonical_name; cross-NGO views use service_category_code. ICNPO display deferred per [Q8].
  8. [Q9] Default rendering is table/list; maps only where geographic distribution is the point. Choropleth supply views deferred to v1.5.
  9. [Q11] Atlas-neutral primary; NGO branding only on /ngo/<slug>/... pages. No NGO logos in cross-NGO contexts.
  10. [Q13] Adopt Tailwind v4 + shadcn/ui + Designsystemet tokens as Phase 1 of the v1 PLAN, not as a deferred migration. See F.3 for the integration recipe.
  11. [Q27] Pathway CTAs (Gi tid / Gi penger / etc.) — skipped in v1. dim_ngo only carries website_url; landing pages show a single "Besøk rodekors.no" link. Pathway data lives in common-schema.md as Pathway entity but isn't in dim_ngo yet — extending the schema is its own future investigation/PLAN.
  12. [Q28] Compare-with CTA on per-NGO pages — deferred in v1. The Compare-NGOs page doesn't exist yet (separate spec at compare-ngos-spec.md). Add the CTA in v1.5+ when the destination exists.
  13. [Q29] NGO logos — plain text NGO name in v1. No logos folder, no per-NGO image assets. Logos deferred to a brand-assets PLAN.
  14. [Q30] Stub page for non-ingested NGOs — single template at /ngo/[slug] showing dim_ngo fields (name, website_url, primary_focus, tier) plus "Tilbud-data er ikke importert ennå. Besøk {website_url}." No per-NGO variation.
  15. [Q31] Chapter table filter set — five URL params: kommune_nr, fylke_nr, chapter_level (national / regional / local), service_category_code (multi-select from the 22 categories), is_active toggle. Plus free-text search by chapter name as ?q=.
  16. [Q32] Admin validation page contents — see Appendix B for the exact spec.
  17. [Q15] /ngo index shows all 11 NGOs with badges; non-ingested NGOs link to a brief stub.
  18. [Q17] Server Components by default; client only when interactive.
  19. [Q18] Filter state in URL query params, read via searchParams server-side.
  20. [Q19] No pagination in v1; introduce TanStack Table with virtualisation when row counts approach ~3000.
  21. [Q22] Norwegian-first UI copy; English deferred. Sami names in parentheses where present.
  22. [Q23] Three-phase rollout: v1 (Red Cross + per-kommune supply section), v1.5 (Folkehjelp + cross-NGO finder + first supply choropleth), v2 (Coverage-gap consumption).

Open Questions

  1. [Q8] ICNPO display on per-NGO landing — stub footer item only, or skip entirely in v1? Recommendation: footer item.
  2. [Q10] Distrikt diagram on /ngo/redcross — small SVG tree, or a flat table with parent links? Recommendation: flat table for v1 (simpler), upgrade to SVG when there are 2+ NGOs to compare hierarchies across.
  3. [Q12] NGO brand-colour list — where does this live (a small JSON in src/lib/)? Source of truth and update process? Defer to first NGO landing PLAN.
  4. [Q14] "Vis også inaktive" toggle — default off (recommended) or default on with a "skjul"? Default off.
  5. [Q16] Staleness thresholds per source for the "Data kan være utdatert" badge. Recommend: 2× the source's expected refresh cadence (so a monthly source warns at 60+ days).
  6. [Q20] Caching policy — accept request-time fetch cost in v1; add Next.js Cache or revalidation only when a real perf concern surfaces.
  7. [Q21] Type generation from dbt schema — defer; manual interfaces work at v1 scale. Surface as an investigation when manual maintenance becomes painful.
  8. [Q24] Mobile layout — does the v1 chapters table degrade gracefully on phones? Probably yes (horizontal scroll). Test with a real device before signing off the PLAN.
  9. [Q25] Should /ngo/redcross/distrikt/<distrikt_id> exist as its own URL, or is "distrikt" just a filter on /ngo/redcross/chapters? Recommend its own URL — distrikt is a navigable entity (it has children, a name, a kommune set).
  10. [Q26] Atlas primary brand colour. Default for v1: #0062BA (Designsystemet's default accent). Final colour deferred until Atlas has a brand identity; trivially regeneratable via the Designsystemet token CLI.

Next Steps

Three sequential PLANs, total estimated effort ~10–15 h focused. The first one is the immediate "see the Red Cross data" PLAN.

  • PLAN-001-supply-display-v1-redcross.md (~10–14h)

    • Phase 1 (~3h): stack setup — Tailwind v4 + shadcn/ui + Designsystemet tokens per F.3. Drop @digdir/designsystemet-react, keep -css. Generate Designsystemet theme with --main-colors "accent:#0062BA" per [Q26]. Wire app/globals.css with the @theme aliases. npx shadcn@latest add table button card badge tabs input select.
    • Phase 2 (~1h): shared libsrc/lib/types.ts extended with DimChapter, DimActivity, FactChapterActivity, DimNgo. src/lib/supply.ts with SQL helpers (mirror src/lib/indicators.ts).
    • Phase 3 (~1h): admin validation/admin/supply/redcross-branches per Appendix B spec.
    • Phase 4 (~2h): NGO index + Red Cross overview + non-ingested stub/ngo (11-NGO index with badges), /ngo/redcross (overview with stat blocks), /ngo/[slug] catch-all stub.
    • Phase 5 (~2h): Red Cross chapters/ngo/redcross/chapters (filterable table per [Q31]), /ngo/redcross/chapters/[chapter_id].
    • Phase 6 (~1.5h): Red Cross activities + distrikter/ngo/redcross/aktiviteter, /ngo/redcross/distrikter, /ngo/redcross/distrikt/[distrikt_id].
    • Phase 7 (~1h): kommune view extension/kommuner/[kommune_nr] adds "Lokale tilbud — Røde Kors" section.
    • Phase 8 (~0.5h): polish — nav link from home page; manual smoke test on phone + desktop.
    • Out of scope: maps, pagination, NGO logos, Compare CTA, Pathway CTAs (all deferred per resolved Q's).
  • PLAN-002-supply-display-v1.5-cross-ngo.md (~4–6h, triggered when Folkehjelp lands)

    • Build /ngo/folkehjelp + sub-views (mirroring Red Cross structure — most code becomes a per-NGO template).
    • Build /aktivitet index + /aktivitet/<service-category-code> cross-NGO finder.
    • Add the first supply choropleth on /aktivitet/<code>?map=true.
    • Extend /kommuner/<kommune_nr> supply section to include both NGOs.
  • PLAN-003-supply-display-v2-coverage-gap.md (~6–8h, triggered by Coverage-gap mart)

    • Build /coverage-gap/<service-category-code>/<kommune_nr> consuming the mart.
    • Add "Aktivitetsforslag basert på behov" cross-link from kommune view.

Each PLAN ships with a manual smoke test checklist (visit each new page in a real browser, verify data and layout) per project-atlas.md.


Files this investigation will produce

New routes (per Section J v1):

  • app/admin/supply/redcross-branches/page.tsx
  • app/ngo/page.tsx
  • app/ngo/redcross/page.tsx
  • app/ngo/redcross/chapters/page.tsx + app/ngo/redcross/chapters/[chapter_id]/page.tsx
  • app/ngo/redcross/aktiviteter/page.tsx
  • app/ngo/redcross/distrikter/page.tsx + app/ngo/redcross/distrikt/[distrikt_id]/page.tsx

Extended:

  • app/kommuner/[kommune_nr]/page.tsx — adds the "Lokale tilbud" section.
  • app/page.tsx — adds nav link to /ngo.

New shared code:

  • src/lib/supply.ts — SQL helpers analogous to src/lib/indicators.ts.
  • src/lib/types.ts — extend with DimChapter, DimActivity, FactChapterActivity, DimNgo interfaces.

Documentation:

  • Brief notes in docs/stack/naming-conventions.md if new vocabulary is introduced (probably not).
  • A short app/admin/README.md explaining the /admin/... convention (noindex, off public nav).

Companion investigations


Appendix A — Page layouts (v1, Red Cross)

One paragraph per route. Components named below are shadcn primitives unless noted.

/admin/supply/redcross-branches

Plain validation page. No nav, no styling beyond default. See Appendix B for content spec.

/ngo — NGO index

Server Component. Fetches all 11 rows from dim_ngo. Renders a grid of Card components, one per NGO, alphabetised by name. Each card shows: NGO name (text, no logo), primary_focus Badge, tier Badge, status indicator (✅ "Tilbud importert" if dim_chapter count > 0, else ⏳ "Under arbeid"). Cards are Link-wrapped to /ngo/<slug> (always — the catch-all stub handles non-ingested). Page heading: "Organisasjoner i Atlas". Sub-heading: "Atlas følger 11 norske frivillige organisasjoner. Trykk på en for å se hva de tilbyr."

/ngo/redcross — Red Cross overview

Server Component. Stat-block layout in three rows:

  • Row 1 — header: NGO name (h1), primary_focus + tier Badges, link out to dim_ngo.website_url ("Besøk rodekors.no").
  • Row 2 — counts (4 stat Cards): "X lokallag", "Y distrikter", "Z aktiviteter", "W kommuner med tilbud". Each is a Server Component query.
  • Row 3 — sections: links into the four sub-views (Chapters →, Distrikter →, Aktiviteter →, plus a "Se i kommunekart" link to /kommuner index for now).

/ngo/redcross/chapters — chapters table

Server Component reading searchParams for filters per [Q31]: kommune_nr, fylke_nr, chapter_level, service_category_code (multi-select), is_active, q (free-text on name). Filters render as Select and Input components in a sticky filter bar. Default is_active=true. Table (shadcn Table) columns: name, chapter_level Badge, kommune_name, fylke_name, parent chapter (link). Sortable by name and kommune_name via column header buttons (Server Component re-fetches via URL params). Empty state when filters return zero: "Ingen lokallag matcher filtrene." Footer: "X av Y lokallag vist".

/ngo/redcross/chapters/[chapter_id] — chapter detail

Server Component fetching one dim_chapter row + its activities via fact_chapter_activities. Layout:

  • Header: name (h1), chapter_level + is_active Badges, kommune_name / fylke_name (linked to /kommuner/<kommune_nr>).
  • Section "Aktiviteter": list of dim_activity.canonical_name rows for this chapter (8–25 items typically). Each item shows the service_category_code as a small label.
  • Section "Hierarki": parent chapter (link to its detail page) and child chapters (list, linked).
  • Section "Kontakt": for v1, just a link to the NGO website. No PII.

/ngo/redcross/aktiviteter — Red Cross activity catalog

Server Component listing all 35 dim_activity rows for ngo_orgnr=864139442. Table columns: canonical_name, service_category_code (Badge linking to a future cross-NGO /aktivitet/<code> page — for v1 the link is dead but the visual is right), count of chapters offering this activity (subquery on fact_chapter_activities). Sortable by name and chapter count.

/ngo/redcross/distrikter — distrikt list

Server Component listing the 19 chapter_level='regional' rows. Table: name, count of child lokallag (subquery), kommune_name (where the distrikt office sits). Each row links to /ngo/redcross/distrikt/[distrikt_id].

/ngo/redcross/distrikt/[distrikt_id] — distrikt detail

Server Component. Header: distrikt name. Two sections: list of child lokallag (linked to chapter detail), aggregate stats (count of chapters, count of distinct kommuner served, count of distinct service categories offered).

/ngo/[slug] — non-ingested NGO stub

Catch-all Server Component. If the slug matches a dim_ngo row but no dim_chapter rows exist, render: NGO name (h1), primary_focus + tier Badges, mission_short if present (currently null in dim_ngo), and a single Card with copy: "Tilbud-data er ikke importert ennå. Følg arbeidet på GitHub eller besøk {website_url}." If no dim_ngo row matches, return 404.

/kommuner/[kommune_nr] — extension to existing route

Existing page rendered above; new "Lokale tilbud" section appended below the indicators table. Heading: "Lokale tilbud i {kommune_name}". Subquery: all fact_chapter_activities rows where kommune_nr matches and chapter is is_active, grouped by service_category_code, listing chapter names per category. Empty state: "Ingen ingestede organisasjoner har lokale tilbud i {kommune_name}." (Per G.2 — does not overclaim).


Appendix B — /admin/supply/redcross-branches validation spec

Single Server Component page. Plain styling (no shadcn components needed — this is for us, not for users). Six sections, each a <section> with an <h2> and a small table or stat block.

Section 1 — Row counts

Compare expected vs actual:

TableExpectedActual (live query)
raw.redcross_branches391 (after L192 dedup)select count(*) from raw.redcross_branches
raw.redcross_branch_activities2143select count(*) from raw.redcross_branch_activities
dim_chapter (Red Cross)391select count(*) from dim_chapter where ngo_orgnr = '864139442'
dim_activity (Red Cross)35select count(*) from dim_activity where ngo_orgnr = '864139442'
fact_chapter_activities (Red Cross)1941select count(*) from fact_chapter_activities where source_id = 'redcross-branches'

Mismatched rows render in red.

Section 2 — Chapter level breakdown

select chapter_level, count(*)
from dim_chapter
where ngo_orgnr = '864139442'
group by chapter_level;

Expected: 1 national, 19 regional, 371 local.

Section 3 — Service category coverage

select da.service_category_code,
count(distinct fca.chapter_id) as chapter_count
from fact_chapter_activities fca
join dim_activity da on da.activity_id = fca.activity_id
where fca.source_id = 'redcross-branches'
group by da.service_category_code
order by chapter_count desc;

Renders as a table — validates the activity → category mapping. Each service_category_code should have ≥1 chapter; categories with 0 are flagged.

Section 4 — Sample chapters

select * from dim_chapter where ngo_orgnr = '864139442' order by random() limit 10 — shows all denormalised columns to spot-check the supply staging.

Section 5 — Distrikt coverage

For each of the 19 distrikter: list child lokallag count, count of distinct kommuner served (via parent_chapter_id rollup). Validates Red Cross's hierarchy.

Section 6 — Ingest run history

select * from raw.ingest_runs where source_slug = 'redcross-branches' order by run_id desc limit 10 — only when raw.ingest_runs table exists (after the scraping infra PLAN ships). Until then, this section displays "Awaiting infrastructure PLAN — raw.ingest_runs not yet created."

Page wrapper

  • <noindex /> meta tag.
  • Header: "Atlas — Red Cross supply data validation".
  • Footer: "Sist oppdatert: {now()}" — this page renders fresh on every request, no caching.