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:
@@ -9,6 +9,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
|
||||
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
||||
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
|
||||
@@ -382,6 +383,10 @@ function LedgerCard({ row, index, ladder }: { row: LedgerRow; index: number; lad
|
||||
which is the legacy chip's lie in subtler form. */}
|
||||
<ClvBadge read={row as unknown as Record<string, unknown>} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -55,3 +55,27 @@ export async function settleBet(betId: string, status: string) {
|
||||
export async function getPerformance() {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user