doxa-maps/plugin-designs/gospel-resources/data-tables.vue
doxa-maps-88655c70 57fecfd0da A gate that asks whether anything is there — and it caught a dead bundle
Wrote this after shipping a page that returned 200 for every asset, reported
zero console errors, and was completely dead: a throw during setup dies inside
the custom-element boundary. Zero errors is not the same as working, so this
does not trust their absence — it looks for PRESENCE, on all five leaves, in
five steps that each name their own failure: the tag is defined, the host is in
the document, a shadowRoot is attached, it has children, it renders text.

Its first real run went red on all five, and it was right. Every gospel leaf was
dead with "Identifier 'Ge' has already been declared" — while the SRI hash
still matched and every request still returned 200. My own doing: four profiles
imported en/gospel.json directly for an English fallback, and library/i18n
already pulls that exact file in through an eager glob, so the IIFE carried two
instances of one module.

The fix is one module: library/i18n now exports EN_MESSAGES and the profiles
read it from there. The duplicate is gone.

Measured after: all five leaves mount with 3,228 to 233,307 characters of
rendered text, verify-gospel-map passes with 2,107 pins and the gap cutting to
370, and --poison confirms the check goes red on a page with no element at all.

The lesson is the gate's whole reason to exist. I had rich assertions about pin
counts and filters and none of them ran on data-tables, charts or the list, so a
bundle-level failure could take out every leaf while the one gate I did run was
pointed elsewhere. Cheap and universal beats rich and narrow for THIS question.
2026-09-25 09:52:25 +00:00

294 lines
18 KiB
Vue

