Read History: the graph becomes something a person can read

The ancestry contract existed and nothing rendered it. This turns it into a
product surface, and stops there.

WHERE IT GOES. `LedgerCard` is the terminal surface — there is no Ledger detail
view — so the history expands in place inside the card, matching the board's
existing "ALL N READS" affordance rather than adding a page, a modal or a
navigation category. It loads on first open, not on render.

WHAT IT IS CALLED. "Read History". Lineage stays engineering vocabulary; a test
asserts no rendered string contains lineage, natural key, ordinal, digest,
graph, origin or recapture. ORIGIN reads "First published", REVISION reads
"Updated", and the persisted `change_type` supplies "The price moved" / "The
read changed" / "The read and the price changed". `change_type` is stored and
trustworthy, so naming it is reporting; no field-level diff is persisted, so
none is invented.

THE SEPARATION, WHICH IS THE LOAD-BEARING PART. The grade strike means the
LETTER changed. A history entry means the published CLAIM changed — often the
price, sometimes the read, frequently with no letter change at all. The history
uses no strike-through, shares no styling, and a tooth fails if it ever does.
The ledger card's own strike is untouched.

RECAPTURES ARE SUMMARISED, NEVER DESTROYED. A republishing board can produce
hundreds of "unchanged" entries that bury the two that matter, so the UI
collapses them to a count. The API still returns every one.

WHAT EACH ENTRY SHOWS came from the acceptance run: without the published grade
the history can say a Read changed but never what it changed to, which answers
none of the questions someone opens a history to ask. `published_grade` /
`published_p_win` / `published_line` are read off the SAME retained row the
lineage action sits on — the authoritative record of the published claim, with
lineage only the pointer to it. A tooth fails if they are ever synthesised from
lineage metadata.

TWO DEFECTS THE PRODUCTION ACCEPTANCE FOUND, NEITHER OF WHICH A TEST HAD.

`ledger_entries.id` is a UUID and the route parsed it with Number.parseInt, so
every real row would have 400'd. The unauthenticated probe that "proved the
route was live" returns 401 from requireAuth before the handler runs, so it
could never have seen this.

And `chase burns / hits_allowed / under / 4.5` has SIX published captures and
four lineage actions — two were published while the writer was off. The response
said `chronology_complete: true` while showing four of six. Completeness now
counts published-but-never-recorded states as well as attempted-and-failed ones,
kept as separate numbers because the causes differ and the copy says which.

