doxa-maps/library/lists/peopleGroupRecords.js
Doxa Maps f8744925e4 Every country prints its prayer list, card deck and packet, from a records provider any map can use
The country wall passed no records, so each of its 175 cards offered only the poster and said the
prayer list was "coming soon", while the people groups were one request away. library/lists/
peopleGroupRecords.js is the records half of the print standard: one people-groups fetch per language
(normalised the way the prints read rows, list membership applied), then a SELECTOR picks the set — a
country by ISO3, explicit slugs, any field, or a host's own selector language. A selector nobody
understands selects nothing rather than every group on earth, and a failed fetch is not cached.

The country wall uses it by ISO3, so every country now prints the list, the deck (all five stocks) and
the full or lightweight packet. The packet's page 1 is, for now, the country's stored poster when it is
really there; the in-browser map generator will replace that.

check:people-group-records asserts the selection two-sided with an injected fetch; registered in
gates:pre.
2026-09-29 05:26:26 +00:00

109 lines
5.5 KiB
JavaScript

/**
* peopleGroupRecords.js — the RECORDS half of the print standard: the people groups of ANY set, in the
* shape usePosterPrints' `records(item)` wants, so any map can offer the prayer list, the card deck and
* the prayer packet for whatever its reader picked — a country, a region, a list, a selection.
*
* ★ WHY IT IS IN THE LIBRARY. Every wall that prints rows used to carry its own fetch: the UPG-100 wall
* had one, and the country walls had none, which is why a country card said "prayer list coming soon"
* while the data to print it was one request away. The fetch, the normalisation and the membership
* rules are the same for every set; only the SET differs, so the set is a parameter (a selector).
*
* A SELECTOR names the set:
* { type: 'country', values: ['TCD', …] } by ISO3 country code
* { type: 'pg_slugs', values: ['slug', …] } explicit people groups
* { type: 'field', field: 'wagf_region', values: […] } any field's value (after normalisation)
* Anything else is handed to the host's `evaluate(selector, records)` (the UPG-100 selector language),
* and a selector nobody understands selects NOTHING rather than everything: a packet of every people
* group on earth printed under one country's name would be worse than no packet.
*
* ONE FETCH PER LANGUAGE, shared by every item: the list endpoint returns every group, so a wall of 175
* countries costs one request, not 175. A failed fetch is not cached, so the next click retries.
*/
import { applyPeopleGroupList } from './peopleGroupList.js'
// What the prints read: the card's face and language, the profile's description (imb_gsec feeds its
// reachedness sentence), the sets page's joins (slug), and the country for the page-1 map and captions.
export const PRINT_FIELDS = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,'
+ 'image_url,primary_language,imb_gsec'
// Fields the API sends as { value, label } dicts. Normalised to the value, with the label kept beside
// it as <field>_label — the shape card-render and describeGroup read. Same rule as the UPG-100
// profile's normalize.js, so a row prints the same whichever surface fetched it.
const NESTED = ['country_code', 'wagf_region', 'wagf_block', 'primary_language', 'religion', 'imb_gsec']
export function normalizeRecord(raw) {
if (raw == null || typeof raw !== 'object') return raw
const out = { ...raw }
for (const f of NESTED) {
if (!(f in out)) continue
const v = out[f]
if (v && typeof v === 'object') {
out[f] = v.value ?? null
if (v.label != null) out[`${f}_label`] = v.label
}
}
return out
}
export function extractRecords(payload) {
if (Array.isArray(payload)) return payload
if (payload && typeof payload === 'object') {
return payload.posts || payload.data || payload.records || payload.results || payload.items || []
}
return []
}
/** The records a selector names, sorted by name. `evaluate` handles selector types this module does not. */
export function selectRecords(records, selector, evaluate = null) {
const rows = Array.isArray(records) ? records : []
const s = selector || {}
const vals = new Set((s.values || []).map(String))
let picked
if (s.type === 'country') {
const up = new Set([...vals].map((v) => v.toUpperCase()))
picked = rows.filter((r) => r && up.has(String(r.country_code || '').toUpperCase()))
} else if (s.type === 'pg_slugs') {
picked = rows.filter((r) => r && vals.has(String(r.slug)))
} else if (s.type === 'field' && s.field) {
picked = rows.filter((r) => r && vals.has(String(r[s.field])))
} else if (typeof evaluate === 'function') {
const slugs = evaluate(selector, rows)
picked = rows.filter((r) => r && slugs && slugs.has(r.slug))
} else {
picked = []
}
return picked.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
}
/**
* @param {object} o
* apiBase () => string the people-groups API origin ('' = none: every call rejects, so the
* print shows its error state rather than an empty packet)
* lang () => string the reader's language; names and labels come back in it
* profileConfig ref|object the host's config, for the universal `list` membership rule
* evaluate (selector, records) => Set<slug> for selector types beyond the built-in three
* fetchImpl fetch injectable, so the module is testable without a network
* @returns {(selector) => Promise<rows[]>}
*/
export function createPeopleGroupRecords({ apiBase, lang = () => 'en', profileConfig = null, evaluate = null, fetchImpl = null } = {}) {
const cache = new Map()
const doFetch = fetchImpl || ((...a) => fetch(...a))
function all() {
const lc = (typeof lang === 'function' ? lang() : lang) || 'en'
if (!cache.has(lc)) {
const p = (async () => {
const base = typeof apiBase === 'function' ? apiBase() : apiBase
if (!base) throw new Error('no people-groups API configured')
const resp = await doFetch(`${base}/api/people-groups/list?fields=${PRINT_FIELDS}&lang=${encodeURIComponent(lc)}`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
const rows = extractRecords(await resp.json())
const member = profileConfig ? await applyPeopleGroupList(rows, profileConfig) : rows
return member.map(normalizeRecord)
})()
p.catch(() => cache.delete(lc))
cache.set(lc, p)
}
return cache.get(lc)
}
return async (selector) => selectRecords(await all(), selector, evaluate)
}