UPG Neighbor: write a letter to a reached group's believers, naming the unreached peoples nearest them

From the selected green group's brief: the unreached peoples that count it among their nearest believers, at the chosen E-level, closest first (the same list as the CSV), the closest 30 by name with a pray.doxa.life link where the group has one. The letter is editable, then copied or downloaded as a .txt (UTF-8, readable in any editor). It opens in a dialog that takes focus, keeps it and closes on Escape.
This commit is contained in:
Doxa Maps 2026-09-29 00:21:44 +00:00
parent a33cb1a40f
commit 1f6b9b0041
3 changed files with 102 additions and 4 deletions

View file

@ -0,0 +1,31 @@
/**
* letter.js — the LETTER to a reached group's believers (Driver, test round 2: UPG Neighbor is a letter
* generator for the green dots). It names the unreached peoples that count this group among their nearest
* reached neighbours, at ONE E-level, closest first: the same list the map draws and the CSV exports.
*
* Pure (no imports, so it runs anywhere): t(key, params) supplies every word (neighbor.letter.* in 11
* locales) and fmt(n) the short population figure (nearData.fmtNum); data values (names, countries,
* languages) are the dataset's own English, as everywhere in this plugin.
*/
export const LETTER_CAP = 30 // a letter names the closest 30; the CSV carries the whole list
/** A group's page on the global prayer movement, when it has one (the doxa list's slug). */
export const prayLink = (g) => (g && g.slug ? `https://pray.doxa.life/${encodeURIComponent(g.slug)}?source=doxalife` : '')
export function buildLetter({ group, neighbors, level, t, fmt = String }) {
const n = neighbors.length
const out = [
t('neighbor.letter.greeting', { group: group.name, country: group.country }), '',
t('neighbor.letter.intro', { n: n.toLocaleString('en-US'), level }), '',
]
for (const x of neighbors.slice(0, LETTER_CAP)) {
const g = x.g
out.push('• ' + t('neighbor.letter.item', { name: g.name, country: g.country, pop: fmt(g.pop), language: g.langName, km: x.km.toLocaleString('en-US') }))
const url = prayLink(g)
if (url) out.push(' ' + t('neighbor.letter.pray', { url }))
}
if (n > LETTER_CAP) out.push('', t('neighbor.letter.more', { n: (n - LETTER_CAP).toLocaleString('en-US') }))
out.push('', t('neighbor.letter.close'), '', t('neighbor.letter.sign'), '')
return out.join('\n')
}

View file

