@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
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { formatPrice, parsePrice } from './price.js'
2
+ import {
3
+ CURRENCIES,
4
+ currencyOptions,
5
+ formatPrice,
6
+ minorUnitExponent,
7
+ parsePrice,
8
+ priceExample,
9
+ } from './price.js'
3
10
 
4
11
  /**
5
12
  * The regression this file exists for.
@@ -9,8 +16,8 @@ import { formatPrice, parsePrice } from './price.js'
9
16
  * languages this module ships, silently, with no error anywhere. `abc` and `-5` were worse: they
10
17
  * came back `null` and the asset saved with no price at all.
11
18
  */
12
- const minor = (raw: string, locale: string) => {
13
- const result = parsePrice(raw, locale)
19
+ const minor = (raw: string, locale: string, currency?: string | null) => {
20
+ const result = parsePrice(raw, locale, currency)
14
21
  return result.ok ? result.minor : 'invalid'
15
22
  }
16
23
 
@@ -76,6 +83,97 @@ describe('parsePrice', () => {
76
83
  })
77
84
  })
78
85
 
86
+ /**
87
+ * The second silent 100×, and the one the picker itself created.
88
+ *
89
+ * `parsePrice` and `formatPrice` both hard-coded 1/100 while the currency list offers **JPY**,
90
+ * which has no minor unit at all, and **KWD**, which has three decimal places. So ¥1000 typed into
91
+ * the field was stored as 100000 minor units — ¥100 000 to anything reading the column honestly —
92
+ * and every dinar amount was out by a factor of ten. **IRR is the one that stings**: the rial has
93
+ * no minor unit either, and it is the currency of a language this module ships.
94
+ */
95
+ describe('the currency decides the minor unit', () => {
96
+ it('asks the currency rather than assuming hundredths', () => {
97
+ expect(minorUnitExponent('USD')).toBe(2)
98
+ expect(minorUnitExponent('EUR')).toBe(2)
99
+ expect(minorUnitExponent('JPY')).toBe(0)
100
+ expect(minorUnitExponent('IRR')).toBe(0)
101
+ expect(minorUnitExponent('KWD')).toBe(3)
102
+ // No currency on an amount is not a reason to make the field unusable, and two decimals is
103
+ // what ISO 4217 assigns by default.
104
+ expect(minorUnitExponent(null)).toBe(2)
105
+ expect(minorUnitExponent('')).toBe(2)
106
+ // The contract takes any three letters, so a code `Intl` refuses must not throw out of a form.
107
+ expect(minorUnitExponent('nonsense')).toBe(2)
108
+ })
109
+
110
+ it('stores ¥1000 as 1000, not as 10', () => {
111
+ expect(minor('1000', 'en', 'JPY')).toBe(1000)
112
+ expect(minor('1,000', 'en', 'JPY')).toBe(1000)
113
+ expect(formatPrice(1000, 'en', 'JPY')).toBe('1000')
114
+ // What it did before: hundredths, so the amount read back a hundred times too small.
115
+ expect(formatPrice(1000, 'en')).toBe('10.00')
116
+ })
117
+
118
+ it('keeps all three of a dinar’s decimal places', () => {
119
+ expect(minor('1.234', 'en', 'KWD')).toBe(1234)
120
+ expect(minor('1,234.567', 'en', 'KWD')).toBe(1234567)
121
+ expect(formatPrice(1234567, 'en', 'KWD')).toBe('1234.567')
122
+ // The old behaviour dropped the third place and was ten times out.
123
+ expect(minor('1.234', 'en')).toBe('invalid')
124
+ })
125
+
126
+ it('still reads a two-decimal currency exactly as before', () => {
127
+ expect(minor('19.99', 'en', 'USD')).toBe(1999)
128
+ expect(minor('1.234,56', 'de', 'EUR')).toBe(123456)
129
+ expect(formatPrice(123456, 'de', 'EUR')).toBe('1234,56')
130
+ })
131
+
132
+ /**
133
+ * The point of the whole fix: there is no path where a wrong number is stored quietly. `19.99`
134
+ * in a yen field is somebody who has not noticed which field they are in, and rounding it to
135
+ * ¥20 for them is the same class of silence as the `1.234,56` → €1.23 bug this file began with.
136
+ */
137
+ it('refuses more decimal places than the currency has, rather than rounding them away', () => {
138
+ expect(minor('19.99', 'en', 'JPY')).toBe('invalid')
139
+ expect(minor('0.5', 'en', 'JPY')).toBe('invalid')
140
+ expect(minor('.5', 'en', 'IRR')).toBe('invalid')
141
+ expect(minor('1.2345', 'en', 'KWD')).toBe('invalid')
142
+ expect(minor('1.234', 'en', 'USD')).toBe('invalid')
143
+ })
144
+
145
+ it('round-trips every shipped locale against a currency of each shape', () => {
146
+ for (const locale of ['en', 'de', 'tr', 'fa', 'ar']) {
147
+ for (const currency of ['USD', 'JPY', 'KWD', 'IRR']) {
148
+ for (const value of [0, 7, 2500, 123456, 99999999]) {
149
+ expect({
150
+ locale,
151
+ currency,
152
+ value,
153
+ back: minor(formatPrice(value, locale, currency), locale, currency),
154
+ }).toEqual({ locale, currency, value, back: value })
155
+ }
156
+ }
157
+ }
158
+ })
159
+
160
+ it('holds up an example the field would actually accept', () => {
161
+ // It said "Enter an amount like 1234.56" in a yen field and then refused 1234.56.
162
+ expect(priceExample('en', 'JPY')).toBe('1234')
163
+ expect(priceExample('en', 'KWD')).toBe('1234.567')
164
+ expect(priceExample('en', 'USD')).toBe('1234.56')
165
+ expect(priceExample('de', null)).toBe('1234,56')
166
+ for (const currency of ['USD', 'JPY', 'KWD', 'IRR', 'EUR']) {
167
+ const example = priceExample('en', currency)
168
+ expect({ currency, example, ok: parsePrice(example, 'en', currency).ok }).toEqual({
169
+ currency,
170
+ example,
171
+ ok: true,
172
+ })
173
+ }
174
+ })
175
+ })
176
+
79
177
  describe('formatPrice', () => {
80
178
  it('has no price to show for no price', () => {
81
179
  expect(formatPrice(null, 'en')).toBe('')
@@ -104,3 +202,53 @@ describe('formatPrice', () => {
104
202
  expect(formatPrice(123456, 'en')).toBe('1234.56')
105
203
  })
106
204
  })
205
+
206
+ /**
207
+ * The list a money field offers.
208
+ *
209
+ * It was `['USD', 'EUR', 'IRR', 'AED']` in a product whose language menu offers Turkish — so a
210
+ * workspace in Istanbul could not record what it paid for anything. The contract takes any ISO
211
+ * 4217 code (`z.string().length(3)`) and the server stores whatever arrives, so nothing but the
212
+ * picker was ever short: four options, one of them missing for one of the five shipped languages.
213
+ */
214
+ describe('CURRENCIES', () => {
215
+ it('has a currency for every language this module ships', () => {
216
+ // The point of the whole fix. `tr` is on the language menu and TRY was not on this list.
217
+ for (const code of ['USD', 'EUR', 'TRY', 'IRR', 'AED']) expect(CURRENCIES).toContain(code)
218
+ })
219
+
220
+ it('offers codes `Intl` recognises, so a price is never labelled with something invented', () => {
221
+ for (const code of CURRENCIES) {
222
+ const formatted = new Intl.NumberFormat('en', { style: 'currency', currency: code }).format(1)
223
+ // An unknown code comes back as the literal code; a real one gets a symbol or a name, and
224
+ // either way `Intl` accepts it rather than throwing on a malformed one.
225
+ expect({ code, formatted: typeof formatted }).toEqual({ code, formatted: 'string' })
226
+ expect(code).toMatch(/^[A-Z]{3}$/)
227
+ }
228
+ })
229
+
230
+ it('lists each code once', () => {
231
+ expect([...new Set(CURRENCIES)]).toEqual([...CURRENCIES])
232
+ })
233
+
234
+ /**
235
+ * The order the comment above the list claims, which the list did not have.
236
+ *
237
+ * It said "the currency of each shipped language first, in the order the locales are listed
238
+ * everywhere else in this package (en, ar, de, fa, tr)" while the list read `USD, EUR, AED, TRY,
239
+ * IRR` — en, de, ar, tr, fa, which is no order at all. A rule nothing checks is a rule the next
240
+ * person appends underneath and quietly breaks, so it is checked.
241
+ */
242
+ it('opens with the currency of each shipped language, in the locale order', () => {
243
+ expect(CURRENCIES.slice(0, 5)).toEqual(['USD', 'AED', 'EUR', 'IRR', 'TRY'])
244
+ })
245
+
246
+ it('puts the empty option first, because an asset with no price has no currency', () => {
247
+ const options = currencyOptions('—')
248
+ expect(options[0]).toEqual({ value: '', label: '—' })
249
+ expect(options.slice(1).map((o) => o.value)).toEqual([...CURRENCIES])
250
+ // Codes rather than names: ISO 4217 is what the column stores, and a name is one more thing to
251
+ // translate five times for no gain over what somebody reads on their own bank statement.
252
+ expect(options.slice(1).every((o) => o.label === o.value)).toBe(true)
253
+ })
254
+ })
@@ -21,6 +21,120 @@
21
21
  /** `minor: null` is a real answer — an empty field means "no price", not a bad one. */
22
22
  export type PriceResult = { ok: true; minor: number | null } | { ok: false }
23
23
 
24
+ /**
25
+ * The currencies a money field offers, in one place because two forms ask for one.
26
+ *
27
+ * The asset form and the repair form put the same question — what is this amount in — and a second
28
+ * copy of the list is a list that gains a currency in one form and not the other. Codes rather than
29
+ * names: ISO 4217 is what the column stores, and a currency name is one more thing to translate
30
+ * five times for no gain over the code somebody already reads on their own bank statement.
31
+ *
32
+ * **This list was `['USD', 'EUR', 'IRR', 'AED']` in a product that ships Turkish.** A Turkish
33
+ * workspace could not record what it paid for anything in lira: the contract takes any three-letter
34
+ * code (`z.string().length(3)`) and the server stores whatever arrives, so the shortage was the
35
+ * picker's alone — four options, one of which was missing for one of the five languages on the
36
+ * language menu. TRY, and the currency of every other locale this module ships, is now here.
37
+ *
38
+ * The order is deliberate and is not alphabetical: the currency of each shipped language first, in
39
+ * the order the locales are listed everywhere else in this package (en, ar, de, fa, tr) — so USD,
40
+ * AED, EUR, IRR, TRY — then the ones a company in those places is most likely to have paid an
41
+ * invoice in. A picker sorted A–Z puts AED above USD for a reader in Berlin, which is tidy and
42
+ * useless.
43
+ *
44
+ * **The comment said that while the list said `USD, EUR, AED, TRY, IRR`**, which is no order at
45
+ * all — en, de, ar, tr, fa — so the rule a sixth currency was supposed to be filed under described
46
+ * something that had never been true. `price.test.ts` now asserts the first five, because a stated
47
+ * order nothing checks drifts back the first time somebody appends a code.
48
+ *
49
+ * It is still a list, not the whole of ISO 4217: all 180 codes in a `<select>` is not a kindness.
50
+ *
51
+ * **What a new asset defaults to is deliberately nothing.** The obvious improvement on "the first
52
+ * code in the list" is "whatever this workspace uses", and that is right — but a workspace currency
53
+ * does not exist: `InventorySettings` holds the tag prefix, the tag padding and the two notice
54
+ * windows, and nothing else, so `defaultCurrency` is a contract change rather than something a
55
+ * picker can invent. The tempting shortcut is to read it off the reader's locale, and that is
56
+ * *worse* than no default: two colleagues in one workspace, one reading German and one reading
57
+ * Turkish, would be handed different pre-selected currencies for the same register, notice nothing,
58
+ * and store EUR against one laptop and TRY against the next. A currency is a fact about the
59
+ * company, not about the language somebody happens to read the screen in. So the forms seed from
60
+ * the *record* — a repair inherits its asset's currency, an edit keeps its own — and a new asset
61
+ * starts empty, which asks the question instead of answering it wrongly.
62
+ */
63
+ export const CURRENCIES = [
64
+ 'USD',
65
+ 'AED',
66
+ 'EUR',
67
+ 'IRR',
68
+ 'TRY',
69
+ 'GBP',
70
+ 'SAR',
71
+ 'QAR',
72
+ 'KWD',
73
+ 'EGP',
74
+ 'CHF',
75
+ 'SEK',
76
+ 'NOK',
77
+ 'DKK',
78
+ 'PLN',
79
+ 'CAD',
80
+ 'AUD',
81
+ 'JPY',
82
+ 'CNY',
83
+ 'INR',
84
+ ] as const
85
+
86
+ /**
87
+ * The options both money fields offer, built once.
88
+ *
89
+ * The empty option is first and is a real answer: an asset with no price has no currency either,
90
+ * and a picker with no way back to "none" is a field somebody can fill in and never clear.
91
+ */
92
+ export function currencyOptions(noneLabel: string): Array<{ value: string; label: string }> {
93
+ return [{ value: '', label: noneLabel }, ...CURRENCIES.map((code) => ({ value: code, label: code }))]
94
+ }
95
+
96
+ const EXPONENTS: Map<string, number> = new Map()
97
+
98
+ /**
99
+ * How many decimal places this currency has — asked of the currency, never assumed to be two.
100
+ *
101
+ * **This module stored ¥1000 as ¥10 and every KWD amount ten times too small.** `parsePrice` and
102
+ * `formatPrice` both hard-coded 1/100, and the picker offers JPY, which has *no* minor unit, and
103
+ * KWD, which has *three* decimal places. So `1000` typed into a yen field became 100000 minor
104
+ * units, read back as `1000.00`, and was ¥100 000 to anything that read the column honestly — a
105
+ * silent 100× on exactly the shape of field this file was rewritten to stop silently mangling.
106
+ *
107
+ * **IRR is the one that matters most here**, because it is the currency of a language this module
108
+ * ships: the rial has no minor unit either, so a Persian workspace was the default victim.
109
+ *
110
+ * CLDR knows each currency's exponent and `Intl` will hand it over, so nothing is tabulated: a
111
+ * currency added to `CURRENCIES` next year gets the right answer without anybody remembering this
112
+ * function exists. A code `Intl` refuses, or one it has never heard of, falls back to two — which
113
+ * is what ISO 4217 assigns by default, and is the only guess available.
114
+ *
115
+ * The locale is fixed at `'en'` deliberately: the exponent is a fact about the *currency*, and
116
+ * asking under the reader's locale would make the same money parse differently for two colleagues.
117
+ */
118
+ export function minorUnitExponent(currency: string | null | undefined): number {
119
+ if (!currency) return 2
120
+ const cached = EXPONENTS.get(currency)
121
+ if (cached !== undefined) return cached
122
+ let exponent = 2
123
+ try {
124
+ // `maximumFractionDigits` is optional in the DOM lib and always present in practice for a
125
+ // currency formatter; `??` is what keeps the two type-checks agreeing about that.
126
+ exponent =
127
+ new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ??
128
+ 2
129
+ } catch {
130
+ // A malformed code — the contract takes any three letters and the picker is not the only way
131
+ // in. Two decimals is ISO 4217's own default and keeps the field usable.
132
+ }
133
+ if (!Number.isInteger(exponent) || exponent < 0 || exponent > 4) exponent = 2
134
+ EXPONENTS.set(currency, exponent)
135
+ return exponent
136
+ }
137
+
24
138
  interface Separators {
25
139
  group: string
26
140
  decimal: string
@@ -86,8 +200,18 @@ function isGroupedInteger(text: string, group: string): boolean {
86
200
  *
87
201
  * Returns `{ ok: false }` for anything that is not a non-negative number in `locale` — including a
88
202
  * negative one, which the contract has no way to mean and the old parser dropped in silence.
203
+ *
204
+ * `currency` decides how many decimal places the amount is allowed and what it is scaled by. More
205
+ * fractional digits than the currency has is a **rejection, not a rounding**: `19.99` in a yen
206
+ * field is not ¥20, it is somebody who has not noticed which field they are in, and quietly
207
+ * storing a number nobody typed is the whole class of failure this file exists to end. Omitting
208
+ * `currency` keeps two decimals, which is what an amount with no currency on it has always meant.
209
+ *
210
+ * The scaled integer is built by **concatenating digits**, never by multiplying: `19.99 * 100` is
211
+ * `1998.9999999999998` in a double, and the `Math.round` that used to hide it would have had to
212
+ * hide `1234.567 * 1000` too. Digits in, digits out, and no float on the path at all.
89
213
  */
90
- export function parsePrice(raw: string, locale: string): PriceResult {
214
+ export function parsePrice(raw: string, locale: string, currency?: string | null): PriceResult {
91
215
  const text = toLatinDigits(raw).replace(BIDI, '').trim()
92
216
  if (!text) return { ok: true, minor: null }
93
217
 
@@ -103,11 +227,12 @@ export function parsePrice(raw: string, locale: string): PriceResult {
103
227
  if (!isGroupedInteger(whole, group)) return { ok: false }
104
228
  if (frac !== undefined && !/^\d+$/.test(frac)) return { ok: false }
105
229
 
106
- const digits = group ? whole.split(group).join('') : whole
107
- const value = Number(`${digits}.${frac ?? '0'}`)
108
- if (!Number.isFinite(value)) return { ok: false }
230
+ const exponent = minorUnitExponent(currency)
231
+ // A currency with no minor unit has no decimal point either, so `frac` of any length fails here.
232
+ if (frac !== undefined && frac.length > exponent) return { ok: false }
109
233
 
110
- const minor = Math.round(value * 100)
234
+ const digits = group ? whole.split(group).join('') : whole
235
+ const minor = Number(`${digits}${(frac ?? '').padEnd(exponent, '0')}`)
111
236
  // A price that cannot survive the round trip through a double is not a price anybody typed.
112
237
  if (!Number.isSafeInteger(minor)) return { ok: false }
113
238
  return { ok: true, minor }
@@ -119,17 +244,36 @@ export function parsePrice(raw: string, locale: string): PriceResult {
119
244
  * The edit form has to seed itself from `priceMinor`, and seeding it with `1234.56` for a German
120
245
  * reader would make them look at a number their own locale says is €123,456 — then `parsePrice`
121
246
  * would reject it on the grouping rule and they would be told their own data is invalid. What comes
122
- * out of here is exactly what goes back in.
247
+ * out of here is exactly what goes back in, for the currency the amount is in.
123
248
  */
124
- export function formatPrice(minor: number | null | undefined, locale: string): string {
249
+ export function formatPrice(
250
+ minor: number | null | undefined,
251
+ locale: string,
252
+ currency?: string | null,
253
+ ): string {
125
254
  if (minor === null || minor === undefined) return ''
255
+ const exponent = minorUnitExponent(currency)
256
+ const value = minor / 10 ** exponent
126
257
  try {
127
258
  return new Intl.NumberFormat(locale, {
128
- minimumFractionDigits: 2,
129
- maximumFractionDigits: 2,
259
+ minimumFractionDigits: exponent,
260
+ maximumFractionDigits: exponent,
130
261
  useGrouping: false,
131
- }).format(minor / 100)
262
+ }).format(value)
132
263
  } catch {
133
- return String(minor / 100)
264
+ return String(value)
134
265
  }
135
266
  }
267
+
268
+ /**
269
+ * The amount the "enter an amount like…" error holds up as an example.
270
+ *
271
+ * It has to be an amount the field would *accept*, or the error tells somebody to type something
272
+ * that is also rejected. `1234.56` was hard-coded, so a yen field said "Enter an amount like
273
+ * 1234.56" and then refused it. Built from the exponent instead: `1234` in yen, `1234.567` in
274
+ * dinars, and the reader's own separators and digits around it either way.
275
+ */
276
+ export function priceExample(locale: string, currency?: string | null): string {
277
+ const exponent = minorUnitExponent(currency)
278
+ return formatPrice(Number(`1234${'567'.slice(0, exponent).padEnd(exponent, '0')}`), locale, currency)
279
+ }
@@ -15,6 +15,12 @@
15
15
  * the list and the dashboard card behind it stayed stale until a reload. Every other module keys
16
16
  * the list and the row off one singular entity name (`['tracker', 'issue', …]`); so does this one
17
17
  * now.
18
+ *
19
+ * **The timeline and the custody list hang off the asset's own key**, one segment deeper. That is
20
+ * what lets custody announce itself as `entity: 'asset'` and refresh all four things it changed —
21
+ * the row, the list, the detail panel and both of its tabs — from one change event, because
22
+ * `partialMatchKey` matches on the prefix. A separate `['inventory', 'custody', …]` would need the
23
+ * server to emit a second entity for the same write.
18
24
  */
19
25
  export const inventoryKeys = {
20
26
  /** Everything under this module, for the blunt invalidation after a write. */
@@ -22,4 +28,34 @@ export const inventoryKeys = {
22
28
  assets: (ws: string, filters?: Record<string, unknown>) =>
23
29
  filters ? (['inventory', 'asset', ws, filters] as const) : (['inventory', 'asset', ws] as const),
24
30
  asset: (ws: string, id: string) => ['inventory', 'asset', ws, id] as const,
31
+ /** The asset's own timeline. Under the asset, so one `asset` change refreshes it. */
32
+ assetHistory: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'history'] as const,
33
+ /** Every custody period for one asset. Under the asset, for the same reason. */
34
+ assetCustody: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'custody'] as const,
35
+ /** One asset's repairs. Under the asset, so a repair announced as an `asset` change refreshes it. */
36
+ assetRepairs: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'repairs'] as const,
37
+ /** One asset's files, its repairs' included — the panel groups them, so there is one key. */
38
+ assetAttachments: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'attachments'] as const,
39
+ /**
40
+ * The workspace's repairs, which belong to no single asset — the "what is away right now" card.
41
+ *
42
+ * Its own entity, because it cannot hang under one asset's key: `src/server/router.ts` therefore
43
+ * announces a repair twice, once as the asset that changed and once as the repair itself.
44
+ */
45
+ repairs: (ws: string, filters?: Record<string, unknown>) =>
46
+ filters ? (['inventory', 'repair', ws, filters] as const) : (['inventory', 'repair', ws] as const),
47
+ /**
48
+ * The register in numbers.
49
+ *
50
+ * Under the **asset** prefix on purpose, one segment deep: every number it holds is a count of
51
+ * assets, so every write that changes one of those counts already announces `entity: 'asset'` and
52
+ * this is refreshed by the same event. `'stats'` cannot collide with `asset(ws, id)` — that
53
+ * segment is always a uuid.
54
+ */
55
+ stats: (ws: string) => ['inventory', 'asset', ws, 'stats'] as const,
56
+ /**
57
+ * The workspace's categories. Its own entity because it has its own change event: a rename
58
+ * touches every asset row on screen, and `src/server/router.ts` therefore emits both.
59
+ */
60
+ categories: (ws: string, archived = false) => ['inventory', 'category', ws, { archived }] as const,
25
61
  }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { moveBy, placementOf, sameOrder } from './reorder.js'
3
+
4
+ /**
5
+ * The arithmetic behind dragging a category, and behind the two buttons that do the same thing
6
+ * without a pointer.
7
+ *
8
+ * The settings page is a `.svelte` file and cannot be unit-tested here, which is exactly why this
9
+ * arithmetic does not live in it. Every case below is one somebody will actually reach: the first
10
+ * row cannot go up, the last cannot go down, a drag that ends where it started must cost nothing,
11
+ * and a row that was archived in another tab is not in the list any more.
12
+ */
13
+ const list = (...ids: string[]) => ids.map((id) => ({ id, name: id.toUpperCase() }))
14
+ const ids = (items: readonly { id: string }[]) => items.map((item) => item.id)
15
+
16
+ describe('moving a row', () => {
17
+ it('moves one up', () => {
18
+ expect(ids(moveBy(list('a', 'b', 'c'), 'c', -1))).toEqual(['a', 'c', 'b'])
19
+ })
20
+
21
+ it('moves one down', () => {
22
+ expect(ids(moveBy(list('a', 'b', 'c'), 'a', 1))).toEqual(['b', 'a', 'c'])
23
+ })
24
+
25
+ it('moves one to the top and to the bottom', () => {
26
+ expect(ids(moveBy(list('a', 'b', 'c', 'd'), 'd', -3))).toEqual(['d', 'a', 'b', 'c'])
27
+ expect(ids(moveBy(list('a', 'b', 'c', 'd'), 'a', 3))).toEqual(['b', 'c', 'd', 'a'])
28
+ })
29
+
30
+ /**
31
+ * Clamped rather than refused. *Move up* on the row that is already first is a button somebody
32
+ * will press, and it is not a mistake — the answer is that it is already first.
33
+ */
34
+ it('clamps a move that would fall off either end', () => {
35
+ expect(ids(moveBy(list('a', 'b', 'c'), 'c', -99))).toEqual(['c', 'a', 'b'])
36
+ expect(ids(moveBy(list('a', 'b', 'c'), 'a', 99))).toEqual(['b', 'c', 'a'])
37
+ })
38
+
39
+ /**
40
+ * The same array back, by reference — the caller reads that as "nothing to send". An equal copy
41
+ * would pass every assertion above and post a reorder every time somebody pressed the button on
42
+ * the row that cannot move.
43
+ */
44
+ it('gives back the very same list when nothing moves', () => {
45
+ const items = list('a', 'b', 'c')
46
+ expect(moveBy(items, 'a', -1)).toBe(items)
47
+ expect(moveBy(items, 'c', 1)).toBe(items)
48
+ expect(moveBy(items, 'b', 0)).toBe(items)
49
+ expect(moveBy(items, 'nobody', -1)).toBe(items)
50
+ expect(moveBy([], 'a', 1)).toEqual([])
51
+ })
52
+
53
+ it('leaves the list it was given alone', () => {
54
+ const items = list('a', 'b', 'c')
55
+ moveBy(items, 'a', 2)
56
+ expect(ids(items)).toEqual(['a', 'b', 'c'])
57
+ })
58
+
59
+ it('moves the only row nowhere', () => {
60
+ const items = list('a')
61
+ expect(moveBy(items, 'a', -1)).toBe(items)
62
+ expect(moveBy(items, 'a', 1)).toBe(items)
63
+ })
64
+ })
65
+
66
+ describe('saying where a row landed', () => {
67
+ it('names the row it now follows, rather than a position', () => {
68
+ expect(placementOf(list('a', 'b', 'c'), 'b')).toEqual({ at: 'after', previous: { id: 'a', name: 'A' } })
69
+ })
70
+
71
+ it('calls the ends the ends', () => {
72
+ expect(placementOf(list('a', 'b', 'c'), 'a')).toEqual({ at: 'first' })
73
+ expect(placementOf(list('a', 'b', 'c'), 'c')).toEqual({ at: 'last' })
74
+ })
75
+
76
+ it('calls the only row first, which is true and reads better than last', () => {
77
+ expect(placementOf(list('a'), 'a')).toEqual({ at: 'first' })
78
+ })
79
+
80
+ it('says a row that is no longer in the list is gone, rather than guessing', () => {
81
+ // Somebody archived it in another tab while this page was open.
82
+ expect(placementOf(list('a', 'b'), 'c')).toEqual({ at: 'gone' })
83
+ })
84
+ })
85
+
86
+ describe('whether anything actually changed', () => {
87
+ it('sees a drag that ended where it started', () => {
88
+ expect(sameOrder(list('a', 'b', 'c'), list('a', 'b', 'c'))).toBe(true)
89
+ })
90
+
91
+ it('sees a real move, a longer list and a different membership', () => {
92
+ expect(sameOrder(list('a', 'b', 'c'), list('a', 'c', 'b'))).toBe(false)
93
+ expect(sameOrder(list('a', 'b'), list('a', 'b', 'c'))).toBe(false)
94
+ expect(sameOrder(list('a', 'b'), list('a', 'z'))).toBe(false)
95
+ })
96
+
97
+ it('calls two empty lists the same', () => {
98
+ expect(sameOrder([], [])).toBe(true)
99
+ })
100
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Moving one row up or down a list, and saying where it landed.
3
+ *
4
+ * Pure arithmetic, in its own file, for the reason `custody.ts` and `price.ts` are: a `.svelte` file
5
+ * cannot be unit-tested here, so anything inside one is only ever checked by reading it. The ends of
6
+ * a list are exactly where an off-by-one lives — moving the first row up, moving the last row down,
7
+ * dragging something to a position it already occupies — and those are three assertions rather than
8
+ * three careful reads.
9
+ *
10
+ * **A no-op returns the array it was given, by reference.** That is the signal the caller acts on:
11
+ * nothing moved, so there is nothing to send and nothing to announce as a move. Returning an equal
12
+ * copy would look identical in a test and cost a request every time somebody pressed *move up* on
13
+ * the row that is already first.
14
+ */
15
+
16
+ /**
17
+ * The one property `svelte-dnd-action` reads, and therefore the one this file needs.
18
+ *
19
+ * The library tracks items by `id` and by nothing else — a list keyed by anything else renders
20
+ * perfectly and refuses to move, by mouse and by keyboard, with no error at all. A `Category` has
21
+ * an `id` already, which is the only reason the settings page hands its rows over unwrapped.
22
+ */
23
+ export interface Ordered {
24
+ id: string
25
+ }
26
+
27
+ /**
28
+ * The list with `id` moved `delta` places, or the same list when that would change nothing.
29
+ *
30
+ * `delta` is clamped rather than refused: *move up* on the first row is a thing somebody will press,
31
+ * and the honest answer is "it is already first", not an error. The input is never mutated.
32
+ */
33
+ export function moveBy<T extends Ordered>(items: readonly T[], id: string, delta: number): readonly T[] {
34
+ const from = items.findIndex((item) => item.id === id)
35
+ if (from === -1) return items
36
+ const to = Math.min(items.length - 1, Math.max(0, from + delta))
37
+ if (to === from) return items
38
+ const next = [...items]
39
+ const [moved] = next.splice(from, 1)
40
+ next.splice(to, 0, moved as T)
41
+ return next
42
+ }
43
+
44
+ /** Where a row sits now, in the terms a sentence can use — never as a number. */
45
+ export type Placement<T> =
46
+ | { at: 'first' }
47
+ | { at: 'last' }
48
+ /** Somewhere in the middle: the row it now follows is what identifies the spot. */
49
+ | { at: 'after'; previous: T }
50
+ /** It is not in this list at all — it was archived or removed while the page was open. */
51
+ | { at: 'gone' }
52
+
53
+ /**
54
+ * Where `id` ended up, for the sentence a screen reader is given.
55
+ *
56
+ * **"first", "last" or "after {name}" rather than a position number.** A number is the thing this
57
+ * whole screen stopped showing: nobody arranges their categories by index, and "moved to position 4
58
+ * of 9" asks somebody to hold two numbers in their head to work out what a neighbour's name would
59
+ * have told them outright. A one-row list is `first`, which is true and reads better than `last`.
60
+ */
61
+ export function placementOf<T extends Ordered>(items: readonly T[], id: string): Placement<T> {
62
+ const at = items.findIndex((item) => item.id === id)
63
+ if (at === -1) return { at: 'gone' }
64
+ if (at === 0) return { at: 'first' }
65
+ if (at === items.length - 1) return { at: 'last' }
66
+ return { at: 'after', previous: items[at - 1] as T }
67
+ }
68
+
69
+ /**
70
+ * Whether two lists hold the same ids in the same order.
71
+ *
72
+ * What decides a request is worth making. A drag that ends where it started fires `finalize` exactly
73
+ * as a real one does — the library has no opinion about whether anything moved — so without this
74
+ * every aborted drag would post a reorder and every open screen in the workspace would be told
75
+ * about a write that changed nothing.
76
+ */
77
+ export function sameOrder(a: readonly Ordered[], b: readonly Ordered[]): boolean {
78
+ return a.length === b.length && a.every((item, index) => item.id === b[index]?.id)
79
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { repairActions } from './repairs.js'
3
+
4
+ /**
5
+ * What the Repairs section is allowed to offer.
6
+ *
7
+ * A decision rather than rendering, and wrong in a way nothing else would catch: a *Send for repair*
8
+ * button on an item that is already at a repairer produces a 409 every single time it is pressed —
9
+ * the unique index refuses the second open repair — so the interface must not offer it at all.
10
+ */
11
+ describe('repairActions', () => {
12
+ it('offers sending it away when nothing is open', () => {
13
+ expect(repairActions({ open: false, archived: false, may: true })).toEqual(['create'])
14
+ })
15
+
16
+ it('offers completing and correcting while one is open, and never a second send', () => {
17
+ const actions = repairActions({ open: true, archived: false, may: true })
18
+ expect(actions).toEqual(['complete', 'edit'])
19
+ // The one combination that would conflict on every press.
20
+ expect(actions).not.toContain('create')
21
+ })
22
+
23
+ it('offers nothing without the permission — hidden, not disabled', () => {
24
+ expect(repairActions({ open: false, archived: false, may: false })).toEqual([])
25
+ expect(repairActions({ open: true, archived: false, may: false })).toEqual([])
26
+ })
27
+
28
+ it('offers nothing new on an archived item, which the server refuses anyway', () => {
29
+ expect(repairActions({ open: false, archived: true, may: true })).toEqual([])
30
+ })
31
+
32
+ it('keeps the way out of an open repair reachable even on an archived item', () => {
33
+ // Archiving is refused while a repair is open, so this state should be unreachable — and if
34
+ // the data ever says otherwise, hiding *complete* would leave the item stuck at `under_repair`
35
+ // for ever with no control that could finish it.
36
+ expect(repairActions({ open: true, archived: true, may: true })).toEqual(['complete', 'edit'])
37
+ })
38
+ })