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).
This commit is contained in:
Doxa Maps 2026-09-28 22:22:14 +00:00
parent 34010ce6d8
commit b1dbd9bc87
2 changed files with 44 additions and 3 deletions

View file

@ -17,6 +17,7 @@
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'
@ -38,6 +39,25 @@ export function useDashboardData(dataOpts = {}) {
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
@ -46,7 +66,11 @@ export function useDashboardData(dataOpts = {}) {
const base = getApiBaseUrl()
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const fields = (src.defaultFields || []).join(',')
const lang = src.queryParams?.lang || 'en'
// 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()}`

View file

@ -35,7 +35,7 @@ import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { useTreeLegendColors } from './useTreeLegendColors.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
import { tStatic } from '@map/i18n/index.js'
import { tStatic, getRuntimeLocale } from '@map/i18n/index.js'
// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
const mapValues = (obj, fn) =>
@ -265,7 +265,24 @@ export function useDashboardShell(config) {
provide('dataStore', dataStore)
provide('searchSourceId', searchSourceId)
onMounted(() => data.load())
// ★ A LOCALE CHANGE MUST REFETCH, not just re-render — the country, religion and region labels
// come from the API's ?lang=, so without this the chrome switches language and every DATA label
// keeps the language of first paint. Same rule and same shape as region-dashboards.vue:680,
// which learned it the same way. Guarded on a real change so the mount fetch is not doubled.
let _lastLang = null
// inject() ONLY works during setup — calling it inside the watch callback would warn on every
// tick and return null, so the ref is captured HERE and only READ later. This file already does
// the same for profileConfig, for the same reason.
let _langRef = null
try { _langRef = inject('lang', null) } catch (_) { /* called outside setup */ }
const _langNow = () => (_langRef && _langRef.value) || getRuntimeLocale() || ''
watch(_langNow, (next) => {
if (next === _lastLang) return
_lastLang = next
data.load()
})
onMounted(() => { _lastLang = _langNow(); data.load() })
return { bus, data, FACET_ACC, filteredRows, kpis, exporter, responsive }
}