@startsimpli/ui 0.4.134 → 0.4.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Ported from marketing-agents-web `src/lib/__tests__/blog-diff.test.ts` with
3
+ * the module (bd startsim-g4rwo). Same assertions, jest instead of vitest, and
4
+ * the generic `unifiedTextDiff` name — see `text-diff.ts` on the rename.
5
+ */
6
+ import { parseDiffFiles } from '../../diff'
7
+ import { diffLines, unifiedTextDiff } from '../text-diff'
8
+
9
+ describe('diffLines', () => {
10
+ it('marks equal / deleted / inserted lines in output order', () => {
11
+ expect(diffLines(['a', 'b', 'c'], ['a', 'x', 'c'])).toEqual([
12
+ { type: 'eq', line: 'a' },
13
+ { type: 'del', line: 'b' },
14
+ { type: 'ins', line: 'x' },
15
+ { type: 'eq', line: 'c' },
16
+ ])
17
+ })
18
+ })
19
+
20
+ describe('unifiedTextDiff', () => {
21
+ it('returns "" for identical values', () => {
22
+ expect(unifiedTextDiff('same\ntext', 'same\ntext')).toBe('')
23
+ })
24
+
25
+ it('emits a git-style block the shared DiffViewer parser can read', () => {
26
+ const diff = unifiedTextDiff(
27
+ 'Line one\nLine two\nLine three',
28
+ 'Line one\nLine 2\nLine three',
29
+ 'blog',
30
+ )
31
+ expect(diff).toContain('diff --git a/blog b/blog')
32
+ expect(diff).toMatch(/^@@ -\d+,\d+ \+\d+,\d+ @@$/m)
33
+ expect(diff).toContain('-Line two')
34
+ expect(diff).toContain('+Line 2')
35
+ const files = parseDiffFiles(diff)
36
+ expect(files).toHaveLength(1)
37
+ expect(files[0]!.filePath).toBe('blog')
38
+ })
39
+
40
+ it('keeps context lines but not the whole document', () => {
41
+ const oldText = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n')
42
+ const newText = oldText.replace('line 10', 'LINE TEN')
43
+ const body = unifiedTextDiff(oldText, newText)
44
+ .split('\n')
45
+ .filter((l) => /^[ +-]/.test(l) && !l.startsWith('+++') && !l.startsWith('---'))
46
+ expect(body).toContain('-line 10')
47
+ expect(body).toContain('+LINE TEN')
48
+ expect(body).toContain(' line 7')
49
+ expect(body).not.toContain(' line 0')
50
+ })
51
+
52
+ it('names the field in the header, so the rendered diff says what moved', () => {
53
+ expect(unifiedTextDiff('a', 'b', 'linkedin')).toContain('diff --git a/linkedin b/linkedin')
54
+ })
55
+
56
+ it('tolerates CRLF and a trailing newline without inventing a last line', () => {
57
+ expect(unifiedTextDiff('a\r\nb\r\n', 'a\nb\n')).toBe('')
58
+ })
59
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @startsimpli/ui/history — the shared record history panel (bd startsim-g4rwo).
3
+ *
4
+ * "Who edited what piece of the text and when", over the append-only revision
5
+ * trail (bd startsim-o1qib). A page-level composer plus the pure helpers it
6
+ * folds with, so a foundry fork holds an injected client and its theme
7
+ * classNames rather than its own timeline, its own fold and its own idea of
8
+ * what `{}` means (rule #9).
9
+ *
10
+ * THE ONE THING A CONSUMER MUST NOT GET WRONG: `actorKind` is THREE-state, and
11
+ * `unknown` is the common case for genuine human edits for up to 14 days after
12
+ * rollout. Render it through this panel — or through `ActorIdentity` with
13
+ * `kind`/`kindSource` — rather than deciding locally, because "absent" read as
14
+ * "a machine did this" is the single worst failure available here.
15
+ */
16
+ export { RecordHistoryPanel, REVISION_ENTRY_TYPES } from './RecordHistoryPanel';
17
+ export type { RecordHistoryPanelProps } from './RecordHistoryPanel';
18
+
19
+ export { RevisionFields } from './RevisionFields';
20
+ export type { RevisionFieldsProps } from './RevisionFields';
21
+
22
+ export {
23
+ RECORD_HISTORY_PANEL_DEFAULTS,
24
+ type RecordHistoryPanelClassNames,
25
+ } from './record-history-panel-default-class-names';
26
+
27
+ export {
28
+ COLLAPSE_WINDOW_MS,
29
+ actorKindOf,
30
+ actorKindSourceOf,
31
+ canDiffFieldChange,
32
+ classifyFieldChange,
33
+ foldRevisions,
34
+ joinNames,
35
+ normalizeFieldChange,
36
+ normalizeRevision,
37
+ sittingFields,
38
+ sittingHeadline,
39
+ sittingSummary,
40
+ } from './revision-history';
41
+ export type {
42
+ FieldChangeKind,
43
+ FoldedFieldChange,
44
+ NormalizedRevision,
45
+ RevisionSitting,
46
+ } from './revision-history';
47
+
48
+ export { diffLines, unifiedTextDiff } from './text-diff';
49
+
50
+ export type {
51
+ ActorKind,
52
+ ActorKindSource,
53
+ EntityRevision,
54
+ RevisionAction,
55
+ RevisionClient,
56
+ RevisionFieldChange,
57
+ RevisionMetadata,
58
+ RevisionPage,
59
+ RevisionPrecondition,
60
+ RevisionQuery,
61
+ RevisionSource,
62
+ } from './types';
@@ -0,0 +1,29 @@
1
+ /** Default Tailwind classes for {@link RecordHistoryPanel}. bd startsim-g4rwo.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe, and no
3
+ * literal colour, so a dark tenant theme recolours this without a fork. */
4
+
5
+ export interface RecordHistoryPanelClassNames {
6
+ root?: string;
7
+ header?: string;
8
+ heading?: string;
9
+ title?: string;
10
+ description?: string;
11
+ body?: string;
12
+ /** The panel-level sentences: the unrecorded-kind explanation, the redaction
13
+ * notice and the "some write paths are not recorded" caveat. */
14
+ notice?: string;
15
+ footer?: string;
16
+ countLine?: string;
17
+ }
18
+
19
+ export const RECORD_HISTORY_PANEL_DEFAULTS: Required<RecordHistoryPanelClassNames> = {
20
+ root: 'rounded-lg border border-border bg-card',
21
+ header: 'flex flex-wrap items-start justify-between gap-2 border-b border-border px-5 py-3',
22
+ heading: 'space-y-1',
23
+ title: 'font-medium text-foreground',
24
+ description: 'text-xs text-muted-foreground',
25
+ body: 'max-h-[32rem] overflow-y-auto px-5 py-4',
26
+ notice: 'border-b border-border px-5 py-2 text-xs text-muted-foreground',
27
+ footer: 'border-t border-border px-5 py-3 text-sm',
28
+ countLine: 'border-t border-border px-5 py-2 text-xs text-muted-foreground',
29
+ };
@@ -0,0 +1,484 @@
1
+ /**
2
+ * The render-time fold, and what one field change actually SAYS
3
+ * (bd startsim-g4rwo; trail bd startsim-o1qib; extraction bd startsim-m7fdm.1).
4
+ *
5
+ * Pure — no React, no fetching — so every rule below unit-tests without a
6
+ * backend and without a render.
7
+ *
8
+ * ── WHY THE FOLD IS HERE AND NOT ON THE SERVER ──────────────────────────────
9
+ *
10
+ * There is NO server-side coalescing, deliberately: the trail is one bare
11
+ * INSERT per write, because growing an open row inside a window is a
12
+ * read-modify-write on a path where the autosave debounce is 1200ms and two
13
+ * saves from one reviewer are routinely in flight — which is precisely the
14
+ * mechanism that drops `data._edit_history` entries today, one table over. A
15
+ * revision that claims text the record does not hold is worse than a missing
16
+ * one. So the server stays dumb and correct, and the folding happens here.
17
+ *
18
+ * The consequence this module exists to absorb: a reviewer working through a
19
+ * 500-word blog issues DOZENS of PATCHes, so a sitting is dozens of rows. One
20
+ * row per PATCH is not accountability, it is a wall of near-identical
21
+ * timestamps that nobody reads — and the wall is worse than nothing, because it
22
+ * hides the one thing the customer asked for.
23
+ *
24
+ * ── WHY FIVE MINUTES ────────────────────────────────────────────────────────
25
+ *
26
+ * Not because five is special — because of where the gaps actually fall. Inside
27
+ * one sitting at the keyboard the gaps are SECONDS (the debounce is 1.2s and
28
+ * typing is continuous), so almost any window kills the autosave noise. What a
29
+ * window has to get right is the other side: the smallest gap that means "she
30
+ * stopped, and came back". Five minutes is comfortably longer than reading a
31
+ * rendered preview or fixing an issue in the rail, and comfortably shorter than
32
+ * leaving the desk. Longer windows only merge sittings that really were
33
+ * separate, which is a loss of truth for no gain in quiet.
34
+ *
35
+ * AND IT IS ONE NUMBER: 0 gives a row per save, higher gives a row per visit.
36
+ * Nothing else in this module knows which a tenant wants.
37
+ */
38
+ import type {
39
+ ActorKind,
40
+ ActorKindSource,
41
+ EntityRevision,
42
+ RevisionFieldChange,
43
+ } from './types';
44
+
45
+ /**
46
+ * How close two writes by the same actor have to be to count as one sitting.
47
+ * See this module's header for why five minutes. Carried over verbatim from
48
+ * `marketing-agents-web/src/lib/edit-history.ts`, whose `COLLAPSE_WINDOW_MS`
49
+ * this replaces.
50
+ */
51
+ export const COLLAPSE_WINDOW_MS = 5 * 60 * 1000;
52
+
53
+ /** A side of a field change, with ABSENCE made explicit.
54
+ *
55
+ * `hasBefore`/`hasAfter` exist because `before`/`after` can legitimately BE
56
+ * `null` — a stored null and a key that was never there are different facts,
57
+ * and `entry.after === undefined` collapses them. */
58
+ export interface FoldedFieldChange {
59
+ hasBefore: boolean;
60
+ before?: unknown;
61
+ hasAfter: boolean;
62
+ after?: unknown;
63
+ /** Either side was too large to store, anywhere in the fold. */
64
+ truncated: boolean;
65
+ /** The per-field read gate withheld this field from this caller. */
66
+ withheld: boolean;
67
+ /** The ROW was over the byte cap: the field moved, the values are not here. */
68
+ omitted: boolean;
69
+ }
70
+
71
+ /**
72
+ * What a field entry actually says, as one word.
73
+ *
74
+ * THE ORDER IS LOAD-BEARING. `withheld` and `omitted` are checked FIRST because
75
+ * both arrive as `{}` — indistinguishable from each other and from a created
76
+ * key by shape alone, and distinguishable only by the row's `redacted_fields`
77
+ * and `values_omitted`. `truncated` is checked BEFORE the created/deleted
78
+ * inference because a truncated side's absence says nothing, so calling it a
79
+ * deletion would invent one.
80
+ */
81
+ export type FieldChangeKind =
82
+ | 'withheld'
83
+ | 'omitted'
84
+ | 'truncated'
85
+ | 'created'
86
+ | 'deleted'
87
+ | 'updated';
88
+
89
+ export function classifyFieldChange(change: FoldedFieldChange): FieldChangeKind {
90
+ if (change.withheld) return 'withheld';
91
+ if (change.omitted) return 'omitted';
92
+ if (change.truncated) return 'truncated';
93
+ if (!change.hasBefore && change.hasAfter) return 'created';
94
+ if (change.hasBefore && !change.hasAfter) return 'deleted';
95
+ return 'updated';
96
+ }
97
+
98
+ /** Whether a within-field diff can honestly be rendered for this change.
99
+ *
100
+ * REFUSES A TRUNCATED SIDE, which is the whole point: a diff over a silently
101
+ * truncated value shows a deletion that never happened. Also refuses anything
102
+ * withheld or omitted (there are no values), and anything that is not text on
103
+ * both sides (a line diff over a dict is noise). */
104
+ export function canDiffFieldChange(change: FoldedFieldChange): boolean {
105
+ if (change.truncated || change.withheld || change.omitted) return false;
106
+ return typeof change.before === 'string' && typeof change.after === 'string';
107
+ }
108
+
109
+ /* -------------------------------------------------------------------------- */
110
+ /* READING THE WIRE */
111
+ /* -------------------------------------------------------------------------- */
112
+
113
+ /** First non-empty of several candidate spellings — the snake/camel tolerance
114
+ * described in `types.ts`. */
115
+ function pick<T>(...values: Array<T | null | undefined>): T | undefined {
116
+ for (const value of values) {
117
+ if (value !== null && value !== undefined && value !== '') return value;
118
+ }
119
+ return undefined;
120
+ }
121
+
122
+ function strings(value: unknown): string[] {
123
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
124
+ }
125
+
126
+ /** The three-state kind, FAILING CLOSED.
127
+ *
128
+ * Anything outside the vocabulary — absent, empty, a value from a future
129
+ * build — reads `unknown`, and NEVER `person`. A trail that labels a machine
130
+ * as your colleague is worse than no trail at all, so the floor is the state
131
+ * that claims nothing. */
132
+ export function actorKindOf(revision: EntityRevision): ActorKind {
133
+ const raw = pick(revision.actor_kind, revision.actorKind);
134
+ return raw === 'person' || raw === 'machine' ? raw : 'unknown';
135
+ }
136
+
137
+ /** Whether the kind was CLAIMED or GUESSED. `undefined` when the row does not
138
+ * say, which a row from a pre-rollout build will not. */
139
+ export function actorKindSourceOf(revision: EntityRevision): ActorKindSource | undefined {
140
+ const raw = pick(revision.actor_kind_source, revision.actorKindSource);
141
+ return raw === 'claim' || raw === 'inferred' ? raw : undefined;
142
+ }
143
+
144
+ /**
145
+ * One `changed` entry read into {@link FoldedFieldChange}.
146
+ *
147
+ * `'before' in entry` rather than `entry.before !== undefined`, because `null`
148
+ * is a legal stored value and the server never writes `after: null` to mean
149
+ * "deleted" — it omits the key. Collapsing the two would turn every
150
+ * null-valued field into a deletion.
151
+ */
152
+ export function normalizeFieldChange(
153
+ entry: RevisionFieldChange | undefined,
154
+ options: { withheld?: boolean; omitted?: boolean } = {},
155
+ ): FoldedFieldChange {
156
+ const raw = entry && typeof entry === 'object' ? entry : {};
157
+ const hasBefore = Object.prototype.hasOwnProperty.call(raw, 'before');
158
+ const hasAfter = Object.prototype.hasOwnProperty.call(raw, 'after');
159
+ return {
160
+ hasBefore,
161
+ ...(hasBefore ? { before: raw.before } : {}),
162
+ hasAfter,
163
+ ...(hasAfter ? { after: raw.after } : {}),
164
+ truncated: raw.truncated === true,
165
+ withheld: options.withheld === true,
166
+ omitted: options.omitted === true,
167
+ };
168
+ }
169
+
170
+ /** An ISO instant we can actually order by, or undefined. */
171
+ function instant(value: unknown): string | undefined {
172
+ if (typeof value !== 'string') return undefined;
173
+ return Number.isNaN(Date.parse(value)) ? undefined : value;
174
+ }
175
+
176
+ /** One revision row, read defensively into a single spelling. */
177
+ export interface NormalizedRevision {
178
+ id: string;
179
+ version: number;
180
+ action: string;
181
+ source: string;
182
+ precondition: string;
183
+ /** ISO-8601, or `''` when the row carried no readable timestamp. */
184
+ createdAt: string;
185
+ actorSub: string;
186
+ actorLabel: string;
187
+ actorKind: ActorKind;
188
+ actorKindSource?: ActorKindSource;
189
+ changed: Record<string, FoldedFieldChange>;
190
+ name?: FoldedFieldChange;
191
+ preserved: string[];
192
+ regenerate: string[];
193
+ redacted: string[];
194
+ valuesOmitted: boolean;
195
+ /** A machine ATTEMPT: something was held back and nothing moved. */
196
+ isAttempt: boolean;
197
+ }
198
+
199
+ export function normalizeRevision(revision: EntityRevision): NormalizedRevision {
200
+ const metadata = revision.metadata && typeof revision.metadata === 'object' ? revision.metadata : {};
201
+ const redacted = strings(pick(revision.redacted_fields, revision.redactedFields));
202
+ const valuesOmitted =
203
+ metadata.values_omitted === true || metadata.valuesOmitted === true;
204
+ const rawChanged =
205
+ metadata.changed && typeof metadata.changed === 'object' ? metadata.changed : {};
206
+
207
+ const changed: Record<string, FoldedFieldChange> = {};
208
+ for (const [field, entry] of Object.entries(rawChanged)) {
209
+ changed[field] = normalizeFieldChange(entry, {
210
+ withheld: redacted.includes(field),
211
+ omitted: valuesOmitted,
212
+ });
213
+ }
214
+
215
+ const preserved = strings(metadata.preserved);
216
+ return {
217
+ id: String(revision.id ?? ''),
218
+ version: typeof revision.version === 'number' ? revision.version : 0,
219
+ action: String(pick(revision.action) ?? 'updated'),
220
+ source: String(pick(revision.source) ?? ''),
221
+ precondition: String(pick(revision.precondition) ?? ''),
222
+ createdAt: instant(pick(revision.created_at, revision.createdAt)) ?? '',
223
+ actorSub: String(pick(revision.actor_sub, revision.actorSub) ?? ''),
224
+ actorLabel: String(pick(revision.actor_label, revision.actorLabel) ?? ''),
225
+ actorKind: actorKindOf(revision),
226
+ ...(actorKindSourceOf(revision) ? { actorKindSource: actorKindSourceOf(revision)! } : {}),
227
+ changed,
228
+ ...(metadata.name
229
+ ? { name: normalizeFieldChange(metadata.name, { omitted: valuesOmitted }) }
230
+ : {}),
231
+ preserved,
232
+ regenerate: strings(metadata.regenerate),
233
+ redacted,
234
+ valuesOmitted,
235
+ isAttempt: preserved.length > 0 && Object.keys(changed).length === 0,
236
+ };
237
+ }
238
+
239
+ /* -------------------------------------------------------------------------- */
240
+ /* THE FOLD */
241
+ /* -------------------------------------------------------------------------- */
242
+
243
+ /** One sitting: who, over what span, and what moved across the whole of it. */
244
+ export interface RevisionSitting {
245
+ /** Stable React key — the newest folded row's id. */
246
+ id: string;
247
+ /** The rows folded in, NEWEST FIRST, so "show each save" needs no re-sort. */
248
+ revisions: NormalizedRevision[];
249
+ actorSub: string;
250
+ actorLabel: string;
251
+ actorKind: ActorKind;
252
+ actorKindSource?: ActorKindSource;
253
+ /** Version of the OLDEST row folded in. */
254
+ fromVersion: number;
255
+ /** Version of the NEWEST row folded in. */
256
+ toVersion: number;
257
+ /** When the sitting started (oldest row). */
258
+ from: string;
259
+ /** When it last saved (newest row). */
260
+ at: string;
261
+ /** How many rows folded in. At least 1. */
262
+ saves: number;
263
+ /** `created` if the sitting contains the record's creation, else the newest
264
+ * row's action. */
265
+ action: string;
266
+ /** Per field: BEFORE from the oldest row that touched it, AFTER from the
267
+ * newest. */
268
+ changed: Record<string, FoldedFieldChange>;
269
+ name?: FoldedFieldChange;
270
+ preserved: string[];
271
+ regenerate: string[];
272
+ redacted: string[];
273
+ valuesOmitted: boolean;
274
+ isAttempt: boolean;
275
+ }
276
+
277
+ /**
278
+ * Whether two adjacent rows are the same sitting.
279
+ *
280
+ * FOUR REFUSALS, each of which would otherwise produce a confident lie:
281
+ *
282
+ * 1. A DIFFERENT ACTOR is a different sitting, obviously — and that includes a
283
+ * different ABSENCE of one, treated as its own actor rather than matching
284
+ * everybody.
285
+ * 2. A DIFFERENT `actorKind` OR `actorKindSource` NEVER FOLDS, even when the
286
+ * sub matches. During the healing window one person's rows legitimately
287
+ * carry `unknown` then `person`, and folding them would stamp the whole
288
+ * sitting with whichever end won — manufacturing a claim the data does not
289
+ * make. bd startsim-jkkn7.2 says fail closed on absence; refusing the fold
290
+ * is the honest version of that.
291
+ * 3. AN ATTEMPT NEVER FOLDS INTO A CHANGE. A `preserved` row records that a
292
+ * machine tried to write and was held back; merging it into a row that DID
293
+ * move a field would read as the attempt having succeeded, which inverts the
294
+ * one fact the actor axis exists to record.
295
+ * 4. A CREATE NEVER ABSORBS A LATER EDIT, so "created" keeps meaning the
296
+ * record's version 1 rather than "created, and then some".
297
+ *
298
+ * And the window itself is checked on the gap between the two rows, `>= 0`
299
+ * guarding a clock that went backwards: an out-of-order row extends the sitting
300
+ * rather than opening one that appears to precede it.
301
+ */
302
+ function sameSitting(
303
+ newer: NormalizedRevision,
304
+ older: NormalizedRevision,
305
+ windowMs: number,
306
+ ): boolean {
307
+ if (newer.actorSub !== older.actorSub) return false;
308
+ if (newer.actorKind !== older.actorKind) return false;
309
+ if (newer.actorKindSource !== older.actorKindSource) return false;
310
+ if (newer.isAttempt !== older.isAttempt) return false;
311
+ if (older.action === 'created' || newer.action === 'created') return false;
312
+ if (!newer.createdAt || !older.createdAt) return false;
313
+ const gap = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
314
+ return gap >= 0 && gap <= windowMs;
315
+ }
316
+
317
+ /**
318
+ * Merge an OLDER row's field change into a sitting that already holds the
319
+ * newer one.
320
+ *
321
+ * THE DIRECTION IS THE WHOLE POINT. `before` must come from the OLDEST row that
322
+ * touched the field and `after` from the NEWEST, so a sitting reports what the
323
+ * paragraph was when she sat down and what it was when she left. A naive union
324
+ * takes whichever side it saw last and reports an intermediate autosave as the
325
+ * final text — which is exactly the kind of confidently-wrong value this trail
326
+ * exists to replace.
327
+ *
328
+ * Flags UNION rather than overwrite: a sitting that folded one truncated,
329
+ * withheld or omitted row carries that forward, because a reader who is shown
330
+ * a clean diff built partly from a withheld row has been told something false.
331
+ */
332
+ function mergeOlder(into: FoldedFieldChange, older: FoldedFieldChange): FoldedFieldChange {
333
+ return {
334
+ // BEFORE moves back to the older row's.
335
+ hasBefore: older.hasBefore,
336
+ ...(older.hasBefore ? { before: older.before } : {}),
337
+ // AFTER stays with the newer row's.
338
+ hasAfter: into.hasAfter,
339
+ ...(into.hasAfter ? { after: into.after } : {}),
340
+ truncated: into.truncated || older.truncated,
341
+ withheld: into.withheld || older.withheld,
342
+ omitted: into.omitted || older.omitted,
343
+ };
344
+ }
345
+
346
+ function startSitting(revision: NormalizedRevision): RevisionSitting {
347
+ return {
348
+ id: revision.id,
349
+ revisions: [revision],
350
+ actorSub: revision.actorSub,
351
+ actorLabel: revision.actorLabel,
352
+ actorKind: revision.actorKind,
353
+ ...(revision.actorKindSource ? { actorKindSource: revision.actorKindSource } : {}),
354
+ fromVersion: revision.version,
355
+ toVersion: revision.version,
356
+ from: revision.createdAt,
357
+ at: revision.createdAt,
358
+ saves: 1,
359
+ action: revision.action,
360
+ changed: { ...revision.changed },
361
+ ...(revision.name ? { name: revision.name } : {}),
362
+ preserved: [...revision.preserved],
363
+ regenerate: [...revision.regenerate],
364
+ redacted: [...revision.redacted],
365
+ valuesOmitted: revision.valuesOmitted,
366
+ isAttempt: revision.isAttempt,
367
+ };
368
+ }
369
+
370
+ function absorb(sitting: RevisionSitting, older: NormalizedRevision): void {
371
+ sitting.revisions.push(older);
372
+ sitting.saves += 1;
373
+ sitting.fromVersion = older.version;
374
+ sitting.from = older.createdAt || sitting.from;
375
+ if (older.action === 'created') sitting.action = 'created';
376
+
377
+ for (const [field, change] of Object.entries(older.changed)) {
378
+ sitting.changed[field] = sitting.changed[field]
379
+ ? mergeOlder(sitting.changed[field]!, change)
380
+ : change;
381
+ }
382
+ if (older.name) {
383
+ sitting.name = sitting.name ? mergeOlder(sitting.name, older.name) : older.name;
384
+ }
385
+ const union = (a: string[], b: string[]) => [...new Set([...a, ...b])].sort();
386
+ sitting.preserved = union(sitting.preserved, older.preserved);
387
+ sitting.regenerate = union(sitting.regenerate, older.regenerate);
388
+ sitting.redacted = union(sitting.redacted, older.redacted);
389
+ sitting.valuesOmitted = sitting.valuesOmitted || older.valuesOmitted;
390
+ }
391
+
392
+ /**
393
+ * Fold a NEWEST-FIRST page of revisions into sittings, newest first.
394
+ *
395
+ * Takes the rows exactly as the endpoint ordered them (`-version`, then
396
+ * `-created_at`) and folds only ADJACENT rows, so the result stays in the
397
+ * server's order and no row is reordered on the strength of a timestamp.
398
+ *
399
+ * FOLD OVER THE ACCUMULATED PAGES, NOT PER PAGE. The panel loads pages with
400
+ * `useInfiniteQuery` and folds the flattened array, which is why a sitting
401
+ * straddling a page boundary is folded once rather than appearing as "3 saves"
402
+ * followed by "2 saves". Calling this per page would be wrong and silently so.
403
+ */
404
+ export function foldRevisions(
405
+ revisions: EntityRevision[],
406
+ windowMs: number = COLLAPSE_WINDOW_MS,
407
+ ): RevisionSitting[] {
408
+ const rows = (revisions ?? []).map(normalizeRevision);
409
+ const out: RevisionSitting[] = [];
410
+ for (const row of rows) {
411
+ const open = out[out.length - 1];
412
+ // `open.revisions[open.revisions.length - 1]` is the OLDEST row folded so
413
+ // far, which is the one this (older still) row is adjacent to.
414
+ const tail = open?.revisions[open.revisions.length - 1];
415
+ if (open && tail && sameSitting(tail, row, windowMs)) {
416
+ absorb(open, row);
417
+ } else {
418
+ out.push(startSitting(row));
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+
424
+ /**
425
+ * What the sitting collapsed, in words.
426
+ *
427
+ * A COLLAPSED SITTING MUST SAY IT IS A COLLAPSE. A bare timestamp over twelve
428
+ * folded saves quietly claims a precision it does not have — the reader should
429
+ * be able to see that this was a sitting, not a keystroke. Locale-free on
430
+ * purpose: the absolute time is rendered by the row, this is only the shape of
431
+ * the sitting.
432
+ */
433
+ export function sittingSummary(sitting: RevisionSitting): string {
434
+ if (sitting.saves <= 1) return '1 save';
435
+ const minutes = Math.floor((Date.parse(sitting.at) - Date.parse(sitting.from)) / 60_000);
436
+ return minutes >= 1 ? `${sitting.saves} saves over ${minutes} min` : `${sitting.saves} saves`;
437
+ }
438
+
439
+ /** The fields a sitting touched, in a stable order, so two renders of the same
440
+ * sitting never disagree about which field comes first. */
441
+ export function sittingFields(sitting: RevisionSitting): string[] {
442
+ return Object.keys(sitting.changed).sort();
443
+ }
444
+
445
+ /** "a", "a and b", "a, b and c" — an Oxford-free list, because these are field
446
+ * names in a sentence and not prose. */
447
+ export function joinNames(names: string[]): string {
448
+ if (names.length === 0) return '';
449
+ if (names.length === 1) return names[0]!;
450
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
451
+ }
452
+
453
+ /**
454
+ * The sitting's headline — one line that says what happened.
455
+ *
456
+ * AN ATTEMPT GETS ITS OWN SENTENCE, and it is the sentence nobody could produce
457
+ * when the 41 machine-written notes could not be cleaned up: "tried to write
458
+ * team_verdict — held back". It must never read as a change, because the record
459
+ * did not move; what happened is that a machine was stopped.
460
+ *
461
+ * Beyond about three fields the names stop helping and the count starts: a
462
+ * headline listing nine attribute names is not scannable, and the full list is
463
+ * one row down anyway.
464
+ */
465
+ export function sittingHeadline(
466
+ sitting: RevisionSitting,
467
+ fieldLabel: (field: string) => string = (field) => field,
468
+ ): string {
469
+ if (sitting.isAttempt) {
470
+ const names = joinNames(sitting.preserved.map(fieldLabel));
471
+ return names ? `Tried to write ${names} — held back` : 'A write was held back';
472
+ }
473
+ const fields = sittingFields(sitting);
474
+ const renamed = sitting.name !== undefined;
475
+ if (sitting.action === 'created') {
476
+ return fields.length ? `Created the record with ${fields.length} ${fields.length === 1 ? 'field' : 'fields'}` : 'Created the record';
477
+ }
478
+ if (!fields.length) return renamed ? 'Renamed the record' : 'Saved the record';
479
+ const named =
480
+ fields.length <= 3
481
+ ? `Edited ${joinNames(fields.map(fieldLabel))}`
482
+ : `Edited ${fields.length} fields`;
483
+ return renamed ? `${named}, and renamed it` : named;
484
+ }