doxa-maps/library/composables/useDashboardData.js
Doxa Maps b1dbd9bc87 The research dashboard asked the API for English, then translated half of it
useDashboardData read `lang` straight out of sources.json, where it is frozen at
"en", and nothing ever overrode it. So every row the dashboard fetched came back
English regardless of the reader's locale.

The result was not a uniformly English page, which would at least be honest. It
was worse. DashboardChartsRail localizes its country labels through
countryName(), so at ?lang=ar the top-countries chart read الصين while the table
row directly beneath it read "People's Republic of China" — and a page that is
half translated looks like a bug in the half that works. Three surfaces rendered
the raw English label: the data table's country column
(dashboard.config.js:299), the detail drawer (DashboardDetailDrawer.vue:171) and
the search-result subtitle (DashboardTopBar.vue:327).

Fixing it at the SOURCE rather than at the three render sites is what makes them
agree: ask the API in the reader's language and the labels arrive correct, with
no countryName() call and no third spelling of a country's name to reconcile.

DataSourceManager.js:261 has done exactly this for its own callers all along —
`if (this.locale) extraParams.lang = this.locale` — and doxa-research-map,
doxa-simple-map and country-posters all pass a locale in. This composable simply
never got the same treatment.

BOTH LOCALE CHANNELS, because it genuinely arrives by either: `lang` is provided
by ProfileLoader for an element configured through profile-config, and
window[PAGE_LOCALE_KEY] is what a generated leaf sets from ?lang= — the URL a
person actually shares. Reading only one left the regions dashboard's data
labels English on the shared-link path while its chrome translated, which is the
measurement this copies rather than rediscovers.

Resolved at REQUEST time, not captured at setup, so a locale switch cannot pin
the dashboard to whichever language happened to load first.

AND A LOCALE CHANGE NOW REFETCHES. load() ran only onMounted, so switching
language mid-session would have re-rendered chrome over data still in the old
language — the same defect one layer up. Same shape as region-dashboards.vue:680,
guarded on a real change so the mount fetch is not doubled.

Caught while writing it: my first version of that watch called inject() inside
the callback, where it does not work — it warns on every tick and returns null.
The ref is captured in setup and only read later, which is what this file already
does for profileConfig, for the same reason.

Proved headless: locale resolution across all four channels (leaf ?lang=,
injected profile-config, neither, and injected-beats-runtime), then that the
chart, the table column, the drawer and the search subtitle produce the SAME
string for CHN, LAO and COD at ar — the disagreement that started this. The
API's ?lang= behaviour itself is not my assumption: it was measured live at ar
and zh on region-dashboards (its comment at :366).
2026-09-28 22:25:31 +00:00

110 lines
6.4 KiB
JavaScript

