Adds /devices — pair an ESP32 sensor node (POST /api/device), reveal its
raw pairing token exactly once with a hard-to-miss "cannot be shown again"
warning (styled like a real API-key-reveal UI), then a live dashboard
subscribing to /ws/device-feed: the initial `devices` frame seeds paired
devices, and `reading` frames update one row per distinct sensor_type in
place. sensor_type/value/unit are rendered fully generically per the
contract (free-form, open-ended) — an unrecognized sensor_type renders
safely with no special-casing.
- frontend/src/lib/deviceFeed.ts: reconnecting WS client for
/ws/device-feed, mirroring VeilSocket's backoff shape (receive-only, no
outbox needed).
- frontend/src/pages/DevicesPage.{tsx,css}: pairing form + one-time token
reveal + live device-card grid. Leans into "hacker" terminal styling
(monospace readouts, terminal device cards) over the app's usual gothic
chrome, per the design spec, while keeping the existing dark/violet
palette tokens from App.css.
- Route + nav link wired into App.tsx / SeancePage.tsx.
- i18n: new `devices.*` / `nav.devices` keys in en.json + es.json; added a
coverage-check.mjs domain rule for the dynamic connection-state key,
mirroring the existing `seance.connection.` rule.
Tests: deviceFeed.test.ts (backoff/reconnect/frame delivery) and
DevicesPage.test.tsx (empty state, name validation, one-time token reveal
and dismissal, live frame updates in place without duplicating rows,
multi-device/multi-sensor rendering, and a mocked unrecognized sensor_type
that must not crash). Full suite: 154 passed (137 pre-existing + 17 new).
`npx tsc -b` and `npm run build` both clean.
Assumption (undocumented in spec): POST /api/device's JSON response shape
is inferred as `{id, name, token, last_seen_at}` since the Contract section
only describes the endpoint in prose. GET /api/device is intentionally not
called — the live dashboard is fully seeded by /ws/device-feed's initial
`devices` frame per the contract, so it's redundant for this page's scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
86 lines
3.8 KiB
JavaScript
86 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']],
|
|
['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')
|