@kernhq/module-inventory 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +106 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +345 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +245 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1108 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +253 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1392 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +385 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +169 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +157 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +309 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/0008_category_order_unique.sql +71 -0
  117. package/migrations/meta/0002_snapshot.json +1054 -0
  118. package/migrations/meta/0003_snapshot.json +1070 -0
  119. package/migrations/meta/0004_snapshot.json +1130 -0
  120. package/migrations/meta/0005_snapshot.json +1135 -0
  121. package/migrations/meta/_journal.json +49 -0
  122. package/package.json +2 -1
  123. package/src/client/api-instance.ts +27 -2
  124. package/src/client/bidi.test.ts +148 -0
  125. package/src/client/bidi.ts +85 -0
  126. package/src/client/components/AssetDetailPanel.svelte +614 -0
  127. package/src/client/components/AssetFormDialog.svelte +71 -22
  128. package/src/client/components/AssetPhoto.svelte +178 -0
  129. package/src/client/components/AttachmentsSection.svelte +327 -0
  130. package/src/client/components/CustodyDialog.svelte +201 -0
  131. package/src/client/components/RepairDialog.svelte +271 -0
  132. package/src/client/components/RepairsSection.svelte +318 -0
  133. package/src/client/components/Timeline.svelte +347 -0
  134. package/src/client/components/TimelineText.svelte +124 -0
  135. package/src/client/core-api.ts +71 -0
  136. package/src/client/custody.test.ts +31 -0
  137. package/src/client/custody.ts +34 -0
  138. package/src/client/errors.test.ts +395 -0
  139. package/src/client/errors.ts +229 -0
  140. package/src/client/links.test.ts +74 -0
  141. package/src/client/links.ts +44 -0
  142. package/src/client/members.test.ts +132 -0
  143. package/src/client/members.ts +116 -0
  144. package/src/client/messages.test.ts +127 -0
  145. package/src/client/messages.ts +1106 -18
  146. package/src/client/mock.test.ts +465 -1
  147. package/src/client/mock.ts +1105 -79
  148. package/src/client/module.ts +73 -1
  149. package/src/client/pages/AssetsPage.svelte +317 -45
  150. package/src/client/permissions.ts +7 -0
  151. package/src/client/price.test.ts +151 -3
  152. package/src/client/price.ts +155 -11
  153. package/src/client/query.ts +36 -0
  154. package/src/client/reorder.test.ts +100 -0
  155. package/src/client/reorder.ts +79 -0
  156. package/src/client/repairs.test.ts +38 -0
  157. package/src/client/repairs.ts +38 -0
  158. package/src/client/sequence.test.ts +248 -0
  159. package/src/client/sequence.ts +185 -0
  160. package/src/client/settings/CategoriesSettings.svelte +746 -0
  161. package/src/client/settings/GeneralSettings.svelte +0 -0
  162. package/src/client/status.ts +29 -0
  163. package/src/client/timeline.test.ts +175 -0
  164. package/src/client/timeline.ts +206 -0
  165. package/src/client/widgets/OverviewWidget.svelte +127 -26
  166. package/src/client/widgets/RepairsWidget.svelte +124 -0
  167. package/src/contract/capabilities.ts +53 -9
  168. package/src/contract/events.ts +49 -0
  169. package/src/contract/index.ts +1 -0
  170. package/src/contract/models.ts +270 -0
  171. package/src/contract/notifications.ts +73 -0
  172. package/src/contract/permissions.ts +55 -2
  173. package/src/contract/router.ts +284 -1
  174. package/src/contract/settings.ts +24 -4
  175. package/src/module.test.ts +217 -4
  176. package/src/server/index.ts +311 -4
  177. package/src/server/inventory.int.test.ts +4109 -19
  178. package/src/server/jobs.ts +444 -0
  179. package/src/server/migrations.test.ts +253 -2
  180. package/src/server/router.ts +486 -6
  181. package/src/server/schema.ts +171 -3
  182. package/src/server/services/assets.ts +214 -54
  183. package/src/server/services/attachments.ts +215 -0
  184. package/src/server/services/audience.ts +77 -0
  185. package/src/server/services/categories.ts +337 -0
  186. package/src/server/services/cursor.ts +104 -0
  187. package/src/server/services/custody.ts +471 -0
  188. package/src/server/services/db-errors.ts +42 -0
  189. package/src/server/services/index.ts +34 -1
  190. package/src/server/services/members.ts +54 -0
  191. package/src/server/services/notify.ts +52 -7
  192. package/src/server/services/offboarding.ts +150 -0
  193. package/src/server/services/repairs.ts +567 -0
  194. package/src/server/services/search.ts +166 -0
  195. package/src/server/services/stats.ts +88 -0
  196. package/src/server/services/status.test.ts +34 -0
  197. package/src/server/services/status.ts +143 -0
  198. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,29 @@
