@startsimpli/ui 0.4.135 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.135",
3
+ "version": "0.4.136",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -20,6 +20,7 @@
20
20
  "./blog": "./src/components/blog/index.ts",
21
21
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
22
22
  "./activity": "./src/components/activity/index.ts",
23
+ "./history": "./src/components/history/index.ts",
23
24
  "./collection": "./src/components/collection/index.ts",
24
25
  "./scope": "./src/components/scope/index.ts",
25
26
  "./facet-tree": "./src/components/facet-tree/index.ts",
@@ -123,8 +124,8 @@
123
124
  "tailwindcss": "^3.4.19",
124
125
  "tsup": "^8.5.1",
125
126
  "typescript": "^6.0.3",
126
- "@startsimpli/i18n": "0.1.3",
127
- "@startsimpli/realtime": "0.0.2"
127
+ "@startsimpli/realtime": "0.0.2",
128
+ "@startsimpli/i18n": "0.1.3"
128
129
  },
129
130
  "keywords": [
130
131
  "ui",
@@ -17,7 +17,7 @@ import { formatDistanceToNow } from 'date-fns';
17
17
  import { Clock, User } from 'lucide-react';
18
18
  import { EmptyState } from './states';
19
19
  import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
20
- import type { ActorIdentityValue } from './ActorIdentity';
20
+ import type { ActorIdentityValue, ActorKind, ActorKindSource } from './ActorIdentity';
21
21
 
22
22
  /** Severity of an entry, for the marker. Presentation only — never a gate. */
23
23
  export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
@@ -37,6 +37,12 @@ export interface ActivityTimelineItem {
37
37
  // --- event-stream shape -------------------------------------------------
38
38
  /** Identity of the person behind the event (preferred over `createdBy`). */
39
39
  actor?: ActivityTimelineActor;
40
+ /** Whether a person or a MACHINE did it, where the stream records it
41
+ * (bd startsim-g4rwo). Forwarded straight to `ActorIdentity`, so a stream
42
+ * that does not record it renders exactly as before — and so a stream that
43
+ * does cannot show a robot's write as a colleague's. */
44
+ actorKind?: ActorKind;
45
+ actorKindSource?: ActorKindSource;
40
46
  /** Colours the marker; `activityColors[type]` still wins when supplied. */
41
47
  tone?: ActivityTone;
42
48
  /** Muted inline qualifier after the title, e.g. "from provisioning". */
@@ -192,9 +198,16 @@ function TimelineEntry({
192
198
  <p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
193
199
  )}
194
200
  {activity.detail && (
195
- <p className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
201
+ // A DIV, NOT A P. `detail` is typed ReactNode and the record
202
+ // history panel passes a field LIST into it — and a <ul> or a
203
+ // <div> inside a <p> is invalid HTML, which React resolves by
204
+ // reparenting during hydration. That produces a hydration
205
+ // mismatch on every row, and neither tsc nor jsdom reports it:
206
+ // only a real browser does (bd startsim-g4rwo). The classes are
207
+ // unchanged, so every existing caller renders identically.
208
+ <div className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
196
209
  {activity.detail}
197
- </p>
210
+ </div>
198
211
  )}
199
212
  </div>
200
213
  <div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
@@ -203,7 +216,12 @@ function TimelineEntry({
203
216
  </div>
204
217
 
205
218
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
206
- <ActorIdentity actor={activity.actor} showInitials={!markerIsAvatar} />
219
+ <ActorIdentity
220
+ actor={activity.actor}
221
+ showInitials={!markerIsAvatar}
222
+ kind={activity.actorKind}
223
+ kindSource={activity.actorKindSource}
224
+ />
207
225
 
208
226
  {!who && activity.createdBy && (
209
227
  <span className="flex items-center gap-1">
@@ -16,7 +16,33 @@
16
16
  * the address echoed back. Every surface that attributes something to a person
17
17
  * renders this component rather than its own arrangement of the same two
18
18
  * fields (rule #9).
19
+ *
20
+ * AND SINCE bd startsim-g4rwo, ONE WAY TO SAY "A MACHINE DID THIS" — the same
21
+ * component, because the question a reader asks is the same question and the
22
+ * answer has to be comparable. Pass `kind`/`kindSource`; see {@link kindMarker}
23
+ * for which states are decorated and the inversion that decides it. Both props
24
+ * are optional and omitting them renders exactly what this component rendered
25
+ * before they existed, which is what lets the surfaces already using it stay
26
+ * untouched.
27
+ */
28
+ import { Bot } from 'lucide-react';
29
+
30
+ /**
31
+ * WHAT the actor is — and the third value is NOT a defect (bd startsim-g4rwo,
32
+ * claim bd startsim-jkkn7.2).
33
+ *
34
+ * `unknown` means CENTRAL DID NOT SAY. The claim is minted at LOGIN and
35
+ * inherited across refreshes, so for up to the 14-day refresh expiry after
36
+ * rollout a GENUINE HUMAN EDIT carries no kind at all. Note precisely what is
37
+ * missing: the KIND, never the actor — the name and email are still there — so
38
+ * an unknown row can still say who, and only has to stay quiet about what.
19
39
  */
40
+ export type ActorKind = 'person' | 'machine' | 'unknown';
41
+
42
+ /** Whether {@link ActorKind} came from a verified claim or from an inference
43
+ * ladder over naming conventions. `inferred` is a GUESS, and a surface that
44
+ * says so is what makes a later cleanup possible instead of a second guess. */
45
+ export type ActorKindSource = 'claim' | 'inferred';
20
46
 
21
47
  export interface ActorIdentityValue {
22
48
  id?: string;
@@ -54,33 +80,147 @@ export function actorHandle(actor?: ActorIdentityValue): string {
54
80
  return email && email !== actorLabel(actor) ? email : '';
55
81
  }
56
82
 
83
+ /**
84
+ * Whether this kind earns a visible marker, and what it should say.
85
+ *
86
+ * ── THE RULE, AND IT IS AN INVERSION OF THE OBVIOUS DESIGN ──────────────────
87
+ *
88
+ * The obvious design is three badges, one per state. It FAILS the requirement
89
+ * even though it renders all three states, because during the rollout window
90
+ * `unknown` is the COMMON case for real human edits — so a badge on every
91
+ * unknown row makes the whole panel look broken for two weeks on every tenant,
92
+ * and a reader who has been trained that the badges mean trouble stops reading
93
+ * the one badge that matters.
94
+ *
95
+ * So ABSENCE OF A MARKER IS THE CORRECT RENDERING FOR THE CALM CASES, and only
96
+ * the cases that genuinely carry information are decorated:
97
+ *
98
+ * - `machine` → ALWAYS marked, with an icon, and visually distinct
99
+ * from any person. This is the single most valuable
100
+ * thing in the epic: the repeated live failure was a
101
+ * machine overwriting a human, and 41 machine-written
102
+ * notes that could not be cleaned up afterwards
103
+ * "precisely because the data cannot say retrospectively
104
+ * which were human".
105
+ * - `person`/`claim` → NOTHING. A colleague edited the record; that is the
106
+ * unremarkable default and decorating it adds noise to
107
+ * the good case.
108
+ * - `person`/`inferred`→ marked, lightly, because the tenant GUESSED and the
109
+ * guess should be visible to whoever might act on it.
110
+ * - `unknown` → NOTHING on the row. The kind is unrecorded, which is
111
+ * not a fact about this edit worth shouting once per
112
+ * row; `RecordHistoryPanel` says it ONCE for the whole
113
+ * panel instead, and only when some loaded row is
114
+ * actually unknown.
115
+ *
116
+ * The truth is never *lost* in the quiet cases — it goes into the element's
117
+ * accessible title, so hover and assistive tech still report it.
118
+ */
119
+ function kindMarker(
120
+ kind: ActorKind | undefined,
121
+ source: ActorKindSource | undefined,
122
+ ): { label: string; icon: 'bot' | null; inferred: boolean } | null {
123
+ if (kind === 'machine') {
124
+ return { label: 'machine', icon: 'bot', inferred: source === 'inferred' };
125
+ }
126
+ if (kind === 'person' && source === 'inferred') {
127
+ return { label: 'person, inferred', icon: null, inferred: true };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /** The full truth about the kind, for `title`/screen readers — including in the
133
+ * cases that render no marker, so "quiet" never means "undiscoverable". */
134
+ export function actorKindTitle(
135
+ kind: ActorKind | undefined,
136
+ source: ActorKindSource | undefined,
137
+ ): string {
138
+ if (kind === 'machine') {
139
+ return source === 'claim'
140
+ ? 'A machine made this change (declared by the sign-in service).'
141
+ : 'A machine made this change (inferred from the account, not declared).';
142
+ }
143
+ if (kind === 'person') {
144
+ return source === 'claim'
145
+ ? 'A person made this change (declared by the sign-in service).'
146
+ : 'A person made this change (inferred from the account, not declared).';
147
+ }
148
+ return 'Whether a person or a machine made this change was not recorded.';
149
+ }
150
+
57
151
  export interface ActorIdentityProps {
58
152
  actor?: ActorIdentityValue;
59
153
  /** Show the initials avatar. Off for a compact one-line attribution, or where
60
154
  * the surrounding row already paints the same initials. */
61
155
  showInitials?: boolean;
156
+ /** Person, machine, or not recorded. Omitted on every surface that predates
157
+ * the revision trail, which is why it is optional and why omitting it
158
+ * renders byte-identically to before. */
159
+ kind?: ActorKind;
160
+ /** Whether {@link kind} was claimed or guessed. */
161
+ kindSource?: ActorKindSource;
62
162
  className?: string;
63
163
  initialsClassName?: string;
64
164
  handleClassName?: string;
165
+ kindClassName?: string;
65
166
  }
66
167
 
67
168
  export function ActorIdentity({
68
169
  actor,
69
170
  showInitials = true,
171
+ kind,
172
+ kindSource,
70
173
  className = 'flex flex-wrap items-center gap-x-1.5',
71
174
  initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
72
175
  handleClassName = 'text-xs text-muted-foreground/80',
176
+ kindClassName,
73
177
  }: ActorIdentityProps) {
74
178
  const label = actorLabel(actor);
75
- if (!label) return null;
76
- const initials = showInitials ? actorInitials(actor) : '';
179
+ const marker = kindMarker(kind, kindSource);
180
+ // An empty label used to be the signal to render nothing at all, rather than
181
+ // a dangling "by" — and it still is, UNLESS there is a kind worth marking. A
182
+ // service token can carry no email whatsoever, and dropping that row's actor
183
+ // entirely would hide the one fact the actor axis exists to record: that a
184
+ // machine, not a colleague, wrote here.
185
+ if (!label && !marker) return null;
186
+ const initials = showInitials && label ? actorInitials(actor) : '';
77
187
  const handle = actorHandle(actor);
78
188
 
79
189
  return (
80
- <span data-testid="actor-identity" className={className}>
190
+ <span
191
+ data-testid="actor-identity"
192
+ data-actor-kind={kind ?? undefined}
193
+ data-actor-kind-source={kindSource ?? undefined}
194
+ className={className}
195
+ >
81
196
  {initials && <span className={initialsClassName}>{initials}</span>}
82
- <span>{label}</span>
197
+ {label && <span>{label}</span>}
83
198
  {handle && <span className={handleClassName}>{handle}</span>}
199
+ {marker && (
200
+ <span
201
+ data-testid="actor-kind"
202
+ // NOT a warning colour. This is a statement of fact about who typed,
203
+ // and the dark-tenant palette reserves red for failure; a machine
204
+ // edit is routine and must not read as an incident. The icon, not a
205
+ // colour, is what makes it scannable — and `inferred` is drawn with a
206
+ // DASHED border and no fill, so "we guessed" is lighter on the page
207
+ // than "we were told" without needing a second colour.
208
+ className={
209
+ kindClassName ??
210
+ [
211
+ 'inline-flex items-center gap-1 rounded border px-1.5 py-px',
212
+ 'text-[10px] font-medium uppercase tracking-wide',
213
+ marker.inferred
214
+ ? 'border-dashed border-border text-muted-foreground'
215
+ : 'border-border bg-secondary text-secondary-foreground',
216
+ ].join(' ')
217
+ }
218
+ title={actorKindTitle(kind, kindSource)}
219
+ >
220
+ {marker.icon === 'bot' && <Bot className="h-3 w-3" aria-hidden="true" />}
221
+ {marker.label}
222
+ </span>
223
+ )}
84
224
  </span>
85
225
  );
86
226
  }
@@ -0,0 +1,303 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * RecordHistoryPanel — who changed which field, from what, to what, and whether
5
+ * a machine did it (bd startsim-g4rwo; trail bd startsim-o1qib).
6
+ *
7
+ * The customer's ask, in their words: "similar to google sheets we need to be
8
+ * able to see who editted what piece of the text and when". This is that
9
+ * surface. It reads `/api/v1/entities/<id>/revisions/` through an injected
10
+ * client, folds the autosave noise in the browser, and renders each sitting
11
+ * through the shared `ActivityTimeline` — the same primitive, and the same
12
+ * conventions, as the control plane's foundry activity stream, rather than a
13
+ * second timeline (rule #9).
14
+ *
15
+ * CLIENT-INJECTED, exactly like `ActivityFeedPanel`: the app passes
16
+ * `{ list: (p) => api.entities.revisions(id, p) }`, which is what keeps
17
+ * @startsimpli/api out of packages/ui.
18
+ *
19
+ * SERVER-SIDE PAGINATION (rule #8) via `useInfiniteQuery`, and the fold runs
20
+ * over the ACCUMULATED pages rather than per page — otherwise one sitting that
21
+ * straddles a page boundary renders as "3 saves" followed by "2 saves", which
22
+ * is wrong and silently so.
23
+ *
24
+ * ── THE THREE SENTENCES THIS PANEL OWES THE READER ──────────────────────────
25
+ *
26
+ * Each exists because a silence in its place reads as a defect, and each is
27
+ * said ONCE for the panel rather than once per row — which is the whole trick
28
+ * to rendering `unknown` without making the panel look broken:
29
+ *
30
+ * 1. WHAT AN UNMARKED ROW MEANS. For up to the 14-day refresh expiry after the
31
+ * actor_kind rollout, a genuine human edit carries no kind at all (the claim
32
+ * is minted at LOGIN and inherited across refreshes). So `unknown` is the
33
+ * COMMON case for real people, and a per-row badge would make every tenant's
34
+ * panel look broken for two weeks while training readers to ignore the one
35
+ * badge that matters. Instead a machine is ALWAYS marked, an unmarked row is
36
+ * left calm, and this panel explains the convention once — and only when
37
+ * some loaded row is actually unknown.
38
+ * 2. THAT SOMETHING IS BEING WITHHELD. The envelope's `redacted_fields` rolls
39
+ * up every field the per-field read gate held back on this page. Named here
40
+ * because this route serves every historical value a record ever held, so it
41
+ * is a richer leak than the row read — and because a `{}` rendered silently
42
+ * reads as data loss.
43
+ * 3. THAT THE TRAIL IS NOT THE WHOLE STORY. Four write paths record NO revision
44
+ * today — `annotate`, `move-scope`, `DELETE` and the ingestion worker
45
+ * (bd startsim-jkkn7.6). The version sequence is still provably gapless
46
+ * (a write that records nothing allocates no number), so there is no hole to
47
+ * see — which is exactly why it has to be SAID. A reader must not conclude
48
+ * from a quiet trail that no machine touched this record. The panel
49
+ * therefore never claims completeness, and its empty state says "no edits
50
+ * recorded" and not "never changed".
51
+ */
52
+ import { ReactNode, useMemo } from 'react';
53
+ import { useInfiniteQuery } from '@tanstack/react-query';
54
+ import { Bot, FilePlus2, Pencil, ShieldAlert, Tag } from 'lucide-react';
55
+
56
+ import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
57
+ import { ErrorState } from '../states';
58
+ import { RevisionFields } from './RevisionFields';
59
+ import {
60
+ COLLAPSE_WINDOW_MS,
61
+ foldRevisions,
62
+ sittingHeadline,
63
+ sittingSummary,
64
+ type RevisionSitting,
65
+ } from './revision-history';
66
+ import type { EntityRevision, RevisionClient, RevisionPage } from './types';
67
+ import {
68
+ RECORD_HISTORY_PANEL_DEFAULTS,
69
+ type RecordHistoryPanelClassNames,
70
+ } from './record-history-panel-default-class-names';
71
+
72
+ /** Timeline entry `type` per sitting, so a host can re-icon or re-colour one
73
+ * class of row without knowing anything else about this module. */
74
+ export const REVISION_ENTRY_TYPES = {
75
+ created: 'revision_created',
76
+ updated: 'revision_updated',
77
+ renamed: 'revision_renamed',
78
+ attempt: 'revision_attempt',
79
+ machine: 'revision_machine',
80
+ } as const;
81
+
82
+ const DEFAULT_ICONS = {
83
+ [REVISION_ENTRY_TYPES.created]: FilePlus2,
84
+ [REVISION_ENTRY_TYPES.updated]: Pencil,
85
+ [REVISION_ENTRY_TYPES.renamed]: Tag,
86
+ [REVISION_ENTRY_TYPES.attempt]: ShieldAlert,
87
+ [REVISION_ENTRY_TYPES.machine]: Bot,
88
+ };
89
+
90
+ function entryType(sitting: RevisionSitting): string {
91
+ if (sitting.isAttempt) return REVISION_ENTRY_TYPES.attempt;
92
+ if (sitting.action === 'created') return REVISION_ENTRY_TYPES.created;
93
+ if (sitting.actorKind === 'machine') return REVISION_ENTRY_TYPES.machine;
94
+ if (Object.keys(sitting.changed).length === 0 && sitting.name) {
95
+ return REVISION_ENTRY_TYPES.renamed;
96
+ }
97
+ return REVISION_ENTRY_TYPES.updated;
98
+ }
99
+
100
+ /** The version this sitting spans. Shown because `version` is the chain's own
101
+ * gapless sequence, which is the only thing a reader (or a later span-blame
102
+ * pass) can use to tell a complete trail from a lossy one. */
103
+ function versionNote(sitting: RevisionSitting): string {
104
+ return sitting.fromVersion === sitting.toVersion
105
+ ? `v${sitting.toVersion}`
106
+ : `v${sitting.fromVersion}–v${sitting.toVersion}`;
107
+ }
108
+
109
+ /** A 404 here means the caller may not read the PARENT record: the route is
110
+ * nested and `get_object()` resolves the parent through the gated queryset, so
111
+ * an unreadable row is simply absent. That is a DIFFERENT fact from "this
112
+ * record has no history" and must never be rendered as an empty trail. */
113
+ function isNotFound(error: unknown): boolean {
114
+ const status = (error as { status?: number; response?: { status?: number } } | null)?.status
115
+ ?? (error as { response?: { status?: number } } | null)?.response?.status;
116
+ if (status === 404) return true;
117
+ const message = (error as Error | null)?.message ?? '';
118
+ return /\b404\b|not found/i.test(message);
119
+ }
120
+
121
+ export interface RecordHistoryPanelProps {
122
+ /** The injected, authed reader for ONE record's revisions. */
123
+ client: RevisionClient;
124
+ /** Cache namespace, e.g. `['entity', id, 'revisions']`. */
125
+ queryKey?: readonly unknown[];
126
+ pageSize?: number;
127
+ /** How close two writes by the same actor count as one sitting. 0 renders a
128
+ * row per save. See {@link COLLAPSE_WINDOW_MS}. */
129
+ windowMs?: number;
130
+ title?: ReactNode;
131
+ description?: ReactNode;
132
+ headerAside?: ReactNode;
133
+ /** Map a wire field name to a reader-facing label. */
134
+ fieldLabel?: (field: string) => string;
135
+ /** Group entries under day headings. On by default — a record's history is
136
+ * read by date far more often than as a flat list. */
137
+ groupByDay?: boolean;
138
+ emptyTitle?: string;
139
+ emptyDescription?: string;
140
+ errorTitle?: string;
141
+ notFoundTitle?: string;
142
+ notFoundMessage?: string;
143
+ /** The standing caveat that the trail has known silent write paths. Pass
144
+ * `null` only if the surrounding page already says it — not to make the
145
+ * panel look more complete than it is. */
146
+ incompleteNote?: ReactNode;
147
+ classNames?: RecordHistoryPanelClassNames;
148
+ }
149
+
150
+ export function RecordHistoryPanel({
151
+ client,
152
+ queryKey = ['record-history'],
153
+ pageSize = 50,
154
+ windowMs = COLLAPSE_WINDOW_MS,
155
+ title = 'History',
156
+ // The customer's question, not a feature name.
157
+ description = 'Who changed which field, from what to what, newest first.',
158
+ headerAside,
159
+ fieldLabel = (field) => field,
160
+ groupByDay = true,
161
+ emptyTitle = 'No edits recorded for this record',
162
+ // NOT "this record has never changed" — see sentence 3 in the header.
163
+ emptyDescription =
164
+ 'Edits made through the app will appear here, newest first.',
165
+ errorTitle = 'Could not load history',
166
+ notFoundTitle = 'History is not available to you',
167
+ notFoundMessage =
168
+ 'This record, or your access to it, has changed. Reopen the record to see its history.',
169
+ incompleteNote =
170
+ 'Some writes are not recorded yet — bulk imports, scope moves, annotations and deletions leave no entry — so a quiet history is not proof that nothing else touched this record.',
171
+ classNames = {},
172
+ }: RecordHistoryPanelProps) {
173
+ const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
174
+
175
+ const query = useInfiniteQuery<RevisionPage>({
176
+ queryKey: [...queryKey, { pageSize, windowMs }],
177
+ queryFn: ({ pageParam }) => client.list({ page: pageParam as number, pageSize }),
178
+ initialPageParam: 1,
179
+ getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
180
+ });
181
+
182
+ const revisions = useMemo<EntityRevision[]>(
183
+ () => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
184
+ [query.data],
185
+ );
186
+
187
+ // THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
188
+ const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
189
+
190
+ const items = useMemo<ActivityTimelineItem[]>(
191
+ () =>
192
+ sittings.map((sitting) => ({
193
+ id: sitting.id,
194
+ type: entryType(sitting),
195
+ title: sittingHeadline(sitting, fieldLabel),
196
+ occurredAt: sitting.at,
197
+ actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
198
+ actorKind: sitting.actorKind,
199
+ actorKindSource: sitting.actorKindSource,
200
+ // An attempt is the one row that is NOT a change to the record, so it
201
+ // is the one row that earns a tone. `warn` and not `error`: nothing
202
+ // failed — a guard did its job.
203
+ tone: sitting.isAttempt ? 'warn' : undefined,
204
+ subtitle: versionNote(sitting),
205
+ note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
206
+ detail: <RevisionFields sitting={sitting} fieldLabel={fieldLabel} />,
207
+ })),
208
+ [sittings, fieldLabel],
209
+ );
210
+
211
+ // Sentence 1 — only when some loaded row really is unknown, so a tenant past
212
+ // the healing window never sees it.
213
+ const anyUnknownKind = useMemo(
214
+ () => sittings.some((s) => s.actorKind === 'unknown'),
215
+ [sittings],
216
+ );
217
+
218
+ // Sentence 2 — the server's own roll-up over this page's rows, unioned
219
+ // across the pages loaded so far.
220
+ const withheld = useMemo(() => {
221
+ const names = new Set<string>();
222
+ for (const page of query.data?.pages ?? []) {
223
+ for (const name of page?.redacted_fields ?? page?.redactedFields ?? []) names.add(name);
224
+ }
225
+ return [...names].sort();
226
+ }, [query.data]);
227
+
228
+ const total = query.data?.pages?.[0]?.count ?? revisions.length;
229
+ const notFound = query.isError && isNotFound(query.error);
230
+
231
+ const loadMore = () => {
232
+ if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
233
+ };
234
+
235
+ return (
236
+ <section className={cx.root} data-testid="record-history-panel">
237
+ <div className={cx.header}>
238
+ <div className={cx.heading}>
239
+ <h2 className={cx.title}>{title}</h2>
240
+ {description && <p className={cx.description}>{description}</p>}
241
+ </div>
242
+ {headerAside}
243
+ </div>
244
+
245
+ {anyUnknownKind && (
246
+ <p className={cx.notice} data-testid="record-history-unknown-kind">
247
+ Entries marked <strong className="font-medium text-foreground">machine</strong> were
248
+ made by an automation. For the rest, whether a person or a machine made the edit was
249
+ not recorded — it is not available for edits made before this was switched on, or by
250
+ anyone who has not signed in since.
251
+ </p>
252
+ )}
253
+
254
+ {withheld.length > 0 && (
255
+ <p className={cx.notice} data-testid="record-history-redacted">
256
+ Some values are not shown to you:{' '}
257
+ {withheld.map((f) => fieldLabel(f)).join(', ')}. The edits are recorded and listed —
258
+ your access does not include reading those fields on this record.
259
+ </p>
260
+ )}
261
+
262
+ <div className={cx.body}>
263
+ {notFound ? (
264
+ <ErrorState title={notFoundTitle} message={notFoundMessage} />
265
+ ) : query.isError ? (
266
+ <ErrorState
267
+ title={errorTitle}
268
+ message={(query.error as Error)?.message || 'Please try again.'}
269
+ onRetry={() => query.refetch()}
270
+ />
271
+ ) : (
272
+ <ActivityTimeline
273
+ activities={items}
274
+ loading={query.isLoading}
275
+ groupByDay={groupByDay}
276
+ autoLoadMore
277
+ hasMore={!!query.hasNextPage}
278
+ onLoadMore={loadMore}
279
+ emptyTitle={emptyTitle}
280
+ emptyDescription={emptyDescription}
281
+ activityIcons={DEFAULT_ICONS}
282
+ defaultIcon={Pencil}
283
+ loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
284
+ />
285
+ )}
286
+ </div>
287
+
288
+ {revisions.length > 0 && total > revisions.length && (
289
+ <p className={cx.countLine}>
290
+ Showing {sittings.length} {sittings.length === 1 ? 'entry' : 'entries'} from{' '}
291
+ {revisions.length} of {total} saves.
292
+ </p>
293
+ )}
294
+
295
+ {/* Sentence 3 — always, when there is anything to qualify. */}
296
+ {incompleteNote && !query.isError && (
297
+ <p className={cx.countLine} data-testid="record-history-incomplete">
298
+ {incompleteNote}
299
+ </p>
300
+ )}
301
+ </section>
302
+ );
303
+ }