diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index 348c2bfff..8a722b748 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -25,7 +25,7 @@ The scripts these commands are grounded in (from this branch's `package.json`): // claiming to be ground truth, and this one had silently stopped being it. "dev": "bun x vite", "build": "bash internal/build-lock.sh", -"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", +"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "preview": "bun x vite preview", "serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public", "cap:sync": "./node_modules/.bin/cap sync android", diff --git a/internal/check-kpi-unit-once.mjs b/internal/check-kpi-unit-once.mjs new file mode 100644 index 000000000..eee606189 --- /dev/null +++ b/internal/check-kpi-unit-once.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * check-kpi-unit-once.mjs — a KPI must print its unit exactly once. + * + * THE DEFECT THIS CATCHES. The dashboard rendered "99% % UNREACHED" and "1% ADOPTED %". + * `kpiText()` returns `k.value` unchanged when `kind === 'pct'`, and that value already carries its + * percent sign; the labels in dashboard.config.js carried a second one ('% unreached', 'Adopted %'). + * + * ★ WHY IT SURVIVED REVIEW, which is the point of gating it. Each half is correct on its own. A + * label reading '% unreached' is a perfectly reasonable label. A formatter that returns a + * ready-formatted percentage is a perfectly reasonable formatter. Only the COMPOSITION is wrong, + * and nothing in either file mentions the other. It was found by reading the rendered screenshot — + * no amount of reading either source file would have shown it. + * + * WHAT IT ASSERTS. No `kind: 'pct'` KPI label contains a '%'. That is the whole rule, and it is + * one-sided on purpose: the VALUE is the right place for the unit (it is produced by the formatter + * that knows the number), so the label must not repeat it. If a future KPI kind formats without a + * unit, add that kind here rather than relaxing the rule. + * + * Parses the config as TEXT rather than importing it. dashboard.config.js is plain data, but the + * research-dashboard module graph reaches library/colors/_registry.js, which uses Vite's + * `import.meta.glob` and throws under plain node — the trap documented in + * documentation/reference.md. Text parsing keeps the gate hermetic: no build, no browser, no + * network, so it can live in build:unlocked. + * + * NEGATIVE-TESTED: restoring either label ('% unreached' or 'Adopted %') fails by name with exit 1. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const CFG = join(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js') + +if (!existsSync(CFG)) { + console.error('KPI-UNIT: FAIL — dashboard.config.js not found at ' + CFG.replace(ROOT + '/', '')) + process.exit(1) +} +const src = readFileSync(CFG, 'utf8') + +// Strip comments before scanning. This file now DOCUMENTS the defect in a comment right above the +// fixed line, and a naive scan matches that prose and fails on the explanation of the fix — the +// same self-reference that has bitten three gates in this repo already. +const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + +// Each KPI entry is a single-line-ish object literal: { id: '…', label: '…', kind: '…', … } +const entries = [...code.matchAll(/\{[^{}]*\bid:\s*'([^']+)'[^{}]*\}/g)] +const offenders = [] +let pctSeen = 0 + +for (const m of entries) { + const body = m[0] + const kind = (body.match(/\bkind:\s*'([^']+)'/) || [])[1] + if (kind !== 'pct') continue + pctSeen++ + const label = (body.match(/\blabel:\s*'([^']*)'/) || [])[1] + if (label && label.includes('%')) offenders.push({ id: m[1], label }) +} + +if (!pctSeen) { + // A gate that finds nothing to check must say so rather than printing a green PASS. If the + // config is refactored so this regex stops matching, silence would read as success forever. + console.error("KPI-UNIT: FAIL — found no `kind: 'pct'` KPI entries to check.") + console.error(' Either the config changed shape or the parse broke. A gate with nothing to') + console.error(' assert is not passing; it is not running.') + process.exit(1) +} + +if (offenders.length) { + console.error(`KPI-UNIT: FAIL — ${offenders.length} percent KPI label(s) carry a redundant '%'.\n`) + for (const o of offenders) { + console.error(` id "${o.id}" has label '${o.label}'`) + console.error(` renders as "% ${o.label.toUpperCase()}" — the unit twice.`) + } + console.error("\n kpiText() returns k.value unchanged for kind:'pct', and that value already") + console.error(' carries its percent sign. The VALUE owns the unit; the LABEL says what is being') + console.error(' measured. Drop the % from the label.') + process.exit(1) +} + +console.log(`KPI-UNIT: PASS — ${pctSeen} percent KPI(s), none repeating the unit in its label.`) +console.log(' Each half of this bug looks correct alone; only the composition was wrong, which is') +console.log(' why it was found by reading a screenshot rather than by reading either source file.') diff --git a/package.json b/package.json index 52b2d37c2..95531e846 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "check:dxlife": "node internal/check-dxlife-untouched.mjs", "check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).", "check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test", - "build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", + "build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", @@ -97,7 +97,8 @@ "check:script-flags": "node internal/check-script-flags-reachable.mjs", "check:shell-scroll": "node internal/verify-shell-scroll.mjs", "check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs", - "check:one-palette": "node internal/check-one-palette.mjs" + "check:one-palette": "node internal/check-one-palette.mjs", + "check:kpi-unit": "node internal/check-kpi-unit-once.mjs" }, "devDependencies": { "@capacitor/android": "^7.6.9", diff --git a/plugin-designs/research-dashboard/dashboard.config.js b/plugin-designs/research-dashboard/dashboard.config.js index 79539b1fa..0c7a0419a 100644 --- a/plugin-designs/research-dashboard/dashboard.config.js +++ b/plugin-designs/research-dashboard/dashboard.config.js @@ -174,11 +174,16 @@ export default { kpis: [ { id: 'total', label: 'UPGs in scope', kind: 'count', showDelta: true }, { id: 'pop', label: 'Σ population', kind: 'sum', field: 'population' }, - { id: 'unreached', label: '% unreached', kind: 'pct', + // ★ NO '%' IN A `kind:'pct'` LABEL. kpiText() returns k.value unchanged for pct, and that + // value ALREADY carries its percent sign — so a label that also carries one renders + // "99% % UNREACHED". Found by reading the rendered screenshot, not by reading the code: + // each half looks correct in isolation and only the composition is wrong, which is why it + // survived review. The label says WHAT is measured; the value says how much. + { id: 'unreached', label: 'unreached', kind: 'pct', test: (r) => !/engaged/i.test(lbl(r.engagement_status)) || /unengaged/i.test(lbl(r.engagement_status)) }, { id: 'countries', label: 'countries touched', kind: 'distinct', field: 'country_code', via: 'geoIso' }, { id: 'praying', label: 'Σ praying', kind: 'sum', field: 'people_praying' }, - { id: 'adopted', label: 'Adopted %', kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 }, + { id: 'adopted', label: 'adopted', kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 }, ], // ── table columns ── (DashboardDataTable) @@ -220,6 +225,18 @@ export default { { key: 'engagement', label: 'Engagement', get: (r) => lbl(r.engagement_status), type: 'str', w: 0.97 }, ], + // ── map ── (DashboardMapPanel) + // `paper` = "near-white minimal — data reads like print". The style registry + // (library/styles/registry.js) already listed `research-dashboard` as this style's own + // `bestFor` target, and already resolves the name on BOTH engines — Mapbox light-v11 and + // the keyless MapLibre Positron. It was simply never set here, so the panel silently took + // each engine's default instead. CARTO ships Positron expressly as the basemap "good for + // POINT data", which is exactly this map's job, so this is an alignment, not a new choice. + // One line, and it survives the MapLibre swap unchanged. + map: { + mapStyle: 'paper', + }, + // ── charts ── (DashboardChartsRail) charts: [ { id: 'religion', type: 'donut', facet: 'religion', title: 'Religion', topN: 8 },