Desk: fork card contract at CultLib 3a51bbab (raised_at, counts, nullable answerer) #100

Merged
void merged 3 commits from void/fork-raised-at into dev 2026-11-11 21:05:24 +00:00
8 changed files with 172 additions and 5 deletions

View file

@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
import { DeskEntry } from './DeskEntry';
import { DeskView, SIGN_IN_NOTE } from './DeskView';
import { ForkList } from './forkContract';
import { STATES } from './fixtures';
import { setDeskSource } from './source';
@ -121,6 +122,88 @@ describe('DeskView', () => {
});
});
describe("a question's age", () => {
const NOW = Date.parse('2026-11-11T12:00:00Z');
const listRaised = (raisedAt: string | null): ForkList => {
const entry = STATES.noAnswers.forks[0];
return {
...STATES.noAnswers,
forks: [{ ...entry, card: { ...entry.card, raised_at: raisedAt } }],
};
};
const html = (raisedAt: string | null) =>
renderToStaticMarkup(
<ScreenSizeProvider value={ScreenSize.Desktop}>
<DeskView mind="void" label="Void" list={listRaised(raisedAt)} now={NOW} />
</ScreenSizeProvider>
);
it('reads days since the card was first admitted', () => {
expect(html('2026-11-06T12:00:00Z')).toContain('raised 5d ago');
});
it('reads hours inside a day, and just now inside an hour', () => {
expect(html('2026-11-11T07:30:00Z')).toContain('raised 4h ago');
expect(html('2026-11-11T11:40:00Z')).toContain('raised just now');
});
it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => {
['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => {
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
});
});
});
describe('counts when the ids are withheld', () => {
const withheld = (): ForkList => {
const entry = STATES.noAnswers.forks[0];
return {
...STATES.noAnswers,
forks: [
{
...entry,
card: { ...entry.card, deciders: [], decider_count: 3 },
record: { ...entry.record, answerer_count: 2 },
},
],
};
};
it('shows how many deciders and answerers there are, naming none', () => {
const html = renderToStaticMarkup(
<ScreenSizeProvider value={ScreenSize.Desktop}>
<DeskView mind="void" label="Void" list={withheld()} />
</ScreenSizeProvider>
);
expect(html).toContain('3 deciders · 2 answerers');
});
it('shows no count line for a served count outside the contract', () => {
const bad = (card: object, record: object): string => {
const entry = STATES.noAnswers.forks[0];
const list = {
...STATES.noAnswers,
forks: [{ card: { ...entry.card, ...card }, record: { ...entry.record, ...record } }],
} as ForkList;
return renderToStaticMarkup(
<ScreenSizeProvider value={ScreenSize.Desktop}>
<DeskView mind="void" label="Void" list={list} />
</ScreenSizeProvider>
);
};
[
[{ decider_count: 0 }, {}],
[{ decider_count: 65 }, {}],
[{ decider_count: undefined }, {}],
[{}, { answerer_count: -1 }],
[{}, { answerer_count: 1.5 }],
].forEach(([c, r]) => {
const out = bad(c, r);
expect(out).not.toContain('undefined');
expect(out).not.toMatch(/deciders? ·/);
});
});
it('says the singular for one', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html).toContain('1 decider · 0 answerers');
});
});
describe('DeskEntry', () => {
afterEach(() => setDeskSource(() => undefined));
const entry = (mind: string) =>

View file

@ -6,11 +6,14 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
import { ForkList } from './forkContract';
import { deskMood } from './deskMood';
import { raisedAge } from './age';
import * as css from './DeskView.css';
/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */
export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.';
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
const date = (iso: string) => iso.slice(0, 10);
const OUTCOME: Record<AnswerRow['outcome'], string> = {
@ -45,7 +48,7 @@ function Answers({ answers }: { answers: AnswerRow[] }) {
);
}
function Row({ row }: { row: DeskRow }) {
function Row({ row, now }: { row: DeskRow; now: number }) {
const phone = useScreenSizeContext() === ScreenSize.Mobile;
// Phones list titles; a tap opens one. Wide screens show every row open.
const [open, setOpen] = useState(!phone);
@ -77,6 +80,14 @@ function Row({ row }: { row: DeskRow }) {
</h3>
{open && (
<>
{raisedAge(row.raisedAt, now) && (
<p className={css.Quiet}>{raisedAge(row.raisedAt, now)}</p>
)}
{row.deciderCount !== undefined && row.answererCount !== undefined && (
<p className={css.Quiet}>
{`${plural(row.deciderCount, 'decider')} · ${plural(row.answererCount, 'answerer')}`}
</p>
)}
<p className={css.Body}>{row.context}</p>
<ul className={css.Options}>
{row.options.map((o) => (
@ -126,10 +137,12 @@ type DeskViewProps = {
list?: ForkList;
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
unverified?: ReadonlySet<string>;
/** The clock the ages are read against; the page's own, a test's fixed one. */
now?: number;
};
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) {
const desk = buildDesk(list, unverified);
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
const mood = deskMood(desk.counts.open, 0);
@ -139,7 +152,7 @@ export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
<div className={css.Layout}>
<ol className={css.Rows}>
{desk.open.map((row) => (
<Row key={row.forkId} row={row} />
<Row key={row.forkId} row={row} now={now} />
))}
</ol>
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}

View file

@ -0,0 +1,29 @@
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/**
* How long ago a question was raised, from the card's `raised_at` (an RFC3339 instant) and the clock the caller
* holds. Nothing when there is no instant, it does not parse, or it is ahead of the clock: the desk shows no age
* rather than a wrong one.
*/
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
/** Date.parse is lenient ("2026-11-06", "Nov 6 2026", "0"); the card's instant is RFC3339 and a real calendar day. */
const parseRfc3339 = (s: string): number => {
const m = RFC3339.exec(s);
if (!m) return NaN;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
const day = new Date(Date.UTC(y, mo - 1, d));
if (day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN;
return Date.parse(s);
};
export const raisedAge = (raisedAt: string | undefined, now: number): string | undefined => {
const then = raisedAt === undefined ? NaN : parseRfc3339(raisedAt);
const ms = now - then;
if (Number.isNaN(ms) || ms < 0) return undefined;
if (ms < HOUR) return 'raised just now';
if (ms < DAY) return `raised ${Math.floor(ms / HOUR)}h ago`;
return `raised ${Math.floor(ms / DAY)}d ago`;
};

View file

@ -28,6 +28,11 @@ export type DeskRow =
/** The recommendation resolved to its option's text; absent if it names no option. */
recommended?: string;
depends: string[];
/** When the question was first admitted, as served; absent when the card carries none. */
raisedAt?: string;
/** Counts as served: the same in the decider and the withheld view, so they show either way. Absent when the served number is outside the contract's range or not an integer. */
deciderCount?: number;
answererCount?: number;
/** In admission order, as served. */
answers: AnswerRow[];
};
@ -57,6 +62,9 @@ const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
counted: entry.record.counted_answer_id === a.answer_id,
});
const validCount = (n: number, min: number, max: number): number | undefined =>
Number.isInteger(n) && n >= min && n <= max ? n : undefined;
export const buildDesk = (
list: ForkList | undefined,
unverified: ReadonlySet<string> = new Set()
@ -83,6 +91,9 @@ export const buildDesk = (
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
depends: card.depends,
raisedAt: card.raised_at ?? undefined,
deciderCount: validCount(card.decider_count, 1, 64),
answererCount: validCount(record.answerer_count, 0, Infinity),
answers: record.answers.map((a) => answerRow(a, entry)),
});
});

View file

@ -16,7 +16,9 @@ export const card = (label: string, over: Partial<ForkCard> = {}): ForkCard => (
leans: null,
flag: null,
raised_in: null,
raised_at: null,
deciders: ['d1'],
decider_count: 1,
status: 'open',
ruling: null,
ruled_from: [],
@ -60,6 +62,7 @@ export const entry = (
fork_id: c.fork_id,
options_hash: c.options_hash,
counted_answer_id: counted,
answerer_count: new Set(answers.map((a) => a.answerer)).size,
answers,
},
};

View file

@ -24,7 +24,19 @@ export type ForkCard = {
/** Not shown by the desk. */
flag: string | null;
raised_in: { kind: string; id: string } | null;
/**
* When the question was first admitted (an RFC3339 instant, as `answered_at`), or nil when the mind holds no
* admission time. Always written; outside `options_hash`; it never moves when the question is edited. Age is
* the client's: now minus `raised_at`.
*/
raised_at: string | null;
/**
* Who may answer. Empty when this reader is not a decider (the ids are withheld); otherwise it holds exactly
* `decider_count` ids.
*/
deciders: string[];
/** How many deciders there are, 1..64: the same number in both reader views. */
decider_count: number;
status: 'open' | 'closed';
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
ruling: string | null;
@ -58,7 +70,8 @@ export type DeskAnswer = {
answered_at: string;
received_at: string;
claim_in_future: boolean;
answerer: string;
/** Who answered; nil when this reader is not a decider (withheld). */
answerer: string | null;
authority: string;
request_id: string;
};
@ -69,6 +82,8 @@ export type ForkRecord = {
options_hash: string;
/** Derived by the receiver; the page renders it and never recomputes it. */
counted_answer_id: string | null;
/** Distinct answerers over every stored answer, refusals included, at least 0: the same number in both views. */
answerer_count: number;
/** In admission order. */
answers: DeskAnswer[];
};

View file

@ -18,14 +18,23 @@ const KEYS = {
'leans',
'flag',
'raised_in',
'raised_at',
'deciders',
'decider_count',
'status',
'ruling',
'ruled_from',
'order',
'options_hash',
],
record: ['schemaVersion', 'fork_id', 'options_hash', 'counted_answer_id', 'answers'],
record: [
'schemaVersion',
'fork_id',
'options_hash',
'counted_answer_id',
'answerer_count',
'answers',
],
answer: [
'schemaVersion',
'answer_id',

View file

@ -25,7 +25,9 @@ const card = (label: string, order: number, over: Partial<ForkCard>): ForkCard =
leans: null,
flag: null,
raised_in: null,
raised_at: null,
deciders: ['demo-decider'],
decider_count: 1,
status: 'open',
ruling: null,
ruled_from: [],
@ -74,6 +76,7 @@ const entry = (
fork_id: c.fork_id,
options_hash: c.options_hash,
counted_answer_id: counted,
answerer_count: new Set(answers.map((a) => a.answerer)).size,
answers,
},
});
@ -90,6 +93,7 @@ const list = (mind: string, forks: ForkEntry[], stamp: string): ForkList => ({
const nibu = (): ForkList => {
const sky = card('sky-label', 4, {
raised_at: new Date(Date.now() - 5 * DAY).toISOString().replace(/\.\d+Z$/, 'Z'),
title: 'Where should the sky label sit on a phone?',
context:
'The label can ride the horizon or sit in the header. The horizon is prettier; the header never moves.',