1
+ import type { BadgeTone } from '@kernhq/ui'
2
+
3
+ /**
4
+ * What colour a status wears, in the one place both screens read it from.
5
+ *
6
+ * The list and the detail panel show the same badge for the same row, and a `switch` copied into
7
+ * two components is two switches that drift — the day `reserved` gets its own colour, one of them
8
+ * changes. `import type` is erased at build time, so this file still pulls no runtime dependency on
9
+ * `@kernhq/ui` and stays unit-testable.
10
+ *
11
+ * The tones say what a reader needs to act on rather than what is "good": `in_stock` is the
12
+ * ordinary resting state, `lost` is the one somebody has to do something about, and an archived row
13
+ * is drawn grey by the screens without asking here — it is not a status, it is the absence of one.
14
+ */
15
+ export function statusTone(status: string): BadgeTone {
16
+ switch (status) {
17
+ case 'assigned':
18
+ case 'reserved':
19
+ return 'info'
20
+ case 'under_repair':
21
+ return 'warning'
22
+ case 'lost':
23
+ return 'danger'
24
+ case 'retired':
25
+ return 'grey'
26
+ default:
27
+ return 'success'
28
+ }
29
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { AssetInput } from '../contract/models.js'
3
+ import { en } from './messages.js'
4
+ import {
5
+ actionKey,
6
+ changeLineKind,
7
+ changeShape,
8
+ fieldKey,
9
+ isKnownAction,
10
+ isLongText,
11
+ isOpaque,
12
+ noteOf,
13
+ personIdOf,
14
+ } from './timeline.js'
15
+
16
+ /**
17
+ * The decisions behind a readable timeline, checked where they can be checked.
18
+ *
19
+ * `Timeline.svelte` supplies the words; every judgement it makes — which sentence, about whom, with
20
+ * what label — is in `timeline.ts`, and each one has a right answer. The three that matter are the
21
+ * ones nothing else can catch:
22
+ *
23
+ * 1. an action the catalogue has no sentence for must not be described as something else;
24
+ * 2. a field added to the contract must not reach a screen as its own column name;
25
+ * 3. a return names who gave it back, not who received it — they are different keys in `data`,
26
+ * and reading the wrong one renders "took it back from nobody".
27
+ */
28
+ describe('actionKey', () => {
29
+ it('names a key the catalogue actually has, for every action the server writes', () => {
30
+ // Exactly what `AssetService` and `CustodyService` write today.
31
+ for (const action of [
32
+ 'created',
33
+ 'updated',
34
+ 'assigned',
35
+ 'transferred',
36
+ 'returned',
37
+ 'retired',
38
+ 'restored',
39
+ ]) {
40
+ expect({ action, key: actionKey(action) }).toEqual({ action, key: `history_${action}` })
41
+ expect({ action, has: `inventory.${actionKey(action)}` in en }).toEqual({ action, has: true })
42
+ }
43
+ })
44
+
45
+ it('falls back honestly for an action written by a newer image', () => {
46
+ // Reservations and stock counts write their own actions, and a browser tab open across a
47
+ // deploy will meet one. "Somebody changed it — reserved" is true; "edited it" would not be.
48
+ expect(actionKey('reserved')).toBe('history_unknown')
49
+ expect(isKnownAction('reserved')).toBe(false)
50
+ expect('inventory.history_unknown' in en).toBe(true)
51
+ })
52
+
53
+ it('phrases what repairs and attachments write, rather than falling back', () => {
54
+ // These four were the fallback's example until the change that made them real. An action this
55
+ // client *does* know must never render as "changed it — repair_logged".
56
+ for (const action of ['repair_logged', 'repair_completed', 'attachment_added', 'attachment_removed']) {
57
+ expect({ action, key: actionKey(action) }).toEqual({ action, key: `history_${action}` })
58
+ expect({ action, has: `inventory.${actionKey(action)}` in en }).toEqual({ action, has: true })
59
+ }
60
+ })
61
+ })
62
+
63
+ describe('fieldKey', () => {
64
+ it('labels every field the contract lets somebody edit', () => {
65
+ // `AssetInput` is the whole of what `assets.update` accepts, and therefore the whole of what a
66
+ // stored diff can name. A field added there with no key here would print `warrantyUntil` in the
67
+ // middle of a Persian sentence.
68
+ for (const field of Object.keys(AssetInput.shape)) {
69
+ const key = fieldKey(field)
70
+ expect({ field, key }).not.toEqual({ field, key: null })
71
+ expect({ field, has: `inventory.${key}` in en }).toEqual({ field, has: true })
72
+ }
73
+ })
74
+
75
+ it('says it does not know a field rather than guessing at one', () => {
76
+ expect(fieldKey('somethingNewer')).toBe(null)
77
+ })
78
+ })
79
+
80
+ describe('changeShape', () => {
81
+ it('tells filling a field in from clearing it from moving it', () => {
82
+ expect(changeShape(null, 'Desk 4')).toBe('set')
83
+ expect(changeShape('Desk 4', null)).toBe('cleared')
84
+ expect(changeShape('Desk 4', 'Desk 5')).toBe('changed')
85
+ })
86
+
87
+ it('treats an empty string as absent, because that is what clearing a description stores', () => {
88
+ // `description` is `not null` in the database, so emptying it writes `''` — and "changed from
89
+ // to x" is a sentence with a hole in it.
90
+ expect(changeShape('', 'A note')).toBe('set')
91
+ expect(changeShape('A note', '')).toBe('cleared')
92
+ })
93
+ })
94
+
95
+ describe('personIdOf', () => {
96
+ it('reads the recipient for a handover and the giver for a return', () => {
97
+ expect(personIdOf('assigned', { userId: 'ada' })).toBe('ada')
98
+ expect(personIdOf('transferred', { userId: 'bruno', previousUserId: 'ada' })).toBe('bruno')
99
+ // The one that would silently read "nobody": `returned` stores no `userId` at all.
100
+ expect(personIdOf('returned', { previousUserId: 'ada' })).toBe('ada')
101
+ })
102
+
103
+ it('has no person for an entry that is not about one', () => {
104
+ expect(personIdOf('created', {})).toBe(null)
105
+ expect(personIdOf('assigned', { userId: 123 })).toBe(null)
106
+ })
107
+ })
108
+
109
+ describe('noteOf', () => {
110
+ it('takes a note only when somebody actually wrote one', () => {
111
+ expect(noteOf({ note: ' For the Berlin trip ' })).toBe('For the Berlin trip')
112
+ expect(noteOf({ note: ' ' })).toBe(null)
113
+ expect(noteOf({})).toBe(null)
114
+ })
115
+ })
116
+
117
+ /**
118
+ * The decision that keeps eight kilobytes of somebody's prose out of a row of the panel.
119
+ *
120
+ * `description` is `z.string().max(8000)` in the contract, and the timeline read a change to it out
121
+ * as "Description changed from … to …" with both versions inline — so one edit to one paragraph
122
+ * pushed every entry below it off a 440px sheet. Nothing failed: the string was correct, the
123
+ * translation was correct, and the row was unusable.
124
+ */
125
+ describe('changeLineKind', () => {
126
+ it('says only that a description changed, and never what it says', () => {
127
+ const long = 'x'.repeat(8000)
128
+ expect(changeLineKind('description', 'before', long)).toBe('text_changed')
129
+ // Filling one in for the first time is the same sentence once the value is gone: two keys
130
+ // saying "Description changed" would be two translations to keep in step for no gain.
131
+ expect(changeLineKind('description', null, long)).toBe('text_changed')
132
+ })
133
+
134
+ it('decides on the field, not on how long the value happens to be today', () => {
135
+ // A short description is still prose. Branching on length would make the timeline's shape
136
+ // depend on what somebody typed, so the same field would render two different ways.
137
+ expect(changeLineKind('description', 'a', 'b')).toBe('text_changed')
138
+ expect(isLongText('description')).toBe(true)
139
+ expect(isLongText('location')).toBe(false)
140
+ })
141
+
142
+ it('still says "cleared" when a description is emptied — there is nothing to disclose', () => {
143
+ // `description` is `not null` in the database, so clearing it stores `''`.
144
+ expect(changeLineKind('description', 'A note', '')).toBe('cleared')
145
+ expect(changeLineKind('description', 'A note', null)).toBe('cleared')
146
+ })
147
+
148
+ it('keeps reading an ordinary field out, which is the whole point of a timeline', () => {
149
+ expect(changeLineKind('location', null, 'Desk 4')).toBe('set')
150
+ expect(changeLineKind('location', 'Desk 4', 'Desk 5')).toBe('changed')
151
+ expect(changeLineKind('location', 'Desk 4', null)).toBe('cleared')
152
+ })
153
+
154
+ it('reports an opaque id as a replacement rather than printing a uuid', () => {
155
+ expect(isOpaque('photoFileId')).toBe(true)
156
+ expect(changeLineKind('photoFileId', 'a', 'b')).toBe('replaced')
157
+ // Removing the photo is still "cleared": there is no id to avoid printing.
158
+ expect(changeLineKind('photoFileId', 'a', null)).toBe('cleared')
159
+ })
160
+
161
+ it('names a key the catalogue has, for every kind it can return', () => {
162
+ const keys = {
163
+ cleared: 'history_cleared',
164
+ replaced: 'history_replaced',
165
+ set: 'history_set',
166
+ changed: 'history_changed',
167
+ text_changed: 'history_text_changed',
168
+ }
169
+ for (const key of Object.values(keys))
170
+ expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
171
+ // And the disclosure's own three, which `TimelineText.svelte` reads.
172
+ for (const key of ['history_show_text', 'history_text_before', 'history_text_after'])
173
+ expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
174
+ })
175
+ })
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Turning a stored history row into something a person reads.
3
+ *
4
+ * `asset_history` holds `{ action, changes: [{field, from, to}], data: {…ids} }`, which is a shape
5
+ * for a database and not a sentence. A panel that renders it raw shows somebody a JSON diff of
6
+ * their own laptop; the point of a timeline is that it reads as "Ada handed it to Bruno".
7
+ *
8
+ * **"it", not the asset tag** — and this note exists because the README claimed otherwise for a
9
+ * release. The timeline only ever appears inside that asset's own panel, under a header already
10
+ * showing `INV-0042`, so naming the tag in all fifty entries would repeat on every line the one
11
+ * thing that cannot change while somebody is reading it.
12
+ *
13
+ * The decisions live here rather than inside the component for the reason `price.ts` does: a
14
+ * `.svelte` file drags a compiler behind it and cannot be unit-tested, and every one of these is a
15
+ * fact with a right answer. The component supplies the words — this file only says which words and
16
+ * about whom.
17
+ *
18
+ * Nothing here imports `@kernhq/ui`. That entry point reaches the Svelte components, and a helper
19
+ * that pulls a compiler into whatever imports it is a helper nothing can test.
20
+ */
21
+
22
+ /** Every action this client knows how to phrase. Anything else is honest about not knowing. */
23
+ const KNOWN = new Set([
24
+ 'created',
25
+ 'updated',
26
+ 'assigned',
27
+ 'transferred',
28
+ 'returned',
29
+ 'retired',
30
+ 'restored',
31
+ 'repair_logged',
32
+ 'repair_completed',
33
+ 'attachment_added',
34
+ 'attachment_removed',
35
+ ])
36
+
37
+ /**
38
+ * The message key for one entry's headline.
39
+ *
40
+ * An unknown action is **not** silently rendered as "changed it". The rows outlive the client that
41
+ * reads them: repairs and attachments write their own actions, and a browser tab left open across
42
+ * a deploy will meet one. `history_unknown` says somebody changed it and names the action, which is
43
+ * honest about the gap; a generic "changed it" would quietly describe a repair as an edit for as
44
+ * long as the tab stayed open.
45
+ */
46
+ export function actionKey(action: string): string {
47
+ return KNOWN.has(action) ? `history_${action}` : 'history_unknown'
48
+ }
49
+
50
+ export const isKnownAction = (action: string): boolean => KNOWN.has(action)
51
+
52
+ /**
53
+ * Which sentence a single field change wants.
54
+ *
55
+ * "from nothing to Desk 4" is not a change a person recognises — they filled a field in. The three
56
+ * shapes are the three sentences: filled in, cleared, and moved from one value to another.
57
+ *
58
+ * An empty string counts as absent, because that is what the asset form writes for a field somebody
59
+ * emptied: `description` is `not null` in the database, so clearing it stores `''` and never
60
+ * `null`, and `'' → 'x'` reported as "changed from to x" is a sentence with a hole in it.
61
+ */
62
+ export type ChangeShape = 'set' | 'cleared' | 'changed'
63
+
64
+ const absent = (value: unknown): boolean => value === null || value === undefined || value === ''
65
+
66
+ export function changeShape(from: unknown, to: unknown): ChangeShape {
67
+ if (absent(to)) return 'cleared'
68
+ if (absent(from)) return 'set'
69
+ return 'changed'
70
+ }
71
+
72
+ /**
73
+ * Who did it is `resolveName` in `members.ts`, and deliberately not here.
74
+ *
75
+ * This file used to carry an `actorKind(actorId, known)` that answered `person`, `former` or
76
+ * `system` from a bare set of ids. It was a second implementation of a question `members.ts`
77
+ * already answers, and it made the same mistake `nameOf` did: a set that has not been fetched yet
78
+ * is indistinguishable from a set somebody has left, so an id absent from it came back as `former`
79
+ * while the request was still in flight. A set cannot say which — only the request's own state can
80
+ * — so the question belongs where the directory is, with the five answers `NameKind` names.
81
+ */
82
+
83
+ /**
84
+ * The message key naming a field, for the one sentence per change.
85
+ *
86
+ * A stored diff names its column (`serialNumber`, `warrantyUntil`), which is a word for a database
87
+ * and not for a reader — «warrantyUntil از ۲۰۲۷ به ۲۰۲۸ تغییر کرد» is English leaking into a Persian
88
+ * sentence in exactly the place a translation was supposed to be. The map is the whole of
89
+ * `AssetInput`, and `timeline.test.ts` holds it to that: a field added to the contract with no key
90
+ * here shows up as a failing test rather than as a column name on somebody's screen.
91
+ *
92
+ * `null` for a field this client has never heard of. The rows outlive the image that wrote them, so
93
+ * a browser tab open across a deploy will meet one, and the component prints the raw name — which is
94
+ * ugly and true, where a guessed label would be neither.
95
+ */
96
+ const FIELD_KEYS: Record<string, string> = {
97
+ name: 'name',
98
+ description: 'description',
99
+ categoryId: 'category',
100
+ serialNumber: 'serial_number',
101
+ location: 'location',
102
+ purchasedFrom: 'purchased_from',
103
+ purchasedOn: 'purchased_on',
104
+ warrantyUntil: 'warranty_until',
105
+ priceMinor: 'price',
106
+ currency: 'currency',
107
+ photoFileId: 'photo',
108
+ }
109
+
110
+ export function fieldKey(field: string): string | null {
111
+ return FIELD_KEYS[field] ?? null
112
+ }
113
+
114
+ /**
115
+ * Which fields hold prose rather than a value, and therefore cannot be read out in a sentence.
116
+ *
117
+ * `description` is `z.string().max(8000)` in the contract, and the timeline rendered a change to it
118
+ * as "Description changed from … to …" with both versions inline — up to sixteen kilobytes of
119
+ * somebody's prose in a single row of a 440px panel, and every row below it pushed off the screen.
120
+ * A diff line is a sentence about a value; eight thousand characters is not a value.
121
+ *
122
+ * So a long-text change says **that** it changed and offers the text behind a disclosure, which is
123
+ * `TimelineText.svelte` — the two versions are only put in the document when somebody asks for
124
+ * them. The list stays a list, and nothing is hidden: the text is one keystroke away, labelled
125
+ * before and after.
126
+ *
127
+ * A set rather than a length check on the value, because the decision is about the *field*: a short
128
+ * description is still prose, and rendering it inline on the days it happens to be short would make
129
+ * the timeline's shape depend on what somebody typed.
130
+ */
131
+ const LONG_TEXT = new Set(['description'])
132
+
133
+ export const isLongText = (field: string): boolean => LONG_TEXT.has(field)
134
+
135
+ /**
136
+ * The sentence a change to one field wants, as a key name.
137
+ *
138
+ * One function rather than the component branching on `isLongText` and `changeShape` separately,
139
+ * because the combination is the decision: clearing a description is still just "Description
140
+ * cleared" — short, complete, and nothing to disclose — while setting or changing one is the case
141
+ * that must not read its own value out.
142
+ *
143
+ * `text_changed` covers both `set` and `changed` for prose. "Description set to …" and "Description
144
+ * changed from … to …" are the same sentence once the values are gone, and two keys saying the same
145
+ * thing are two translations to keep in step for no gain.
146
+ */
147
+ export type ChangeLine = 'cleared' | 'replaced' | 'set' | 'changed' | 'text_changed'
148
+
149
+ /**
150
+ * A field whose value is an opaque id is reported as a replacement rather than with a uuid in it.
151
+ *
152
+ * `photoFileId` is the only one: the value is a file id, and "Photo changed from 0192… to 0193…"
153
+ * says less than "Photo replaced" while being longer and uglier.
154
+ */
155
+ const OPAQUE = new Set(['photoFileId'])
156
+
157
+ export const isOpaque = (field: string): boolean => OPAQUE.has(field)
158
+
159
+ export function changeLineKind(field: string, from: unknown, to: unknown): ChangeLine {
160
+ const shape = changeShape(from, to)
161
+ if (shape === 'cleared') return 'cleared'
162
+ if (isOpaque(field)) return 'replaced'
163
+ return isLongText(field) ? 'text_changed' : shape
164
+ }
165
+
166
+ /**
167
+ * The other person in a custody sentence, out of the entry's `data`.
168
+ *
169
+ * `assigned` and `transferred` name who received it; `returned` names who gave it back, and both are
170
+ * stored under different keys because they are different facts. Reading `userId` for all three would
171
+ * make a return read "took it back from nobody" — the key is simply absent there.
172
+ */
173
+ export function personIdOf(action: string, data: Record<string, unknown>): string | null {
174
+ const value = action === 'returned' ? data.previousUserId : data.userId
175
+ return typeof value === 'string' && value ? value : null
176
+ }
177
+
178
+ /** The handover note, if whoever handed the item over left one. */
179
+ export function noteOf(data: Record<string, unknown>): string | null {
180
+ const note = data.note
181
+ return typeof note === 'string' && note.trim() ? note.trim() : null
182
+ }
183
+
184
+ /**
185
+ * The thing an entry is *about*, when the sentence alone does not say it.
186
+ *
187
+ * "Ada sent it for repair" is a sentence; which repair is the summary somebody typed, and "Ada
188
+ * attached a file" is missing the only interesting word in it. Both are rendered as a second line
189
+ * under the headline rather than interpolated into it, and neither goes through the catalogue —
190
+ * this is text a person typed or a file they named, so translating around it would be translating
191
+ * a placeholder.
192
+ *
193
+ * Two keys rather than one because they are two facts: a repair's `summary` and a file's `name`.
194
+ * Reading one key for both would print a file id where a summary belongs the day a third action
195
+ * arrives. Null for anything else, and for a row written before this client knew the key — the rows
196
+ * outlive the image that wrote them.
197
+ */
198
+ export function subjectOf(action: string, data: Record<string, unknown>): string | null {
199
+ const value =
200
+ action === 'repair_logged' || action === 'repair_completed'
201
+ ? data.summary
202
+ : action === 'attachment_added' || action === 'attachment_removed'
203
+ ? data.name
204
+ : null
205
+ return typeof value === 'string' && value.trim() ? value.trim() : null
206
+ }
@@ -1,21 +1,28 @@
1
1
  <script lang="ts">
2
- import { Badge, type BadgeTone, WidgetState } from '@kernhq/ui'
2
+ import { Badge, messageLocale, type WidgetProps, WidgetState } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getInventoryApi } from '../api-instance.js'
5
+ import { isolated } from '../bidi.js'
5
6
  import { t } from '../i18n.js'
