Desk: fork card contract at CultLib 3a51bbab (raised_at, counts, nullable answerer) #100
8 changed files with 172 additions and 5 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
29
src/app/features/desk/age.ts
Normal file
29
src/app/features/desk/age.ts
Normal 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`;
|
||||
};
|
||||
|
|
@ -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)),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Reference in a new issue