Files
qtalker---/frontend/src/i18n/coverage-check.mjs
Indiana 3b33b5d6f6 feat: presence beyond the tab — PWA manifest and the Ghost Log recap
Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
  index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
  entity, per-kind event counts (one GROUP BY) and up to 3 spirit
  echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
  timestamps, counts and echo lines; LOG link in the séance topbar;
  en/es i18n parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:55 +00:00

87 lines
3.8 KiB
JavaScript

// Throwaway audit: every t() key used in src must exist in en.json and es.json.
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
const SRC = new URL('../', import.meta.url).pathname
const en = JSON.parse(readFileSync(join(SRC, 'i18n/en.json'), 'utf8'))
const es = JSON.parse(readFileSync(join(SRC, 'i18n/es.json'), 'utf8'))
// ---- collect source files (skip tests) ----
const files = []
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p)
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) files.push(p)
}
}
walk(SRC)
const literal = new Set()
const templates = new Set()
for (const f of files) {
const text = readFileSync(f, 'utf8')
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*'([^']+)'/g)) literal.add(m[1])
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*"([^"]+)"/g)) literal.add(m[1])
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*`([^`]+)`/g)) templates.add(m[1])
}
// ---- template domains, by key prefix ----
const RARITY = ['common', 'uncommon', 'rare', 'mythic']
const RULES = [
['seance.connection.', ['connecting', 'open', 'unstable', 'closed']],
['devices.dashboard.connection.', ['connecting', 'open', 'unstable', 'closed']],
['seance.status.', ['attuning', 'summoning', 'gathering']],
['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message
['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']],
['seance.views.', ['board', 'matrix', 'graphs']],
['seance.entity.rarity.', RARITY],
['codex.rarity.', RARITY],
['codex.sort.', ['recent', 'contacted']],
['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']],
['log.counts.', ['question', 'anomaly', 'utterance']],
['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']],
['landing.modes.', ['radio', 'evp', 'wire', 'ouija']],
['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
['shop.interests.', ['quantumBox', 'emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
]
const expanded = new Set(literal)
for (const tpl of templates) {
const rule = RULES.find(([prefix]) => tpl.startsWith(prefix))
if (!rule) throw new Error('no domain for template: ' + tpl)
const expand = (key) => {
const m = key.match(/^(.*?)\$\{[^}]*\}(.*)$/)
if (!m) return expanded.add(key)
for (const v of rule[1]) expand(m[1] + v + m[2])
}
expand(tpl)
}
// ---- lookup helper (with plural fallback) ----
const get = (obj, key) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), obj)
const has = (obj, key) =>
get(obj, key) !== undefined || get(obj, key + '_other') !== undefined || get(obj, key + '_one') !== undefined
const missingEn = [...expanded].filter((k) => !has(en, k)).sort()
const missingEs = [...expanded].filter((k) => !has(es, k)).sort()
// ---- parity check en <-> es (all leaf paths) ----
const leaves = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
v && typeof v === 'object' && !Array.isArray(v) ? leaves(v, prefix + k + '.') : [prefix + k],
)
const enLeaves = new Set(leaves(en))
const esLeaves = new Set(leaves(es))
const enOnly = [...enLeaves].filter((k) => !esLeaves.has(k))
const esOnly = [...esLeaves].filter((k) => !enLeaves.has(k))
console.log('literal keys:', literal.size, '| template keys:', templates.size, '| expanded total:', expanded.size)
console.log('templates:', [...templates].sort())
console.log('missing from en.json:', missingEn.length ? missingEn : 'none')
console.log('missing from es.json:', missingEs.length ? missingEs : 'none')
console.log('en-only leaves:', enOnly.length ? enOnly : 'none')
console.log('es-only leaves:', esOnly.length ? esOnly : 'none')
if (missingEn.length || missingEs.length || enOnly.length || esOnly.length) process.exit(1)
console.log('coverage ok')