7
+ import { assetHref } from '../links.js'
6
8
  import { inventoryKeys } from '../query.js'
9
+ import { statusTone } from '../status.js'
7
10
 
8
11
  /**
9
12
  * A dashboard card.
10
13
  *
11
14
  * `WidgetState` draws loading, failed and empty so every card on the board reports those three the
12
15
  * same way — and so a module does not have to translate "Retry" to show a widget that failed.
16
+ *
17
+ * `WidgetProps` rather than a hand-written pair, and that is what makes the rows work: the shell
18
+ * passes `workspaceSlug`, which is the half a link to another screen cannot be built without.
19
+ * Declaring two of the eight props by hand is how a card ends up unable to act on anything.
20
+ *
21
+ * `kern-widget` §3 also says to hide row actions while `editing`, and there is nothing to hide
22
+ * here: the action *is* the row, and `WidgetFrame` drags from its grip button rather than from the
23
+ * card's body — so an anchor in the body is never what somebody is holding.
13
24
  */
14
- interface Props {
15
- workspaceId: string
16
- settings?: Record<string, string | number | boolean | null>
17
- }
18
- const { workspaceId, settings }: Props = $props()
25
+ const { workspaceId, workspaceSlug, settings }: WidgetProps = $props()
19
26
 
20
27
  const limit = $derived(Number(settings?.limit ?? 5))
21
28
  const api = getInventoryApi()
@@ -37,21 +44,46 @@ const assets = createQuery(() => ({
37
44
 
38
45
  const items = $derived(assets.data?.items ?? [])
39
46
 
40
- function statusTone(status: string): BadgeTone {
41
- switch (status) {
42
- case 'assigned':
43
- case 'reserved':
44
- return 'info'
45
- case 'lost':
46
- return 'danger'
47
- case 'under_repair':
48
- return 'warning'
49
- case 'retired':
50
- return 'grey'
51
- default:
52
- return 'success'
53
- }
54
- }
47
+ /**
48
+ * The three numbers that make this card worth a place on a dashboard.
49
+ *
50
+ * A list of five recent rows says what somebody added last week, which is rarely the question. How
51
+ * many things there are, how many are sitting unclaimed, and how many are away being fixed is the
52
+ * question — and one request answers all three, so the strip costs a query rather than a screen.
53
+ *
54
+ * It fails **soft**: the card is a list of recent assets first, and a numbers strip that could not
55
+ * be loaded simply is not drawn. A dashboard where one card reports an error because a secondary
56
+ * query failed is a worse dashboard than one where a strip is missing.
57
+ */
58
+ const stats = createQuery(() => ({
59
+ queryKey: inventoryKeys.stats(workspaceId),
60
+ queryFn: () => api.stats.summary({ workspaceId }),
61
+ enabled: Boolean(workspaceId),
62
+ }))
63
+
64
+ /**
65
+ * `outForRepair` is null for a workspace that does not track repairs — not zero, which would be a
66
+ * claim it has not made. A null tile is simply absent.
67
+ */
68
+ const tiles = $derived(
69
+ stats.data
70
+ ? [
71
+ { label: t('stats_total'), value: stats.data.total },
72
+ { label: t('stats_unassigned'), value: stats.data.unassigned },
73
+ ...(stats.data.outForRepair !== null
74
+ ? [{ label: t('stats_out_for_repair'), value: stats.data.outForRepair }]
75
+ : []),
76
+ ]
77
+ : [],
78
+ )
79
+
80
+ /**
81
+ * A count in the reader's own digits — «۱۲» in Persian, not "12".
82
+ *
83
+ * Not `formatCount`, which caps at "99+": that is a badge helper, and a workspace with 214 assets
84
+ * would be told it has 99+ of them on the line whose job is to be the number.
85
+ */
86
+ const number = (n: number) => new Intl.NumberFormat(messageLocale()).format(n)
55
87
  </script>
56
88
 
57
89
  <WidgetState
@@ -62,37 +94,102 @@ function statusTone(status: string): BadgeTone {
62
94
  emptyIcon="briefcase"
63
95
  onRetry={() => assets.refetch()}
64
96
  >
97
+ {#if tiles.length}
98
+ <dl class="stats">
99
+ {#each tiles as tile (tile.label)}
100
+ <div class="stat">
101
+ <dt>{tile.label}</dt>
102
+ <dd>{number(tile.value)}</dd>
103
+ </div>
104
+ {/each}
105
+ </dl>
106
+ {/if}
65
107
  <ul>
66
108
  {#each items as asset (asset.id)}
67
109
  <li>
68
- <span class="code">{asset.code}</span>
69
- <span class="name">{asset.name}</span>
70
- <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
110
+ <!--
111
+ The row *is* the action. `kern-widget` §3: a card that only shows rows is a table of
112
+ contents, and this one showed five assets with nothing to do about any of them. An anchor
113
+ rather than a click handler, so the keyboard route is the same route — Tab reaches it,
114
+ Enter follows it, and the browser offers "open in a new tab" for free. It lands on the
115
+ list with the asset's own panel open, which is exactly where clicking the row on that
116
+ screen puts somebody.
117
+ -->
118
+ <a class="row" href={assetHref(workspaceSlug, asset.id)}
119
+ aria-label={t('open_asset', isolated({ name: asset.name }))}>
120
+ <span class="code">{asset.code}</span>
121
+ <span class="name">{asset.name}</span>
122
+ <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
123
+ </a>
71
124
  </li>
72
125
  {/each}
73
126
  </ul>
74
127
  </WidgetState>
75
128
 
76
129
  <style>
130
+ .stats {
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ gap: 6px 18px;
134
+ margin: 0 0 12px;
135
+ padding: 0 0 12px;
136
+ border-bottom: 1px solid var(--kern-border-hairline);
137
+ }
138
+ .stat {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 1px;
142
+ min-width: 0;
143
+ }
144
+ dt {
145
+ font-size: 11px;
146
+ /* Muted with a colour rather than opacity, which fades the label against the card. */
147
+ color: var(--kern-ink-500);
148
+ }
149
+ dd {
150
+ margin: 0;
151
+ font-size: 18px;
152
+ font-weight: 600;
153
+ line-height: 1.15;
154
+ letter-spacing: -0.02em;
155
+ color: var(--kern-ink-900);
156
+ font-variant-numeric: tabular-nums;
157
+ }
77
158
  ul {
78
159
  list-style: none;
79
160
  margin: 0;
80
161
  padding: 0;
81
162
  display: flex;
82
163
  flex-direction: column;
83
- gap: 8px;
164
+ gap: 2px;
84
165
  }
85
- li {
166
+ .row {
86
167
  display: flex;
87
168
  align-items: center;
88
169
  gap: 10px;
89
170
  font-size: 13px;
171
+ color: inherit;
172
+ text-decoration: none;
173
+ /* Logical, so the row's breathing room is on the reading-start edge in Persian too. Six
174
+ vertical pixels take the row past the 24px WCAG 2.5.8 target height on its own. */
175
+ padding-block: 5px;
176
+ padding-inline: 6px;
177
+ margin-inline: -6px;
178
+ border-radius: var(--kern-r-sm);
179
+ }
180
+ .row:hover {
181
+ background: var(--kern-surface-hover);
90
182
  }
91
183
  .code {
92
184
  font-size: 11px;
93
185
  color: var(--kern-ink-500);
94
186
  font-variant-numeric: tabular-nums;
95
187
  min-width: 64px;
188
+ /* A tag is read character by character off a sticker, so it stays left-to-right inside a
189
+ Persian or Arabic card and the bidi algorithm does not reorder its digits — the same rule
190
+ the repairs card next to it already follows. */
191
+ direction: ltr;
192
+ unicode-bidi: isolate;
96
193
  }
97
194
  .name {
98
195
  flex: 1;
@@ -101,5 +198,9 @@ function statusTone(status: string): BadgeTone {
101
198
  text-overflow: ellipsis;
102
199
  white-space: nowrap;
103
200
  color: var(--kern-ink-900);
201
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
202
+ first strong character, so a Latin name inside a Persian screen reads left to right and
203
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
204
+ unicode-bidi: plaintext;
104
205
  }
105
206
  </style>