From 50a79dd8e39fd9938ffa85ed6c5002c428d1fb27 Mon Sep 17 00:00:00 2001 From: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:11:49 +0300 Subject: [PATCH] fix(activity): plain sentences, not JSON and event codes (#140) Every audit action has a sentence of its own in five languages. The action list lives in @kept/shared; audited(), the server's English and the web's sentences are typed by it, so a new action without a sentence fails the typecheck. A map arrangement is said change by change ("Put HD webcam on Corner desk"), more than five behind Show, and its layout JSON is no longer sent. Ids, versions, counters, digits, locale and filters are hidden; a report's things and photos become lines. A field set from nothing shows its value without "empty". An upload folds into the row that used it, and a capture's extraction into the capture (D253). Closes #140 Signed-off-by: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> --- .../assets/screens/activity-desktop-dark.webp | Bin 0 -> 39410 bytes .../screens/activity-desktop-light.webp | Bin 0 -> 40388 bytes .../src/content/docs/users/everyday-use.md | 7 + apps/server/src/admin/backup-routes.ts | 3 +- apps/server/src/admin/cli.ts | 3 +- apps/server/src/admin/routes.ts | 7 +- apps/server/src/audit/audited.test.ts | 6 +- apps/server/src/audit/audited.ts | 6 +- apps/server/src/audit/undo.ts | 15 +- apps/server/src/extraction/extraction.test.ts | 4 +- apps/server/src/history/arrange.ts | 131 ++++ apps/server/src/history/history.test.ts | 170 ++++- apps/server/src/history/service.ts | 171 ++++- apps/server/src/history/summary.test.ts | 214 ++++++ apps/server/src/history/summary.ts | 502 +++++++++++-- apps/server/src/imports/archive.ts | 7 +- apps/server/src/lending/service.ts | 4 +- apps/server/src/locations/membership-jobs.ts | 3 +- apps/server/src/notices/transparency.ts | 4 +- apps/server/src/notify/prefs.ts | 3 +- apps/server/src/registries/account.ts | 4 +- apps/server/src/schedules/service.ts | 4 +- apps/server/src/schedules/services.ts | 4 +- apps/server/src/schedules/undo.ts | 2 +- apps/server/src/things/move.ts | 2 +- apps/server/src/things/service.ts | 3 +- apps/web/src/api/inventory/mock/db.ts | 6 +- apps/web/src/api/inventory/mock/fixtures.ts | 76 ++ apps/web/src/api/inventory/types.ts | 5 + .../web/src/components/history/fields.test.ts | 34 + apps/web/src/components/history/fields.ts | 79 +++ .../src/components/history/labels.test.tsx | 50 ++ apps/web/src/components/history/labels.ts | 670 +++++++++++------- apps/web/src/components/history/timeline.tsx | 110 ++- apps/web/src/locales/ar/messages.po | 552 +++++++++++++++ apps/web/src/locales/de/messages.po | 552 +++++++++++++++ apps/web/src/locales/en/messages.po | 552 +++++++++++++++ apps/web/src/locales/fr/messages.po | 552 +++++++++++++++ apps/web/src/locales/it/messages.po | 552 +++++++++++++++ apps/web/src/test/screens/history.test.tsx | 160 +++++ docs/specs/2026-09-25-kept-product-design.md | 1 + packages/shared/src/audit-actions.ts | 379 ++++++++++ packages/shared/src/index.ts | 1 + 43 files changed, 5224 insertions(+), 386 deletions(-) create mode 100644 apps/docs/src/assets/screens/activity-desktop-dark.webp create mode 100644 apps/docs/src/assets/screens/activity-desktop-light.webp create mode 100644 apps/server/src/history/arrange.ts create mode 100644 apps/server/src/history/summary.test.ts create mode 100644 apps/web/src/components/history/fields.test.ts create mode 100644 apps/web/src/components/history/fields.ts create mode 100644 apps/web/src/components/history/labels.test.tsx create mode 100644 packages/shared/src/audit-actions.ts diff --git a/apps/docs/src/assets/screens/activity-desktop-dark.webp b/apps/docs/src/assets/screens/activity-desktop-dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..8040c10e1a9d534ef4fe8d07615ad02d9ba80c33 GIT binary patch literal 39410 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

OP6_V-1D)Raj5>YQ6`2^DObm?@ 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 literal 0 HcmV?d00001 diff --git a/apps/docs/src/assets/screens/activity-desktop-light.webp b/apps/docs/src/assets/screens/activity-desktop-light.webp new file mode 100644 index 0000000000000000000000000000000000000000..20153c34be20992df442534f274940edd8c034fe GIT binary patch literal 40388 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

YcoArEEVfW$U30xwbFLAYguyEq>zM5GuYbg6|t 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 literal 0 HcmV?d00001 diff --git a/apps/docs/src/content/docs/users/everyday-use.md b/apps/docs/src/content/docs/users/everyday-use.md index a6555abe..bd4995ad 100644 --- a/apps/docs/src/content/docs/users/everyday-use.md +++ b/apps/docs/src/content/docs/users/everyday-use.md @@ -117,6 +117,13 @@ tracks: **Details**, **Paperwork and warranties**, **Value**, **Meters**, **Loan Trash. - **History** lists every change with who made it; most changes can be undone from there or from the toast that follows them. **Activity** in the menu is the same across your locations. + Each change is one sentence: "Put Galaxy S23 on the map in Office", "Captured Bosch drill, 18 V + from a photo", "Made an insurance report for Home". A field set for the first time shows its value; a + map arrangement with more than five changes says how many, and **Show** lists them. + + ![Activity in Home: Arranged the map with four changes listed under it, a capture showing the name and model it got, and an insurance report with 8 things and photos](../../../assets/screens/activity-desktop-light.webp) + + ![The same activity in the dark theme](../../../assets/screens/activity-desktop-dark.webp) - **Trash** keeps trashed things for 30 days; members and above restore, admins delete for good. - **Passwords and codes** (the Complete preset) are secret fields: hidden until someone allowed to reveals one, which is logged. diff --git a/apps/server/src/admin/backup-routes.ts b/apps/server/src/admin/backup-routes.ts index c70d17da..e8b7c4ad 100644 --- a/apps/server/src/admin/backup-routes.ts +++ b/apps/server/src/admin/backup-routes.ts @@ -1,4 +1,5 @@ import { + type AuditAction, BACKUP_PASSWORD_MAX, BACKUP_PASSWORD_MIN, BACKUP_RUN_KINDS, @@ -117,7 +118,7 @@ async function isInstanceAdmin(deps: InventoryDeps, req: FastifyRequest) { function auditInstance( tx: Tx, req: FastifyRequest, - action: string, + action: AuditAction, after: Record, ) { return audited(tx, { diff --git a/apps/server/src/admin/cli.ts b/apps/server/src/admin/cli.ts index fb0970b5..ead156c5 100644 --- a/apps/server/src/admin/cli.ts +++ b/apps/server/src/admin/cli.ts @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import type { AuditAction } from '@kept/shared'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import { audited } from '../audit/audited.js'; @@ -75,7 +76,7 @@ export async function asOwner( function auditSystem( tx: Tx, - action: string, + action: AuditAction, entity: { type: string; id: string | null }, extra: { locationId?: string; diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index cf1ecdbe..0cc75e53 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -1,3 +1,4 @@ +import type { AuditAction } from '@kept/shared'; import { sql } from 'drizzle-orm'; import type { FastifyRequest } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; @@ -209,7 +210,7 @@ async function isInstanceAdmin(pools: Pick, req: FastifyRequest) { function auditInstance( tx: Tx, req: FastifyRequest, - action: string, + action: AuditAction, entity: { type: string; id?: string | null }, change: { before?: Record; after?: Record } = {}, ) { @@ -387,7 +388,7 @@ export async function adminRoutes(app: KeptApp, deps: AdminDeps): Promise const userAction = ( path: string, action: AdminAction, - auditAction: string, + auditAction: AuditAction, work: (userId: string) => Promise, guard?: (req: FastifyRequest, target: AuthUserRow) => void, ) => @@ -620,7 +621,7 @@ export async function adminRoutes(app: KeptApp, deps: AdminDeps): Promise /** Retry or discard one failed job: audited in the admin's transaction, done by pg-boss on * kept_system inside it, so a refused audit leaves the job as it was. */ - const jobAction = (path: 'retry' | 'discard', auditAction: string) => + const jobAction = (path: 'retry' | 'discard', auditAction: AuditAction) => admin.post( `/api/v1/admin/jobs/failed/:id/${path}`, { schema: { params: UserId } }, diff --git a/apps/server/src/audit/audited.test.ts b/apps/server/src/audit/audited.test.ts index 0e4064d3..616d9225 100644 --- a/apps/server/src/audit/audited.test.ts +++ b/apps/server/src/audit/audited.test.ts @@ -159,7 +159,7 @@ describe('audited()', () => { audited(tx, { locationId: t.locationId, actor: { type: 'user', id: t.userId }, - action: 'invite.update', + action: 'invite.create', entity: { type: 'invite', id: inviteId }, before: { tokenHash: oldHash, role: 'member' }, after: { tokenHash: newHash, role: 'viewer' }, @@ -181,7 +181,7 @@ describe('audited()', () => { audited(tx, { locationId: t.locationId, actor: { type: 'user', id: t.userId }, - action: 'invite.update', + action: 'invite.create', entity: { type: 'invite', id: newId() }, before: { tokenHash: 'a' }, after: { tokenHash: 'b' }, @@ -263,7 +263,7 @@ describe('audited()', () => { locationId: null, ownerAccountId: t.accountId, actor: { type: 'user', id: t.userId }, - action: 'account.update', + action: 'account.email_change', entity: { type: 'account', id: t.accountId }, before: { x: 1 }, after: { x: 2 }, diff --git a/apps/server/src/audit/audited.ts b/apps/server/src/audit/audited.ts index 77c8c1b6..97a45535 100644 --- a/apps/server/src/audit/audited.ts +++ b/apps/server/src/audit/audited.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; -import { newId } from '@kept/shared'; +import { type AuditAction, newId } from '@kept/shared'; import { type SQL, sql } from 'drizzle-orm'; import { type ACTOR_TYPES, auditEventSubjects, auditEvents } from '../db/schema/index.js'; import type { Tx } from '../db/scope.js'; @@ -32,8 +32,8 @@ export type AuditEventInput = { /** Filled from the location when omitted. Pass it for account-level events (§7.13). */ ownerAccountId?: string | null; actor: AuditActor; - /** `.`, e.g. `location.update`. */ - action: string; + /** `.`, e.g. `location.update`: one of @kept/shared's AUDIT_ACTIONS (#140). */ + action: AuditAction; entity: { type: string; id?: string | null }; /** The row before the write (null or omitted for a create) and after it (null for a delete). * Top-level keys may be camelCase (Drizzle rows) or snake_case; the diff stores snake_case. */ diff --git a/apps/server/src/audit/undo.ts b/apps/server/src/audit/undo.ts index 7c5f4a22..1a574c01 100644 --- a/apps/server/src/audit/undo.ts +++ b/apps/server/src/audit/undo.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; -import { can, type Role } from '@kept/shared'; +import { type AuditAction, can, type Role } from '@kept/shared'; import type { FastifyBaseLogger } from 'fastify'; import type pg from 'pg'; import { z } from 'zod'; @@ -55,7 +55,8 @@ export type UndoableEvent = { ownerAccountId: string | null; actorType: string; actorId: string | null; - action: string; + /** A registered handler's action, so an undo writes it back as it is (#140). */ + action: AuditAction; entityType: string; entityId: string | null; rootThingId: string | null; @@ -116,7 +117,7 @@ const HANDLERS = new Map(); const ACCOUNT_HANDLERS = new Map(); /** Registers the handler for an account-level `action` (e.g. `fx_rate.set`). One per action. */ -export function registerAccountUndo(action: string, handler: AccountUndoHandler): void { +export function registerAccountUndo(action: AuditAction, handler: AccountUndoHandler): void { if (ACCOUNT_HANDLERS.has(action) && ACCOUNT_HANDLERS.get(action) !== handler) { throw new Error(`account undo handler for ${action} is already registered`); } @@ -124,7 +125,7 @@ export function registerAccountUndo(action: string, handler: AccountUndoHandler) } /** Registers the handler for `action` (e.g. `place.update`). One handler per action. */ -export function registerUndo(action: string, handler: UndoHandler): void { +export function registerUndo(action: AuditAction, handler: UndoHandler): void { if (HANDLERS.has(action) && HANDLERS.get(action) !== handler) { throw new Error(`undo handler for ${action} is already registered`); } @@ -324,7 +325,8 @@ export async function undoEvent( ownerAccountId: e.owner_account_id, actorType: e.actor_type, actorId: e.actor_id, - action: e.action, + // Only an action with a handler gets here, and handlers are registered by AuditAction. + action: e.action as AuditAction, entityType: e.entity_type, entityId: e.entity_id, rootThingId: e.root_thing_id, @@ -406,7 +408,8 @@ async function undoAccountEvent( ownerAccountId: accountId, actorType: e.actor_type, actorId: e.actor_id, - action: e.action, + // Only an action with a handler gets here, and handlers are registered by AuditAction. + action: e.action as AuditAction, entityType: e.entity_type, entityId: e.entity_id, rootThingId: e.root_thing_id, diff --git a/apps/server/src/extraction/extraction.test.ts b/apps/server/src/extraction/extraction.test.ts index 7c5b485b..14017cdd 100644 --- a/apps/server/src/extraction/extraction.test.ts +++ b/apps/server/src/extraction/extraction.test.ts @@ -455,7 +455,9 @@ describe('a thing’s history shows its AI calls (step 6, T18; D206, §7.15)', ( ok(await call(t, `/api/v1/things/${thingId}/history${q}`, { as })) as unknown as Page; const all = await history(louis); expect(all.items.map((e) => e.action)).toContain('ai.call'); - expect(all.items.map((e) => e.action)).toContain('thing.extract'); + // #140: the extraction joins its capture's row, one row per action. + expect(all.items.map((e) => e.action)).toContain('thing.capture'); + expect(all.items.map((e) => e.action)).not.toContain('thing.extract'); const ai = await history(louis, '?kind=ai'); expect(ai.items).toHaveLength(1); expect(ai.items[0]).toMatchObject({ diff --git a/apps/server/src/history/arrange.ts b/apps/server/src/history/arrange.ts new file mode 100644 index 00000000..2d5a1ea6 --- /dev/null +++ b/apps/server/src/history/arrange.ts @@ -0,0 +1,131 @@ +import type { SummaryLine } from '@kept/shared'; + +// An arrange session in words (#140): the stored before/after layouts (map/service.ts LayoutImage) +// compared node by node, one line per change, so the activity row says "Put HD webcam on Corner +// desk" instead of two layouts of JSON. Names are whatever the reader may see (history/service.ts +// resolves them as the caller, under the policies); a node they can't see reads as "a thing" or +// "a place". Shapes, room kinds and floors travel as codes the web says in the reader's language. + +/** A layout row as place.arrange stores it (map/service.ts LayoutImage). */ +export type ArrangedNode = { + id: string; + s: 'place' | 'thing'; + x: number; + z: number; + w: number | null; + d: number | null; + rot: number; + model: string | null; + kind: string | null; + surface: string | null; + host: string | null; +}; + +/** What the reader may see of a node: its name, and the place it is in (for "in Study"). */ +export type NodeName = { name: string; room: string | null }; + +/** The layout of a place.arrange diff side, or null when it isn't one. */ +export function layoutOf(value: unknown): ArrangedNode[] | null { + if (!Array.isArray(value)) return null; + return value.filter( + (n): n is ArrangedNode => + !!n && typeof n === 'object' && typeof (n as ArrangedNode).id === 'string', + ); +} + +const isRoom = (n: ArrangedNode) => n.s === 'place' && n.kind !== null; + +/** Every change between two layouts, rooms first, then the rest, each in name order. */ +export function arrangeLines( + before: readonly ArrangedNode[], + after: readonly ArrangedNode[], + names: ReadonlyMap, +): SummaryLine[] { + const was = new Map(before.map((n) => [n.id, n])); + const now = new Map(after.map((n) => [n.id, n])); + const nameOf = (id: string, s: 'place' | 'thing') => + names.get(id)?.name ?? (s === 'place' ? 'a place' : 'a thing'); + const hostName = (id: string) => { + const host = now.get(id) ?? was.get(id); + return nameOf(id, host?.s ?? 'thing'); + }; + // A node whose host moved or turned moves with it: that is the host's line, not its own. + const hostShifted = (n: ArrangedNode) => { + const a = n.host ? was.get(n.host) : undefined; + const b = n.host ? now.get(n.host) : undefined; + return !!a && !!b && (a.x !== b.x || a.z !== b.z || a.rot !== b.rot); + }; + + const ids = [...new Set([...was.keys(), ...now.keys()])]; + const node = (id: string) => (now.get(id) ?? was.get(id)) as ArrangedNode; + ids.sort((a, b) => { + const ra = isRoom(node(a)) ? 0 : 1; + const rb = isRoom(node(b)) ? 0 : 1; + if (ra !== rb) return ra - rb; + const na = nameOf(a, node(a).s); + const nb = nameOf(b, node(b).s); + return na.localeCompare(nb) || (a < b ? -1 : 1); + }); + + const out: SummaryLine[] = []; + for (const id of ids) { + const a = was.get(id); + const b = now.get(id); + const n = node(id); + const name = nameOf(id, n.s); + if (!a && b) { + if (b.host) out.push({ key: 'map.on', params: { name, host: hostName(b.host) } }); + else { + const room = isRoom(b) ? null : (names.get(id)?.room ?? null); + out.push( + room + ? { key: 'map.put.room', params: { name, room } } + : { key: 'map.put', params: { name } }, + ); + } + continue; + } + if (a && !b) { + out.push({ key: 'map.remove', params: { name } }); + continue; + } + if (!a || !b) continue; + if (a.host !== b.host) { + if (b.host) out.push({ key: 'map.on', params: { name, host: hostName(b.host) } }); + else if (a.host) out.push({ key: 'map.off', params: { name, host: hostName(a.host) } }); + } else if ((a.x !== b.x || a.z !== b.z) && !(b.host && hostShifted(b))) { + out.push({ key: 'map.move', params: { name } }); + } + if (a.rot !== b.rot && !(b.host && a.host === b.host && hostShifted(b))) { + out.push({ key: 'map.turn', params: { name } }); + } + if (a.model !== b.model && a.model && b.model) { + out.push({ + key: 'map.shape', + params: { name, from: a.model, to: b.model, vocab: 'model' }, + }); + } + if (a.w !== b.w || a.d !== b.d) out.push({ key: 'map.resize', params: { name } }); + if (a.kind !== b.kind && a.kind && b.kind) { + out.push({ key: 'map.kind', params: { name, from: a.kind, to: b.kind, vocab: 'kind' } }); + } + if (a.surface !== b.surface && a.surface && b.surface) { + out.push({ + key: 'map.floor', + params: { name, from: a.surface, to: b.surface, vocab: 'surface' }, + }); + } + } + return out; +} + +/** The ids of the nodes an arrangement touched, by side, for resolving their names. */ +export function arrangedIds(diff: unknown): { things: string[]; places: string[] } { + const layout = (diff as { layout?: { before?: unknown; after?: unknown } } | null)?.layout; + const all = [...(layoutOf(layout?.before) ?? []), ...(layoutOf(layout?.after) ?? [])]; + const things = new Set(); + const places = new Set(); + // A host is a node of the layout too, so it is named with the rest. + for (const n of all) (n.s === 'place' ? places : things).add(n.id); + return { things: [...things], places: [...places] }; +} diff --git a/apps/server/src/history/history.test.ts b/apps/server/src/history/history.test.ts index db8da8ec..226d0a4a 100644 --- a/apps/server/src/history/history.test.ts +++ b/apps/server/src/history/history.test.ts @@ -131,6 +131,7 @@ type HistoryEvent = { summary: string; summaryKey: string; summaryParams: Record; + summaryLines?: { key: string; params: Record; text: string }[]; movedInFromElsewhere?: true; }; type Page = { items: HistoryEvent[]; next_cursor: string | null }; @@ -633,8 +634,8 @@ describe('GET /api/v1/activity (D174)', () => { const items = (await feed(as, '?limit=200')).items; expect(items.find((e) => e.id === brandEvent)).toMatchObject({ location_id: null, - summaryKey: 'event', - summaryParams: { action: 'brand.create' }, + // #140: every action has a sentence of its own; `event` is for unknown ones only. + summaryKey: 'brand.create', summary: 'Brand added', }); } @@ -731,3 +732,168 @@ describe('GET /api/v1/locations/:id/actors', () => { expect((await get(bob, `/api/v1/locations/${home.id}/actors`)).statusCode).toBe(404); }); }); + +describe('#140: plain sentences, not JSON and event codes', () => { + const plain = (before: unknown, after: unknown) => ({ before, after, class: 'plain' }); + const node = (id: string, s: 'thing' | 'place', more: Record = {}) => ({ + id, + s, + x: 100, + z: 100, + w: null, + d: null, + rot: 0, + model: s === 'thing' ? 'device' : 'desk', + kind: null, + surface: null, + host: null, + ...more, + }); + const arrange = (before: unknown[], after: unknown[]) => + event({ + loc: home, + actor: max, + action: 'place.arrange', + entity: { type: 'location', id: home.id }, + rootThingId: null, + diff: { layout: plain(before, after) }, + }); + const one = async (as: Person, id: string) => + (await feed(as, `?locationId=${home.id}&limit=200`)).items.find((e) => e.id === id); + + it('says an arrangement in words, and never sends its layout', async () => { + const study = await place(home, 'Study'); + const desk = await place(home, 'Corner desk'); + const webcam = newId(); + await own( + `INSERT INTO public.things (id, location_id, place_id, name) VALUES ($1, $2, $3, $4)`, + [webcam, home.id, study, 'HD webcam'], + ); + const lamp = await thing(home, 'Desk lamp'); + const theirs = await thing(bobs, 'Bob radio'); + const id = await arrange( + [node(desk, 'place'), node(lamp, 'thing', { model: 'lamp' })], + [ + node(desk, 'place'), + node(lamp, 'thing', { model: 'device', host: desk }), + node(webcam, 'thing'), + node(theirs, 'thing'), + ], + ); + const row = await one(max, id); + expect(row?.summaryKey).toBe('place.arrange'); + expect(row?.summary).toBe('Arranged the map'); + expect(row?.summaryLines?.map((l) => l.text)).toEqual([ + 'Put a thing on the map', + 'Put Desk lamp on Corner desk', + 'Changed the shape of Desk lamp: lamp → device', + 'Put HD webcam on the map in Study', + ]); + expect(row?.summaryLines?.[2]).toMatchObject({ + key: 'map.shape', + params: { from: 'lamp', to: 'device', vocab: 'model' }, + }); + // The layout is in the lines; neither it nor the other location's thing's name is sent. + expect(row?.diff).toEqual({}); + expect(JSON.stringify(row)).not.toContain('Bob radio'); + expect(JSON.stringify(row)).not.toContain('"layout"'); + // Bob sees no row of a location he can't see. + expect((await feed(bob, '?limit=200')).items.some((e) => e.id === id)).toBe(false); + }); + + it('counts a busy arrangement, with every change in its lines', async () => { + const things = await Promise.all( + Array.from({ length: 6 }, (_, i) => thing(home, `Box ${i + 1}`)), + ); + const id = await arrange( + things.map((x) => node(x, 'thing')), + things.map((x) => node(x, 'thing', { x: 300, rot: 90 })), + ); + const row = await one(ola, id); + expect(row?.summaryKey).toBe('place.arrange.many'); + expect(row?.summaryParams.count).toBe('12'); + expect(row?.summary).toBe('Arranged the map: 12 changes'); + expect(row?.summaryLines).toHaveLength(12); + expect(row?.summaryLines?.slice(0, 2).map((l) => l.text)).toEqual([ + 'Moved Box 1', + 'Turned Box 1', + ]); + }); + + it('folds an upload into the row that took it up, and an extraction into its capture', async () => { + const lamp = await thing(home, 'Reading lamp'); + const fileId = newId(); + const upload = await event({ + loc: home, + actor: max, + action: 'file.upload', + entity: { type: 'file', id: fileId }, + rootThingId: null, + diff: { mime: plain(null, 'image/jpeg'), bytes: plain(null, 1000) }, + }); + const attach = await event({ + loc: home, + actor: max, + action: 'attachment.create', + entity: { type: 'attachment', id: newId() }, + rootThingId: lamp, + diff: { role: plain(null, 'photo'), file_id: plain(null, fileId) }, + }); + const lone = newId(); + const alone = await event({ + loc: home, + actor: max, + action: 'file.upload', + entity: { type: 'file', id: lone }, + rootThingId: null, + }); + const keyboard = await thing(home, 'Wireless keyboard'); + const capture = await event({ + loc: home, + actor: max, + action: 'thing.capture', + entity: { type: 'thing', id: keyboard }, + diff: { + mode: plain(null, 'thing'), + file_ids: plain(null, [newId()]), + name: plain(null, null), + }, + }); + const extract = await event({ + loc: home, + actor: max, + action: 'thing.extract', + entity: { type: 'thing', id: keyboard }, + diff: { name: plain(null, 'Wireless keyboard') }, + }); + const items = (await feed(max, `?locationId=${home.id}&limit=200`)).items; + const ids = items.map((e) => e.id); + expect(ids).not.toContain(upload); + expect(ids).toContain(alone); + expect(ids).not.toContain(extract); + expect(items.find((e) => e.id === attach)?.summary).toBe('Added a photo to Reading lamp'); + const row = items.find((e) => e.id === capture); + expect(row?.summary).toBe('Captured Wireless keyboard from a photo'); + expect(row?.diff?.name).toEqual(plain(null, 'Wireless keyboard')); + }); + + it("says a report's kind and what it covered, by the location's name", async () => { + const id = await event({ + loc: home, + actor: ola, + action: 'report.generate', + entity: { type: 'report', id: newId() }, + rootThingId: null, + diff: { + kind: plain(null, 'insurance'), + include: plain(null, { photos: true }), + filters: plain(null, { thingIds: [newId(), newId()] }), + locale: plain(null, 'en'), + digits: plain(null, 'latn'), + }, + }); + const row = await one(ola, id); + expect(row?.summary).toBe('Made an insurance report for Home'); + expect(row?.summaryLines?.map((l) => l.text)).toEqual(['2 things', 'With photos']); + }); +}); diff --git a/apps/server/src/history/service.ts b/apps/server/src/history/service.ts index 94ecf1db..0083d36c 100644 --- a/apps/server/src/history/service.ts +++ b/apps/server/src/history/service.ts @@ -3,6 +3,7 @@ import { ACTIVITY_KINDS, LEDGER_TASKS, type LedgerTask, + SUMMARY_LINE_KEYS, tsQuery, } from '@kept/shared'; import type pg from 'pg'; @@ -22,6 +23,7 @@ import { invalid, notFound } from '../http/errors.js'; import { filterOf, lowerIds, manyOf, matchOf, notOf, When } from '../http/list-filters.js'; import { requireMembership } from '../locations/access.js'; import { gateFor } from '../serialize/gates.js'; +import { arrangedIds, type NodeName } from './arrange.js'; import { type Summary, summarise } from './summary.js'; // History and the activity feed (T21; D76, D110, D150, D174, D183; engineering spec §7.2, §7.5). @@ -88,6 +90,17 @@ export const HistoryEventSchema = z.object({ summary: z.string(), summaryKey: z.string(), summaryParams: z.record(z.string(), z.string()), + /** #140: what the action covered, one sentence each (an arrangement's changes, a report's + * counts), as a key and params the web localises, with the English. */ + summaryLines: z + .array( + z.object({ + key: z.enum(SUMMARY_LINE_KEYS), + params: z.record(z.string(), z.string()), + text: z.string(), + }), + ) + .optional(), movedInFromElsewhere: z.literal(true).optional(), /** A thing's history: an event of a thing merged into it (D36), "merged from " (T15). */ mergedFrom: z.object({ id: z.uuid(), name: z.string().nullable() }).optional(), @@ -181,6 +194,21 @@ const NOT_A_SECOND_COPY = `NOT (e.action = 'thing.move' AND e.diff ? 'location_i AND e.location_id::text = e.diff->'location_id'->>'before' AND (e.diff->'location_id'->>'after') IN (SELECT v::text FROM kept.visible_location_ids() v))`; +/** + * One row per action (#140): an upload that a capture, an attachment or a claim's file took up is + * part of that row ("Added a photo to Desk lamp"), not one of its own. The audit keeps both; the + * row that names the file (`file_id`, or a capture's `file_ids`) comes after the upload, within a + * day, in the same location, so the location's index finds it. + */ +const NOT_A_FOLDED_UPLOAD = `NOT (e.action = 'file.upload' AND e.entity_id IS NOT NULL + AND EXISTS (SELECT 1 FROM public.audit_events c + WHERE c.location_id = e.location_id + AND c.at >= e.at AND c.at < e.at + interval '1 day' + AND c.id <> e.id AND c.action <> 'file.upload' + AND (c.diff->'file_id'->>'after' = e.entity_id::text + OR (jsonb_typeof(c.diff->'file_ids'->'after') = 'array' + AND c.diff->'file_ids'->'after' ? e.entity_id::text))))`; + type Key = [string, string]; class Where { @@ -223,6 +251,8 @@ async function eventsPage( const after = afterKey(page.cursor); if (after) where.add((v) => `(e.at, e.id) < (${v(after[0])}::timestamptz, ${v(after[1])}::uuid)`); where.add(() => NOT_A_SECOND_COPY); + where.add(() => NOT_A_FOLDED_UPLOAD); + where.add(() => NOT_A_FOLDED_EXTRACTION); where.values.push(page.limit + 1); const { rows } = await client.query( `SELECT ${EVENT_COLUMNS} @@ -354,6 +384,7 @@ async function labelsOf( const NAME_SOURCES: Record = { thing: 'public.things', place: 'public.places', + location: 'public.locations', brand: 'public.brands', vendor: 'public.vendors', tag: 'public.tags', @@ -373,6 +404,102 @@ export function stockServiceOf(e: { return null; } +/** A vehicle history report's thing, from its diff. */ +function reportThingOf(e: { diff: unknown }): string | null { + const thing = (e.diff as Record | null)?.thing_id?.after; + return typeof thing === 'string' ? thing : null; +} + +/** + * The nodes of the page's arrangements (#140) as the caller may see them: each one's name, and + * the place it is in now, for "Put HD webcam on the map in Study". Read as the caller, so a thing + * or place they can't see has no entry and reads as "a thing" / "a place". + */ +async function arrangedNodesOf( + client: pg.ClientBase, + events: readonly EventRecord[], +): Promise> { + const things = new Set(); + const places = new Set(); + for (const e of events) { + if (e.action !== 'place.arrange') continue; + const ids = arrangedIds(e.diff); + for (const id of ids.things) things.add(id); + for (const id of ids.places) places.add(id); + } + const out = new Map(); + if (things.size === 0 && places.size === 0) return out; + const { rows } = await client.query<{ id: string; name: string | null; room: string | null }>( + `SELECT t.id, t.name, p.name AS room + FROM public.things t LEFT JOIN public.places p ON p.id = t.place_id + WHERE t.id = ANY ($1::uuid[]) + UNION ALL + SELECT c.id, c.name, p.name AS room + FROM public.places c LEFT JOIN public.places p ON p.id = c.parent_id + WHERE c.id = ANY ($2::uuid[])`, + [[...things], [...places]], + ); + for (const r of rows) if (r.name) out.set(r.id, { name: r.name, room: r.room }); + return out; +} + +/** + * One row per action (#140): a capture's extraction (the AI filling in the thing from the photo) + * is part of the capture's row, its changes shown there. The audit keeps both events. The page + * leaves out an extraction that follows its capture within half an hour (so paging never splits + * them), and the capture's row reads the extraction's diff, rendered for the same reader. A later + * re-read, or an undo, keeps its own row. + */ +const NOT_A_FOLDED_EXTRACTION = `NOT (e.action = 'thing.extract' AND e.undo_of IS NULL + AND e.entity_id IS NOT NULL + AND EXISTS (SELECT 1 FROM public.audit_events c + WHERE c.location_id = e.location_id AND c.action = 'thing.capture' + AND c.entity_id = e.entity_id AND c.undo_of IS NULL + AND c.at <= e.at AND c.at > e.at - interval '30 minutes'))`; + +/** The extractions folded into the page's captures, by capture event id. */ +async function extractionsOf( + client: pg.ClientBase, + events: readonly EventRecord[], +): Promise> { + const captures = events.filter( + (e) => e.action === 'thing.capture' && !e.undo_of && e.entity_id && e.location_id, + ); + const out = new Map(); + if (captures.length === 0) return out; + const { rows } = await client.query( + `SELECT ${EVENT_COLUMNS} + FROM public.audit_events e ${ACTOR_JOIN} + WHERE e.action = 'thing.extract' AND e.undo_of IS NULL + AND e.location_id = ANY ($1::uuid[]) AND e.entity_id = ANY ($2::uuid[]) + AND e.at >= $3::timestamptz AND e.at < $4::timestamptz + interval '30 minutes' + ORDER BY e.at`, + [ + [...new Set(captures.map((c) => c.location_id))], + [...new Set(captures.map((c) => c.entity_id))], + new Date(Math.min(...captures.map((c) => c.at.getTime()))).toISOString(), + new Date(Math.max(...captures.map((c) => c.at.getTime()))).toISOString(), + ], + ); + for (const c of captures) { + // The latest capture of that thing at or before the extraction: the one it belongs to. + for (const x of rows) { + const gap = x.at.getTime() - c.at.getTime(); + if (x.entity_id !== c.entity_id || x.location_id !== c.location_id) continue; + if (gap < 0 || gap >= 30 * 60 * 1000) continue; + const later = captures.some( + (o) => + o !== c && + o.entity_id === c.entity_id && + o.at.getTime() > c.at.getTime() && + o.at.getTime() <= x.at.getTime(), + ); + if (!later) out.set(c.id, x); + } + } + return out; +} + /** Current names of what the events are about, as the caller may see them. */ async function namesOf( client: pg.ClientBase, @@ -393,6 +520,12 @@ async function namesOf( want(e.entity_type, e.entity_id); const service = stockServiceOf(e); if (service) want(service.type, service.id); + // A report is about its location, or a vehicle report's thing (#140). + if (e.entity_type === 'report') { + const thing = reportThingOf(e); + if (thing) want('thing', thing); + else want('location', e.location_id); + } } const out = new Map(); for (const [type, ids] of wanted) { @@ -493,8 +626,10 @@ async function renderEvents( ], ); const names = await namesOf(client, events); + const nodes = await arrangedNodesOf(client, events); const codes = await shortCodesOf(client, events); const tokens = await tokenActorsOf(client, scope, events); + const extracted = await extractionsOf(client, events); return events.map((e) => { const row = rowOf(e); @@ -505,14 +640,38 @@ async function renderEvents( const owner = e.entity_type === 'place' ? e.entity_id : subjectThing(e); const byKey = owner ? labels.get(owner) : undefined; const rendered = renderAudit(row, viewer, { labelOf: (key) => byKey?.get(key) }); + const extraction = extracted.get(e.id); + if (extraction) { + // Its changes, redacted for this reader as its own row would have been. + const x = renderAudit(rowOf(extraction), viewer, { labelOf: (key) => byKey?.get(key) }); + rendered.diff = { ...(rendered.diff ?? {}), ...(x.diff ?? {}) }; + } const movedOut = crossingOf(row, visible) === 'out'; + const own = names.get(`${e.entity_type}:${e.entity_id}`); + const root = e.root_thing_id ? names.get(`thing:${e.root_thing_id}`) : undefined; + const report = + e.entity_type === 'report' + ? names.get( + reportThingOf(e) ? `thing:${reportThingOf(e)}` : `location:${e.location_id ?? ''}`, + ) + : undefined; const name = movedOut || rendered.movedInFromElsewhere ? null - : (names.get(`${e.entity_type}:${e.entity_id}`) ?? - (e.root_thing_id ? names.get(`thing:${e.root_thing_id}`) : undefined) ?? - null); + : // A merge is about the duplicate that went; its sentence names the thing it joined. + ((e.action === 'thing.merge' ? (root ?? own) : (own ?? root ?? report)) ?? null); const service = stockServiceOf(e); + const summary = summarise(rendered, { + name, + movedOut, + ...(service ? { service: names.get(`${service.type}:${service.id}`) ?? null } : {}), + ...(e.action === 'place.arrange' ? { nodes } : {}), + }); + // The layouts are in summaryLines now, in words: the JSON never reaches the page (#140). + if (rendered.diff && e.action === 'place.arrange') { + const { layout: _layout, ...rest } = rendered.diff; + rendered.diff = rest; + } return { ...rendered, actor: { @@ -525,11 +684,7 @@ async function renderEvents( // Gone to a location the reader can't see: no address to offer beyond the id. shortCode: movedOut ? null : (codes.get(`${e.entity_type}:${e.entity_id}`) ?? null), }, - ...summarise(rendered, { - name, - movedOut, - ...(service ? { service: names.get(`${service.type}:${service.id}`) ?? null } : {}), - }), + ...summary, }; }); } diff --git a/apps/server/src/history/summary.test.ts b/apps/server/src/history/summary.test.ts new file mode 100644 index 00000000..f0a34e3e --- /dev/null +++ b/apps/server/src/history/summary.test.ts @@ -0,0 +1,214 @@ +import { AUDIT_ACTIONS, LEGACY_AUDIT_ACTIONS, SUMMARY_KEYS, SUMMARY_LINE_KEYS } from '@kept/shared'; +import { describe, expect, it } from 'vitest'; +import type { RenderedAuditEvent } from '../audit/render.js'; +import { type ArrangedNode, arrangeLines } from './arrange.js'; +import { englishOf, linesOf, summarise } from './summary.js'; + +// #140: every action the server writes reads as a sentence of its own. AuditEventInput takes +// only @kept/shared's AUDIT_ACTIONS (a write with a new action fails the typecheck until it is +// listed), ENGLISH is typed by them (a listed action without English fails it too), and this +// checks what the types can't: that each one says something other than the fallback. + +const ev = (action: string, diff: RenderedAuditEvent['diff'] = null): RenderedAuditEvent => ({ + id: '00000000-0000-7000-8000-000000000001', + at: '2026-10-16T10:00:00.000Z', + location_id: '00000000-0000-7000-8000-000000000002', + action, + actor: { type: 'user', id: null }, + entity: { type: action.split('.')[0] as string, id: '00000000-0000-7000-8000-000000000003' }, + root_thing_id: null, + diff, + undo_of: null, + undoable_until: null, +}); +const plain = (before: unknown, after: unknown) => ({ before, after, class: 'plain' as const }); + +describe('summaries (#140)', () => { + it('gives every audit action a sentence of its own, never the fallback', () => { + for (const action of [...AUDIT_ACTIONS, ...LEGACY_AUDIT_ACTIONS]) { + const s = summarise(ev(action), { name: 'Desk lamp' }); + expect(s.summaryKey, action).not.toBe('event'); + expect(s.summaryKey, action).toBe(action); + expect(s.summary, action).not.toMatch(/\{|undefined|_/); + } + }); + + it('has English for every key and every line', () => { + for (const key of SUMMARY_KEYS) { + expect(englishOf(key, { name: 'Desk lamp', count: '2', action: 'x.y' }), key).toBeTruthy(); + } + expect(new Set(SUMMARY_KEYS).size).toBe(SUMMARY_KEYS.length); + expect(SUMMARY_LINE_KEYS.length).toBeGreaterThan(0); + }); + + it('keeps the fallback for an action this build does not know', () => { + const s = summarise(ev('gizmo.frobnicate'), { name: null }); + expect(s).toMatchObject({ + summaryKey: 'event', + summaryParams: { action: 'gizmo.frobnicate' }, + summary: 'Gizmo frobnicate', + }); + }); + + it('refines a capture, a photo, a report and a label batch by what the row says', () => { + const name = { name: 'Wireless keyboard' }; + expect( + summarise(ev('thing.capture', { file_ids: plain(null, ['f']), name: plain(null, 'x') }), name) + .summary, + ).toBe('Captured Wireless keyboard from a photo'); + expect( + summarise(ev('thing.capture', { attachment_ids: plain(null, ['a']) }), name).summary, + ).toBe('Added a photo to Wireless keyboard'); + expect(summarise(ev('attachment.create', { role: plain(null, 'photo') }), name).summary).toBe( + 'Added a photo to Wireless keyboard', + ); + expect( + summarise(ev('report.generate', { kind: plain(null, 'insurance') }), { name: 'Beach house' }) + .summary, + ).toBe('Made an insurance report for Beach house'); + expect( + summarise( + ev('label_batch.create', { kind: plain(null, 'blank'), code_count: plain(null, 24) }), + { name: null }, + ).summary, + ).toBe('Made 24 blank labels'); + expect(summarise(ev('inbox.discard'), { name: null }).summary).toBe('Discarded a draft'); + expect(summarise(ev('capture.batch_undo'), { name: null }).summary).toBe('Undid a capture'); + }); +}); + +describe('detail lines: the field table in words (#140)', () => { + it("names a report's things and photos, a trash's contents and an undone capture's things", () => { + const report = ev('report.generate', { + include: plain(null, { photos: true }), + filters: plain(null, { thingIds: ['a', 'b', 'c'] }), + locale: plain(null, 'en'), + digits: plain(null, 'arab'), + }); + expect(linesOf(report)).toEqual([ + { key: 'line.things', params: { count: '3' } }, + { key: 'line.photos', params: {} }, + ]); + const trash = ev('place.trash', { trashed: plain(null, 4), moved: plain(null, 0) }); + expect(summarise(trash, { name: 'Shelf 2' }).summaryLines?.map((l) => l.text)).toEqual([ + 'With 3 things inside', + ]); + const undone = ev('capture.batch_undo', { trashed: plain([], ['a']) }); + expect(summarise(undone, { name: null }).summaryLines?.map((l) => l.text)).toEqual(['1 thing']); + expect(linesOf(ev('thing.update', { name: plain('a', 'b') }))).toEqual([]); + }); +}); + +describe('arrangements in words (#140)', () => { + const n = (id: string, more: Partial = {}): ArrangedNode => ({ + id, + s: 'thing', + x: 0, + z: 0, + w: null, + d: null, + rot: 0, + model: 'device', + kind: null, + surface: null, + host: null, + ...more, + }); + const room = (id: string, more: Partial = {}) => + n(id, { s: 'place', model: null, kind: 'room', surface: 'wood', w: 300, d: 400, ...more }); + const names = new Map([ + ['webcam', { name: 'HD webcam', room: 'Study' }], + ['desk', { name: 'Corner desk', room: 'Study' }], + ['shelf', { name: 'Shelf 2', room: 'Study' }], + ['tv', { name: 'TV', room: 'Lounge' }], + ['study', { name: 'Study', room: null }], + ]); + const text = (lines: ReturnType) => + lines.map((l) => `${l.key} ${JSON.stringify(l.params)}`); + + it('puts on and takes off the map, and onto and off a host', () => { + expect(text(arrangeLines([], [n('webcam')], names))).toEqual([ + 'map.put.room {"name":"HD webcam","room":"Study"}', + ]); + expect(text(arrangeLines([n('webcam')], [], names))).toEqual([ + 'map.remove {"name":"HD webcam"}', + ]); + expect( + text( + arrangeLines([n('desk'), n('webcam')], [n('desk'), n('webcam', { host: 'desk' })], names), + ), + ).toEqual(['map.on {"name":"HD webcam","host":"Corner desk"}']); + expect( + text( + arrangeLines([n('desk'), n('webcam', { host: 'desk' })], [n('desk'), n('webcam')], names), + ), + ).toEqual(['map.off {"name":"HD webcam","host":"Corner desk"}']); + }); + + it('moves, turns and changes shape; a hosted thing moving with its host says nothing', () => { + expect( + text( + arrangeLines( + [n('desk'), n('webcam', { host: 'desk' }), n('tv'), n('shelf', { model: 'desk' })], + [ + n('desk', { x: 50 }), + n('webcam', { host: 'desk', x: 50 }), + n('tv', { rot: 90 }), + n('shelf', { model: 'webcam' }), + ], + names, + ), + ), + ).toEqual([ + 'map.move {"name":"Corner desk"}', + 'map.shape {"name":"Shelf 2","from":"desk","to":"webcam","vocab":"model"}', + 'map.turn {"name":"TV"}', + ]); + }); + + it('says what changed in a room', () => { + expect( + text( + arrangeLines( + [room('study')], + [room('study', { w: 350, kind: 'balcony', surface: 'tile' })], + names, + ), + ), + ).toEqual([ + 'map.resize {"name":"Study"}', + 'map.kind {"name":"Study","from":"room","to":"balcony","vocab":"kind"}', + 'map.floor {"name":"Study","from":"wood","to":"tile","vocab":"surface"}', + ]); + expect(text(arrangeLines([], [room('study')], names))).toEqual(['map.put {"name":"Study"}']); + }); + + it('names nothing the reader cannot see', () => { + expect(text(arrangeLines([], [n('hidden'), n('elsewhere', { s: 'place' })], names))).toEqual([ + 'map.put {"name":"a place"}', + 'map.put {"name":"a thing"}', + ]); + }); + + it('collapses more than five changes into a count', () => { + const many = ['a', 'b', 'c'].map((id) => n(id)); + const s = summarise( + ev('place.arrange', { + layout: plain( + many, + many.map((x) => ({ ...x, x: 9, rot: 90 })), + ), + }), + { name: 'Home', nodes: new Map() }, + ); + expect(s.summaryKey).toBe('place.arrange.many'); + expect(s.summary).toBe('Arranged the map: 6 changes'); + expect(s.summaryLines).toHaveLength(6); + const few = summarise(ev('place.arrange', { layout: plain([], [n('webcam')]) }), { + name: 'Home', + nodes: names, + }); + expect(few.summary).toBe('Arranged the map'); + expect(few.summaryLines?.map((l) => l.text)).toEqual(['Put HD webcam on the map in Study']); + }); +}); diff --git a/apps/server/src/history/summary.ts b/apps/server/src/history/summary.ts index 99c23be8..96baf21c 100644 --- a/apps/server/src/history/summary.ts +++ b/apps/server/src/history/summary.ts @@ -1,37 +1,65 @@ +import { + isAuditAction, + LEGACY_AUDIT_ACTIONS, + SUMMARY_LINES_INLINE, + type SummaryKey, + type SummaryLine, + type SummaryLineKey, +} from '@kept/shared'; import type { RenderedAuditEvent } from '../audit/render.js'; +import { arrangeLines, layoutOf, type NodeName } from './arrange.js'; -// One line per history or activity row (plan T21; T27 decision): a structured `summaryKey` with -// `summaryParams` that the web localises, and `summary`, the same sentence in English, as the +// One line per history or activity row (plan T21; T27 decision; #140): a structured `summaryKey` +// with `summaryParams` that the web localises, and `summary`, the same sentence in English, as the // fallback. It is built from the *rendered* event, after renderAudit() has redacted it, so it // can never say more than the row it sits on: money and secrets never reach the params (the -// only diff values read are the name, the lifecycle and a stock use's service ids, all plain), +// only diff values read are names, codes, counts and a stock use's service ids, all plain), // and a move from a location the viewer can't see has no diff to read at all (D183). // // Params are data, never English: names as stored, and codes (`lifecycle: 'given_away'`) the web -// localises. Keys are `.` in the audit's own vocabulary, with three refinements the action -// alone doesn't say: `thing.move.in` / `thing.move.out` (a move across the edge of what the -// viewer can see), `.restore` for an undone trash, and `undo` for any other undo. -// Anything without a sentence of its own gets `event` with `{action}`. +// localises. Keys are `.` in the audit's own vocabulary (@kept/shared AUDIT_ACTIONS: +// every action the server writes has a sentence of its own, and ENGLISH below is typed by them, so +// a new action without one fails the typecheck), refined where the row says more than the action: +// `thing.move.in` / `thing.move.out` (a move across the edge of what the viewer can see), +// `.restore` for an undone trash, `undo` for any other undo, a capture that only added a +// photo, a report's kind, a busy arrangement (SUMMARY_REFINEMENTS). Only an action this build +// doesn't know (an imported history's, a newer server's) gets `event` with `{action}`. +// +// `summaryLines` (#140) say what the action covered, one sentence each: an arrangement's changes +// (history/arrange.ts), and the counts a report, a trash or an undone capture would otherwise show +// as raw fields ("12 things", "With photos"). export type SummaryParams = Record; -export type Summary = { summaryKey: string; summaryParams: SummaryParams; summary: string }; +/** A detail line with its English, the fallback for a client that doesn't know its key. */ +export type RenderedLine = SummaryLine & { text: string }; +export type Summary = { + summaryKey: string; + summaryParams: SummaryParams; + summary: string; + summaryLines?: RenderedLine[]; +}; /** What the summary needs beyond the rendered event. */ export type SummaryContext = { - /** The current name of what the event is about (the thing, the place, the registry row), or - * null when the viewer can't see it any more. */ + /** The current name of what the event is about (the thing, the place, the registry row, the + * location of a location's own event), or null when the viewer can't see it any more. */ name: string | null; /** The event is a move out to a location the viewer can't see (crossingOf() = 'out'). */ movedOut?: boolean; /** A `thing.stock_use` (D236): the current name of the thing or place the service was for. */ service?: string | null; + /** A `place.arrange`: what the reader may see of each node it touched. */ + nodes?: ReadonlyMap; }; type Sentence = (p: SummaryParams) => string; -/** English for every key with a sentence of its own. `{name}` is always set (a fallback noun - * when the name is unknown). */ -const ENGLISH: Record = { +/** English plural: `#` is the count. */ +const plural = (count: string | undefined, one: string, other: string) => + (Number(count) === 1 ? one : other).replaceAll('#', count ?? '0'); + +/** English for every key. `{name}` is always set (a fallback noun when the name is unknown). */ +const ENGLISH: Record = { 'thing.create': (p) => `Added ${p.name}`, 'thing.update': (p) => `Edited ${p.name}`, 'thing.retype': (p) => `Changed the type of ${p.name}`, @@ -64,38 +92,320 @@ const ENGLISH: Record = { // D236: a service line took the consumable off the shelf, or gave it back. 'thing.stock_use': (p) => `Used ${p.name} in a service of ${p.service}`, 'thing.stock_return': (p) => `Put ${p.name} back from a service of ${p.service}`, - // 1.x (D237): an arrange session on the location map, about the location. + // 1.x (D237): an arrange session on the location map, about the location; its changes are lines. 'place.arrange': () => 'Arranged the map', + 'place.arrange.many': (p) => + plural(p.count, 'Arranged the map: # change', 'Arranged the map: # changes'), 'attachment.create': (p) => `Attached a file to ${p.name}`, 'attachment.update': (p) => `Changed a file on ${p.name}`, 'attachment.delete': (p) => `Removed a file from ${p.name}`, + // A capture that added a photo to a thing that was already there, or named one without a photo. + 'thing.capture.attach': (p) => `Added a photo to ${p.name}`, + 'thing.capture.named': (p) => `Added ${p.name}`, + 'report.generate.condition': (p) => `Made a condition report for ${p.name}`, + 'report.generate.move': (p) => `Made a moving report for ${p.name}`, undo: (p) => `Undid a change to ${p.name}`, event: (p) => fallback(p.action ?? ''), + 'brand.create': () => 'Brand added', + 'brand.update': () => 'Brand changed', + 'brand.delete': () => 'Brand deleted', + 'brand.merge': () => 'Brand merged', + 'vendor.create': () => 'Vendor added', + 'vendor.update': () => 'Vendor changed', + 'vendor.delete': () => 'Vendor deleted', + 'vendor.merge': () => 'Vendor merged', + 'person.create': () => 'Person added', + 'person.update': () => 'Person changed', + 'person.delete': () => 'Person deleted', + 'person.merge': () => 'Person merged', + 'person.contact_update': () => 'Contact details changed', + 'tag.create': () => 'Tag added', + 'tag.update': () => 'Tag changed', + 'tag.delete': () => 'Tag deleted', + 'tag.merge': () => 'Tag merged', + 'type.create': () => 'Type added', + 'type.update': () => 'Type changed', + 'type.delete': () => 'Type deleted', + 'type.merge': () => 'Type merged', + 'type.customise': () => 'Type customised', + 'type_field.create': () => 'Field added', + 'type_field.update': () => 'Field changed', + 'type_field.archive': () => 'Field archived', + 'type_field.restore': () => 'Field restored', + 'place_kind.create': () => 'Place kind added', + 'place_kind.update': () => 'Place kind changed', + 'place_kind.customise': () => 'Place kind customised', + 'file.upload': () => 'File uploaded', + 'file.delete_original': () => 'Original file deleted', + 'purchase.create': () => 'Purchase added', + 'purchase.update': () => 'Purchase changed', + 'purchase.delete': () => 'Purchase deleted', + 'purchase_line.create': () => 'Purchase line added', + 'purchase_line.update': () => 'Purchase line changed', + 'purchase_line.delete': () => 'Purchase line deleted', + 'thing.purchase_link': () => 'Linked to a purchase', + 'thing.purchase_unlink': () => 'Unlinked from a purchase', + 'thing.label': () => 'Label assigned', + 'meter.create': () => 'Meter added', + 'meter.update': () => 'Meter changed', + 'meter.replaced': () => 'Meter replaced', + 'reading.create': () => 'Reading logged', + 'reading.update': () => 'Reading changed', + 'reading.accept': () => 'Reading kept', + 'reading.delete': () => 'Reading discarded', + 'saved_view.create': () => 'Saved view added', + 'location.create': () => 'Location created', + 'location.update': () => 'Location settings changed', + 'location.modules': () => 'What to track changed', + 'location.delete': () => 'Location deleted', + 'location.restore': () => 'Location restored', + 'invite.create': () => 'Invite created', + 'invite.update': () => 'Invite changed', + 'invite.revoke': () => 'Invite revoked', + 'member.join': () => 'Member joined', + 'member.leave': () => 'Member left', + 'member.update': () => "Member's role changed", + 'member.expire': () => 'Membership ended', + 'managed_account.create': () => 'Managed account created', + 'saved_view.update': () => 'Saved view changed', + 'saved_view.delete': () => 'Saved view deleted', + 'secret.set': () => 'Secret changed', + 'secret.clear': () => 'Secret cleared', + 'secret.reveal': () => 'Secret revealed', + 'secret.copied': () => 'Secret copied', + 'secret_policy.update': () => 'Secret policy changed', + 'warranty.create': () => 'Warranty added', + 'warranty.update': () => 'Warranty changed', + 'warranty.delete': () => 'Warranty removed', + 'claim.create': () => 'Claim opened', + 'claim.update': () => 'Claim changed', + 'claim.delete': () => 'Claim deleted', + 'valuation.create': () => 'Valuation added', + 'valuation.update': () => 'Valuation changed', + 'valuation.delete': () => 'Valuation deleted', + 'loan.create': () => 'Loan added', + 'loan.update': () => 'Loan changed', + 'loan.return': () => 'Loan returned', + 'loan.delete': () => 'Loan deleted', + 'schedule.create': () => 'Schedule added', + 'schedule.update': () => 'Schedule changed', + 'schedule.delete': () => 'Schedule deleted', + 'schedule.snooze': () => 'Schedule snoozed', + 'schedule.unsnooze': () => 'Snooze cleared', + 'schedule.skip': () => 'Next one skipped', + 'service_record.create': () => 'Service logged', + 'service_record.update': () => 'Service changed', + 'service_record.delete': () => 'Service deleted', + 'service_record.draft': () => 'Service draft started from an invoice', + 'service_record.confirm': () => 'Service logged', + 'meter.delete': () => 'Meter removed', + 'fuel.create': () => 'Fuel logged', + 'fuel.update': () => 'Fuel changed', + 'fuel.delete': () => 'Fuel deleted', + 'schedule.starter': () => 'Starter schedules added', + 'document.create': () => 'Document added', + 'document.update': () => 'Document changed', + 'document.renew': () => 'Document renewed', + 'document.delete': () => 'Document deleted', + 'incident.create': () => 'Incident recorded', + 'incident.update': () => 'Incident changed', + 'incident.things': () => "Incident's things changed", + 'incident.delete': () => 'Incident deleted', + 'fx_rate.set': () => 'Exchange rate set', + 'fx_rate.delete': () => 'Exchange rate deleted', + 'brand.logo_set': () => 'Brand logo changed', + 'brand.logo_remove': () => 'Brand logo removed', + 'thing.stock_rule': () => 'Minimum to keep changed', + 'type.field_convert': () => 'Field converted', + 'things.export_csv': () => 'List exported as a CSV', + 'file.import': () => 'File imported', + 'webhook.create': () => 'Webhook added', + 'webhook.update': () => 'Webhook changed', + 'webhook.delete': () => 'Webhook removed', + 'webhook.rotate_secret': () => 'Webhook secret replaced', + 'share_link.create': () => 'Share link made', + 'share_link.revoke': () => 'Share link revoked', + 'move.create': () => 'Move started', + 'move.update': () => 'Move changed', + 'move.delete': () => 'Move deleted', + 'move_box.create': () => 'Added to a move as a box', + 'move_box.update': () => 'Moving box changed', + 'move_box.unpack': () => 'Box unpacked', + 'move_box.delete': () => 'Taken out of a move', + 'paperless.connect': () => 'Paperless connected', + 'paperless.update': () => 'Paperless connection changed', + 'paperless.disconnect': () => 'Paperless disconnected', + 'paperless_link.create': () => 'Paperless document linked', + 'paperless_link.delete': () => 'Paperless document unlinked', + 'paperless_link.copy': () => 'Paperless document copied into Kept', + 'purchase.reextract': () => 'Receipt read again', + 'thing.enrich': () => 'Other names added', + 'import.enrich': () => 'Other names requested for an import', + 'import.homebox_connect': () => 'Connected to Homebox', + 'instance.setup': () => 'Kept set up', + 'instance.settings_update': () => 'Instance settings changed', + 'instance.backup': () => 'Backup made', + 'instance.backup_run': () => 'Backup started by hand', + 'instance.backup_settings': () => 'Backup settings changed', + 'instance.backup_test': () => 'Backup target tested', + 'instance.restore': () => 'Restored from a backup', + 'instance.export': () => 'Whole instance exported', + 'instance.downgrade_forced': () => 'Started on an older version anyway', + 'instance.rotate_key': () => 'Encryption key replaced', + 'instance.drop_key': () => 'Old encryption key removed', + 'instance.embeddings_source': () => 'Semantic search source changed', + 'instance.recovery_kit_download': () => 'Recovery kit downloaded', + 'instance.recovery_kit_acknowledge': () => 'Recovery kit noted as kept', + 'instance.update_check': () => 'Checked for updates', + 'instance.setup_code_reissue': () => 'New setup code issued', + 'instance.oidc_changed': () => 'OIDC sign-in settings changed', + 'instance.smtp_changed': () => 'Email settings changed', + 'attachment.create.photo': (p) => `Added a photo to ${p.name}`, + 'thing.capture': (p) => `Captured ${p.name} from a photo`, + 'thing.capture.reading': (p) => `Logged a reading of ${p.name} from a photo`, + 'thing.extract': (p) => `Filled in ${p.name} from a photo`, + 'thing.reextract': (p) => `Read the photo of ${p.name} again`, + 'thing.merge': (p) => `Merged a duplicate into ${p.name}`, + 'box.check': (p) => `Checked what is in ${p.name}`, + 'label.claim': (p) => `Gave ${p.name} a label`, + 'location.own_codes': (p) => `Changed how ${p.name} numbers its codes`, + 'location.transfer_ownership': (p) => `Handed ${p.name} to a new owner`, + 'report.generate': (p) => `Made an inventory report of ${p.name}`, + 'report.generate.insurance': (p) => `Made an insurance report for ${p.name}`, + 'report.generate.vehicle': (p) => `Made a vehicle history report for ${p.name}`, + 'account.deletion_cancel': () => 'Cancelled the account deletion', + 'account.deletion_request': () => 'Asked to delete the account', + 'account.email_change': () => 'Changed the account email', + 'admin.cli_reset_password': () => 'Reset a password from the command line', + 'admin.cli_user_disable': () => 'Turned off an account from the command line', + 'admin.cli_user_enable': () => 'Turned an account back on from the command line', + 'admin.currency_disable': () => 'Turned off a currency', + 'admin.currency_enable': () => 'Turned on a currency', + 'admin.instance_admin_grant': () => 'Made someone an instance admin', + 'admin.instance_admin_revoke': () => 'Removed an instance admin', + 'admin.job_discard': () => 'Discarded a failed job', + 'admin.job_retry': () => 'Retried a failed job', + 'admin.user_disable': () => 'Turned off an account', + 'admin.user_enable': () => 'Turned an account back on', + 'admin.user_reset_2fa': () => 'Reset someone’s two-step sign-in', + 'admin.user_sign_out_everywhere': () => 'Signed someone out everywhere', + 'ai.cap_clear': () => 'Removed the AI spending cap', + 'ai.cap_set': () => 'Set the AI spending cap', + 'ai.pause': () => 'Paused AI', + 'ai.resume': () => 'Resumed AI', + 'ai.price_prefill': () => 'Looked up AI prices', + 'ai.price_recost': () => 'Worked out AI costs again with new prices', + 'ai.price_remove': () => 'Removed an AI price', + 'ai.price_set': () => 'Set an AI price', + 'ai.provider_models': () => 'Listed an AI provider’s models', + 'ai.provider_remove': () => 'Removed an AI provider', + 'ai.provider_set': () => 'Set up an AI provider', + 'ai.provider_test': () => 'Tested an AI provider', + 'ai.usage_export': () => 'Exported AI usage', + 'instance.google_changed': () => 'Google sign-in settings changed', + 'managed_account.password_set': () => 'Set a managed account’s password', + 'managed_account.reset_code': () => 'Made a reset code for a managed account', + 'me.notification_preferences': () => 'Changed notification settings', + 'me.notifications': () => 'Changed which notifications arrive', + 'me.preferences': () => 'Changed preferences', + 'session.revoke': () => 'Signed out a session', + 'oauth.deny': () => 'Turned down an app’s request to connect', + 'oauth.grant': () => 'Let an app connect', + 'token.create': () => 'Made an access token', + 'token.revoke': () => 'Revoked an access token', + 'token.update': () => 'Changed an access token', + 'push_subscription.create': () => 'Turned on notifications on a device', + 'push_subscription.delete': () => 'Turned off notifications on a device', + 'push_subscription.update': () => 'Changed notifications on a device', + 'hint.dismiss': () => 'Dismissed a tip', + 'hint.restore': () => 'Brought back a tip', + 'hint.seen': () => 'Read a tip', + 'saved_view_prefs.update': () => 'Changed how a list shows', + 'member.remove': () => 'Removed a member', + 'member.recap': () => 'Changed when guest access ends', + 'member.owner_notified': () => 'Told the owner about a new guest', + 'mailbox.create': () => 'Set up the mailbox', + 'mailbox.update': () => 'Changed the mailbox', + 'mailbox.delete': () => 'Removed the mailbox', + 'channel.create': () => 'Added a notification channel', + 'channel.delete': () => 'Removed a notification channel', + 'calendar_feed.create': () => 'Made a calendar feed', + 'calendar_feed.revoke': () => 'Revoked a calendar feed', + 'capture.batch_undo': () => 'Undid a capture', + 'inbox.discard': () => 'Discarded a draft', + 'inbox.resolve': () => 'Dealt with an inbox item', + 'inbox.restore': () => 'Brought back an inbox item', + 'inbox.bulk': () => 'Changed several inbox items at once', + 'labels.printed': () => 'Printed labels', + 'purchase.capture': () => 'Captured a receipt', + 'purchase.confirm': () => 'Confirmed a receipt', + 'purchase.extract': () => 'Read a receipt', + 'reading.extract': () => 'Read a meter from a photo', + 'file.crop': () => 'Cropped a photo', + 'file.display_set': () => 'Changed the photo shown', + 'template.create': () => 'Added a template', + 'template.update': () => 'Changed a template', + 'template.delete': () => 'Deleted a template', + 'claim.status': () => 'Changed a claim’s status', + 'claim_pack.create': () => 'Made a claim pack', + 'claim_pack.link': () => 'Shared a claim pack link', + 'claim_pack.link_revoke': () => 'Revoked a claim pack link', + 'condition_report.create': () => 'Started a condition report', + 'condition_report.update': () => 'Changed a condition report', + 'condition_report.finish': () => 'Finished a condition report', + 'condition_report.delete': () => 'Deleted a condition report', + 'condition_room.create': () => 'Added a room to a condition report', + 'condition_room.update': () => 'Changed a room in a condition report', + 'condition_room.delete': () => 'Removed a room from a condition report', + 'export.create': () => 'Started an export', + 'export.cancel': () => 'Cancelled an export', + 'export.download': () => 'Downloaded an export', + 'export.secrets': () => 'Put secrets in an export', + 'import.create': () => 'Uploaded a file to import', + 'import.archive': () => 'Uploaded an archive to import', + 'import.inspect': () => 'Opened an archive to import', + 'import.passphrase': () => 'Entered an import’s passphrase', + 'import.target': () => 'Chose where an import goes', + 'import.choices': () => 'Changed an import’s choices', + 'import.check': () => 'Checked an import', + 'import.start': () => 'Started an import', + 'import.run': () => 'Ran an import', + 'import.cancel': () => 'Cancelled an import', + 'report.export_csv': () => 'Exported a report as a CSV', + 'report.schedule': () => 'Scheduled a report', + 'report.unschedule': () => 'Stopped a scheduled report', + 'label_batch.create': (p) => plural(p.count, 'Made # blank label', 'Made # blank labels'), + 'label_batch.create.things': (p) => + plural(p.count, 'Made labels for # thing', 'Made labels for # things'), + 'label_batch.create.places': (p) => + plural(p.count, 'Made labels for # place', 'Made labels for # places'), }; -const PAST: Record = { - create: 'added', - update: 'changed', - delete: 'deleted', - merge: 'merged', - customise: 'customised', - archive: 'archived', - restore: 'restored', - trash: 'trashed', - revoke: 'revoked', - join: 'joined', - leave: 'left', - expire: 'expired', - upload: 'uploaded', - reveal: 'revealed', +/** English for every detail line. */ +const LINE_ENGLISH: Record = { + 'line.things': (p) => plural(p.count, '# thing', '# things'), + 'line.inside': (p) => plural(p.count, 'With # thing inside', 'With # things inside'), + 'line.moved_out': (p) => plural(p.count, '# thing taken out first', '# things taken out first'), + 'line.photos': () => 'With photos', + 'map.put.room': (p) => `Put ${p.name} on the map in ${p.room}`, + 'map.put': (p) => `Put ${p.name} on the map`, + 'map.remove': (p) => `Took ${p.name} off the map`, + 'map.on': (p) => `Put ${p.name} on ${p.host}`, + 'map.off': (p) => `Took ${p.name} off ${p.host}`, + 'map.move': (p) => `Moved ${p.name}`, + 'map.turn': (p) => `Turned ${p.name}`, + 'map.shape': (p) => `Changed the shape of ${p.name}: ${p.from} → ${p.to}`, + 'map.resize': (p) => `Resized ${p.name}`, + 'map.kind': (p) => `Changed the kind of ${p.name}: ${p.from} → ${p.to}`, + 'map.floor': (p) => `Changed the floor of ${p.name}: ${p.from} → ${p.to}`, }; -/** "Brand added", "Member joined": the English for an action with no sentence of its own. */ +/** "Brand added": the English for an action this build doesn't know. */ function fallback(action: string): string { const dot = action.indexOf('.'); const entity = words(dot < 0 ? action : action.slice(0, dot)); const verb = dot < 0 ? '' : action.slice(dot + 1); - const said = PAST[verb] ?? (verb ? `${words(verb)}` : 'changed'); + const said = verb ? words(verb) : 'changed'; const subject = entity ? entity[0]?.toUpperCase() + entity.slice(1) : 'Something'; return `${subject} ${said}`; } @@ -113,6 +423,7 @@ const NOUNS: Record = { person: 'a person', tag: 'a tag', location: 'the location', + report: 'the location', }; const words = (value: string) => value.replaceAll('_', ' '); @@ -124,8 +435,17 @@ function plainOf(event: RenderedAuditEvent, field: string): Plain | null { return change && change.class === 'plain' && 'before' in change ? (change as Plain) : null; } -/** The key of an event: its action, refined as the header says. */ -function keyOf(event: RenderedAuditEvent, ctx: SummaryContext): string { +const afterOf = (event: RenderedAuditEvent, field: string) => plainOf(event, field)?.after; + +const REPORT_KINDS: Record = { + insurance: 'report.generate.insurance', + vehicle_history: 'report.generate.vehicle', + condition: 'report.generate.condition', + move: 'report.generate.move', +}; + +/** The key of an event: its action, refined as the row says. */ +function keyOf(event: RenderedAuditEvent, ctx: SummaryContext): SummaryKey { const { action } = event; if (event.movedInFromElsewhere) return 'thing.move.in'; if (action === 'thing.move' && ctx.movedOut) return 'thing.move.out'; @@ -133,20 +453,100 @@ function keyOf(event: RenderedAuditEvent, ctx: SummaryContext): string { // An undone trash writes the trash action again, with deleted_at going back to null. if (action.endsWith('.trash') && event.undo_of) { const deleted = plainOf(event, 'deleted_at'); - if (deleted && deleted.after === null) return `${entity}.restore`; + if (deleted && deleted.after === null && (entity === 'thing' || entity === 'place')) { + return `${entity}.restore`; + } } if (event.undo_of) return 'undo'; - // D236: the stock went back up (the line shrank or went, the service was deleted or undone). - if (action === 'thing.stock_use') { - const q = plainOf(event, 'quantity'); - if (q && Number(q.after) > Number(q.before)) return 'thing.stock_return'; + switch (action) { + // D236: the stock went back up (the line shrank or went, the service was deleted or undone). + case 'thing.stock_use': { + const q = plainOf(event, 'quantity'); + return q && Number(q.after) > Number(q.before) ? 'thing.stock_return' : action; + } + case 'thing.capture': { + if (event.diff?.reading_id || event.diff?.meter_id) return 'thing.capture.reading'; + // A photo added to a thing that was there already: the row has no thing of its own. + if (event.diff?.attachment_ids && !event.diff?.name) return 'thing.capture.attach'; + const files = afterOf(event, 'file_ids'); + return Array.isArray(files) && files.length === 0 ? 'thing.capture.named' : action; + } + case 'attachment.create': + return afterOf(event, 'role') === 'photo' ? 'attachment.create.photo' : action; + case 'report.generate': + return REPORT_KINDS[String(afterOf(event, 'kind'))] ?? action; + case 'label_batch.create': { + const kind = afterOf(event, 'kind'); + return kind === 'things' + ? 'label_batch.create.things' + : kind === 'places' + ? 'label_batch.create.places' + : action; + } + default: + return isAuditAction(action) || LEGACY.has(action) ? (action as SummaryKey) : 'event'; } - return ENGLISH[action] ? action : 'event'; +} + +const LEGACY: ReadonlySet = new Set(LEGACY_AUDIT_ACTIONS); + +const countOf = (value: unknown): number | null => + Array.isArray(value) ? value.length : typeof value === 'number' ? value : null; + +/** What a report, a trash or an undone capture covered, in words (the raw fields stay hidden). */ +export function linesOf(event: RenderedAuditEvent): SummaryLine[] { + const out: SummaryLine[] = []; + const count = (key: SummaryLineKey, n: number | null) => { + if (n && n > 0) out.push({ key, params: { count: String(n) } }); + }; + switch (event.action) { + case 'report.generate': { + const filters = afterOf(event, 'filters') as Record | null | undefined; + count('line.things', countOf(filters?.thingIds)); + const include = afterOf(event, 'include') as Record | null | undefined; + if (include?.photos === true) out.push({ key: 'line.photos', params: {} }); + break; + } + case 'place.trash': { + const trashed = countOf(afterOf(event, 'trashed')); + // The place itself is one of them. + count('line.inside', trashed === null ? null : trashed - 1); + count('line.moved_out', countOf(afterOf(event, 'moved'))); + break; + } + case 'capture.batch_undo': + count('line.things', countOf(afterOf(event, 'trashed') ?? afterOf(event, 'restored'))); + break; + default: + break; + } + return out; +} + +function rendered(lines: readonly SummaryLine[]): RenderedLine[] { + return lines.map((l) => ({ ...l, text: LINE_ENGLISH[l.key](l.params) })); } export function summarise(event: RenderedAuditEvent, ctx: SummaryContext): Summary { - const key = keyOf(event, ctx); + let key = keyOf(event, ctx); const params: SummaryParams = {}; + let lines: SummaryLine[] = []; + if (event.action === 'place.arrange') { + const layout = event.diff?.layout; + if (layout && 'before' in layout) { + lines = arrangeLines( + layoutOf(layout.before) ?? [], + layoutOf(layout.after) ?? [], + ctx.nodes ?? new Map(), + ); + } + if (key === 'place.arrange' && lines.length > SUMMARY_LINES_INLINE) { + key = 'place.arrange.many'; + params.count = String(lines.length); + } + } else { + lines = linesOf(event); + } if (key === 'event') { params.action = event.action; } else if (key !== 'thing.move.in') { @@ -159,8 +559,8 @@ export function summarise(event: RenderedAuditEvent, ctx: SummaryContext): Summa if (key === 'thing.stock_use' || key === 'thing.stock_return') { // The service's subject: its name (a noun when the reader can't see it), and its id for the // link, read from the rendered diff (plain ids, never money). - const thing = plainOf(event, 'service_thing_id')?.after; - const place = plainOf(event, 'service_place_id')?.after; + const thing = afterOf(event, 'service_thing_id'); + const place = afterOf(event, 'service_place_id'); params.service = ctx.service ?? (typeof place === 'string' ? 'a place' : 'a thing'); if (typeof thing === 'string') params.serviceThingId = thing; else if (typeof place === 'string') params.servicePlaceId = place; @@ -168,10 +568,22 @@ export function summarise(event: RenderedAuditEvent, ctx: SummaryContext): Summa if (key === 'thing.lifecycle') { // The stored code (`given_away`), never English: the web says it in the reader's language. // Left out when the row shows no lifecycle change. - const lifecycle = plainOf(event, 'lifecycle')?.after; + const lifecycle = afterOf(event, 'lifecycle'); if (typeof lifecycle === 'string') params.lifecycle = lifecycle; } + if (key.startsWith('label_batch.create')) { + params.count = String(countOf(afterOf(event, 'code_count')) ?? 0); + } } - const sentence = ENGLISH[key] ?? ENGLISH.event; - return { summaryKey: key, summaryParams: params, summary: (sentence as Sentence)(params) }; + return { + summaryKey: key, + summaryParams: params, + summary: ENGLISH[key](params), + ...(lines.length ? { summaryLines: rendered(lines) } : {}), + }; +} + +/** The English of `key` with `params`: for the completeness test, and nothing else. */ +export function englishOf(key: SummaryKey, params: SummaryParams): string { + return ENGLISH[key](params); } diff --git a/apps/server/src/imports/archive.ts b/apps/server/src/imports/archive.ts index 35c283ca..af1e53ea 100644 --- a/apps/server/src/imports/archive.ts +++ b/apps/server/src/imports/archive.ts @@ -5,6 +5,7 @@ import { ARCHIVE_SOURCES, type ArchiveRefusal, type ArchiveSource, + type AuditAction, HomeboxChoices, IMPORT_ISSUE_CODES, IMPORT_STALE_MINUTES, @@ -260,7 +261,7 @@ type Ctx = { tx: Tx; client: pg.PoolClient; scope: Scope; requestId: string }; export async function auditRun( c: Ctx, run: Pick, - action: string, + action: AuditAction, after: Record, before: Record | null = null, ): Promise { @@ -335,7 +336,7 @@ async function refuse( req: FastifyRequest, run: ArchiveRunRow, err: ArchiveError | ArchiveContentError, - action: string, + action: AuditAction, ): Promise { // The detail may hold a name from the archive: logged, never answered. req.log.info( @@ -369,7 +370,7 @@ async function withArchive( req: FastifyRequest, files: FileStorage, run: ArchiveRunRow, - action: string, + action: AuditAction, fn: (archive: OpenArchive, importer: ArchiveImporter) => Promise, ): Promise { const importer = importerOf(run.source); diff --git a/apps/server/src/lending/service.ts b/apps/server/src/lending/service.ts index 008b01a5..5f7bbecf 100644 --- a/apps/server/src/lending/service.ts +++ b/apps/server/src/lending/service.ts @@ -1,4 +1,4 @@ -import { type LoanDirection, newId } from '@kept/shared'; +import { type AuditAction, type LoanDirection, newId } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -219,7 +219,7 @@ async function thingRow(ctx: Ctx, id: string): Promise { async function auditLoan( ctx: Ctx, - action: string, + action: AuditAction, before: LoanRecord | null, after: LoanRecord | null, extra: { before?: Record; after?: Record } = {}, diff --git a/apps/server/src/locations/membership-jobs.ts b/apps/server/src/locations/membership-jobs.ts index 151af7aa..e992459c 100644 --- a/apps/server/src/locations/membership-jobs.ts +++ b/apps/server/src/locations/membership-jobs.ts @@ -1,3 +1,4 @@ +import type { AuditAction } from '@kept/shared'; import type pg from 'pg'; import { audited } from '../audit/audited.js'; import { isUndeliverableEmail } from '../auth/emails.js'; @@ -28,7 +29,7 @@ async function auditSystem( tx: Tx, client: pg.ClientBase, locationId: string, - action: string, + action: AuditAction, membershipId: string, detail: Record, ): Promise { diff --git a/apps/server/src/notices/transparency.ts b/apps/server/src/notices/transparency.ts index dec82756..5e5020a8 100644 --- a/apps/server/src/notices/transparency.ts +++ b/apps/server/src/notices/transparency.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { PROVIDER_KINDS, type ProviderKind } from '@kept/shared'; +import { type AuditAction, PROVIDER_KINDS, type ProviderKind } from '@kept/shared'; import type pg from 'pg'; import { audited } from '../audit/audited.js'; import { isUndeliverableEmail } from '../auth/emails.js'; @@ -105,7 +105,7 @@ async function checkOne( deps: ConfigNoticeDeps, key: string, hash: string, - action: string, + action: AuditAction, facts: Record, ): Promise { return withSystem(deps.pools.system, async (tx, client) => { diff --git a/apps/server/src/notify/prefs.ts b/apps/server/src/notify/prefs.ts index 8d0eb94c..74f3a774 100644 --- a/apps/server/src/notify/prefs.ts +++ b/apps/server/src/notify/prefs.ts @@ -1,5 +1,6 @@ import { ACCOUNT_LEVEL_KINDS, + type AuditAction, defaultPreference, NOTIFY_KINDS, type NotifyKind, @@ -214,7 +215,7 @@ export async function auditMine( tx: Tx, userId: string, event: { - action: string; + action: AuditAction; entity: { type: string; id: string | null }; before?: Record | null; after?: Record | null; diff --git a/apps/server/src/registries/account.ts b/apps/server/src/registries/account.ts index 11c9b399..69ef4059 100644 --- a/apps/server/src/registries/account.ts +++ b/apps/server/src/registries/account.ts @@ -1,4 +1,4 @@ -import { type Action, can, type Role } from '@kept/shared'; +import { type Action, type AuditAction, can, type Role } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -71,7 +71,7 @@ export async function auditRegistry( accountId: string; userId: string; tokenId?: string | null; - action: string; + action: AuditAction; entity: { type: string; id: string }; before?: Record | null; after?: Record | null; diff --git a/apps/server/src/schedules/service.ts b/apps/server/src/schedules/service.ts index 5bd7da5e..0cc6ea69 100644 --- a/apps/server/src/schedules/service.ts +++ b/apps/server/src/schedules/service.ts @@ -1,4 +1,4 @@ -import { type Action, type ModuleId, newId, type Role } from '@kept/shared'; +import { type Action, type AuditAction, type ModuleId, newId, type Role } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -166,7 +166,7 @@ const subjectsOf = (r: { thing_id: string | null }) => (r.thing_id ? [r.thing_id async function auditSchedule( ctx: Ctx, - action: string, + action: AuditAction, before: ScheduleRow | null, after: ScheduleRow | null, undoable: boolean, diff --git a/apps/server/src/schedules/services.ts b/apps/server/src/schedules/services.ts index 490f4d2b..717cb458 100644 --- a/apps/server/src/schedules/services.ts +++ b/apps/server/src/schedules/services.ts @@ -1,4 +1,4 @@ -import { isDateFilterValue, newId, type ServiceLineKind } from '@kept/shared'; +import { type AuditAction, isDateFilterValue, newId, type ServiceLineKind } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -418,7 +418,7 @@ export async function noonOf( export type CreateServiceOptions = { /** The action of its audit event: `schedule.complete` from POST …/schedules/:id/complete. */ - action?: string; + action?: AuditAction; }; /** diff --git a/apps/server/src/schedules/undo.ts b/apps/server/src/schedules/undo.ts index b4bd0495..d977a6fa 100644 --- a/apps/server/src/schedules/undo.ts +++ b/apps/server/src/schedules/undo.ts @@ -488,7 +488,7 @@ export function registerScheduleUndo(): void { 'schedule.snooze', 'schedule.skip', 'schedule.unsnooze', - ]) { + ] as const) { registerUndo(action, undoScheduleFields); } registerUndo('schedule.delete', undoScheduleDelete); diff --git a/apps/server/src/things/move.ts b/apps/server/src/things/move.ts index 4d73833c..5f998a82 100644 --- a/apps/server/src/things/move.ts +++ b/apps/server/src/things/move.ts @@ -626,7 +626,7 @@ async function moveAndAudit( const now = after.get(before.id) as Moving; const event = { actor, - action: 'thing.move', + action: 'thing.move' as const, entity: { type: 'thing', id: before.id }, before: image(before), after: image(now), diff --git a/apps/server/src/things/service.ts b/apps/server/src/things/service.ts index c25b432e..8c34c065 100644 --- a/apps/server/src/things/service.ts +++ b/apps/server/src/things/service.ts @@ -1,5 +1,6 @@ import { type Action, + type AuditAction, can, canonicalAmount, isShortCode, @@ -289,7 +290,7 @@ export type CaptureExtras = { fieldStatus?: Record; /** The caller checked the client id already (a sync op's 90-day window, Q2). */ idChecked?: boolean; - audit?: { action: string; after?: Record; undoableUntil?: Date }; + audit?: { action: AuditAction; after?: Record; undoableUntil?: Date }; }; /** CreateBody, with the name a draft may leave out. */ diff --git a/apps/web/src/api/inventory/mock/db.ts b/apps/web/src/api/inventory/mock/db.ts index ed3b2217..4fdf0481 100644 --- a/apps/web/src/api/inventory/mock/db.ts +++ b/apps/web/src/api/inventory/mock/db.ts @@ -4,6 +4,7 @@ * the way the server does), pagination over `next_cursor`, If-Match checks with the D156 412 * body, and a small Arabic-aware fold for search. Test and demo data only. */ +import { isAuditAction } from '@kept/shared'; import type { LocationDetail } from '../../types'; import type { AccountSummary, @@ -301,8 +302,11 @@ export function summaryOf( : { summaryKey: action, summaryParams: { name }, summary: `Changed the status of ${name}` }; } const sentence = SENTENCES[action]; + // #140: every action the server writes has a key of its own; `event` is for unknown ones. if (!sentence) - return { summaryKey: 'event', summaryParams: { action }, summary: action.replace('.', ' ') }; + return isAuditAction(action) + ? { summaryKey: action, summaryParams: { name }, summary: action.replace('.', ' ') } + : { summaryKey: 'event', summaryParams: { action }, summary: action.replace('.', ' ') }; return { summaryKey: action, summaryParams: action === 'thing.move.in' ? {} : { name }, diff --git a/apps/web/src/api/inventory/mock/fixtures.ts b/apps/web/src/api/inventory/mock/fixtures.ts index 669b99ba..4a167c83 100644 --- a/apps/web/src/api/inventory/mock/fixtures.ts +++ b/apps/web/src/api/inventory/mock/fixtures.ts @@ -11,6 +11,7 @@ import type { LocationDetail } from '../../types'; import type { Capability, FieldKind, + HistoryEvent, PlaceKindNode, ResolvedField, ThingView, @@ -964,6 +965,8 @@ export function inventoryFixtures(): InventoryState { event(4, 'thing.create', 'thing', I.thing.arHdmi, LOC.family, 'بروس', 'كابل HDMI', 3), event(5, 'place.create', 'place', I.place.office, LOC.home, 'Ibrahim', 'Office', 9), event(6, 'thing.trash', 'thing', I.thing.brokenLamp, LOC.home, 'Ibrahim', 'Broken lamp', 5), + // #140: an arrangement, a capture and a report, as the server says them. + ...said(), ], hints: [], checklistDismissed: false, @@ -971,6 +974,79 @@ export function inventoryFixtures(): InventoryState { }; } +/** An arrangement, a capture and a report in Home, in the server's words (#140). */ +function said(): HistoryEvent[] { + const at = (n: number, daysAgo: number) => ({ + id: `01926f00-0000-7000-8000-0000000ee${String(n).padStart(3, '0')}`, + at: ago(daysAgo), + location_id: LOC.home, + actor: { type: 'user' as const, id: null, displayName: 'Ibrahim' }, + undo_of: null, + undoable_until: null, + }); + const plain = (after: unknown) => ({ before: null, after, class: 'plain' as const }); + return [ + { + ...at(7, 10), + action: 'place.arrange', + entity: { type: 'location', id: LOC.home }, + root_thing_id: null, + diff: {}, + summaryKey: 'place.arrange', + summaryParams: { name: 'Home' }, + summary: 'Arranged the map', + summaryLines: [ + { + key: 'map.put.room', + params: { name: 'Galaxy S23', room: 'Office' }, + text: 'Put Galaxy S23 on the map in Office', + }, + { key: 'map.move', params: { name: 'Cable box' }, text: 'Moved Cable box' }, + { key: 'map.turn', params: { name: 'Samsung TV, 55″' }, text: 'Turned Samsung TV, 55″' }, + { + key: 'map.floor', + params: { name: 'Living room', from: 'wood', to: 'tile', vocab: 'surface' }, + text: 'Changed the floor of Living room: wood → tile', + }, + ], + }, + { + ...at(8, 10.1), + action: 'thing.capture', + entity: { type: 'thing', id: I.thing.drill }, + root_thing_id: I.thing.drill, + diff: { + name: plain('Bosch drill, 18 V'), + model: plain('GSR 18V-55'), + mode: plain('thing'), + file_ids: plain(['01926f00-0000-7000-8000-0000000ef001']), + }, + summaryKey: 'thing.capture', + summaryParams: { name: 'Bosch drill, 18 V' }, + summary: 'Captured Bosch drill, 18 V from a photo', + }, + { + ...at(9, 10.2), + action: 'report.generate', + entity: { type: 'report', id: '01926f00-0000-7000-8000-0000000ef002' }, + root_thing_id: null, + diff: { + kind: plain('insurance'), + locale: plain('en'), + digits: plain('latn'), + include: plain({ photos: true }), + }, + summaryKey: 'report.generate.insurance', + summaryParams: { name: 'Home' }, + summary: 'Made an insurance report for Home', + summaryLines: [ + { key: 'line.things', params: { count: '8' }, text: '8 things' }, + { key: 'line.photos', params: {}, text: 'With photos' }, + ], + }, + ]; +} + function event( n: number, action: string, diff --git a/apps/web/src/api/inventory/types.ts b/apps/web/src/api/inventory/types.ts index b8500387..270e9312 100644 --- a/apps/web/src/api/inventory/types.ts +++ b/apps/web/src/api/inventory/types.ts @@ -1271,6 +1271,9 @@ export type RenderedChange = ( * A history or activity row: the server's `RenderedAuditEvent` (snake_case, as `renderAudit` * returns it) plus the actor's display name and a one-line summary (plan task 21). */ +/** A history row's detail line (#140): a key and params the web says, and the server's English. */ +export type SummaryLineView = { key: string; params: Record; text: string }; + export type HistoryEvent = { id: string; at: string; @@ -1295,6 +1298,8 @@ export type HistoryEvent = { */ summaryKey: string; summaryParams: Record; + /** #140: what the action covered, one line each (an arrangement's changes, a report's counts). */ + summaryLines?: SummaryLineView[]; /** A `thing.move` arriving from a location the viewer can't see (D183): rendered with no diff. */ movedInFromElsewhere?: boolean; /** diff --git a/apps/web/src/components/history/fields.test.ts b/apps/web/src/components/history/fields.test.ts new file mode 100644 index 00000000..677855c5 --- /dev/null +++ b/apps/web/src/components/history/fields.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { fieldShown, isNewValue } from './fields'; + +describe('fieldShown (#140)', () => { + it.each([ + ['name', 'thing', true], + ['notes', 'thing', true], + ['quantity', 'thing', true], + ['custom.insured_value', 'thing', true], + ['custom.serial_id', 'thing', true], + ['place_id', 'thing', false], + ['file_ids', 'thing', false], + ['thingIds', 'thing', false], + ['id', 'thing', false], + ['version', 'thing', false], + ['moved', 'place', false], + ['trashed', 'place', false], + ['digits', 'location', false], + ['locale', 'location', false], + ['include', 'report', false], + ['filters', 'report', false], + ['kind', 'report', false], + ['layout', 'location', false], + ['name', 'location', true], + ['mime', 'file', false], + ])('%s on a %s: %s', (key, entity, shown) => { + expect(fieldShown(key, entity)).toBe(shown); + }); + + it('treats a field set from nothing as new', () => { + for (const v of [null, undefined, '', [], {}]) expect(isNewValue(v)).toBe(true); + for (const v of ['Desk', 0, false, ['a']]) expect(isNewValue(v)).toBe(false); + }); +}); diff --git a/apps/web/src/components/history/fields.ts b/apps/web/src/components/history/fields.ts new file mode 100644 index 00000000..a476adc5 --- /dev/null +++ b/apps/web/src/components/history/fields.ts @@ -0,0 +1,79 @@ +/** + * Which fields of a rendered audit diff a reader sees (#140). The summary line says what happened + * and its detail lines say what it covered ("12 things", "With photos"); a field earns a line of + * its own only when it is something a person set and would recognise. Everything else stays in + * the audit, not on the page: + * + * - ids and id lists (`place_id`, `file_ids`, `thingIds`): a reader can't use a UUID; + * - bookkeeping: row versions, counters a trash or an undo keeps for itself, order, storage; + * - a report's or a print's settings (digits, locale, filters, include): its sentence and lines + * say what it was; + * - an arrangement's layout, which the server says change by change; + * - every field of a row whose sentence is the whole story (a report, a label batch, an inbox + * item, an upload, an undone capture). + */ +const HIDDEN = new Set([ + // Step 4 (T29): what a delete keeps so undo can put it back (its documents, a service's lines + // and the schedules it completed), not a change anyone made. + 'documents', + 'lines', + 'completes', + // Step 5: what a service or a fill keeps for its undo, and a starter set's or a draft's ids. + 'reading_created', + 'cleared_schedules', + 'surface', + 'sort', + 'class', + 'has_gps', + 'derivative_state', + 'sha256', + 'width', + 'height', + // #140: bookkeeping and settings. + 'version', + 'row_version', + 'meter_version', + 'created_via', + 'moved', + 'trashed', + 'restored', + 'contents', + 'deleted_at', + 'digits', + 'locale', + 'include', + 'filters', + 'scope', + 'layout', + 'mode', + 'meter_created', + 'token_hash', + 'unprinted', +]); + +/** Entities whose sentence says it all. */ +const SENTENCE_ONLY = new Set(['report', 'label_batch', 'capture_batch', 'inbox_item', 'file']); + +/** `id`, `place_id`, `file_ids`, `thingId`, `attachmentIds`. */ +const ID = /(^id$|_ids?$|[a-z]Ids?$)/; + +/** Whether a diff field gets a line of its own on `entity`'s row. */ +export function fieldShown(key: string, entity?: string): boolean { + if (entity && SENTENCE_ONLY.has(entity)) return false; + if (key.startsWith('custom.') || key.startsWith('archived_custom.')) return true; + return !HIDDEN.has(key) && !ID.test(key); +} + +const isEmpty = (v: unknown): boolean => + v === null || + v === undefined || + v === '' || + (Array.isArray(v) && v.length === 0) || + (typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0); + +/** New is new (#140): a field that had no value shows only the one it got, with no arrow. */ +export function isNewValue(before: unknown): boolean { + return isEmpty(before); +} + +export { isEmpty as isEmptyValue }; diff --git a/apps/web/src/components/history/labels.test.tsx b/apps/web/src/components/history/labels.test.tsx new file mode 100644 index 00000000..6d450c20 --- /dev/null +++ b/apps/web/src/components/history/labels.test.tsx @@ -0,0 +1,50 @@ +import { SUMMARY_KEYS, SUMMARY_LINE_KEYS } from '@kept/shared'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AppProviders } from '@/app-providers'; +import { activateLocale } from '@/i18n/i18n'; +import { plainText, useSummary, useSummaryLine } from './labels'; + +// #140: every key the server can send reads as a sentence in the reader's language, never the +// server's English fallback. The sentence map is typed by SUMMARY_KEYS, so a missing key fails the +// typecheck; this checks each one renders, in English and in Arabic. + +const FALLBACK = 'English fallback'; +const params = { name: 'Desk lamp', count: '3', service: 'Car', host: 'Desk', room: 'Study' }; + +function All() { + const say = useSummary(); + const line = useSummaryLine(); + return ( +

+ ); +} + +describe.each(['en', 'ar'] as const)('every summary in %s', (locale) => { + it('is a sentence of its own', async () => { + await activateLocale(locale); + render( + + + , + ); + const items = await screen.findAllByRole('listitem'); + expect(items).toHaveLength(SUMMARY_KEYS.length - 1 + SUMMARY_LINE_KEYS.length); + for (const li of items) { + const text = li.textContent ?? ''; + expect(text, li.dataset.key).not.toBe(FALLBACK); + expect(text, li.dataset.key).not.toMatch(/^$|\{|undefined|[a-z]+\.[a-z_]+/); + } + }); +}); diff --git a/apps/web/src/components/history/labels.ts b/apps/web/src/components/history/labels.ts index 5d961fbb..2b09c231 100644 --- a/apps/web/src/components/history/labels.ts +++ b/apps/web/src/components/history/labels.ts @@ -4,8 +4,10 @@ * them. An unknown key is shown as itself, made readable ("insured_value" → "Insured value"): * a field the client doesn't know yet still says what changed. */ +import { SUMMARY_KEYS, type SummaryKey, type SummaryLineKey } from '@kept/shared'; +import { plural } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; -import type { HistoryEvent, RenderedChange } from '@/api/inventory/types'; +import type { HistoryEvent, RenderedChange, SummaryLineView } from '@/api/inventory/types'; import { useClaimStatusLabels } from '@/components/claims/labels'; import { useDocumentKindLabels } from '@/components/documents/labels'; import { @@ -16,6 +18,7 @@ import { import { useFieldLabel as useTypeFieldLabel } from '@/components/things/names'; import { useWarrantyKindLabels } from '@/components/warranties/labels'; import { isolate } from '@/lib/bidi'; +import { useModelNames, useRoomKindNames, useSurfaceNames } from '@/map/labels'; /** "custom.insured_value" → "Insured value"; "safe_code" → "Safe code". */ export function readableKey(key: string): string { @@ -167,15 +170,373 @@ export { FSI, PDI, plainText } from '@/lib/bidi'; /** * The summary line of a history or activity row in the reader's language, from the server's - * `summaryKey` and `summaryParams` (apps/server/src/history/summary.ts). A key this build - * doesn't know falls back to the server's English `summary`. The name in it is wrapped in FSI…PDI - * (see `isolate`); EventRow renders those as and strips them from accessible names. + * `summaryKey` and `summaryParams` (apps/server/src/history/summary.ts). Every key the server + * sends has a sentence here (#140: the map is typed by @kept/shared's SUMMARY_KEYS, so a new + * action without one fails the typecheck); only a key this build doesn't know falls back to the + * server's English `summary`. The name in it is wrapped in FSI…PDI (see `isolate`); EventRow + * renders those as and strips them from accessible names. */ export function useSummary() { const { t } = useLingui(); const words = useValueWords(); - // The server's stand-in nouns when the name isn't visible to the reader. - const nouns: Record = { + const nouns = useNouns(); + /** What a stock use was for: its name, or the server's noun when the reader can't see it. */ + const serviceName = (raw: string | undefined) => nouns[raw ?? ''] ?? isolate(raw ?? ''); + return (e: Pick) => { + const p = e.summaryParams; + const raw = p.name ?? ''; + const name = nouns[raw] ?? isolate(raw); + // An older server sends `event` with the action for what it had no sentence for. + const key = e.summaryKey === 'event' ? (p.action ?? '') : e.summaryKey; + if (!isSummaryKey(key) || key === 'event') return e.summary; + if (key === 'thing.lifecycle') { + // The stored code (`given_away`), left out when the row shows no lifecycle change. + const code = p.lifecycle; + if (!code) return t`Changed the status of ${name}`; + const status = words('lifecycle', code).toLocaleLowerCase(); + return t`Marked ${name} as ${status}`; + } + const service = serviceName(p.service); + const count = Number(p.count ?? 0); + return sentences({ name, service, count })[key](); + }; + + /** Every key's sentence, built lazily: only the one asked for is translated. */ + function sentences({ + name, + service, + count, + }: Vars): Record, () => string> { + return { + 'thing.create': () => t`Added ${name}`, + 'place.create': () => t`Added ${name}`, + 'thing.update': () => t`Edited ${name}`, + 'place.update': () => t`Edited ${name}`, + 'thing.retype': () => t`Changed the type of ${name}`, + 'thing.move': () => t`Moved ${name}`, + 'place.move': () => t`Moved ${name}`, + 'thing.move.in': () => t`Moved in from another location`, + 'thing.move.out': () => t`Moved ${name} to another location`, + 'thing.trash': () => t`Trashed ${name}`, + 'place.trash': () => t`Trashed ${name}`, + 'thing.restore': () => t`Restored ${name}`, + 'place.restore': () => t`Restored ${name}`, + 'thing.delete': () => t`Deleted ${name} for good`, + 'place.delete': () => t`Deleted ${name} for good`, + 'thing.seen': () => t`Saw ${name}`, + 'thing.not_here': () => t`Marked ${name} as not here`, + 'thing.split': () => t`Split ${name}`, + 'thing.duplicate': () => t`Duplicated ${name}`, + 'thing.link': () => t`Linked ${name}`, + 'thing.unlink': () => t`Unlinked ${name}`, + 'thing.convert_to_place': () => t`Turned ${name} into a place`, + 'thing.codes': () => t`Changed the codes of ${name}`, + 'place.codes': () => t`Changed the codes of ${name}`, + 'place.merge': () => t`Merged ${name} into another place`, + 'place.convert_to_container': () => t`Turned ${name} into a container`, + 'place.label': () => t`Labelled ${name}`, + 'attachment.create': () => t`Attached a file to ${name}`, + 'attachment.update': () => t`Changed a file on ${name}`, + 'attachment.delete': () => t`Removed a file from ${name}`, + 'thing.stock_use': () => t`Used ${name} in a service of ${service}`, + 'thing.stock_return': () => t`Put ${name} back from a service of ${service}`, + undo: () => t`Undid a change to ${name}`, + 'thing.capture.attach': () => t`Added a photo to ${name}`, + 'thing.capture.named': () => t`Added ${name}`, + // Step 2 onwards: what used to fall back to the action's words. + 'brand.create': () => t`Brand added`, + 'brand.update': () => t`Brand changed`, + 'brand.delete': () => t`Brand deleted`, + 'brand.merge': () => t`Brand merged`, + 'vendor.create': () => t`Vendor added`, + 'vendor.update': () => t`Vendor changed`, + 'vendor.delete': () => t`Vendor deleted`, + 'vendor.merge': () => t`Vendor merged`, + 'person.create': () => t`Person added`, + 'person.update': () => t`Person changed`, + 'person.delete': () => t`Person deleted`, + 'person.merge': () => t`Person merged`, + 'person.contact_update': () => t`Contact details changed`, + 'tag.create': () => t`Tag added`, + 'tag.update': () => t`Tag changed`, + 'tag.delete': () => t`Tag deleted`, + 'tag.merge': () => t`Tag merged`, + 'type.create': () => t`Type added`, + 'type.update': () => t`Type changed`, + 'type.delete': () => t`Type deleted`, + 'type.merge': () => t`Type merged`, + 'type.customise': () => t`Type customised`, + 'type_field.create': () => t`Field added`, + 'type_field.update': () => t`Field changed`, + 'type_field.archive': () => t`Field archived`, + 'type_field.restore': () => t`Field restored`, + 'place_kind.create': () => t`Place kind added`, + 'place_kind.update': () => t`Place kind changed`, + 'place_kind.customise': () => t`Place kind customised`, + 'file.upload': () => t`File uploaded`, + 'file.delete_original': () => t`Original file deleted`, + 'purchase.create': () => t`Purchase added`, + 'purchase.update': () => t`Purchase changed`, + 'purchase.delete': () => t`Purchase deleted`, + 'purchase_line.create': () => t`Purchase line added`, + 'purchase_line.update': () => t`Purchase line changed`, + 'purchase_line.delete': () => t`Purchase line deleted`, + 'thing.purchase_link': () => t`Linked to a purchase`, + 'thing.purchase_unlink': () => t`Unlinked from a purchase`, + 'thing.label': () => t`Label assigned`, + 'meter.create': () => t`Meter added`, + 'meter.update': () => t`Meter changed`, + 'meter.replaced': () => t`Meter replaced`, + 'reading.create': () => t`Reading logged`, + 'reading.update': () => t`Reading changed`, + 'reading.accept': () => t`Reading kept`, + 'reading.delete': () => t`Reading discarded`, + 'saved_view.create': () => t`Saved view added`, + 'location.create': () => t`Location created`, + 'location.update': () => t`Location settings changed`, + 'location.modules': () => t`What to track changed`, + 'location.delete': () => t`Location deleted`, + 'location.restore': () => t`Location restored`, + 'invite.create': () => t`Invite created`, + 'invite.update': () => t`Invite changed`, + 'invite.revoke': () => t`Invite revoked`, + 'member.join': () => t`Member joined`, + 'member.leave': () => t`Member left`, + 'member.update': () => t`Member's role changed`, + 'member.expire': () => t`Membership ended`, + 'managed_account.create': () => t`Managed account created`, + 'saved_view.update': () => t`Saved view changed`, + 'saved_view.delete': () => t`Saved view deleted`, + 'secret.set': () => t`Secret changed`, + 'secret.clear': () => t`Secret cleared`, + 'secret.reveal': () => t`Secret revealed`, + 'secret.copied': () => t`Secret copied`, + 'secret_policy.update': () => t`Secret policy changed`, + 'warranty.create': () => t`Warranty added`, + 'warranty.update': () => t`Warranty changed`, + 'warranty.delete': () => t`Warranty removed`, + 'claim.create': () => t`Claim opened`, + 'claim.update': () => t`Claim changed`, + 'claim.delete': () => t`Claim deleted`, + 'valuation.create': () => t`Valuation added`, + 'valuation.update': () => t`Valuation changed`, + 'valuation.delete': () => t`Valuation deleted`, + 'loan.create': () => t`Loan added`, + 'loan.update': () => t`Loan changed`, + 'loan.return': () => t`Loan returned`, + 'loan.delete': () => t`Loan deleted`, + 'schedule.create': () => t`Schedule added`, + 'schedule.update': () => t`Schedule changed`, + 'schedule.delete': () => t`Schedule deleted`, + 'schedule.snooze': () => t`Schedule snoozed`, + 'schedule.unsnooze': () => t`Snooze cleared`, + 'schedule.skip': () => t`Next one skipped`, + 'service_record.create': () => t`Service logged`, + 'service_record.update': () => t`Service changed`, + 'service_record.delete': () => t`Service deleted`, + 'service_record.draft': () => t`Service draft started from an invoice`, + 'service_record.confirm': () => t`Service logged`, + 'meter.delete': () => t`Meter removed`, + 'fuel.create': () => t`Fuel logged`, + 'fuel.update': () => t`Fuel changed`, + 'fuel.delete': () => t`Fuel deleted`, + 'schedule.starter': () => t`Starter schedules added`, + 'document.create': () => t`Document added`, + 'document.update': () => t`Document changed`, + 'document.renew': () => t`Document renewed`, + 'document.delete': () => t`Document deleted`, + 'incident.create': () => t`Incident recorded`, + 'incident.update': () => t`Incident changed`, + 'incident.things': () => t`Incident's things changed`, + 'incident.delete': () => t`Incident deleted`, + 'fx_rate.set': () => t`Exchange rate set`, + 'fx_rate.delete': () => t`Exchange rate deleted`, + 'brand.logo_set': () => t`Brand logo changed`, + 'brand.logo_remove': () => t`Brand logo removed`, + 'thing.stock_rule': () => t`Minimum to keep changed`, + 'type.field_convert': () => t`Field converted`, + 'things.export_csv': () => t`List exported as a CSV`, + 'file.import': () => t`File imported`, + 'webhook.create': () => t`Webhook added`, + 'webhook.update': () => t`Webhook changed`, + 'webhook.delete': () => t`Webhook removed`, + 'webhook.rotate_secret': () => t`Webhook secret replaced`, + 'share_link.create': () => t`Share link made`, + 'share_link.revoke': () => t`Share link revoked`, + 'move.create': () => t`Move started`, + 'move.update': () => t`Move changed`, + 'move.delete': () => t`Move deleted`, + 'move_box.create': () => t`Added to a move as a box`, + 'move_box.update': () => t`Moving box changed`, + 'move_box.unpack': () => t`Box unpacked`, + 'move_box.delete': () => t`Taken out of a move`, + 'paperless.connect': () => t`Paperless connected`, + 'paperless.update': () => t`Paperless connection changed`, + 'paperless.disconnect': () => t`Paperless disconnected`, + 'paperless_link.create': () => t`Paperless document linked`, + 'paperless_link.delete': () => t`Paperless document unlinked`, + 'paperless_link.copy': () => t`Paperless document copied into Kept`, + 'purchase.reextract': () => t`Receipt read again`, + 'thing.enrich': () => t`Other names added`, + 'import.enrich': () => t`Other names requested for an import`, + 'import.homebox_connect': () => t`Connected to Homebox`, + 'instance.setup': () => t`Kept set up`, + 'instance.settings_update': () => t`Instance settings changed`, + 'instance.backup': () => t`Backup made`, + 'instance.backup_run': () => t`Backup started by hand`, + 'instance.backup_settings': () => t`Backup settings changed`, + 'instance.backup_test': () => t`Backup target tested`, + 'instance.restore': () => t`Restored from a backup`, + 'instance.export': () => t`Whole instance exported`, + 'instance.downgrade_forced': () => t`Started on an older version anyway`, + 'instance.rotate_key': () => t`Encryption key replaced`, + 'instance.drop_key': () => t`Old encryption key removed`, + 'instance.embeddings_source': () => t`Semantic search source changed`, + 'instance.recovery_kit_download': () => t`Recovery kit downloaded`, + 'instance.recovery_kit_acknowledge': () => t`Recovery kit noted as kept`, + 'instance.update_check': () => t`Checked for updates`, + 'instance.setup_code_reissue': () => t`New setup code issued`, + 'instance.oidc_changed': () => t`OIDC sign-in settings changed`, + 'instance.smtp_changed': () => t`Email settings changed`, + // #140: a sentence for every other action, and the refinements. + 'attachment.create.photo': () => t`Added a photo to ${name}`, + 'thing.capture': () => t`Captured ${name} from a photo`, + 'thing.capture.reading': () => t`Logged a reading of ${name} from a photo`, + 'thing.extract': () => t`Filled in ${name} from a photo`, + 'thing.reextract': () => t`Read the photo of ${name} again`, + 'thing.merge': () => t`Merged a duplicate into ${name}`, + 'place.arrange': () => t`Arranged the map`, + 'box.check': () => t`Checked what is in ${name}`, + 'label.claim': () => t`Gave ${name} a label`, + 'location.own_codes': () => t`Changed how ${name} numbers its codes`, + 'location.transfer_ownership': () => t`Handed ${name} to a new owner`, + 'report.generate': () => t`Made an inventory report of ${name}`, + 'report.generate.insurance': () => t`Made an insurance report for ${name}`, + 'report.generate.vehicle': () => t`Made a vehicle history report for ${name}`, + 'account.deletion_cancel': () => t`Cancelled the account deletion`, + 'account.deletion_request': () => t`Asked to delete the account`, + 'account.email_change': () => t`Changed the account email`, + 'admin.cli_reset_password': () => t`Reset a password from the command line`, + 'admin.cli_user_disable': () => t`Turned off an account from the command line`, + 'admin.cli_user_enable': () => t`Turned an account back on from the command line`, + 'admin.currency_disable': () => t`Turned off a currency`, + 'admin.currency_enable': () => t`Turned on a currency`, + 'admin.instance_admin_grant': () => t`Made someone an instance admin`, + 'admin.instance_admin_revoke': () => t`Removed an instance admin`, + 'admin.job_discard': () => t`Discarded a failed job`, + 'admin.job_retry': () => t`Retried a failed job`, + 'admin.user_disable': () => t`Turned off an account`, + 'admin.user_enable': () => t`Turned an account back on`, + 'admin.user_reset_2fa': () => t`Reset someone’s two-step sign-in`, + 'admin.user_sign_out_everywhere': () => t`Signed someone out everywhere`, + 'ai.cap_clear': () => t`Removed the AI spending cap`, + 'ai.cap_set': () => t`Set the AI spending cap`, + 'ai.pause': () => t`Paused AI`, + 'ai.resume': () => t`Resumed AI`, + 'ai.price_prefill': () => t`Looked up AI prices`, + 'ai.price_recost': () => t`Worked out AI costs again with new prices`, + 'ai.price_remove': () => t`Removed an AI price`, + 'ai.price_set': () => t`Set an AI price`, + 'ai.provider_models': () => t`Listed an AI provider’s models`, + 'ai.provider_remove': () => t`Removed an AI provider`, + 'ai.provider_set': () => t`Set up an AI provider`, + 'ai.provider_test': () => t`Tested an AI provider`, + 'ai.usage_export': () => t`Exported AI usage`, + 'instance.google_changed': () => t`Google sign-in settings changed`, + 'managed_account.password_set': () => t`Set a managed account’s password`, + 'managed_account.reset_code': () => t`Made a reset code for a managed account`, + 'me.notification_preferences': () => t`Changed notification settings`, + 'me.notifications': () => t`Changed which notifications arrive`, + 'me.preferences': () => t`Changed preferences`, + 'session.revoke': () => t`Signed out a session`, + 'oauth.deny': () => t`Turned down an app’s request to connect`, + 'oauth.grant': () => t`Let an app connect`, + 'token.create': () => t`Made an access token`, + 'token.revoke': () => t`Revoked an access token`, + 'token.update': () => t`Changed an access token`, + 'push_subscription.create': () => t`Turned on notifications on a device`, + 'push_subscription.delete': () => t`Turned off notifications on a device`, + 'push_subscription.update': () => t`Changed notifications on a device`, + 'hint.dismiss': () => t`Dismissed a tip`, + 'hint.restore': () => t`Brought back a tip`, + 'hint.seen': () => t`Read a tip`, + 'saved_view_prefs.update': () => t`Changed how a list shows`, + 'member.remove': () => t`Removed a member`, + 'member.recap': () => t`Changed when guest access ends`, + 'member.owner_notified': () => t`Told the owner about a new guest`, + 'mailbox.create': () => t`Set up the mailbox`, + 'mailbox.update': () => t`Changed the mailbox`, + 'mailbox.delete': () => t`Removed the mailbox`, + 'channel.create': () => t`Added a notification channel`, + 'channel.delete': () => t`Removed a notification channel`, + 'calendar_feed.create': () => t`Made a calendar feed`, + 'calendar_feed.revoke': () => t`Revoked a calendar feed`, + 'capture.batch_undo': () => t`Undid a capture`, + 'inbox.discard': () => t`Discarded a draft`, + 'inbox.resolve': () => t`Dealt with an inbox item`, + 'inbox.restore': () => t`Brought back an inbox item`, + 'inbox.bulk': () => t`Changed several inbox items at once`, + 'labels.printed': () => t`Printed labels`, + 'purchase.capture': () => t`Captured a receipt`, + 'purchase.confirm': () => t`Confirmed a receipt`, + 'purchase.extract': () => t`Read a receipt`, + 'reading.extract': () => t`Read a meter from a photo`, + 'file.crop': () => t`Cropped a photo`, + 'file.display_set': () => t`Changed the photo shown`, + 'template.create': () => t`Added a template`, + 'template.update': () => t`Changed a template`, + 'template.delete': () => t`Deleted a template`, + 'claim.status': () => t`Changed a claim’s status`, + 'claim_pack.create': () => t`Made a claim pack`, + 'claim_pack.link': () => t`Shared a claim pack link`, + 'claim_pack.link_revoke': () => t`Revoked a claim pack link`, + 'condition_report.create': () => t`Started a condition report`, + 'condition_report.update': () => t`Changed a condition report`, + 'condition_report.finish': () => t`Finished a condition report`, + 'condition_report.delete': () => t`Deleted a condition report`, + 'condition_room.create': () => t`Added a room to a condition report`, + 'condition_room.update': () => t`Changed a room in a condition report`, + 'condition_room.delete': () => t`Removed a room from a condition report`, + 'export.create': () => t`Started an export`, + 'export.cancel': () => t`Cancelled an export`, + 'export.download': () => t`Downloaded an export`, + 'export.secrets': () => t`Put secrets in an export`, + 'import.create': () => t`Uploaded a file to import`, + 'import.archive': () => t`Uploaded an archive to import`, + 'import.inspect': () => t`Opened an archive to import`, + 'import.passphrase': () => t`Entered an import’s passphrase`, + 'import.target': () => t`Chose where an import goes`, + 'import.choices': () => t`Changed an import’s choices`, + 'import.check': () => t`Checked an import`, + 'import.start': () => t`Started an import`, + 'import.run': () => t`Ran an import`, + 'import.cancel': () => t`Cancelled an import`, + 'report.export_csv': () => t`Exported a report as a CSV`, + 'report.schedule': () => t`Scheduled a report`, + 'report.unschedule': () => t`Stopped a scheduled report`, + 'report.generate.condition': () => t`Made a condition report for ${name}`, + 'report.generate.move': () => t`Made a moving report for ${name}`, + 'label_batch.create': () => + plural(count, { one: 'Made # blank label', other: 'Made # blank labels' }), + 'label_batch.create.things': () => + plural(count, { one: 'Made labels for # thing', other: 'Made labels for # things' }), + 'label_batch.create.places': () => + plural(count, { one: 'Made labels for # place', other: 'Made labels for # places' }), + 'place.arrange.many': () => + plural(count, { one: 'Arranged the map: # change', other: 'Arranged the map: # changes' }), + }; + } +} + +type Vars = { name: string; service: string; count: number }; + +const KEYS: ReadonlySet = new Set(SUMMARY_KEYS); +const isSummaryKey = (key: string): key is SummaryKey => KEYS.has(key); + +/** The server's stand-in nouns when the name isn't visible to the reader. */ +function useNouns(): Record { + const { t } = useLingui(); + return { 'a thing': t`a thing`, 'a place': t`a place`, 'a type': t`a type`, @@ -188,259 +549,54 @@ export function useSummary() { 'the location': t`the location`, something: t`something`, }; - /** What a stock use was for: its name, or the server's noun when the reader can't see it. */ - const serviceName = (raw: string | undefined) => nouns[raw ?? ''] ?? isolate(raw ?? ''); - return (e: Pick) => { - const raw = e.summaryParams.name ?? ''; - const name = nouns[raw] ?? isolate(raw); - switch (e.summaryKey) { - case 'thing.create': - case 'place.create': - return t`Added ${name}`; - case 'thing.update': - case 'place.update': - return t`Edited ${name}`; - case 'thing.retype': - return t`Changed the type of ${name}`; - case 'thing.lifecycle': { - // The stored code (`given_away`), left out when the row shows no lifecycle change. - const code = e.summaryParams.lifecycle; - if (!code) return t`Changed the status of ${name}`; - const status = words('lifecycle', code).toLocaleLowerCase(); - return t`Marked ${name} as ${status}`; - } - case 'thing.move': - case 'place.move': - return t`Moved ${name}`; - case 'thing.move.in': - return t`Moved in from another location`; - case 'thing.move.out': - return t`Moved ${name} to another location`; - case 'thing.trash': - case 'place.trash': - return t`Trashed ${name}`; - case 'thing.restore': - case 'place.restore': - return t`Restored ${name}`; - case 'thing.delete': - case 'place.delete': - return t`Deleted ${name} for good`; - case 'thing.seen': - return t`Saw ${name}`; - case 'thing.not_here': - return t`Marked ${name} as not here`; - case 'thing.split': - return t`Split ${name}`; - case 'thing.duplicate': - return t`Duplicated ${name}`; - case 'thing.link': - return t`Linked ${name}`; - case 'thing.unlink': - return t`Unlinked ${name}`; - case 'thing.convert_to_place': - return t`Turned ${name} into a place`; - case 'thing.codes': - case 'place.codes': - return t`Changed the codes of ${name}`; - case 'place.merge': - return t`Merged ${name} into another place`; - case 'place.convert_to_container': - return t`Turned ${name} into a container`; - case 'place.label': - return t`Labelled ${name}`; - case 'attachment.create': - return t`Attached a file to ${name}`; - case 'attachment.update': - return t`Changed a file on ${name}`; - case 'attachment.delete': - return t`Removed a file from ${name}`; - // D236: a service line took the consumable off the shelf, or gave it back. - case 'thing.stock_use': { - const service = serviceName(e.summaryParams.service); - return t`Used ${name} in a service of ${service}`; - } - case 'thing.stock_return': { - const service = serviceName(e.summaryParams.service); - return t`Put ${name} back from a service of ${service}`; - } - case 'undo': - return t`Undid a change to ${name}`; - case 'event': - return eventSentence(e.summaryParams.action ?? '') ?? e.summary; - default: - return e.summary; - } - }; +} - /** The events without a sentence of their own that step 2 writes. */ - function eventSentence(action: string): string | null { - const known: Record = { - 'brand.create': t`Brand added`, - 'brand.update': t`Brand changed`, - 'brand.delete': t`Brand deleted`, - 'brand.merge': t`Brand merged`, - 'vendor.create': t`Vendor added`, - 'vendor.update': t`Vendor changed`, - 'vendor.delete': t`Vendor deleted`, - 'vendor.merge': t`Vendor merged`, - 'person.create': t`Person added`, - 'person.update': t`Person changed`, - 'person.delete': t`Person deleted`, - 'person.merge': t`Person merged`, - 'person.contact_update': t`Contact details changed`, - 'tag.create': t`Tag added`, - 'tag.update': t`Tag changed`, - 'tag.delete': t`Tag deleted`, - 'tag.merge': t`Tag merged`, - 'type.create': t`Type added`, - 'type.update': t`Type changed`, - 'type.delete': t`Type deleted`, - 'type.merge': t`Type merged`, - 'type.customise': t`Type customised`, - 'type_field.create': t`Field added`, - 'type_field.update': t`Field changed`, - 'type_field.archive': t`Field archived`, - 'type_field.restore': t`Field restored`, - 'place_kind.create': t`Place kind added`, - 'place_kind.update': t`Place kind changed`, - 'place_kind.customise': t`Place kind customised`, - 'file.upload': t`File uploaded`, - 'file.delete_original': t`Original file deleted`, - 'purchase.create': t`Purchase added`, - 'purchase.update': t`Purchase changed`, - 'purchase.delete': t`Purchase deleted`, - 'purchase_line.create': t`Purchase line added`, - 'purchase_line.update': t`Purchase line changed`, - 'purchase_line.delete': t`Purchase line deleted`, - 'thing.purchase_link': t`Linked to a purchase`, - 'thing.purchase_unlink': t`Unlinked from a purchase`, - 'thing.label': t`Label assigned`, - 'meter.create': t`Meter added`, - 'meter.update': t`Meter changed`, - 'meter.replaced': t`Meter replaced`, - 'reading.create': t`Reading logged`, - 'reading.update': t`Reading changed`, - 'reading.accept': t`Reading kept`, - 'reading.delete': t`Reading discarded`, - 'saved_view.create': t`Saved view added`, - 'location.create': t`Location created`, - 'location.update': t`Location settings changed`, - 'location.modules': t`What to track changed`, - 'location.delete': t`Location deleted`, - 'location.restore': t`Location restored`, - 'invite.create': t`Invite created`, - 'invite.update': t`Invite changed`, - 'invite.revoke': t`Invite revoked`, - 'member.join': t`Member joined`, - 'member.leave': t`Member left`, - 'member.update': t`Member's role changed`, - 'member.expire': t`Membership ended`, - 'managed_account.create': t`Managed account created`, - 'saved_view.update': t`Saved view changed`, - 'saved_view.delete': t`Saved view deleted`, - 'secret.set': t`Secret changed`, - 'secret.clear': t`Secret cleared`, - 'secret.reveal': t`Secret revealed`, - 'secret.copied': t`Secret copied`, - 'secret_policy.update': t`Secret policy changed`, - // Step 4 (T29). - 'warranty.create': t`Warranty added`, - 'warranty.update': t`Warranty changed`, - 'warranty.delete': t`Warranty removed`, - 'claim.create': t`Claim opened`, - 'claim.update': t`Claim changed`, - 'claim.delete': t`Claim deleted`, - 'valuation.create': t`Valuation added`, - 'valuation.update': t`Valuation changed`, - 'valuation.delete': t`Valuation deleted`, - 'loan.create': t`Loan added`, - 'loan.update': t`Loan changed`, - 'loan.return': t`Loan returned`, - 'loan.delete': t`Loan deleted`, - 'schedule.create': t`Schedule added`, - 'schedule.update': t`Schedule changed`, - 'schedule.delete': t`Schedule deleted`, - 'schedule.snooze': t`Schedule snoozed`, - 'schedule.unsnooze': t`Snooze cleared`, - 'schedule.skip': t`Next one skipped`, - 'service_record.create': t`Service logged`, - 'service_record.update': t`Service changed`, - 'service_record.delete': t`Service deleted`, - // Step 5 (T20): an invoice attached first makes a draft; saving it confirms it. - 'service_record.draft': t`Service draft started from an invoice`, - 'service_record.confirm': t`Service logged`, - 'meter.delete': t`Meter removed`, - 'fuel.create': t`Fuel logged`, - 'fuel.update': t`Fuel changed`, - 'fuel.delete': t`Fuel deleted`, - 'schedule.starter': t`Starter schedules added`, - 'document.create': t`Document added`, - 'document.update': t`Document changed`, - 'document.renew': t`Document renewed`, - 'document.delete': t`Document deleted`, - 'incident.create': t`Incident recorded`, - 'incident.update': t`Incident changed`, - 'incident.things': t`Incident's things changed`, - 'incident.delete': t`Incident deleted`, - 'fx_rate.set': t`Exchange rate set`, - 'fx_rate.delete': t`Exchange rate deleted`, - 'brand.logo_set': t`Brand logo changed`, - 'brand.logo_remove': t`Brand logo removed`, - // Step 7 (T17, T18, T16, T8): a consumable's minimum, a field converted, a list exported - // as a CSV, and a file an archive import brought in. - 'thing.stock_rule': t`Minimum to keep changed`, - 'type.field_convert': t`Field converted`, - 'things.export_csv': t`List exported as a CSV`, - 'file.import': t`File imported`, - // Step 6: location webhooks and reading a receipt again; step 7: other names from AI and - // Homebox; step 8: the instance's own events (backups, keys, updates, setup). - 'webhook.create': t`Webhook added`, - 'webhook.update': t`Webhook changed`, - 'webhook.delete': t`Webhook removed`, - 'webhook.rotate_secret': t`Webhook secret replaced`, - // 1.5 (D233): read-only share links. - 'share_link.create': t`Share link made`, - 'share_link.revoke': t`Share link revoked`, - // 1.x (D251): moving mode, a move and its boxes. - 'move.create': t`Move started`, - 'move.update': t`Move changed`, - 'move.delete': t`Move deleted`, - 'move_box.create': t`Added to a move as a box`, - 'move_box.update': t`Moving box changed`, - 'move_box.unpack': t`Box unpacked`, - 'move_box.delete': t`Taken out of a move`, - // 1.x (D248): Paperless-ngx, the connection and the links to its documents. - 'paperless.connect': t`Paperless connected`, - 'paperless.update': t`Paperless connection changed`, - 'paperless.disconnect': t`Paperless disconnected`, - 'paperless_link.create': t`Paperless document linked`, - 'paperless_link.delete': t`Paperless document unlinked`, - 'paperless_link.copy': t`Paperless document copied into Kept`, - 'purchase.reextract': t`Receipt read again`, - 'thing.enrich': t`Other names added`, - 'import.enrich': t`Other names requested for an import`, - 'import.homebox_connect': t`Connected to Homebox`, - 'instance.setup': t`Kept set up`, - 'instance.settings_update': t`Instance settings changed`, - 'instance.backup': t`Backup made`, - 'instance.backup_run': t`Backup started by hand`, - 'instance.backup_settings': t`Backup settings changed`, - 'instance.backup_test': t`Backup target tested`, - 'instance.restore': t`Restored from a backup`, - 'instance.export': t`Whole instance exported`, - 'instance.downgrade_forced': t`Started on an older version anyway`, - 'instance.rotate_key': t`Encryption key replaced`, - 'instance.drop_key': t`Old encryption key removed`, - 'instance.embeddings_source': t`Semantic search source changed`, - 'instance.recovery_kit_download': t`Recovery kit downloaded`, - 'instance.recovery_kit_acknowledge': t`Recovery kit noted as kept`, - 'instance.update_check': t`Checked for updates`, - 'instance.setup_code_reissue': t`New setup code issued`, - 'instance.oidc_changed': t`OIDC sign-in settings changed`, - 'instance.smtp_changed': t`Email settings changed`, +/** + * A row's detail lines (#140) in the reader's language: an arrangement's changes and what a + * report or a trash covered. Names are isolated like the summary's; a map shape, room kind or + * floor arrives as its code and is said in the map's own words. A line this build doesn't know + * shows the server's English. + */ +export function useSummaryLine() { + const { t } = useLingui(); + const nouns = useNouns(); + const vocab: Record> = { + model: useModelNames(), + kind: useRoomKindNames(), + surface: useSurfaceNames(), + }; + return (line: SummaryLineView) => { + const p = line.params; + const named = (raw: string | undefined) => nouns[raw ?? ''] ?? isolate(raw ?? ''); + const said = (code: string | undefined) => + (p.vocab ? vocab[p.vocab]?.[code ?? ''] : undefined) ?? code ?? ''; + const name = named(p.name); + const host = named(p.host); + const room = named(p.room); + const from = said(p.from); + const to = said(p.to); + const count = Number(p.count ?? 0); + const all: Record string> = { + 'line.things': () => plural(count, { one: '# thing', other: '# things' }), + 'line.inside': () => + plural(count, { one: 'With # thing inside', other: 'With # things inside' }), + 'line.moved_out': () => + plural(count, { one: '# thing taken out first', other: '# things taken out first' }), + 'line.photos': () => t`With photos`, + 'map.put.room': () => t`Put ${name} on the map in ${room}`, + 'map.put': () => t`Put ${name} on the map`, + 'map.remove': () => t`Took ${name} off the map`, + 'map.on': () => t`Put ${name} on ${host}`, + 'map.off': () => t`Took ${name} off ${host}`, + 'map.move': () => t`Moved ${name}`, + 'map.turn': () => t`Turned ${name}`, + 'map.shape': () => t`Changed the shape of ${name}: ${from} → ${to}`, + 'map.resize': () => t`Resized ${name}`, + 'map.kind': () => t`Changed the kind of ${name}: ${from} → ${to}`, + 'map.floor': () => t`Changed the floor of ${name}: ${from} → ${to}`, }; - return known[action] ?? null; - } + return (all as Record string) | undefined>)[line.key]?.() ?? line.text; + }; } /** Lifecycle and condition values (D119, Q10) as words; anything else as it is. */ diff --git a/apps/web/src/components/history/timeline.tsx b/apps/web/src/components/history/timeline.tsx index ae2366f1..f97bc9b0 100644 --- a/apps/web/src/components/history/timeline.tsx +++ b/apps/web/src/components/history/timeline.tsx @@ -13,11 +13,11 @@ * can't see is its own row, "Moved in from another location", with no old path (D183). A thing's * AI calls (D206) are rows of their own, with the AI line (components/ai/ai-history-row.tsx). */ -import { printedCode } from '@kept/shared'; +import { printedCode, SUMMARY_LINES_INLINE } from '@kept/shared'; import { Trans, useLingui } from '@lingui/react/macro'; import { useInfiniteQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { type ReactNode, useEffect, useMemo, useRef } from 'react'; +import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useUndoable } from '@/api/capture/queries'; import { inventoryApi, inventoryKeys as k, nextCursor } from '@/api/inventory/queries'; import type { HistoryEvent, RenderedChange } from '@/api/inventory/types'; @@ -29,7 +29,16 @@ import { Button } from '@/components/ui/button'; import { addressOf } from '@/lib/address'; import { sep, useBytes, useFormat } from '@/lib/format'; import { cn } from '@/lib/utils'; -import { FSI, PDI, plainText, useFieldLabel, useSummary, useValueWords } from './labels'; +import { fieldShown, isNewValue } from './fields'; +import { + FSI, + PDI, + plainText, + useFieldLabel, + useSummary, + useSummaryLine, + useValueWords, +} from './labels'; import { useUndo } from './undo'; export type HistorySubject = { kind: 'thing' | 'place'; id: string }; @@ -200,7 +209,7 @@ export function EventRow({ moved || compact ? [] : Object.entries(event.diff ?? {}).filter( - (entry) => shown(entry) && !(created && startsAsDefault(entry)), + (entry) => shown(entry, event.entity.type) && !(created && startsAsDefault(entry)), ); return (
) : null} + {!compact && event.summaryLines?.length ? : null} {changes.length ? (
{changes.map(([key, change]) => ( @@ -280,6 +290,51 @@ export function EventRow({ ); } +/** + * What the action covered, one sentence a line (#140): an arrangement's changes ("Put HD webcam on + * Corner desk"), a report's things and photos. Up to five show as they are; a row with more says + * how many in its summary ("Arranged the map: 12 changes") and opens them with Show. + */ +function Lines({ event }: { event: HistoryEvent }) { + const { t } = useLingui(); + const say = useSummaryLine(); + const [open, setOpen] = useState(false); + const id = useId(); + const lines = event.summaryLines ?? []; + const folded = lines.length > SUMMARY_LINES_INLINE; + return ( +
+ {!folded || open ? ( +
    + {lines.map((line, i) => ( +
  • + +
  • + ))} +
+ ) : null} + {folded ? ( + + ) : null} +
+ ); +} + /** * "merged from " (D36, T15): an event of a thing that was merged into this one, in this * thing's history. A thing merged away without a name says so. @@ -381,6 +436,14 @@ function Change({