/**
* useDashboardData.js — RDASH phase 2: the single in-memory row store (kills the hardcode).
*
* Loads ALL people groups once + /statistics into a slug-keyed store. Uses the SHARED data layer
* — sources.json (active source = pray-tools, its endpoints + defaultFields + lang) and getApiBaseUrl
* (window.MAP_APP_API_URL / VITE_API_BASE_URL / pray.doxa.life) — but keeps the RAW API record per
* row rather than DataSourceManager's normalized projection: the dashboard table/charts/CSV reference
* raw fields ({value,label} objects like country_code, wagf_region, religion) and the CSV export must
* serialize the FULL API record. DataSourceManager.normalizeData renames fields to system names and
* drops no-coordinate rows (geo set for the map layer, phase 7) — wrong shape for this store.
*
* Shadow-DOM safe: pure data, no DOM/style. Reuses read-only @map config without editing it.
*/
// KIT LANDING 4d-2: moved from plugin-designs/research-dashboard/src/composables/. It needed no
// adapter — every import was already @map/ — and it injects profileConfig in a try/catch because it
// can be called outside setup, which is the one property the move had to preserve.
import { ref, shallowRef, inject } from 'vue'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { getRuntimeLocale } from '@map/i18n/index.js'
import sourcesConfig from '@map/api/sources.json'
import { applyPeopleGroupList, resolvePeopleGroupList, LIST_DEFAULT } from '@map/lists/peopleGroupList.js'
export function useDashboardData(dataOpts = {}) {
const loading = ref(false)
const error = ref(null)
const rowStore = shallowRef(new Map()) // slug (PK) -> full raw API record
const rows = shallowRef([]) // array view of the same records
const stats = ref(null) // /statistics global aggregates (KPI baselines)
const total = ref(0) // rowStore size (live-data marker; mock had 3)
// Which sources.json source the rows come from. Honors the profile's declared
// config.data.source (parameterizable) and falls back to the shared activeSource,
// preserving current pray-tools behavior. Exposed so the shell can normalize the
// SAME source's fieldMappings for the geocoder dataStore (rows + normalization stay
// in lockstep — no desync if a dashboard ever points at a non-active source).
const sourceId = dataOpts.source || sourcesConfig.activeSource
// The map's people-group LIST (library/lists/): read here, in setup, where inject works.
let profileConfig = null
try { profileConfig = inject('profileConfig', null) } catch (_) { /* called outside setup */ }
// ★ THE READER'S LOCALE, READ IN SETUP WHERE inject WORKS (the same reason profileConfig is).
// This dashboard asked the API for lang=en on every request, because sources.json freezes that
// value and nothing here ever overrode it. The effect was not a uniformly English page — it was
// WORSE: DashboardChartsRail localizes its country labels through countryName(), so at ?lang=ar
// the top-countries chart read الصين while the table row beneath it read "People's Republic of
// China". A page that is half-translated looks like a translation bug in the half that works.
//
// DataSourceManager.js:261 has done exactly this for its own callers all along —
// `if (this.locale) extraParams.lang = this.locale` — and doxa-research-map, doxa-simple-map and
// country-posters all pass a locale in. This composable simply never got the same treatment.
//
// BOTH CHANNELS, because the locale genuinely arrives by either: `lang` is provided by
// ProfileLoader for an element configured through profile-config, and window[PAGE_LOCALE_KEY] is
// what a generated leaf sets from ?lang= — which is the URL a person actually shares. Measured on
// the regions dashboard, where reading only one of the two left every data label English on the
// shared-link path while the chrome translated.
let injectedLang = null
try { injectedLang = inject('lang', null) } catch (_) { /* called outside setup */ }
async function load() {
loading.value = true
error.value = null
try {
const src = sourcesConfig?.sources?.[sourceId] || {}
const base = getApiBaseUrl()
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const fields = (src.defaultFields || []).join(',')
// The runtime locale WINS over the source's frozen value; 'en' remains the floor. Resolved at
// REQUEST time, not at setup: a locale switch refetches through this same function, and a
// value captured once would pin the dashboard to whichever locale happened to load first.
const runtime = (injectedLang && injectedLang.value) || getRuntimeLocale() || ''
const lang = runtime || src.queryParams?.lang || 'en'
// ── 1) bulk list (shared config: endpoint + defaultFields + lang + cache-bust) ──
const listUrl = `${base}${bulk}?${fields ? `fields=${fields}&` : ''}lang=${lang}&_=${Date.now()}`
const res = await fetch(listUrl, { cache: 'no-store' })
if (!res.ok) throw new Error(`list HTTP ${res.status}`)
const json = await res.json()
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) {
const slug = r && (r.slug || r.id)
if (slug) map.set(slug, r)
}
rowStore.value = map
rows.value = raw
total.value = map.size
// ── 2) /statistics seeds KPI baselines ──
const statEndpoint = src.endpoints?.statistics || '/api/people-groups/statistics'
// /statistics describes DOXA's whole list, so under any other list its totals would be about
// the wrong set of groups. They are optional baselines; leave them out there.
if (resolvePeopleGroupList(profileConfig) === LIST_DEFAULT) try {
const sres = await fetch(`${base}${statEndpoint}?_=${Date.now()}`, { cache: 'no-store' })
if (sres.ok && (sres.headers.get('content-type') || '').includes('json')) {
stats.value = await sres.json()
}
} catch (_) { /* KPI baselines optional; the list is the source of truth */ }
if (total.value === 0) error.value = 'No records loaded from the live API.'
} catch (e) {
error.value = (e && e.message) ? e.message : String(e)
} finally {
loading.value = false
}
}
return { loading, error, rowStore, rows, stats, total, load, sourceId }
}