Suite 399/5,544/0 · tsc 0 · web build 0 · teeth 15/15.
Lint is not runnable in this repository (`next lint` removed in Next 16, no
eslint.config.*) — pre-existing, untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
Kev
2026-08-31 00:42:37 -04:00
parent cdb01f97a2
commit 9dda9df132
12 changed files with 734 additions and 5 deletions
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/* READ-HISTORY UX TEETH. Inject, prove the suite CATCHES it, restore exactly.
Every tooth asserts its injection landed first — a green teeth run means the
test is missing, not that the risk is absent. */
const fs = require('fs'); const path = require('path'); const cp = require('child_process');
const F = (p) => path.join(__dirname, p);
const U = 'tests/unit/readHistoryUx.test.js';
const C = 'tests/unit/lineageCloseout.test.js';
const P = 'tests/unit/lineageProductization.test.js';
const TEETH = [
{ n: 1, name: 'the history reuses the grade-revision strike',
file: 'web/src/components/vyndr/ReadHistory.tsx',
from: "fontSize: 11, fontWeight: 700, color: TONE[e.tone]",
to: "fontSize: 11, fontWeight: 700, textDecoration: 'line-through', color: TONE[e.tone]",
suite: U },
{ n: 2, name: 'legacy renders as a complete empty history',
file: 'web/src/lib/readHistory.js',
from: " if (state !== 'LINEAGE_AVAILABLE') {",
to: " if (state === 'NEVER') {",
suite: U },
{ n: 3, name: 'LINEAGE_UNAVAILABLE silently reads as legacy',
file: 'web/src/lib/readHistory.js',
from: " LINEAGE_UNAVAILABLE: {\n headline: 'History unavailable',",
to: " LINEAGE_UNAVAILABLE: {\n headline: 'History not recorded',",
suite: U },
{ n: 4, name: 'an incomplete chronology claims to be complete',
file: 'web/src/lib/readHistory.js',
from: " const incomplete = a.chronology_complete === false || unrecorded > 0 || before > 0;",
to: " const incomplete = false;",
suite: U },
{ n: 5, name: 'invalid lineage elects a head anyway',
file: 'src/services/read/readAncestry.js',
from: " state: ANCESTRY_STATE.INVALID_LINEAGE,\n reason: chron.reason,",
to: " state: ANCESTRY_STATE.LINEAGE_AVAILABLE,\n reason: chron.reason,",
suite: `${C} ${P}` },
{ n: 6, name: 'the public route is keyed on the raw natural key',
file: 'src/routes/ancestry.js',
from: "const LEDGER_COLUMNS = 'id, sport",
to: "const NATURAL = 'read_natural_key';\nconst LEDGER_COLUMNS = 'id, sport",
suite: `${U} ${C}` },
{ n: 7, name: 'ancestry succeeds unauthenticated',
file: 'src/routes/ancestry.js',
from: "router.get('/ledger/:id', requireAuth, async (req, res) => {",
to: "router.get('/ledger/:id', async (req, res) => {",
suite: `${U} ${C}` },
{ n: 8, name: 'the UI bypasses the API client with ad hoc fetch',
file: 'web/src/components/vyndr/ReadHistory.tsx',
from: " const { ok, status, data } = await getReadAncestry(ledgerId, token);",
to: " const r = await fetch('/api/ancestry/ledger/' + ledgerId); const ok = r.ok, status = r.status, data = await r.json();",
suite: U },
{ n: 9, name: 'recaptures are destroyed instead of summarised',
file: 'web/src/lib/readHistory.js',
from: " if (list.length === 0) return null;\n return {\n kind: 'recapture-summary',",
to: " if (list.length >= 0) return null;\n return {\n kind: 'recapture-summary',",
suite: U },
{ n: 10, name: 'published values are synthesised from lineage metadata',
file: 'src/services/read/readAncestry.js',
from: " published_grade: row.grade ?? null,",
to: " published_grade: row.claim_digest ? row.claim_digest.slice(0, 1) : null,",
suite: U },
{ n: 11, name: 'the existing grade strike is removed from the ledger card',
file: 'web/src/app/ledger/page.tsx',
from: "textDecoration: 'line-through'",
to: "textDecoration: 'none'",
suite: U },
{ n: 12, name: 'persistent shadow becomes lease-dependent',
file: 'src/services/lineageWriteMode.js',
from: " return Object.prototype.hasOwnProperty.call(evaluate(now).modes, sp);",
to: " return canary.isEnabled(sp, now);",
suite: P },
{ n: 13, name: 'the coverage monitor denominator reads lineage_action',
file: 'src/services/lineageCoverage.js',
from: " if (!r || r.published !== true) continue;",
to: " if (!r || r.published !== true || r.lineage_action == null) continue;",
suite: `${C} ${P}` },
{ n: 14, name: 'participant identity convergence reverted',
file: 'src/services/model/participantIdentity.js',
from: " const canonical = p.mlb_person_id != null ? p.canonical_player_name : null;",
to: " const canonical = null;",
suite: `${P} tests/unit/participantIdentityConvergence.test.js` },
{ n: 15, name: 'the retention collision gate is weakened',
file: 'src/services/retentionService.js',
from: " if (seen.has(id)) collisions += 1;",
to: " if (false) collisions += 1;",
suite: `${P} tests/unit/canonicalParticipant.test.js` },
];
let caught = 0; const missed = [];
for (const t of TEETH) {
const p = F(t.file);
const orig = fs.readFileSync(p, 'utf8');
if (!orig.includes(t.from)) { missed.push(`${t.n} ANCHOR NOT FOUND: ${t.name}`); continue; }
const broken = orig.replace(t.from, t.to);
if (broken === orig) { missed.push(`${t.n} INJECTION NO-OP: ${t.name}`); continue; }
fs.writeFileSync(p, broken);
const present = fs.readFileSync(p, 'utf8').includes(t.to);
let failed = false;
try {
cp.execSync(`npx jest ${t.suite} --testPathIgnorePatterns "/node_modules/" --silent --forceExit`,
{ cwd: __dirname, stdio: 'pipe' });
} catch { failed = true; }
fs.writeFileSync(p, orig);
if (fs.readFileSync(p, 'utf8') !== orig) { console.error('RESTORE FAILED', t.file); process.exit(2); }
if (!present) { missed.push(`${t.n} INJECTION NOT PRESENT: ${t.name}`); continue; }
if (failed) { caught += 1; console.log(` tooth ${String(t.n).padStart(2)} CAUGHT ${t.name}`); }
else { missed.push(`${t.n} NOT CAUGHT: ${t.name}`); console.log(` tooth ${String(t.n).padStart(2)} MISSED ${t.name}`); }
}
console.log(`\n ${caught}/${TEETH.length} teeth landed`);
if (missed.length) { console.log(' PROBLEMS:'); missed.forEach((m) => console.log(' ', m)); process.exit(1); }
+30 -1
View File
@@ -73,6 +73,15 @@ function project(row) {
snapshot_id: row.snapshot_id ?? null, snapshot_id: row.snapshot_id ?? null,
model_version: row.model_version ?? null, model_version: row.model_version ?? null,
code_sha: row.code_sha ?? null, code_sha: row.code_sha ?? null,
// WHAT WAS PUBLISHED AT THIS POINT, read off the SAME retained row the
// lineage action sits on. This is the authoritative record of the published
// claim — lineage is only the pointer to it, and nothing here is
// reconstructed from lineage metadata. Without it the history can say a
// Read changed but never what it changed to, which answers none of the
// questions a person actually opens a history to ask.
published_grade: row.grade ?? null,
published_p_win: row.p_win === null || row.p_win === undefined ? null : Number(row.p_win),
published_line: row.line === null || row.line === undefined ? null : Number(row.line),
}); });
} }
@@ -147,11 +156,29 @@ function classifyAncestry(rows) {
// over; it is the same failure this surface exists to make visible, just at // over; it is the same failure this surface exists to make visible, just at
// a finer grain than a whole Read. // a finer grain than a whole Read.
const unrecorded = attempted.filter((r) => !isValidAction(r)).length; const unrecorded = attempted.filter((r) => !isValidAction(r)).length;
// A SECOND WAY THE HISTORY CAN HAVE A HOLE, found by the production
// acceptance run and not by any test.
//
// `chase burns / hits_allowed / under / 4.5` has SIX published captures and
// four lineage actions: two were published while the writer was switched off,
// so they carry no publication stamp at all. They are not failed attempts —
// nothing tried to record them — but they ARE published states the reader
// cannot see, and answering `complete` while showing four of six is an
// overstatement a user would reasonably be misled by.
//
// Counted separately from `unrecorded_publications` because the causes differ
// (attempted-and-failed vs never-attempted) and collapsing them would hide
// which one happened. Completeness now requires BOTH to be zero.
const publishedWithoutLineage = list.filter(
(r) => r.published === true && !isValidAction(r)
&& (r.publication_id === null || r.publication_id === undefined),
).length;
return Object.freeze({ return Object.freeze({
state: ANCESTRY_STATE.LINEAGE_AVAILABLE, state: ANCESTRY_STATE.LINEAGE_AVAILABLE,
reason: null, reason: null,
chronology_complete: unrecorded === 0, chronology_complete: unrecorded === 0 && publishedWithoutLineage === 0,
unrecorded_publications: unrecorded, unrecorded_publications: unrecorded,
publications_before_recording: publishedWithoutLineage,
read_id: chron.read_id, read_id: chron.read_id,
read_natural_key: chain[0].read_natural_key, read_natural_key: chain[0].read_natural_key,
revision_count: chron.revision_count, revision_count: chron.revision_count,
@@ -170,6 +197,8 @@ const ANCESTRY_COLUMNS = [
'id', 'snapshot_id', 'sport', 'game_date', 'player_key', 'stat', 'side', 'line', 'id', 'snapshot_id', 'sport', 'game_date', 'player_key', 'stat', 'side', 'line',
'canonical_event_id', 'event_occurrence', 'captured_at', 'published', 'published_at', 'canonical_event_id', 'event_occurrence', 'captured_at', 'published', 'published_at',
'publication_id', 'model_version', 'code_sha', 'publication_id', 'model_version', 'code_sha',
// The published claim itself, from the authoritative retained row.
'grade', 'p_win',
...VALID_ACTION_FIELDS, 'supersedes_id', 'recaptures_id', 'change_type', ...VALID_ACTION_FIELDS, 'supersedes_id', 'recaptures_id', 'change_type',
].join(', '); ].join(', ');
+31
View File
@@ -309,6 +309,37 @@ describe('ancestry response states, end to end', () => {
expect(ancestry.classifyAncestry([]).state).toBe(A.NOT_FOUND); expect(ancestry.classifyAncestry([]).state).toBe(A.NOT_FOUND);
}); });
test('a published capture the writer never recorded also breaks completeness', () => {
// The real `chase burns / hits_allowed / under / 4.5` shape: published
// captures from a window when the writer was off. Not a failed attempt —
// nothing tried — but still a published state the reader cannot see.
const out = ancestry.classifyAncestry([
act({ id: 1 }),
{ id: 2, published: true, publication_id: null },
]);
expect(out.state).toBe(A.LINEAGE_AVAILABLE);
expect(out.chronology_complete).toBe(false);
expect(out.publications_before_recording).toBe(1);
expect(out.unrecorded_publications).toBe(0); // distinct cause, distinct count
});
test('the two kinds of hole are counted separately', () => {
const out = ancestry.classifyAncestry([
act({ id: 1 }),
{ id: 2, published: true, publication_id: 'p2' }, // attempted, failed
{ id: 3, published: true, publication_id: null }, // never attempted
]);
expect(out.unrecorded_publications).toBe(1);
expect(out.publications_before_recording).toBe(1);
expect(out.chronology_complete).toBe(false);
});
test('a genuinely whole history still reports complete', () => {
const out = ancestry.classifyAncestry([act({ id: 1, published: true })]);
expect(out.chronology_complete).toBe(true);
expect(out.publications_before_recording).toBe(0);
});
test('other sports are unaffected — nothing here is sport-specific product logic', () => { test('other sports are unaffected — nothing here is sport-specific product logic', () => {
const out = ancestry.classifyAncestry([act({ id: 1 })]); const out = ancestry.classifyAncestry([act({ id: 1 })]);
expect(out.state).toBe(A.LINEAGE_AVAILABLE); expect(out.state).toBe(A.LINEAGE_AVAILABLE);
+10 -1
View File
@@ -367,7 +367,16 @@ describe('NON-AUTHORITY — lineage cannot serve the product', () => {
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`); for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
} }
} }
expect(hits).toEqual([]); // ONE product-side consumer of the ancestry CONTRACT is permitted, by name.
// It reads two fields off an API RESPONSE — never from the store — which is
// the whole point of the additive contract, and the assertion below keeps
// that distinction honest: if this file ever learns to query, it fails.
const ANCESTRY_CONSUMER = 'web/src/lib/readHistory.js';
const consumer = fs.readFileSync(path.join(ROOT, ANCESTRY_CONSUMER), 'utf8');
for (const q of ['supabase', 'from(', 'select(', 'createClient']) {
expect(consumer).not.toContain(q);
}
expect(hits.filter((h) => !h.startsWith(ANCESTRY_CONSUMER))).toEqual([]);
}); });
test('the served envelope comes from the cache, not from model_snapshots', () => { test('the served envelope comes from the cache, not from model_snapshots', () => {
+200
View File
@@ -0,0 +1,200 @@
/**
* READ HISTORY — the product surface for the one truth lineage owns.
*
* The load-bearing separation this suite defends: a lineage REVISION means the
* published CLAIM changed; the grade strike means the LETTER changed. They must
* never look, read, or be styled like the same event.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..');
const rh = require('../../web/src/lib/readHistory');
const ancestry = require('../../src/services/read/readAncestry');
const COMPONENT = fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReadHistory.tsx'), 'utf8');
const ROUTE = fs.readFileSync(path.join(ROOT, 'src/routes/ancestry.js'), 'utf8');
const LEDGER_PAGE = fs.readFileSync(path.join(ROOT, 'web/src/app/ledger/page.tsx'), 'utf8');
const chain = (over = {}) => ({
ancestry: {
state: 'LINEAGE_AVAILABLE',
revisions: [
{ snapshot_row_id: 1, revision_ordinal: 0, action: 'ORIGIN', change_type: 'INITIAL_PUBLICATION', captured_at: '2026-08-30T01:02:44Z', published_grade: 'C', published_p_win: 0.567, published_line: 4.5 },
{ snapshot_row_id: 2, revision_ordinal: 1, action: 'REVISION', change_type: 'MARKET_REPRICE', captured_at: '2026-08-30T03:01:43Z', published_grade: 'C', published_p_win: 0.567, published_line: 4.5 },
{ snapshot_row_id: 3, revision_ordinal: 2, action: 'REVISION', change_type: 'BELIEF_CHANGE', captured_at: '2026-08-31T03:47:09Z', published_grade: 'C-', published_p_win: 0.515, published_line: 4.5 },
],
recaptures: [],
chronology_complete: true,
unrecorded_publications: 0,
publications_before_recording: 0,
...over,
},
});
describe('the history reads as history, not as a database', () => {
test('ORIGIN, REVISION and change types get plain words', () => {
const v = rh.mapAncestry(chain());
expect(v.available).toBe(true);
expect(v.entries.map((e) => e.label)).toEqual(['First published', 'Updated', 'Updated']);
expect(v.entries[1].what).toBe('The price moved');
expect(v.entries[2].what).toBe('The read changed');
});
test('the newest entry is marked current, and it is worded, not only coloured', () => {
const v = rh.mapAncestry(chain());
expect(v.entries[2].is_current).toBe(true);
expect(v.entries[0].is_current).toBe(false);
expect(COMPONENT).toMatch(/CURRENT/);
});
test('each entry carries what was PUBLISHED then, from the authoritative row', () => {
const v = rh.mapAncestry(chain());
expect(v.entries.map((e) => e.grade)).toEqual(['C', 'C', 'C-']);
// Sourced from model_snapshots via the projection, never from lineage metadata.
const svc = fs.readFileSync(path.join(ROOT, 'src/services/read/readAncestry.js'), 'utf8');
expect(svc).toMatch(/published_grade: row\.grade/);
});
test('no backend vocabulary reaches the user', () => {
// Only the RENDERED strings. The lookup keys are ORIGIN/REVISION/RECAPTURE
// by design — they are how the code addresses an action, not what a person
// reads, and asserting over them tests the wrong thing.
const rendered = [
...Object.values(rh.STATE_COPY).filter(Boolean).flatMap((c) => [c.headline, c.detail]),
...Object.values(rh.ACTION_COPY).map((c) => c.label),
...Object.values(rh.CHANGE_COPY),
rh.summariseRecaptures([{}, {}]).label,
].join(' | ').toLowerCase();
for (const w of ['lineage', 'natural key', 'ordinal', 'digest', 'graph', 'recapture', 'origin', 'chronology']) {
expect(rendered).not.toContain(w);
}
});
test('the feature is not called Lineage anywhere a user can see', () => {
const visible = COMPONENT.split('\n').filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//') && !l.trim().startsWith('/*'));
expect(visible.join('\n')).not.toMatch(/>[^<]*[Ll]ineage[^<]*</);
expect(COMPONENT).toMatch(/READ HISTORY/);
});
});
describe('RECAPTURE is summarised, never destroyed', () => {
test('repeated unchanged observations collapse to one honest line', () => {
const v = rh.mapAncestry(chain({ recaptures: [{ captured_at: 'a' }, { captured_at: 'b' }, { captured_at: 'c' }] }));
expect(v.recaptureSummary.count).toBe(3);
expect(v.recaptureSummary.label).toMatch(/Rechecked 3 times with no change/);
// The chronology itself is unchanged — nothing was removed from the data.
expect(v.entries).toHaveLength(3);
});
test('the API still returns every recapture — the UI summarises, the contract does not', () => {
const act = (o) => ({
id: 1, read_id: 'r1', read_natural_key: 'k', lineage_action: 'ORIGIN', claim_digest: 'd',
revision_ordinal: 0, lineage_state: 's', lineage_version: 'lin@1',
claim_schema_version: 'claim@1', digest_algorithm_version: 'x', publication_id: 'p', ...o,
});
const out = ancestry.classifyAncestry([
act({ id: 1 }),
act({ id: 2, lineage_action: 'RECAPTURE' }),
act({ id: 3, lineage_action: 'RECAPTURE' }),
]);
expect(out.recaptures).toHaveLength(2);
expect(out.recapture_count).toBe(2);
});
});
describe('truth states', () => {
test('legacy says history was not recorded — not that nothing happened', () => {
const v = rh.mapAncestry({ ancestry: { state: 'LEGACY_UNVERIFIED' } });
expect(v.available).toBe(false);
expect(v.headline).toBe('History not recorded');
expect(v.detail).toMatch(/started keeping Read history after/i);
expect(v.entries).toEqual([]);
});
test('unavailable is NOT presented as legacy', () => {
const v = rh.mapAncestry({ ancestry: { state: 'LINEAGE_UNAVAILABLE' } });
expect(v.headline).toBe('History unavailable');
expect(v.headline).not.toBe(rh.STATE_COPY.LEGACY_UNVERIFIED.headline);
expect(v.detail).toMatch(/should have a history/i);
});
test('invalid fails closed into a restrained state and names nothing scary', () => {
const v = rh.mapAncestry({ ancestry: { state: 'INVALID_LINEAGE' } });
expect(v.available).toBe(false);
expect(v.entries).toEqual([]);
// The USER-VISIBLE copy, not the machine `state` field the component keys off.
const shown = `${v.headline} ${v.detail}`.toLowerCase();
expect(shown).not.toMatch(/corrupt|invalid|fork|defect|integrity/);
});
test('an incomplete chronology never claims to be complete, and says WHICH hole', () => {
const before = rh.mapAncestry(chain({ chronology_complete: false, publications_before_recording: 2 }));
expect(before.incomplete).toBe(true);
expect(before.incompleteNote).toMatch(/before VYNDR started recording/i);
const failed = rh.mapAncestry(chain({ chronology_complete: false, unrecorded_publications: 1 }));
expect(failed.incompleteNote).toMatch(/couldn’t be recorded/i);
const both = rh.mapAncestry(chain({ chronology_complete: false, publications_before_recording: 1, unrecorded_publications: 1 }));
expect(both.incompleteNote).toMatch(/Some earlier updates aren’t recorded/i);
expect(rh.mapAncestry(chain()).incomplete).toBe(false);
});
});
describe('THE SEPARATION — claim changed is not letter changed', () => {
test('the history never uses the grade strike styling', () => {
expect(COMPONENT).not.toMatch(/line-through/);
expect(COMPONENT).not.toMatch(/revised_from_grade/);
expect(COMPONENT).not.toMatch(/GradePill|gradeShift|GradeShift/);
});
test('"Updated" never asserts the grade moved', () => {
const v = rh.mapAncestry(chain());
// ordinal 1 is a pure reprice with the SAME letter on both sides.
expect(v.entries[1].what).toBe('The price moved');
expect(v.entries[1].grade).toBe(v.entries[0].grade);
expect(JSON.stringify(rh.ACTION_COPY).toLowerCase()).not.toMatch(/grade|letter/);
});
test('the ledger card still renders the grade strike, untouched', () => {
expect(LEDGER_PAGE).toMatch(/revised_from_grade/);
expect(LEDGER_PAGE).toMatch(/textDecoration: 'line-through'/);
expect(LEDGER_PAGE).toMatch(/<ReadHistory ledgerId=\{row\.id\} \/>/);
});
});
describe('client, transport and accessibility', () => {
test('the component uses the established API client, not ad hoc fetch', () => {
expect(COMPONENT).toMatch(/import \{ getReadAncestry \} from '@\/lib\/api'/);
const body = COMPONENT.replace(/\/\*[\s\S]*?\*\//g, '');
expect(body).not.toMatch(/\bfetch\(/);
expect(fs.readFileSync(path.join(ROOT, 'web/src/lib/api.ts'), 'utf8')).toMatch(/export async function getReadAncestry/);
});
test('transport failures are never rendered as history states', () => {
for (const code of ['401', '404', '429']) expect(COMPONENT).toContain(code);
expect(COMPONENT).toMatch(/transportError/);
// The unavailable-state copy is chosen by mapAncestry; transport copy is separate.
expect(COMPONENT).toMatch(/History could not be loaded right now/);
expect(JSON.stringify(rh.STATE_COPY)).not.toMatch(/could not be loaded/);
});
test('the disclosure is keyboard and screen-reader usable', () => {
expect(COMPONENT).toMatch(/type="button"/);
expect(COMPONENT).toMatch(/aria-expanded=\{open\}/);
expect(COMPONENT).toMatch(/aria-controls=\{panelId\}/);
expect(COMPONENT).toMatch(/aria-hidden="true"/); // the caret is decorative
expect(COMPONENT).toMatch(/role="status"/);
expect(COMPONENT).toMatch(/<ol/); // chronology is a list
expect(COMPONENT).toMatch(/<time dateTime=/);
expect(COMPONENT).toMatch(/minHeight: 40/); // touch target
});
test('the route is keyed on the ledger UUID and is authenticated', () => {
expect(ROUTE).toMatch(/requireAuth/);
expect(ROUTE).toMatch(/UUID_RE/);
expect(ROUTE).not.toMatch(/read_natural_key/);
});
});
+10 -1
View File
@@ -428,7 +428,16 @@ describe('SHADOW AUTHORITY', () => {
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`); for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
} }
} }
expect(hits).toEqual([]); // ONE product-side consumer of the ancestry CONTRACT is permitted, by name.
// It reads two fields off an API RESPONSE — never from the store — which is
// the whole point of the additive contract, and the assertion below keeps
// that distinction honest: if this file ever learns to query, it fails.
const ANCESTRY_CONSUMER = 'web/src/lib/readHistory.js';
const consumer = fs.readFileSync(path.join(ROOT, ANCESTRY_CONSUMER), 'utf8');
for (const q of ['supabase', 'from(', 'select(', 'createClient']) {
expect(consumer).not.toContain(q);
}
expect(hits.filter((h) => !h.startsWith(ANCESTRY_CONSUMER))).toEqual([]);
}); });
}); });
+1 -1
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -9,6 +9,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr'; import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
import ClvBadge from '@/components/vyndr/ClvBadge'; import ClvBadge from '@/components/vyndr/ClvBadge';
import ReadHistory from '@/components/vyndr/ReadHistory';
/** /**
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
@@ -382,6 +383,10 @@ function LedgerCard({ row, index, ladder }: { row: LedgerRow; index: number; lad
which is the legacy chip's lie in subtler form. */} which is the legacy chip's lie in subtler form. */}
<ClvBadge read={row as unknown as Record<string, unknown>} /> <ClvBadge read={row as unknown as Record<string, unknown>} />
</div> </div>
{/* The history of this published Read — expands in place, loads on first
open. Distinct from the grade strike above it, which means the LETTER
changed; this means the published CLAIM changed. */}
<ReadHistory ledgerId={row.id} />
</article> </article>
); );
} }
+159
View File
@@ -0,0 +1,159 @@
'use client';
/**
* READ HISTORY — what VYNDR published for this Read, and when it changed.
*
* Expands in place inside the Ledger card, matching the board's existing
* "ALL N READS" affordance rather than introducing a page or a modal. Mobile
* first: one column, large touch target, no table.
*
* IT IS NOT THE GRADE-REVISION BADGE. That strike-through means the LETTER
* changed. This means the PUBLISHED CLAIM changed — often the price, sometimes
* the read, frequently with no letter change at all. No strike-through is used
* here and no styling is shared with it.
*/
import { useCallback, useId, useState } from 'react';
import { getReadAncestry } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
const { mapAncestry } = require('@/lib/readHistory');
type View = ReturnType<typeof mapAncestry>;
const TONE: Record<string, string> = {
origin: 'var(--text-secondary)',
revision: 'var(--amber, #f0a020)',
quiet: 'var(--text-tertiary)',
};
export default function ReadHistory({ ledgerId }: { ledgerId: string }) {
const { session } = useAuth();
const [open, setOpen] = useState(false);
const [view, setView] = useState<View | null>(null);
const [loading, setLoading] = useState(false);
const [transportError, setTransportError] = useState<string | null>(null);
const panelId = useId();
const load = useCallback(async () => {
const token = session?.access_token;
if (!token) { setTransportError('Sign in to see this Read’s history.'); return; }
setLoading(true);
setTransportError(null);
try {
const { ok, status, data } = await getReadAncestry(ledgerId, token);
if (!ok) {
// TRANSPORT, not a history state. A 429 is not "no history".
setTransportError(
status === 401 ? 'Sign in to see this Read’s history.'
: status === 404 ? 'This Read could not be found.'
: status === 429 ? 'Too many requests — try again shortly.'
: 'History could not be loaded right now.',
);
return;
}
setView(mapAncestry(data));
} catch {
setTransportError('History could not be loaded right now.');
} finally {
setLoading(false);
}
}, [ledgerId, session]);
const toggle = useCallback(() => {
const next = !open;
setOpen(next);
if (next && !view && !loading) load();
}, [open, view, loading, load]);
return (
<div style={{ marginTop: 8, borderTop: '1px solid var(--border)', paddingTop: 8 }}>
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={panelId}
className="mono"
style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
minHeight: 40, padding: '4px 2px', background: 'none', border: 'none',
color: 'var(--text-tertiary)', fontSize: 10.5, fontWeight: 700,
letterSpacing: '0.1em', cursor: 'pointer', textAlign: 'left',
}}
>
<span aria-hidden="true" style={{ display: 'inline-block', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 120ms' }}>›</span>
READ HISTORY
</button>
<div id={panelId} hidden={!open}>
{loading && <p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', margin: '4px 0' }}>Loading…</p>}
{transportError && !loading && (
<p role="status" className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', margin: '4px 0' }}>
{transportError}
</p>
)}
{view && !loading && !transportError && !view.available && (
<div style={{ margin: '4px 0' }}>
<p className="mono" style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--text-secondary)', marginBottom: 2 }}>
{view.headline}
</p>
<p style={{ fontSize: 11.5, color: 'var(--text-tertiary)', lineHeight: 1.45 }}>{view.detail}</p>
</div>
)}
{view && !loading && !transportError && view.available && (
<>
<ol style={{ listStyle: 'none', margin: '4px 0 0', padding: 0 }}>
{view.entries.map((e: any) => (
<li
key={e.id}
style={{
display: 'flex', gap: 8, padding: '6px 0 6px 10px',
borderLeft: `2px solid ${TONE[e.tone] || 'var(--border)'}`,
marginBottom: 2,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: TONE[e.tone] || 'var(--text-secondary)' }}>
{e.label}
</span>
{/* Never colour alone: the current entry is also worded. */}
{e.is_current && (
<span className="mono" style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-tertiary)' }}>
CURRENT
</span>
)}
</div>
{e.what && e.action !== 'ORIGIN' && (
<p style={{ fontSize: 11.5, color: 'var(--text-secondary)', margin: '1px 0 0', lineHeight: 1.4 }}>{e.what}</p>
)}
<p className="mono" style={{ fontSize: 10.5, color: 'var(--text-tertiary)', margin: '2px 0 0' }}>
<time dateTime={e.when_iso || undefined}>{e.when}</time>
{e.grade ? <> · published <strong style={{ fontWeight: 700 }}>{e.grade}</strong></> : null}
{e.line != null ? <> · line {e.line}</> : null}
</p>
</div>
</li>
))}
</ol>
{view.recaptureSummary && (
<p className="mono" style={{ fontSize: 10.5, color: 'var(--text-tertiary)', margin: '4px 0 0' }}>
{view.recaptureSummary.label}
</p>
)}
{view.incomplete && (
<p role="note" style={{ fontSize: 11, color: 'var(--text-tertiary)', margin: '6px 0 0', lineHeight: 1.45 }}>
{view.incompleteNote}
</p>
)}
</>
)}
</div>
</div>
);
}
+24
View File
@@ -55,3 +55,27 @@ export async function settleBet(betId: string, status: string) {
export async function getPerformance() { export async function getPerformance() {
return fetchAPI('/api/bets/performance'); return fetchAPI('/api/bets/performance');
} }
/**
* Read ancestry — the history of one published Read.
*
* Goes through the RELATIVE Next proxy rather than `fetchAPI`'s direct
* API_BASE hop, because the proxy path is the one proven end-to-end in
* production (authenticated 200 through vyndr.app) and is what every other
* authenticated product read on this page already uses.
*
* Returns transport outcome and body SEPARATELY. A 401 or a 500 is not a
* history state, and collapsing "we could not fetch" into "there is no
* history" is the exact confusion this whole contract exists to avoid.
*/
export async function getReadAncestry(
ledgerId: string,
token: string,
): Promise<{ ok: boolean; status: number; data: unknown }> {
const res = await fetch(`/api/ancestry/ledger/${encodeURIComponent(ledgerId)}`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
cache: 'no-store',
});
const data = await res.json().catch(() => null);
return { ok: res.ok, status: res.status, data };
}
+153
View File
@@ -0,0 +1,153 @@
/* ============================================================
READ HISTORY — turning the ancestry contract into something a person can
read. CommonJS so the .tsx surface (allowJs) and the plain-JS Jest suite can
both load it, matching emptyState.js / clvBadge.js.
THIS IS NOT THE GRADE-REVISION BADGE. The strike-through beside a grade means
the LETTER changed. A history entry means the PUBLISHED CLAIM changed, which
may be the price, the read, or both — and often happens with no letter change
at all. Different concept, different words, deliberately different styling.
============================================================ */
/** Product copy per response state. Every one is a truthful sentence, and none
* of them says "nothing happened" when the real answer is "we don't know". */
const STATE_COPY = {
LINEAGE_AVAILABLE: null, // the chronology itself is the answer
LEGACY_UNVERIFIED: {
headline: 'History not recorded',
detail: 'VYNDR started keeping Read history after this one was published.',
},
LINEAGE_UNAVAILABLE: {
headline: 'History unavailable',
detail: 'This Read should have a history and it could not be read back.',
},
// Deliberately the same restrained surface as UNAVAILABLE. An integrity
// failure is not a user's problem to decode, and "corrupt" is not a word to
// put in front of someone looking at their bet. The precise reason stays on
// the internal diagnostics route.
INVALID_LINEAGE: {
headline: 'History unavailable',
detail: 'This Read’s history could not be verified.',
},
NOT_FOUND: {
headline: 'History unavailable',
detail: 'No record was found for this Read.',
},
};
/** What each recorded action MEANS, in words that do not overstate it. */
const ACTION_COPY = {
ORIGIN: { label: 'First published', tone: 'origin' },
REVISION: { label: 'Updated', tone: 'revision' },
RECAPTURE: { label: 'Rechecked, unchanged', tone: 'quiet' },
};
/**
* `change_type` is PERSISTED and trustworthy, so naming it is reporting, not
* inventing. What is NOT persisted is a field-level diff, so nothing here
* claims to know which number moved.
*/
const CHANGE_COPY = {
INITIAL_PUBLICATION: 'First published',
BELIEF_CHANGE: 'The read changed',
MARKET_REPRICE: 'The price moved',
MIXED_CHANGE: 'The read and the price changed',
NO_MATERIAL_PUBLISHED_CHANGE: 'No material change',
};
function fmtWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
});
}
/**
* RECAPTURE RESTRAINT.
*
* A recapture is real evidence — the same claim was published again — but a
* board that republishes every cycle can produce hundreds, and a list of
* "unchanged" is noise that buries the two entries that matter. They are
* SUMMARISED, never dropped: the count is shown, the underlying data is
* untouched, and the full set is still on the API response.
*/
function summariseRecaptures(recaptures) {
const list = Array.isArray(recaptures) ? recaptures : [];
if (list.length === 0) return null;
return {
kind: 'recapture-summary',
count: list.length,
label: list.length === 1
? 'Rechecked once with no change'
: `Rechecked ${list.length} times with no change`,
last_at: list[list.length - 1] ? list[list.length - 1].captured_at : null,
};
}
/**
* Map an ancestry API payload to what the UI renders. Pure — the copy decisions
* are testable without a browser, and cannot drift into the component.
*/
function mapAncestry(payload) {
const p = payload || {};
const a = p.ancestry || p;
const state = a.state || 'NOT_FOUND';
if (state !== 'LINEAGE_AVAILABLE') {
const copy = STATE_COPY[state] || STATE_COPY.NOT_FOUND;
return {
state,
available: false,
headline: copy.headline,
detail: copy.detail,
entries: [],
recaptureSummary: null,
incomplete: false,
incompleteNote: null,
};
}
const entries = (a.revisions || []).map((r) => ({
id: r.snapshot_row_id,
ordinal: r.revision_ordinal,
action: r.action,
label: (ACTION_COPY[r.action] || ACTION_COPY.REVISION).label,
tone: (ACTION_COPY[r.action] || ACTION_COPY.REVISION).tone,
what: CHANGE_COPY[r.change_type] || null,
when: fmtWhen(r.captured_at),
when_iso: r.captured_at || null,
grade: r.published_grade ?? null,
p_win: r.published_p_win ?? null,
line: r.published_line ?? null,
is_current: false,
}));
if (entries.length > 0) entries[entries.length - 1].is_current = true;
// Two different holes, and the copy says which. "We tried and failed" and
// "we were not recording yet" are different admissions and a reader deserves
// the right one.
const unrecorded = Number(a.unrecorded_publications || 0);
const before = Number(a.publications_before_recording || 0);
const incomplete = a.chronology_complete === false || unrecorded > 0 || before > 0;
let incompleteNote = null;
if (incomplete) {
if (before > 0 && unrecorded > 0) incompleteNote = 'Some earlier updates aren’t recorded.';
else if (before > 0) incompleteNote = 'Updates published before VYNDR started recording history aren’t shown.';
else incompleteNote = 'Some updates couldn’t be recorded and aren’t shown.';
}
return {
state,
available: true,
headline: null,
detail: null,
entries,
recaptureSummary: summariseRecaptures(a.recaptures),
incomplete,
incompleteNote,
};
}
module.exports = { mapAncestry, summariseRecaptures, STATE_COPY, ACTION_COPY, CHANGE_COPY, fmtWhen };
File diff suppressed because one or more lines are too long