{row.options.map((o) => (
@@ -126,10 +137,12 @@ type DeskViewProps = {
list?: ForkList;
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
unverified?: ReadonlySet;
+ /** 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) {
{desk.open.map((row) => (
-
+
))}
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
diff --git a/src/app/features/desk/age.ts b/src/app/features/desk/age.ts
new file mode 100644
index 00000000..52853032
--- /dev/null
+++ b/src/app/features/desk/age.ts
@@ -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`;
+};
diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts
index e09dec1f..fbe2caaf 100644
--- a/src/app/features/desk/buildDesk.ts
+++ b/src/app/features/desk/buildDesk.ts
@@ -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 = 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)),
});
});
diff --git a/src/app/features/desk/fixtures.ts b/src/app/features/desk/fixtures.ts
index e74c37ba..8f999f43 100644
--- a/src/app/features/desk/fixtures.ts
+++ b/src/app/features/desk/fixtures.ts
@@ -16,7 +16,9 @@ export const card = (label: string, over: Partial = {}): 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,
},
};
diff --git a/src/app/features/desk/forkContract.ts b/src/app/features/desk/forkContract.ts
index ce196116..5bf10080 100644
--- a/src/app/features/desk/forkContract.ts
+++ b/src/app/features/desk/forkContract.ts
@@ -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[];
};
diff --git a/src/preview-demo/deskForks.test.ts b/src/preview-demo/deskForks.test.ts
index 035dd2d8..001342f5 100644
--- a/src/preview-demo/deskForks.test.ts
+++ b/src/preview-demo/deskForks.test.ts
@@ -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',
diff --git a/src/preview-demo/deskForks.ts b/src/preview-demo/deskForks.ts
index a0ca67bf..91543cab 100644
--- a/src/preview-demo/deskForks.ts
+++ b/src/preview-demo/deskForks.ts
@@ -25,7 +25,9 @@ const card = (label: string, order: number, over: Partial): 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.',