UPG-100 batch selection: a stalled people-group load can never hang Copy. Copy waits at most 8 s (settleWithin), then copies the territory lines apply-grouping needs and says the people groups are still loading (11 locales); the records fetch aborts after 30 s and leaves the cache. The gate proves a never-settling source still settles in time

This commit is contained in:
Doxa Maps 2026-09-30 23:28:59 +00:00
parent 85508b899d
commit 0b266d8110
14 changed files with 65 additions and 17 deletions

View file

@ -112,6 +112,21 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country
ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`,
`the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`)
// ── A4. A STALLED people-group load can never hang Copy ─────────────────────────────────────────────────
// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory
// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to
// the marker in time; a value passes through; a failure still rejects (its own message is kept).
{
const MARK = {}, t0 = Date.now()
// The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name.
const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))])
const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK)
const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message)
ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down',
`settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`,
'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)')
}
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
@ -148,8 +163,10 @@ const wired = {
'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue),
'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"),
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue),
// India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected
'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue),
'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue),
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
'India states detected from the served table': /import \{ loadIndiaStates \} from '@map\/data\/indiaStates\.js'/.test(vue)
&& /india\.byShapeName\.get\(t\.name\)/.test(vue) && /t\.kind === 'adm1'\) indiaStates\(\)\.catch/.test(vue),
@ -160,7 +177,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const KEYS = {
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []
for (const [f, keys] of Object.entries(KEYS)) {

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة",
"displayName": "ملصقات قائمة UPG 100",
"wallHeading": "ملصقات قائمة UPG 100",
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم "
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren",
"displayName": "Poster der UPG 100 Liste",
"wallHeading": "Poster der UPG 100 Liste",
"wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten "
"wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ",
"peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately",
"displayName": "UPG 100 List Posters",
"wallHeading": "UPG 100 List Posters",
"wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them "
"wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ",
"peopleStillLoading": "the people groups are still loading, try again in a moment"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países",
"displayName": "Carteles de la lista UPG 100",
"wallHeading": "Carteles de la lista UPG 100",
"wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos "
"wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ",
"peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays",
"displayName": "Affiches de la liste UPG 100",
"wallHeading": "Affiches de la liste UPG 100",
"wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux "
"wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ",
"peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें",
"displayName": "UPG 100 सूची पोस्टर",
"wallHeading": "UPG 100 सूची पोस्टर",
"wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए "
"wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ",
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi",
"displayName": "Poster della lista UPG 100",
"wallHeading": "Poster della lista UPG 100",
"wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro "
"wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ",
"peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países",
"displayName": "Cartazes da lista UPG 100",
"wallHeading": "Cartazes da lista UPG 100",
"wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles "
"wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ",
"peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările",
"displayName": "Afișe din lista UPG 100",
"wallHeading": "Afișe din lista UPG 100",
"wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei "
"wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ",
"peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны",
"displayName": "Плакаты списка UPG 100",
"wallHeading": "Плакаты списка UPG 100",
"wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них "
"wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ",
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение"
}

View file

@ -18,5 +18,6 @@
"copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家",
"displayName": "UPG 100 名单海报",
"wallHeading": "UPG 100 名单海报",
"wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 "
"wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ",
"peopleStillLoading": "族群仍在加载中,请稍后再试"
}

View file

@ -74,6 +74,17 @@ export function selectionClipboard(selection, opts = {}, csv = '') {
return csv ? head + '\n\n' + csv : head;
}
/**
* `promise` if it settles within `ms`, else `marker`. A rejection still rejects (so a load failure keeps its own
* message). Copy uses it so a stalled people-group load can never hold the territory lines hostage: those need
* no network, and apply-grouping needs nothing else.
*/
export function settleWithin(promise, ms, marker) {
let timer = 0;
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(marker), ms); });
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
}
/** A file name a partner can recognise: the selection's label, reduced to safe characters. */
export function selectionCsvFileName(label) {
const base = String(label || 'selection').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
@ -81,4 +92,4 @@ export function selectionCsvFileName(label) {
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, selectionCsvFileName };
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, selectionCsvFileName };

View file

@ -128,7 +128,7 @@ import { colorForZone } from './data/zone-colors.js'
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable } from './data/selection-export.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin } from './data/selection-export.js'
import visionFunnel from '../../data/people-groups/vision-funnel.json'
import { upgTotal, formatCount } from './data/upg-total.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
@ -1828,13 +1828,20 @@ function selectedAdm1Names() {
// Copy tap builds its text from cache and writes it inside the tap's user activation (a clipboard write
// after a network wait is refused, on phones above all). A failed load is dropped so the next try refetches.
let _selRecords = null; // { lang, promise }
const RECORDS_TIMEOUT_MS = 30000; // the people-group list: abort a request that has not answered by then
const COPY_WAIT_MS = 8000; // Copy waits this long for the people groups, then copies the territories alone
function selectionRecordsLoad() {
const lang = packetLang();
if (_selRecords && _selRecords.lang === lang) return _selRecords.promise;
if (!API_BASE) return Promise.reject(keyedError('wall.hud.noApi', 'no people-groups service is configured'));
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
// A STALLED request must not hold the cache forever: it aborts after RECORDS_TIMEOUT_MS, rejects, and the catch
// below drops it, so the next action fetches again.
const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = ctrl ? setTimeout(() => ctrl.abort(), RECORDS_TIMEOUT_MS) : 0;
const promise = (async () => {
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store' });
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
.finally(() => clearTimeout(timer));
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
return normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
})();
@ -1922,7 +1929,9 @@ function copySelection() {
if (!sel.length) return;
const color = selectedColor.value;
let count = null, note = '';
const text = selectionPeople().then((p) => {
const STALLED = {};
const text = settleWithin(selectionPeople(), COPY_WAIT_MS, STALLED).then((p) => {
if (p === STALLED) { note = t('upgMap.peopleStillLoading'); return selectionClipboard(sel, { color }); }
count = p.records.length; note = selectionNote(p.missing);
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });