refactor(packet): the legend's packet export uses the shared action
Some checks are pending
build-gate / build (push) Waiting to run

Continues the consolidation. The card deck moved to usePosterPrints last tick;
the PACKET export in upg-100-list.vue was still a second hand-maintained copy of
the same shape — its own renderer import, its own strings fetch, its own
language resolution. Two copies of one fact drift.

printPacket now lives in the composable, so the browser export and the
build-time generator share one renderer AND one calling convention. What stays
in the bundle is what genuinely cannot be shared:

  packetRecords  who is in this list — the people-groups API, the selector
                 evaluator, and the pg_slugs special case that 12 of the 27
                 lists need (the evaluator intentionally THROWS on that type)
  packetMapSvg   where this bundle's posters are served from. Absent is fine:
                 the packet prints a cover without the map rather than failing

⚠ AND THE MIGRATION WOULD HAVE MADE THE PACKET PRINT SILENTLY. The composable
reports through its own exportState; this profile has no HUD for that and shows
`status` instead — exportState appears ZERO times in the file. Without a bridge
there would be no "building…", and a failure would vanish with nothing said: the
mechanism working while the feedback does not, which is the exact trap the wall
consolidation hit from the other direction. A watcher now maps the composable's
phases onto `status`.

Verified: check:poster-prints drives the shared action and asserts the packet's
SHAPE — 4 sheets for 2 groups (map + people page + points + wheel), 12 wheel
segments, Mark 5:1-20 present — not merely that bytes were produced. Confirmed
to fail before acceptance by removing the wheel page (3 sheets, 0 segments). The
build-time generator still passes check:packet-render at 8/8, and
check:deck-click still prints 116 cards through the real button.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 05:22:06 +00:00
parent bc18753985
commit a0910bcdef
8 changed files with 197 additions and 149 deletions

View file

@ -164,5 +164,37 @@ export function usePosterPrints(opts = {}) {
} catch (e) { fail(item, kind, e); }
}
return { exportState, dismiss, printPoster, printList, printCardDeck };
// ── 4. THE PRAYER PACKET ──────────────────────────────────────────────────
// The full booklet: map one-pager, people groups 20-per-page with photos, the 12
// prayer points, the prayer wheel. Shares the renderer with the build-time
// generator (library/packet/packet-render.js), so a printed packet and a
// generated one cannot diverge.
//
// The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because
// only the bundle knows where its posters are served from. If it cannot be
// fetched the packet still prints — a cover without the map beats no packet.
async function printPacket(item, { mapSvg = '' } = {}) {
if (busy()) return;
const kind = 'Prayer Packet';
set(item, kind, 'fetch', 'fetching people groups', 30);
try {
const data = await rows(item);
const [{ createPacketRenderer }, strings] = await Promise.all([
import('../packet/packet-render.js'),
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: lang() });
// Photos stay as URLs. The Node generator base64-inlines them so its file is
// self-contained offline; in a browser the print frame fetches them, and
// inlining ~95 images client-side would stall the tab for no gain.
const photos = new Map(
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
);
set(item, kind, 'render', `printing ${data.length} people groups`, 70);
openPrintFrame(R.buildPacket(item, data, photos, mapSvg), { autoprint: true });
done(item, kind);
} catch (e) { fail(item, kind, e); }
}
return { exportState, dismiss, printPoster, printList, printCardDeck, printPacket };
}

View file

@ -81,7 +81,7 @@ import { useI18n } from 'vue-i18n'
// The in-place print helper: a hidden same-page iframe, NOT window.open(). A popup
// is blockable and drops the user out of the map; the iframe opens the print dialog
// right here. Shared so the packet, the poster and the card deck all print the same way.
import { openPrintFrame } from '@map/composables/printInPlace.js'
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { buildLegendNodes, leafZones } from './data/legend-tree.js'
import { resolveAllZones } from './data/zone-geometry.js'
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
@ -344,10 +344,10 @@ const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
// Prayer-packet printing (legend Export > Prayer packet). openPrintFrame is the
// SHARED in-place print helper — a hidden same-page iframe, so the dialog opens in
// this tab rather than a popup a browser may block. The packet's locale follows the
// app's, so a reader gets it in their own language.
// Prayer-packet printing (legend Export > Prayer packet). The ACTION lives in the
// shared usePosterPrints composable — it prints through a hidden same-page iframe,
// so the dialog opens in this tab rather than a popup a browser may block. The
// packet's locale follows the app's, so a reader gets it in their own language.
const { locale: packetLocale } = useI18n()
const DEAF_COUNT = 123
@ -1132,68 +1132,84 @@ async function onLegendExport(kind) {
// Build and print a zone's prayer packet without leaving the page.
// PAGE ORDER IS THE PRODUCT SPEC: map · people groups 20/page with photos ·
// 12 prayer points · prayer wheel.
let packetBusy = false;
async function printPrayerPacket(zoneId) {
if (packetBusy) return; // a second click must not open a second job
packetBusy = true;
try {
const zone = allLists().find((z) => z.id === zoneId);
if (!zone) throw new Error(`no list "${zoneId}"`);
status.value = 'Building the prayer packet…';
// ── THE PRAYER PACKET: the SHARED action ────────────────────────────────────
// This was a second hand-maintained copy of the packet print — its own renderer
// import, its own strings fetch, its own language resolution — beside the card
// deck's, which was consolidated first. Two copies of one fact drift.
//
// It now delegates to library/composables/usePosterPrints.js. What stays HERE is
// what genuinely cannot be shared: resolving which people groups are in a list
// (the people-groups API, the selector evaluator, and the pg_slugs special case
// that 12 of the 27 lists need), and where this bundle's posters are served from.
const packetPrints = usePosterPrints({
records: (zone) => packetRecords(zone),
lang: () => packetLang(),
label: (zone) => zone?.label || zone?.id || '',
});
if (!API_BASE) throw new Error('no API configured');
const fields = 'slug,name,country_code,population,religion,image_url';
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(extractRecords(await resp.json()));
// ⚠ pg_slugs BYPASSES the shared evaluator — it intentionally THROWS on that
// type, and 12 of the 27 lists use it. Same special-case the map and the poster
// wall already apply, so all three agree about who is in a list.
let slugs;
if (zone.selector && zone.selector.type === 'pg_slugs') {
slugs = new Set(zone.selector.values || []);
} else {
const { evaluate } = await import('./data/selector-evaluator.js');
slugs = new Set(evaluate(zone.selector, records)); // evaluate returns a Set
}
const groups = records.filter((r) => slugs.has(r.slug))
.sort((a, b) => String(a.name).localeCompare(String(b.name), packetLang()));
if (!groups.length) throw new Error('that list resolved to no people groups');
// Photos stay as URLs here. The Node generator base64-inlines them so its file
// is self-contained offline; in a browser the print frame can fetch them, and
// inlining 95 images client-side would stall the tab for no gain.
const photos = new Map(groups.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]));
const [{ createPacketRenderer }, strings] = await Promise.all([
import('@map/packet/packet-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
// The map page takes the poster's <svg>. If it cannot be fetched the packet
// still prints — a cover without the map beats no packet at all.
let mapSvg = '';
try {
const base = resolveAssetsBase(profileConfig.value);
const html = await (await fetch(`${base}/posters/${zoneId}.html`, { cache: 'force-cache' })).text();
const svg = html.match(/<svg[\s\S]*?<\/svg>/);
if (svg) mapSvg = `<style>${R.mapCss(html)}</style>`
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
+ `transform:scale(0.62);transform-origin:top center">${svg[0]}</div>`;
} catch { /* cover page without the map */ }
status.value = `Printing ${groups.length} people groups…`;
openPrintFrame(R.buildPacket(zone, groups, photos, mapSvg), { autoprint: true });
status.value = '';
} catch (err) {
status.value = `Packet failed: ${err && err.message ? err.message : err}`;
setTimeout(() => { status.value = ''; }, 4000);
} finally {
packetBusy = false;
// ⚠ BRIDGE THE PROGRESS BACK TO THE SURFACE THIS MAP ACTUALLY SHOWS.
// The composable reports through its own exportState; this profile has no HUD for
// that and displays `status` instead. Without this watcher the packet would print
// SILENTLY — no "building…", and a failure would vanish with nothing said — which
// is the mechanism working while the feedback does not.
watch(packetPrints.exportState, (st) => {
if (!st) { if (/packet/i.test(status.value)) status.value = ''; return; }
if (st.phase === 'error') {
status.value = `Packet failed: ${st.message}`;
setTimeout(() => { if (status.value.startsWith('Packet failed')) status.value = ''; }, 4000);
return;
}
status.value = st.phase === 'done' ? '' : `${st.message}…`;
});
// BUNDLE-SPECIFIC: who is in this list. ⚠ pg_slugs BYPASSES the shared evaluator —
// it intentionally THROWS on that type, and 12 of the 27 lists use it. Same
// special-case the map and the poster wall apply, so all three agree about
// membership.
async function packetRecords(zone) {
if (!API_BASE) throw new Error('no API configured');
const fields = 'slug,name,country_code,population,religion,image_url';
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(extractRecords(await resp.json()));
let slugs;
if (zone.selector && zone.selector.type === 'pg_slugs') {
slugs = new Set(zone.selector.values || []);
} else {
const { evaluate } = await import('./data/selector-evaluator.js');
slugs = new Set(evaluate(zone.selector, records)); // evaluate returns a Set
}
return records.filter((r) => slugs.has(r.slug))
.sort((a, b) => String(a.name).localeCompare(String(b.name), packetLang()));
}
// BUNDLE-SPECIFIC: the map page's <svg>, lifted from this bundle's own poster.
// Absent is fine — the packet prints a cover without the map rather than failing.
async function packetMapSvg(zoneId, R) {
try {
const base = resolveAssetsBase(profileConfig.value);
const html = await (await fetch(`${base}/posters/${zoneId}.html`, { cache: 'force-cache' })).text();
const svg = html.match(/<svg[\s\S]*?<\/svg>/);
if (!svg) return '';
return `<style>${R.mapCss(html)}</style>`
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
+ `transform:scale(0.62);transform-origin:top center">${svg[0]}</div>`;
} catch { return ''; }
}
async function printPrayerPacket(zoneId) {
const zone = allLists().find((z) => z.id === zoneId);
if (!zone) return;
// The map svg needs the renderer's own mapCss/W, so it is resolved here and
// handed in rather than fetched inside the shared action, which knows nothing
// about where this bundle serves its posters.
const [{ createPacketRenderer }, strings] = await Promise.all([
import('@map/packet/packet-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
await packetPrints.printPacket(zone, { mapSvg: await packetMapSvg(zoneId, R) });
}
// Every list across all three cluster buckets, flattened.

File diff suppressed because one or more lines are too long

View file

@ -658,7 +658,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A",
"integrity": "sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g",
"elements": [
"my-upg-100-list-map"
],
@ -678,7 +678,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -692,7 +692,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -706,7 +706,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-VYXU1i1xnVyXAsxLHy565gD+tQp090jzfT6YPRJAYAKAmYIIqr1KqUtQZnO/zv0A" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-Ej+g06Za6ycJVU7jiduRD2BxYmc5BVxj+CgbSilao4zU2CvutTxaX910/k6p033g" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: