From 71d3b7b78692c31ba0596ec88874986ff1b58116 Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 7 Aug 2026 22:17:59 -0400 Subject: [PATCH] E10 Report issue template + E12 /report archive, to spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHASE 0 — the spec, read not recalled. E10: "Hybrid: dark billboard header that survives every client, light paper body Gmail can't wreck. 600px, stacked, no webfont dependence." Content law: "One email per slate day. Top read, what changed, the record. Nothing else." E12: "EVERY ISSUE SHOWS ITS OWN DAY RECORD -- THE ARCHIVE IS A LEDGER TOO." COMPOSED, NOT FORKED. The audit had E10 as PARTIAL, not absent: newsletterService already builds the daily report's CONTENT and lints its voice. What was missing is the designed hybrid SHELL, so reportTemplate.js is a template over that builder rather than a second report -- the same call made for the movement strip, and for the same reason. PHASE 1 — the hybrid shell is an ENGINEERING constraint, not a look, and the tests say so: Gmail strips style blocks, Outlook ignores flexbox, and a dark body renders as a black rectangle in several clients. Hence tables, inline styles, 600px fixed, system fonts, no image required to read, and the green SHIFTS from #00D4A0 to #00A57D on paper because the dark-mode green is unreadable there. FACT-CONTRACTED: a section whose data is absent is OMITTED and NAMED in `omitted`, never filled. There is no code path producing a placeholder figure. The honesty block carries the real numbers -- graded count, cleared-ceiling count, the realized rate against baseline, and that we do not issue A grades. E1'S LAW TRAVELS EVEN THOUGH ITS RENDERING CANNOT. An SVG strip is not reliable in email, so movementText carries the RULE: green only when the move favours the read, and a flat market says FLAT · [N]D rather than showing nothing. NO DESIGNER SAMPLE DATA. Nabers 1,120.5, No 128, DAY RECORD 9-4 are a spec for what a live issue renders; pasting them in would be fabrication carrying a designer's authority and would look entirely correct. Tested. PHASE 2 — /report is now the real archive, REPLACING the S41 redirect to /blog. That redirect existed because the surface did not; E12 built it, so the placeholder is correctly gone and the S41 test is updated rather than worked around. Every row carries its own day record, and an unknown record says UNSETTLED -- never a dash that reads as zero. Empty archive is an honest state. Backend: public read-only /api/report over Redis issues, plus the Next proxy. Both surfaces registered under the reachability guard. A test bug I made twice now: my check for forbidden sample values matched the template's own doc block, which NAMES those values as things never to paste. Documentation worth keeping, so both suites strip comments before matching -- a guard that reads its own warning is not reading the code. WAVE-2 STATUS: E1, F9-F11, E10, E12 done. Still gated -- F5 article media and E16/F8 on the card-system reconciliation; the in-season hub IA on the social chat's formula; E9/E15 on model; E2/E6 on licensing. Read-only throughout; serving fingerprint unchanged including newsletterService; accrual clock unchanged at 0 eligible dates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 --- src/app.js | 3 + src/routes/report.js | 55 +++++++ src/services/report/reportTemplate.js | 169 +++++++++++++++++++++ tests/unit/renderReachability.test.js | 3 + tests/unit/reportTemplate.test.js | 133 ++++++++++++++++ tests/unit/session41AuditFixes.test.js | 11 +- web/src/app/api/report/route.ts | 15 ++ web/src/app/report/page.tsx | 16 +- web/src/components/vyndr/ReportArchive.tsx | 101 ++++++++++++ web/tsconfig.tsbuildinfo | 2 +- 10 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 src/routes/report.js create mode 100644 src/services/report/reportTemplate.js create mode 100644 tests/unit/reportTemplate.test.js create mode 100644 web/src/app/api/report/route.ts create mode 100644 web/src/components/vyndr/ReportArchive.tsx diff --git a/src/app.js b/src/app.js index b711bb9..c833e0b 100644 --- a/src/app.js +++ b/src/app.js @@ -204,6 +204,9 @@ app.use('/api/content', contentRoutes); // Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe // (forwards to the self-hosted Listmonk; graceful no-op without env). app.use('/api/newsletter', require('./routes/newsletter')); +// E12 — The Report archive. Public, read-only; every issue carries its own +// day record, because the archive is a ledger too. +app.use('/api/report', require('./routes/report')); // A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth + // per-tier daily quota inside the router). Values are user-slip values. app.use('/api/slips', require('./routes/slips')); diff --git a/src/routes/report.js b/src/routes/report.js new file mode 100644 index 0000000..ed57ffd --- /dev/null +++ b/src/routes/report.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * /api/report — E12, the issue archive. + * + * The spec's law is one line: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE + * ARCHIVE IS A LEDGER TOO."** So an archive row is not a headline with a date; + * it carries the record that issue's reads actually produced. An archive that + * showed only titles would be a blog, and the point of this one is that it + * cannot quietly bury a bad day. + * + * Public and READ-ONLY. Issues live in Redis under `report:issue:{date}`, + * written by the send path; nothing here touches a serving, model or ledger + * table. + */ + +const express = require('express'); + +const router = express.Router(); +const INDEX_KEY = 'report:index'; +const ISSUE_KEY = (d) => `report:issue:${d}`; + +/** GET /api/report — the archive list, newest first. */ +router.get('/', async (req, res) => { + try { + const { cacheGet } = require('../utils/redis'); + const index = (await cacheGet(INDEX_KEY)) || []; + const issues = Array.isArray(index) ? index : []; + const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 60)); + res.set('Cache-Control', 'public, max-age=300'); + res.json({ + count: issues.length, + // Honest empty state is the caller's to render; we simply report zero. + issues: issues.slice(0, limit), + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** GET /api/report/:date — one issue. 404 rather than an invented shell. */ +router.get('/:date', async (req, res) => { + try { + const { cacheGet } = require('../utils/redis'); + const issue = await cacheGet(ISSUE_KEY(req.params.date)); + if (!issue) return res.status(404).json({ error: 'no issue for that date' }); + res.set('Cache-Control', 'public, max-age=300'); + return res.json(issue); + } catch (e) { + return res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; +module.exports.__internals = { INDEX_KEY, ISSUE_KEY }; diff --git a/src/services/report/reportTemplate.js b/src/services/report/reportTemplate.js new file mode 100644 index 0000000..9639096 --- /dev/null +++ b/src/services/report/reportTemplate.js @@ -0,0 +1,169 @@ +'use strict'; + +/** + * E10 — THE VYNDR REPORT, the designed issue template. + * + * The gap audit had this as PARTIAL, not absent: `newsletterService` already + * builds the daily report's CONTENT and lints its voice. What was missing is the + * designed HYBRID SHELL. So this is a template over that builder, not a second + * report — the same compose-don't-fork call made for the movement strip. + * + * ── THE SPEC'S STRUCTURAL LAW ──────────────────────────────────────────── + * "Hybrid: dark billboard header that survives every client, light paper body + * Gmail can't wreck. 600px, stacked, no webfont dependence." + * + * That is an engineering constraint, not a look. Gmail strips `