<script setup>
/**
* data-tables.vue — the GOSPEL RESOURCES list view.
*
* @description Eleven tables covering both halves of one question: every gospel resource with its real URL and how much Scripture it is, and the 1,613 people groups where nothing was found with the pools searched recorded per row, plus translation priority, bridge languages, city bridges, the deaf gap and access against population growth.
*
* Eleven tables, and the first two answer the two halves of one question. "Every Resource
* + Link" lists what EXISTS — 8,437 real, openable resources across 4,231 languages, each
* named and scoped as a Full Bible, New Testament or portions. "Where Nothing Was Found"
* lists what does NOT — 1,613 people groups, 208 million people — with each row carrying
* the pools that were searched and the pools that were not, so an exported CSV can never
* be read as a claim about the world.
*
* The growth table is the one with teeth: `growing_without_resource` marks a group
* whose population is RISING while its language still has no scripture and no film
* in any pool checked. 431 groups, 34.5M people, ~599 estimated deaths/day.
*
* The mortality estimate applies a NATIONAL crude death rate to a SUB-POPULATION.
* It is order-of-magnitude, not a count — and DataTable renders that caveat from
* the table's own meta so the method travels with the number.
*/
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { EN_MESSAGES } from '@map/i18n/index.js'
import DataTable from '@map/components/tables/DataTable.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
const profileConfig = inject('profileConfig', ref(null))
const isDark = computed(() => profileConfig.value?.isDark === true)
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
/* NAMED tr(), NOT t(). The tab loop already binds `t` to a tab object — `v-for="t in TABS"` —
so a translator called t() would be shadowed inside the one block that needs it most, and
the labels would silently render as whatever the tab object stringifies to. Guarded like
every other useI18n() call in this bundle: the host may never have installed i18n. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
tr = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host: the shipped English catalogue is the fallback */ }
const TABS = [
// ★ THE DOXA TABLE LEADS, because it is the question the Driver actually asked: which of
// THEIR people groups have gospel resources and which do not. Every other tab here is
// scoped to the research corpus — 12,379 groups, 4,231 languages — which is the right
// scope for finding what exists and the wrong one for working a list of 2,107. A
// mobiliser cannot plan from a table that is four-fifths other people's groups.
{ id: 'doxa', labelKey: 'gospel.tables.t_doxa', file: 'doxa-gospel-access' },
// THE LINKS lead. Every other table here is keyed by PEOPLE GROUP and answers "who has
// nothing"; this one is keyed by the RESOURCE and answers "what exists and where do I
// open it" — the question a mobiliser asks first, and the only table whose every row
// carries a URL you can click.
{ id: 'links', labelKey: 'gospel.tables.t_links', file: 'gospel-resource-links' },
// The other half of the same question. Every other table here lists what EXISTS; this
// one lists the groups with nothing found, which is the list a mobiliser can
// actually export and work through. It is NOT the "Gaps & Opportunities" table below:
// that one is one row per group across the curated 2,106-record set and structurally
// cannot see these groups.
{ id: 'nothing', labelKey: 'gospel.tables.t_nothing', file: 'gospel-gaps' },
// The same gap, aggregated to the place a team gets sent. It existed only inside the
// map's sheet — readable, not exportable — while every other tab here offers CSV and
// JSON. Derived from gospel-by-country, which is refreshed from the pin index, so it
// cannot drift from the map above it.
{ id: 'countrygap', labelKey: 'gospel.tables.t_countrygap', file: 'country-gap' },
// The OPPOSITE question from "where nothing was found", and none of these groups appear
// in that table because none of them is tier 0: they can already hear the gospel and have
// no text to read. It was selectable on the map and nowhere else, and a filter cannot be
// sorted, exported, or handed to a committee — which is precisely what this list is for.
{ id: 'translation', labelKey: 'gospel.tables.t_translation', file: 'translation-priority' },
// The same population aggregated to the place a team is SENT. Its two columns disagree on
// purpose: Nigeria and the DRC lead on languages, which is the translation workload, while
// China and Pakistan lead on people across far fewer languages.
{ id: 'translationcountry', labelKey: 'gospel.tables.t_translationcountry', file: 'translation-priority-by-country' },
// GAPS & OPPORTUNITIES leads: it is the table that answers "where can the work
// actually happen", which is the whole point of the corpus.
{ id: 'gaps', labelKey: 'gospel.tables.t_gaps', file: 'gaps-and-opportunities' },
// The actionable shortlist: 28 places where a co-located language ALREADY
// carries a resource the group's own language lacks.
{ id: 'bridges', labelKey: 'gospel.tables.t_bridges', file: 'city-bridge-opportunities' },
// The largest structurally-invisible bloc. Rebuilt from the pins: it used to be derived
// from the curated 2,106-record set and showed 73 sign languages / 120 groups / 19.35M,
// while the map drew 150 / 234 / 84.2M — the Deaf tab was missing more than half of the
// thing it is named for. Counts live in the table's own meta now, not in this comment.
{ id: 'deaf', labelKey: 'gospel.tables.t_deaf', file: 'deaf-gap' },
{ id: 'resources', labelKey: 'gospel.tables.t_resources', file: 'people-group-resources' },
// Ranked languages per country joined to resources — the bridge-language view.
{ id: 'languages', labelKey: 'gospel.tables.t_languages', file: 'languages-by-country' },
{ id: 'growth', labelKey: 'gospel.tables.t_growth', file: 'gospel-access-vs-growth' },
]
// DEEP-LINK FILTER. The map and this table are separate PAGES, so "click a pin and the
// table follows" is a URL, not shared state. The map links here with ?tab=links&iso=<code>
// or &country=<ISO3>, and the rows are filtered HERE rather than by teaching the shared
// DataTable a new prop — that component has other consumers and this needs none of them.
function qp() {
try { return new URLSearchParams(window.location.search) } catch { return new URLSearchParams() }
}
const params = qp()
const fIso = ref((params.get('iso') || '').trim().toLowerCase())
const fCountry = ref((params.get('country') || '').trim().toUpperCase())
const fLabel = ref((params.get('label') || '').trim())
const hasFilter = computed(() => !!(fIso.value || fCountry.value))
function clearFilter() {
fIso.value = ''; fCountry.value = ''; fLabel.value = ''
try { window.history.replaceState({}, '', window.location.pathname) } catch (_) {}
}
const active = ref(TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'links')
const current = computed(() => TABS.find((t) => t.id === active.value) || TABS[0])
// ═══ COMPOSED MODE ═══ when these tables are stacked under the map in the dashboard, the
// map asks them to filter in place instead of linking to this page. Null on the standalone
// page, where the URL remains the only source of a filter and nothing here changes.
const composedFilter = inject('composedFilter', null)
if (composedFilter) {
watch(composedFilter, (f) => {
if (!f) return
fIso.value = String(f.iso || '').trim().toLowerCase()
fCountry.value = String(f.country || '').trim().toUpperCase()
fLabel.value = String(f.label || '').trim()
// The links tab is the one that carries URLs; a filter is meaningless on the others.
if (TABS.some((t) => t.id === 'links')) active.value = 'links'
})
}
// Fetched at runtime — see the note in cities-internet/data-tables.vue. These two
// tables alone are 2.8MB of JSON; inlining them blocked first paint.
const cache = ref({})
const loading = ref(false)
const loadErr = ref(null)
async function ensure(file) {
if (cache.value[file]) return
loading.value = true; loadErr.value = null
try {
const r = await fetch(`${assetsBase.value}/tables/${file}.json`)
if (!r.ok) throw new Error(`table not found (${r.status})`)
cache.value = { ...cache.value, [file]: await r.json() }
} catch (e) { loadErr.value = String(e.message || e) }
finally { loading.value = false }
}
watch(current, (t) => t && ensure(t.file), { immediate: true })
const rawTable = computed(() => cache.value[current.value?.file] || null)
// Only the links table carries iso639_3/country_codes, so a filter is a NO-OP elsewhere
// rather than an empty table — switching tabs under an active filter must not look broken.
// Both keyed tables filter. The links table carries country_codes (the ISO3 set a
// LANGUAGE is spoken in); the gaps table is one row per people-group-IN-COUNTRY and
// carries iso3 directly. Filtering only the links table meant a country click could show
// you what a country HAS and never what it lacks — which is the half a mobiliser came for.
const FILTERABLE = new Set(['doxa', 'links', 'nothing'])
const table = computed(() => {
const t = rawTable.value
if (!t || !hasFilter.value || !FILTERABLE.has(current.value?.id)) return t
const rows = (t.rows || []).filter((r) => {
if (fIso.value && String(r.iso639_3 || '').toLowerCase() !== fIso.value) return false
if (!fCountry.value) return true
// links: a language spoken in many countries. gaps: one row, one country.
const set = r.country_codes != null
? String(r.country_codes).toUpperCase().split(' ')
: [String(r.iso3 || '').toUpperCase()]
return set.includes(fCountry.value)
})
/* THE TITLE TRAVELS WITH THE ROWS.
*
* Export three filtered views and you get three files called
* gospel-resources-every-link-and-who-it-reaches.csv — DataTable names the download from
* meta.title, and meta.title did not know about the filter. Measured: China exported 265
* lines and Germany 7, both correct, both under the same name, so the second download
* either overwrote the first or landed as "(1)". The rows were right and the file was
* unidentifiable, which is the failure mode for the person this export exists for: someone
* pulling a list per country to hand to a team.
*
* Naming it here rather than teaching DataTable a new prop keeps the decision this file
* already made — that component has other consumers and none of them need this — and it
* fixes the on-screen heading in the same move: it read "every link, and who it reaches"
* over a table showing one country's. */
const title = t.meta?.title
? `${t.meta.title} — ${fLabel.value || fIso.value || fCountry.value}`
: t.meta?.title
return { ...t, meta: { ...t.meta, title }, rows }
})
const filterCount = computed(() => (table.value?.rows || []).length)
const totalCount = computed(() => (rawTable.value?.rows || []).length)
// Computed from the growth table ONCE it has loaded — the hero is hidden until
// then rather than rendering zeros, which would read as "no gap" (the opposite of
// the truth) while the fetch is still in flight.
const gap = computed(() => {
const g0 = cache.value['gospel-access-vs-growth']
if (!g0) return null
const r = g0.rows || []
const g = r.filter((x) => x.growing_without_resource)
return {
groups: g.length,
population: g.reduce((n, x) => n + (x.population || 0), 0),
deathsDay: Math.round(g.reduce((n, x) => n + (x.est_deaths_per_day || 0), 0)),
}
})
const fmt = (n) => (Number(n) || 0).toLocaleString()
useShadowStyles(
'.gt{position:absolute;inset:0;overflow-y:auto;padding:18px;background:#f6f7f9;color:#0f1419;' +
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
'.gt.is-dark{background:#0a1410;color:#e5e7eb}' +
'.gt-h{margin:0 0 4px;font-size:18px;font-weight:700}' +
'.gt-sub{margin:0 0 12px;font-size:12.5px;opacity:.72;line-height:1.5}' +
'.gt-hero{margin:0 0 14px;padding:12px 14px;border-radius:9px;font-size:12.5px;line-height:1.6;' +
'background:rgba(220,38,38,.07);border:1px solid rgba(220,38,38,.18)}' +
'.is-dark .gt-hero{background:rgba(220,38,38,.12);border-color:rgba(248,113,113,.25)}' +
'.gt-hero b{font-weight:800;font-variant-numeric:tabular-nums}' +
'.gt-filter{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-size:13px}' +
'.gt-clear{border:1px solid #d1d4cd;background:#fff;color:#3b463d;font:600 12px inherit;padding:5px 11px;border-radius:7px;cursor:pointer;min-height:32px}' +
'.gt-clear:hover{background:#f3f3f1}' +
'.gt-tabs{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}' +
'.gt-tab{font:600 12px inherit;padding:7px 14px;border-radius:999px;cursor:pointer;' +
'border:1px solid rgba(0,0,0,.14);background:transparent;color:inherit}' +
'.gt-tab.on{background:#10b981;border-color:#10b981;color:#04241a}' +
'.gt-load{padding:22px;text-align:center;font-size:12.5px;opacity:.6}' +
'.gt-err{padding:11px 13px;border-radius:8px;background:rgba(220,38,38,.10);color:#a12020;font-size:12.5px}' +
'.gt-n{font-size:10.5px;opacity:.6;margin-left:5px;font-variant-numeric:tabular-nums}',
'doxa-gospel-tables',
)
</script>
<template>
<!-- The blanket dir="ltr" is gone: this profile's chrome is translated now, so Arabic gets
the RTL layout it should have. What stays LTR is each block of English prose, marked
where it sits — the same split the map profile already uses.
★ CORRECTION, measured 2026-09-25. The previous commit recorded "in RTL the leftmost
data column clips against the edge" as known debt. That was wrong, and it was wrong in
the way that wastes someone else's afternoon: I read a screenshot instead of measuring.
Counting cells that fall outside the table holder, en and ar are IDENTICAL — 13 of 20
columns off-screen in both, scrollWidth 3146 against clientWidth 1400 either way. A
twenty-column table is simply wider than 1,400px, and RTL mirrors which edge the
overflow sits on. There is no RTL clipping bug to chase. Whether twenty columns is the
right design for a 1,400px screen is a real question, but it is a design question and
it is the same question in English. -->
<div class="gt" :class="{ 'is-dark': isDark }">
<h2 class="gt-h">{{ tr('gospel.tables.title') }}</h2>
<p class="gt-sub" dir="ltr">
<!-- Named three media while the tables below carry seven. Kept short by naming the
question rather than enumerating a list that will go stale again. -->
Which people groups can reach the gospel in their own language — Scripture to read or
hear, film, Bible video, Bible stories, a gospel presentation or discipleship training
— and whether that access is keeping pace with population growth.
</p>
<div v-if="gap" class="gt-hero">
<b>{{ fmt(gap.groups) }}</b> people groups are <b>growing</b> with no scripture and no film
found in the pools checked — <b>{{ fmt(gap.population) }}</b> people,
an estimated <b>{{ fmt(gap.deathsDay) }}</b> deaths per day.
<br>The estimate applies a national death rate to a sub-population; it is
order-of-magnitude, not a count.
</div>
<div class="gt-tabs">
<button v-for="t in TABS" :key="t.id" type="button"
class="gt-tab" :class="{ on: active === t.id }" @click="active = t.id">
{{ tr(t.labelKey) }}<span class="gt-n" v-if="cache[t.file]">{{ (cache[t.file].rows || []).length.toLocaleString() }}</span>
</button>
</div>
<div v-if="hasFilter" class="gt-filter">
<span v-if="FILTERABLE.has(current.id)">
{{ tr('gospel.tables.showing') }} <b>{{ fmt(filterCount) }}</b> {{ tr('gospel.tables.of') }} {{ fmt(totalCount) }}
{{ tr('gospel.tables.for') }} <b>{{ fLabel || fIso || fCountry }}</b>
<!-- The explanation stays English and declares its direction, like the map's prose:
it is the caveat that says an empty result is not proof of absence. -->
<template v-if="!filterCount"><span dir="ltr"> — nothing found for this selection in the pools checked, which is not proof that nothing exists.</span></template>
</span>
<span v-else>{{ tr('gospel.tables.filteredTo') }} <b>{{ fLabel || fIso || fCountry }}</b><span dir="ltr"> — this tab is not filtered; only the keyed tables carry a language or country key.</span></span>
<!-- "Show all" WHAT. Stacked under the map in the dashboard there are three controls
with this exact label — two in the map's group lists and this one — and they do
different things. A reader cannot tell which clears the table filter, and neither
could a probe: the first attempt at verifying this clicked one of the map's and
reported the filter as stuck when it was not. Naming the noun costs one word. -->
<button type="button" class="gt-clear" @click="clearFilter">{{ tr('gospel.tables.showAll') }}</button>
</div>
<div v-if="loadErr" class="gt-err">{{ loadErr }}</div>
<div v-else-if="!table" class="gt-load">Loading {{ current.label }}…</div>
<DataTable
v-else
:key="current.id + ':' + fIso + ':' + fCountry"
:table="table"
:assets-base="assetsBase"
:is-dark="isDark"
height="600px"
/>
</div>
</template>