@ -14,7 +14,8 @@ import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
import { useNeighborT } from './composables/useNeighborT.js'
import { tStatic } from '@map/i18n/index.js'
import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv } from './nearData.js'
import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv, downloadText } from './nearData.js'
import { buildLetter } from './letter.js'
const nt = useNeighborT()
// Full literal keys (the i18n gate reads literals; a key built by concatenation looks missing to it).
@ -151,6 +152,47 @@ function exportSegments() {
})
downloadCsv(u.reached ? `unreached-neighbors-${u.pgid}-E${eLevel.value}.csv` : `near-neighbors-${u.pgid}.csv`, header, rows)
}
// ─── The letter (phase 3): to the selected REACHED group's believers ─────────
// Written from the same list as the CSV (this E-level, closest first). The text is editable before it is
// copied or downloaded; it opens in a dialog that takes focus, keeps it, and closes on Escape.
const letterOpen = ref(false)
const letterText = ref('')
const letterBox = ref(null), letterBtn = ref(null)
const letterStatus = ref('')
let statusToken = null
function openLetter() {
const g = selected.value
if (!g || !g.reached) return
letterText.value = buildLetter({ group: g, neighbors: neighbors.value, level: nt(E_SHORT[eLevel.value]), t: nt, fmt: fmtNum })
letterStatus.value = ''
letterOpen.value = true
nextTick(() => letterBox.value?.querySelector('textarea')?.focus())
}
function closeLetter() { letterOpen.value = false; nextTick(() => letterBtn.value?.focus?.()) }
function flashStatus(msg) {
// Cleared by identity (the token set here), never by comparing the translated text.
const tok = {}; statusToken = tok; letterStatus.value = msg
setTimeout(() => { if (statusToken === tok) letterStatus.value = '' }, 2000)
}
async function copyLetter() {
try { await navigator.clipboard.writeText(letterText.value); flashStatus(tStatic('messages.copied')) }
catch (_) { letterBox.value?.querySelector('textarea')?.select() } // no clipboard (an http page): select it for Ctrl/Cmd-C
}
function downloadLetter() {
const g = selected.value
if (g) downloadText(`letter-${g.pgid}-E${eLevel.value}.txt`, letterText.value)
}
function onLetterKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeLetter(); return }
if (e.key !== 'Tab') return
const f = [...(letterBox.value?.querySelectorAll('textarea,button') || [])]
if (!f.length) return
const root = letterBox.value.getRootNode(), active = root.activeElement
if (e.shiftKey && active === f[0]) { e.preventDefault(); f[f.length - 1].focus() }
else if (!e.shiftKey && active === f[f.length - 1]) { e.preventDefault(); f[0].focus() }
}
watch(() => nbState.pgid, () => { letterOpen.value = false })
function openBridge() { if (selected.value) { nbState.lang = selected.value.lang; nbState.view = 'campaigns' } }
const isBridge = computed(() => !!selected.value && !!data.value?.bridges.some((b) => b.lang === selected.value.lang))
@ -311,6 +353,13 @@ useShadowStyles(`
.mb-res span { font:600 11px/1 inherit; border-radius:999px; padding:4px 8px; border:1px solid #d1d5db; color:#6b7280; }
.mb-res span.yes { border-color:#9fd3b0; color:#0b5e33; background:#eaf7ef; }
.mb-actions { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.mb-modal { position:absolute; inset:0; z-index:40; background:rgba(0,20,50,.45); display:flex; align-items:center; justify-content:center; padding:16px; }
.mb-letter { background:#fff; border-radius:12px; width:min(680px,100%); max-height:100%; display:flex; flex-direction:column; gap:10px; padding:16px 18px; box-shadow:0 12px 32px rgba(0,20,60,.25); }
.mb-letter h2 { margin:0; font:700 16px/1.25 inherit; }
.mb-letter textarea { flex:1 1 auto; min-height:260px; width:100%; box-sizing:border-box; border:1px solid #c9d1dd; border-radius:8px; padding:10px 12px; font:13px/1.55 inherit; color:inherit; resize:vertical; }
.mb-letter textarea:focus { outline:2px solid ${NAVY}; outline-offset:1px; }
.mb-letter .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.mb-letter .status { font-size:12px; color:#0b5e33; margin-inline-start:auto; }
.mb-btn { border:1px solid ${NAVY}; background:${NAVY}; color:#fff; border-radius:8px; padding:8px 11px; font:600 12px/1 inherit; cursor:pointer; }
.mb-btn.ghost { background:#fff; color:${NAVY}; }
.mb-level { margin-top:12px; }
@ -420,7 +469,8 @@ useShadowStyles(`
</div>
<div class="mb-actions">
<button class="mb-btn" @click="exportSegments">{{ $t('neighbor.mob.export') }}</button>
<button v-if="isReachedSel && neighbors.length" ref="letterBtn" class="mb-btn" @click="openLetter">{{ $t('neighbor.letter.button') }}</button>
<button class="mb-btn" :class="{ ghost: isReachedSel && neighbors.length }" @click="exportSegments">{{ $t('neighbor.mob.export') }}</button>
<button v-if="isBridge" class="mb-btn ghost" @click="openBridge">{{ $t('neighbor.mob.openBridge', { lang: selected.langName }) }}</button>
</div>
@ -445,5 +495,18 @@ useShadowStyles(`
</div>
</div>
</aside>
<div v-if="letterOpen && selected" class="mb-modal" @click.self="closeLetter">
<div ref="letterBox" class="mb-letter" role="dialog" aria-modal="true" :aria-label="$t('neighbor.letter.title', { group: selected.name })" @keydown="onLetterKey">
<h2>{{ $t('neighbor.letter.title', { group: selected.name }) }}</h2>
<textarea v-model="letterText" dir="auto" :aria-label="$t('neighbor.letter.title', { group: selected.name })"></textarea>
<div class="row">
<button type="button" class="mb-btn" @click="copyLetter">{{ $t('neighbor.letter.copy') }}</button>
<button type="button" class="mb-btn ghost" @click="downloadLetter">{{ $t('neighbor.letter.download') }}</button>
<button type="button" class="mb-btn ghost" @click="closeLetter">{{ $t('neighbor.about.close') }}</button>
<span class="status" role="status">{{ letterStatus }}</span>
</div>
</div>
</div>
</div>
</template>

View file

@ -82,8 +82,12 @@ export function fmtNum(n) {
// CSV download that works from inside a shadow root (the anchor is appended to document.body).
export function downloadCsv(filename, header, rows) {
const cell = (v) => { const s = String(v ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s }
const text = [header, ...rows].map((r) => r.map(cell).join(',')).join('\n') + '\n'
const url = URL.createObjectURL(new Blob(['' + text], { type: 'text/csv;charset=utf-8' }))
downloadText(filename, [header, ...rows].map((r) => r.map(cell).join(',')).join('\n') + '\n', 'text/csv')
}
// Any text file, UTF-8 with a BOM (so Windows editors read Arabic, Hindi and Chinese correctly).
export function downloadText(filename, text, type = 'text/plain') {
const url = URL.createObjectURL(new Blob(['' + text], { type: type + ';charset=utf-8' }))
const a = document.createElement('a')
a.href = url; a.download = filename; a.style.display = 'none'
document.body.appendChild(a); a.click(); a.remove()