E10 Report issue template + E12 /report archive, to spec
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -204,6 +204,9 @@ app.use('/api/content', contentRoutes);
|
|||||||
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
|
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
|
||||||
// (forwards to the self-hosted Listmonk; graceful no-op without env).
|
// (forwards to the self-hosted Listmonk; graceful no-op without env).
|
||||||
app.use('/api/newsletter', require('./routes/newsletter'));
|
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 +
|
// A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth +
|
||||||
// per-tier daily quota inside the router). Values are user-slip values.
|
// per-tier daily quota inside the router). Values are user-slip values.
|
||||||
app.use('/api/slips', require('./routes/slips'));
|
app.use('/api/slips', require('./routes/slips'));
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 `<style>` blocks,
|
||||||
|
* Outlook ignores flexbox, and a dark body renders as a black rectangle in
|
||||||
|
* several clients. Hence: tables, inline styles only, 600px fixed, system-font
|
||||||
|
* stacks, and no image required to read the issue.
|
||||||
|
*
|
||||||
|
* Its content law is equally short: *"One email per slate day. Top read, what
|
||||||
|
* changed, the record. Nothing else."*
|
||||||
|
*
|
||||||
|
* ── FACT-CONTRACTED ──────────────────────────────────────────────────────
|
||||||
|
* Same discipline as the content engine: a section whose data is absent is
|
||||||
|
* OMITTED, never filled. There is no code path producing a placeholder figure,
|
||||||
|
* and the designer's sample values (Nabers 1,120.5, Nº 128, 9-4) are a spec for
|
||||||
|
* what a live issue renders — never content to paste.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { knownNumber } = require('../../utils/known');
|
||||||
|
|
||||||
|
/** Design tokens, inlined because email clients drop stylesheets. */
|
||||||
|
const T = Object.freeze({
|
||||||
|
billboard: '#06060B', // dark header — survives every client
|
||||||
|
paper: '#F7F6F2', // light body — Gmail cannot wreck it
|
||||||
|
ink: '#1A1A22',
|
||||||
|
greenOnDark: '#00D4A0',
|
||||||
|
greenOnPaper: '#00A57D', // the green SHIFTS on paper for contrast
|
||||||
|
rule: '#D8D5CC',
|
||||||
|
dim: '#6B6B76',
|
||||||
|
mono: "'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace",
|
||||||
|
sans: "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif",
|
||||||
|
});
|
||||||
|
const WIDTH = 600;
|
||||||
|
|
||||||
|
const esc = (s) => String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
/** Present means renderable. Zero is present; empty and null are not. */
|
||||||
|
const has = (v) => v !== null && v !== undefined && !(typeof v === 'string' && v.trim() === '')
|
||||||
|
&& !(Array.isArray(v) && v.length === 0) && !(typeof v === 'number' && !Number.isFinite(v));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The movement line — E1's law expressed in email-safe text.
|
||||||
|
*
|
||||||
|
* A `<canvas>` or SVG strip cannot be relied on in email, so the primitive's
|
||||||
|
* RULE travels even though its rendering cannot: green only when the move
|
||||||
|
* favours the read, and a flat market says FLAT rather than showing nothing.
|
||||||
|
*/
|
||||||
|
function movementText(m) {
|
||||||
|
if (!m || !has(m.from) || !has(m.to)) return null;
|
||||||
|
const from = knownNumber(m.from);
|
||||||
|
const to = knownNumber(m.to);
|
||||||
|
if (from === null || to === null) return null;
|
||||||
|
if (from === to) return { text: `FLAT${has(m.days) ? ` · ${m.days}D` : ''}`, colour: T.dim };
|
||||||
|
const favours = m.dir === 'toward';
|
||||||
|
return {
|
||||||
|
text: `${from} → ${to}`,
|
||||||
|
colour: favours ? T.greenOnPaper : '#B0762A', // amber-on-paper for against
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (inner) => `<tr><td style="padding:0 28px;">${inner}</td></tr>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} issue
|
||||||
|
* number, date_label, read_time
|
||||||
|
* top_read { grade, subject, line_text, movement:{from,to,dir,days}, model, best_book, note }
|
||||||
|
* changed [{ time, tag, text }]
|
||||||
|
* record { line, hit, miss, pct|null, note|null }
|
||||||
|
* honesty { graded, cleared_ceiling, ceiling_letter, ceiling_realized, base_rate, unissuable }
|
||||||
|
* @returns {object} { html, text, omitted[] } — omitted names what had no data.
|
||||||
|
*/
|
||||||
|
function renderIssue(issue = {}) {
|
||||||
|
const omitted = [];
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// ── DARK BILLBOARD HEADER ──
|
||||||
|
parts.push(`<tr><td style="background:${T.billboard};padding:26px 28px;">
|
||||||
|
<div style="font-family:${T.mono};font-size:20px;font-weight:800;letter-spacing:2px;color:#FFFFFF;">VYND<span style="color:${T.greenOnDark};">R</span></div>
|
||||||
|
<div style="font-family:${T.mono};font-size:11px;letter-spacing:2px;color:#8A8A96;padding-top:8px;">
|
||||||
|
THE REPORT${has(issue.number) ? ` · Nº ${esc(issue.number)}` : ''}${has(issue.date_label) ? ` · ${esc(issue.date_label)}` : ''}${has(issue.read_time) ? ` · READ TIME ${esc(issue.read_time)}` : ''}
|
||||||
|
</div></td></tr>`);
|
||||||
|
|
||||||
|
// ── TOP READ OF THE DAY ──
|
||||||
|
const tr = issue.top_read;
|
||||||
|
if (tr && has(tr.subject) && has(tr.grade)) {
|
||||||
|
const mv = movementText(tr.movement);
|
||||||
|
parts.push(row(`<div style="padding:22px 0 0;">
|
||||||
|
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};">TOP READ OF THE DAY</div>
|
||||||
|
<div style="font-family:${T.sans};font-size:19px;font-weight:700;color:${T.ink};padding-top:10px;">
|
||||||
|
<span style="font-family:${T.mono};color:${T.greenOnPaper};font-weight:800;">${esc(tr.grade)}</span>
|
||||||
|
${esc(tr.subject)}${has(tr.line_text) ? ` <span style="font-weight:400;">${esc(tr.line_text)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${mv || has(tr.model) || has(tr.best_book) ? `<div style="font-family:${T.mono};font-size:12px;color:${T.dim};padding-top:8px;">
|
||||||
|
${mv ? `<span style="color:${mv.colour};font-weight:700;">${esc(mv.text)}</span>` : ''}
|
||||||
|
${has(tr.model) ? ` · VYNDR ${esc(tr.model)}` : ''}
|
||||||
|
${has(tr.best_book) ? ` · BEST: ${esc(tr.best_book)}` : ''}
|
||||||
|
</div>` : ''}
|
||||||
|
${has(tr.note) ? `<p style="font-family:${T.sans};font-size:14px;line-height:1.6;color:${T.ink};padding-top:12px;margin:0;">${esc(tr.note)}</p>` : ''}
|
||||||
|
</div>`));
|
||||||
|
} else omitted.push('top_read');
|
||||||
|
|
||||||
|
// ── WHAT CHANGED ──
|
||||||
|
if (Array.isArray(issue.changed) && issue.changed.length) {
|
||||||
|
const rows = issue.changed.map((c) => `<div style="padding:7px 0;border-top:1px solid ${T.rule};">
|
||||||
|
<span style="font-family:${T.mono};font-size:11px;color:${T.dim};">${esc(c.time)}</span>
|
||||||
|
<span style="font-family:${T.mono};font-size:11px;font-weight:800;color:${T.ink};"> ${esc(String(c.tag).toUpperCase())}</span>
|
||||||
|
<span style="font-family:${T.sans};font-size:13px;color:${T.ink};"> ${esc(c.text)}</span></div>`).join('');
|
||||||
|
parts.push(row(`<div style="padding:26px 0 0;">
|
||||||
|
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};padding-bottom:6px;">WHAT CHANGED</div>${rows}</div>`));
|
||||||
|
} else omitted.push('changed');
|
||||||
|
|
||||||
|
// ── THE RECORD ── (dark band: survives every client, per spec)
|
||||||
|
const rec = issue.record;
|
||||||
|
if (rec && has(rec.line)) {
|
||||||
|
parts.push(`<tr><td style="background:${T.billboard};padding:18px 28px;margin-top:20px;">
|
||||||
|
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:#8A8A96;">THE RECORD</div>
|
||||||
|
<div style="font-family:${T.mono};font-size:16px;font-weight:800;color:#FFFFFF;padding-top:6px;">${esc(rec.line)}</div>
|
||||||
|
${has(rec.note) ? `<div style="font-family:${T.mono};font-size:11px;color:#8A8A96;padding-top:6px;">${esc(rec.note)}</div>` : ''}
|
||||||
|
</td></tr>`);
|
||||||
|
} else omitted.push('record');
|
||||||
|
|
||||||
|
// ── THE HONESTY BLOCK ── real figures or nothing.
|
||||||
|
const h = issue.honesty;
|
||||||
|
if (h && has(h.graded) && has(h.ceiling_letter)) {
|
||||||
|
parts.push(row(`<div style="padding:22px 0 0;">
|
||||||
|
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};">HONESTLY</div>
|
||||||
|
<p style="font-family:${T.sans};font-size:13px;line-height:1.65;color:${T.ink};padding-top:8px;margin:0;">
|
||||||
|
We graded ${esc(h.graded)} props${has(h.cleared_ceiling) ? ` and ${esc(h.cleared_ceiling)} cleared ${esc(h.ceiling_letter)}` : ''}.
|
||||||
|
${has(h.ceiling_realized) && has(h.base_rate) ? `Those reads land about ${esc(h.ceiling_realized)}% against a ${esc(h.base_rate)}% baseline. ` : ''}
|
||||||
|
${has(h.unissuable) ? `We do not issue ${esc(h.unissuable)} — no band of this model has hit at a rate that would justify one.` : ''}
|
||||||
|
</p></div>`));
|
||||||
|
} else omitted.push('honesty');
|
||||||
|
|
||||||
|
const html = `<!doctype html><html><body style="margin:0;padding:0;background:${T.paper};">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${T.paper};">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="${WIDTH}" cellpadding="0" cellspacing="0" style="width:${WIDTH}px;max-width:${WIDTH}px;background:${T.paper};">
|
||||||
|
${parts.join('\n')}
|
||||||
|
<tr><td style="padding:22px 28px 30px;">
|
||||||
|
<div style="font-family:${T.mono};font-size:10px;color:${T.dim};line-height:1.7;">
|
||||||
|
One email per slate day. Top read, what changed, the record. Nothing else.<br>
|
||||||
|
No outcome is promised. 21+. <a href="{{ UnsubscribeURL }}" style="color:${T.dim};">Unsubscribe</a>.
|
||||||
|
</div></td></tr>
|
||||||
|
</table></td></tr></table></body></html>`;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`THE REPORT${has(issue.number) ? ` No ${issue.number}` : ''}${has(issue.date_label) ? ` — ${issue.date_label}` : ''}`,
|
||||||
|
tr && has(tr.subject) ? `\nTOP READ: ${tr.grade} ${tr.subject}${has(tr.line_text) ? ` ${tr.line_text}` : ''}` : '',
|
||||||
|
tr && has(tr.note) ? tr.note : '',
|
||||||
|
rec && has(rec.line) ? `\nRECORD: ${rec.line}` : '',
|
||||||
|
'\nOne email per slate day. Top read, what changed, the record. Nothing else.',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
return { html, text, omitted, width: WIDTH };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderIssue, movementText, T, WIDTH, has };
|
||||||
@@ -90,6 +90,9 @@ const CONTRACT = [
|
|||||||
{ promise: 'F9-F11 the offseason desk',
|
{ promise: 'F9-F11 the offseason desk',
|
||||||
payload: null, backend: null, adapter: null,
|
payload: null, backend: null, adapter: null,
|
||||||
component: 'web/src/app/offseason/page.tsx' },
|
component: 'web/src/app/offseason/page.tsx' },
|
||||||
|
{ promise: 'E12 the Report archive',
|
||||||
|
payload: null, backend: 'src/routes/report.js', adapter: null,
|
||||||
|
component: 'web/src/app/report/page.tsx' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E10 issue template + E12 archive.
|
||||||
|
*
|
||||||
|
* The failure mode being guarded is the one the hub taught: a design file full
|
||||||
|
* of sample data (Nabers 1,120.5, Nº 128, DAY RECORD 9-4) is a SPEC for what a
|
||||||
|
* live issue renders. Pasting it in would be fabrication carrying a designer's
|
||||||
|
* authority, and it would look completely correct.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const t = require('../../src/services/report/reportTemplate');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
/**
|
||||||
|
* Strip comments first. The template's own doc block NAMES the forbidden sample
|
||||||
|
* values ("Nabers 1,120.5 ... never content to paste") -- documentation worth
|
||||||
|
* keeping, and a check that reads it is reading the warning rather than the
|
||||||
|
* code. Same bug I made on the offseason hub.
|
||||||
|
*/
|
||||||
|
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||||
|
const ARCHIVE = stripComments(fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReportArchive.tsx'), 'utf8'));
|
||||||
|
const TPL = stripComments(fs.readFileSync(path.join(ROOT, 'src/services/report/reportTemplate.js'), 'utf8'));
|
||||||
|
const ARCHIVE_RAW = fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReportArchive.tsx'), 'utf8');
|
||||||
|
|
||||||
|
const full = {
|
||||||
|
number: 3, date_label: 'FRI · AUG 07, 2026', read_time: '3 MIN',
|
||||||
|
top_read: { grade: 'B+', subject: 'Real Player', line_text: 'Over 0.5 Hits',
|
||||||
|
movement: { from: 0.5, to: 0.5, days: 2 }, model: '0.61', best_book: 'DK', note: 'A real note.' },
|
||||||
|
changed: [{ time: '11:42 AM', tag: 'injury', text: 'Something real happened.' }],
|
||||||
|
record: { line: '9–4', note: 'settled' },
|
||||||
|
honesty: { graded: 2140, cleared_ceiling: 70, ceiling_letter: 'B+', ceiling_realized: 66, base_rate: 60, unissuable: 'A+, A, A-' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('no designer sample data ships', () => {
|
||||||
|
it('the template source contains none of the spec\'s sample values', () => {
|
||||||
|
for (const sample of ['Nabers', '1,120.5', '1,188', 'Skenes', 'Wemby', '12,408']) {
|
||||||
|
expect(TPL).not.toContain(sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the archive contains no sample issues', () => {
|
||||||
|
for (const sample of ['Nabers', 'SKENES', '9–4', 'Nº 128']) {
|
||||||
|
expect(ARCHIVE).not.toContain(sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a section with no data is OMITTED, never filled', () => {
|
||||||
|
it('names every omitted section rather than hiding the gap', () => {
|
||||||
|
const out = t.renderIssue({ number: 1, date_label: 'X' });
|
||||||
|
expect(out.omitted).toEqual(expect.arrayContaining(['top_read', 'changed', 'record', 'honesty']));
|
||||||
|
expect(out.html).not.toMatch(/TOP READ OF THE DAY/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty issue still renders a valid, readable shell', () => {
|
||||||
|
const out = t.renderIssue({});
|
||||||
|
expect(out.html).toMatch(/VYND/);
|
||||||
|
expect(out.html).toMatch(/One email per slate day/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every section when the data is there', () => {
|
||||||
|
const out = t.renderIssue(full);
|
||||||
|
expect(out.omitted).toEqual([]);
|
||||||
|
for (const s of ['TOP READ OF THE DAY', 'WHAT CHANGED', 'THE RECORD', 'HONESTLY']) {
|
||||||
|
expect(out.html).toContain(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the E1 movement law travels into email', () => {
|
||||||
|
it('a flat market says FLAT with its day count, not nothing', () => {
|
||||||
|
// A strip cannot render in email, but its RULE still applies.
|
||||||
|
expect(t.movementText({ from: 1.5, to: 1.5, days: 4 })).toMatchObject({ text: 'FLAT · 4D' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('green is reserved for a move that FAVOURS the read', () => {
|
||||||
|
const toward = t.movementText({ from: 1.5, to: 1.2, dir: 'toward' });
|
||||||
|
const against = t.movementText({ from: 1.5, to: 1.8, dir: 'against' });
|
||||||
|
expect(toward.colour).toBe(t.T.greenOnPaper);
|
||||||
|
expect(against.colour).not.toBe(t.T.greenOnPaper);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unreadable movement returns null rather than a guess', () => {
|
||||||
|
expect(t.movementText({ from: null, to: 1.2 })).toBeNull();
|
||||||
|
expect(t.movementText(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the hybrid shell is an engineering constraint, not a look', () => {
|
||||||
|
it('is 600px and table-based — Gmail strips style blocks, Outlook ignores flex', () => {
|
||||||
|
const out = t.renderIssue(full);
|
||||||
|
expect(out.width).toBe(600);
|
||||||
|
expect(out.html).toMatch(/<table role="presentation"/);
|
||||||
|
expect(out.html).not.toMatch(/<style/);
|
||||||
|
expect(out.html).not.toMatch(/display:\s*flex/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the green SHIFTS on paper — the dark-mode green is unreadable there', () => {
|
||||||
|
expect(t.T.greenOnPaper).not.toBe(t.T.greenOnDark);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('depends on no webfont and no image to be readable', () => {
|
||||||
|
const out = t.renderIssue(full);
|
||||||
|
expect(out.html).not.toMatch(/@font-face|fonts\.googleapis/);
|
||||||
|
expect(out.html).not.toMatch(/<img/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the LITERAL unsubscribe token for Listmonk to substitute', () => {
|
||||||
|
expect(t.renderIssue(full).html).toContain('{{ UnsubscribeURL }}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships a plain-text alternative', () => {
|
||||||
|
expect(t.renderIssue(full).text).toMatch(/TOP READ/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E12 — the archive is a ledger too', () => {
|
||||||
|
it('every row renders its own day record', () => {
|
||||||
|
expect(ARCHIVE).toMatch(/DAY RECORD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown record says UNSETTLED, not a dash that reads as zero', () => {
|
||||||
|
expect(ARCHIVE).toMatch(/UNSETTLED/);
|
||||||
|
expect(ARCHIVE_RAW).toMatch(/never a dash that reads as zero/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty archive is an honest state, not a broken page', () => {
|
||||||
|
expect(ARCHIVE).toMatch(/No issues published yet/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,16 @@ describe('Session 41 — broken-route redirects', () => {
|
|||||||
expect(src).toContain('DANGER ZONE');
|
expect(src).toContain('DANGER ZONE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/report redirects to /blog (THE REPORT link target)', () => {
|
it('/report is now the REAL Report archive, no longer a redirect to /blog', () => {
|
||||||
|
// The S41 redirect existed BECAUSE the surface did not. E12 built it, so
|
||||||
|
// the placeholder is correctly gone: /report is the archive, and every row
|
||||||
|
// carries its own day record.
|
||||||
|
const page = read('app/report/page.tsx');
|
||||||
|
expect(page).toContain('ReportArchive');
|
||||||
|
expect(page).not.toContain("redirect('/blog')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('SUPERSEDED — /report used to redirect to /blog', () => {
|
||||||
const src = read('app/report/page.tsx');
|
const src = read('app/report/page.tsx');
|
||||||
expect(src).toContain("redirect('/blog')");
|
expect(src).toContain("redirect('/blog')");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/** Next proxy for the public Report archive (the S25 rule). */
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND}/api/report`, { next: { revalidate: 300 } });
|
||||||
|
const body = await res.json().catch(() => ({ count: 0, issues: [] }));
|
||||||
|
return NextResponse.json(body, { status: res.ok ? 200 : res.status });
|
||||||
|
} catch {
|
||||||
|
// A dead upstream is an EMPTY archive, never a fabricated one.
|
||||||
|
return NextResponse.json({ count: 0, issues: [], degraded: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import ReportArchive from '@/components/vyndr/ReportArchive';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'The VYNDR Report — Archive',
|
||||||
|
description: 'One email per slate day. Top read, what changed, the record. Every issue shows its own day record.',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /report (Session 41 — P0 audit fix).
|
* E12 — /report, the issue archive.
|
||||||
*
|
*
|
||||||
* The MORE dropdown links "THE REPORT" to /report, but the blog lives at
|
* REPLACES the S41 redirect to /blog. That redirect was a placeholder for a
|
||||||
* /blog. Forward there so the link resolves instead of 404ing.
|
* surface that did not exist; this is the surface. The archive is the owned
|
||||||
|
* channel's on-site home and its SEO asset.
|
||||||
*/
|
*/
|
||||||
export default function ReportPage() {
|
export default function ReportPage() {
|
||||||
redirect('/blog');
|
return <ReportArchive />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import SectionHead from '@/components/vyndr/SectionHead';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E12 — the Report archive.
|
||||||
|
*
|
||||||
|
* The spec's law, verbatim: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE
|
||||||
|
* ARCHIVE IS A LEDGER TOO."**
|
||||||
|
*
|
||||||
|
* So every row carries the record that issue's reads produced. An archive of
|
||||||
|
* headlines would let a bad day disappear into a title; this one cannot. A row
|
||||||
|
* whose record is unknown says UNSETTLED rather than showing a dash that reads
|
||||||
|
* like zero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Issue = {
|
||||||
|
number?: number; date: string; date_label?: string; headline?: string;
|
||||||
|
top_read?: { subject?: string; grade?: string } | null;
|
||||||
|
record?: { hit?: number; miss?: number; line?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReportArchive() {
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/report', { cache: 'no-store' });
|
||||||
|
const j = await res.json();
|
||||||
|
if (live) setIssues(Array.isArray(j?.issues) ? j.issues : []);
|
||||||
|
} catch { if (live) setIssues([]); }
|
||||||
|
finally { if (live) setLoading(false); }
|
||||||
|
})();
|
||||||
|
return () => { live = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recordText = (r: Issue['record']) => {
|
||||||
|
if (!r) return null;
|
||||||
|
if (typeof r.line === 'string' && r.line.trim()) return r.line;
|
||||||
|
if (typeof r.hit === 'number' && typeof r.miss === 'number') return `${r.hit}–${r.miss}`;
|
||||||
|
return null; // unknown is unknown; never a dash that reads as zero
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 820, margin: '0 auto', padding: '24px 20px' }}>
|
||||||
|
<h1 className="mono" style={{ fontSize: 22, fontWeight: 800, letterSpacing: '.08em', margin: 0 }}>
|
||||||
|
THE VYNDR REPORT
|
||||||
|
</h1>
|
||||||
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-3)', letterSpacing: '.06em', margin: '8px 0 0' }}>
|
||||||
|
One email per slate day. Top read, what changed, the record. Nothing else.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<SectionHead>ARCHIVE{issues.length ? ` · ${issues.length} ISSUE${issues.length === 1 ? '' : 'S'}` : ''}</SectionHead>
|
||||||
|
|
||||||
|
{loading && <p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 12 }}>loading the archive…</p>}
|
||||||
|
|
||||||
|
{/* HONEST EMPTY STATE — no issues is a real state, not a broken page. */}
|
||||||
|
{!loading && issues.length === 0 && (
|
||||||
|
<div style={{ marginTop: 12, padding: '18px 16px', border: '1px solid var(--line)' }}>
|
||||||
|
<p className="mono" style={{ fontSize: 13, color: 'var(--text-2)', margin: 0, lineHeight: 1.6 }}>
|
||||||
|
No issues published yet. The first one goes out on the next slate day, and it will show its
|
||||||
|
own record like every one after it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && issues.map((iss) => {
|
||||||
|
const rec = recordText(iss.record);
|
||||||
|
return (
|
||||||
|
<div key={iss.date} style={{ display: 'flex', gap: 14, alignItems: 'baseline', padding: '14px 0', borderBottom: '1px solid var(--line)' }}>
|
||||||
|
{typeof iss.number === 'number' && (
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 52 }}>Nº {iss.number}</span>
|
||||||
|
)}
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 74 }}>
|
||||||
|
{iss.date_label || iss.date}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--text-1)', flex: 1 }}>
|
||||||
|
{iss.headline || 'Untitled issue'}
|
||||||
|
</span>
|
||||||
|
{iss.top_read?.subject && (
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>
|
||||||
|
TOP READ · {iss.top_read.subject.toUpperCase()}
|
||||||
|
{iss.top_read.grade ? ` ${iss.top_read.grade}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* THE LAW: every issue shows its own day record. */}
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 800, color: rec ? 'var(--text-1)' : 'var(--text-3)' }}>
|
||||||
|
{rec ? `DAY RECORD ${rec}` : 'UNSETTLED'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user