@kernhq/module-hr 0.10.0 → 0.10.2

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 (90) hide show
  1. package/dist/contract/attendance.d.ts +1 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +9 -0
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +1 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +489 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1888 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +154 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +17 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +81 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +0 -2
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +12 -4
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -8
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -14
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/ledger.d.ts +0 -2
  44. package/dist/server/services/ledger.d.ts.map +1 -1
  45. package/dist/server/services/ledger.js +1 -2
  46. package/dist/server/services/ledger.js.map +1 -1
  47. package/dist/server/services/policies.d.ts +6 -2
  48. package/dist/server/services/policies.d.ts.map +1 -1
  49. package/dist/server/services/policies.js +7 -2
  50. package/dist/server/services/policies.js.map +1 -1
  51. package/migrations/0005_approval_requester.sql +11 -1
  52. package/migrations/0006_schedule_no_overlap.sql +85 -0
  53. package/migrations/0007_hot_path_indexes.sql +38 -0
  54. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  55. package/migrations/meta/0005_snapshot.json +4047 -0
  56. package/migrations/meta/0007_snapshot.json +4225 -0
  57. package/migrations/meta/0009_snapshot.json +4231 -0
  58. package/migrations/meta/_journal.json +21 -0
  59. package/package.json +7 -7
  60. package/src/client/components/ClockControls.svelte +127 -20
  61. package/src/client/components/ClockControls.test.ts +446 -0
  62. package/src/client/components/DelegationDialog.svelte +12 -3
  63. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  64. package/src/client/components/PersonFormDialog.svelte +53 -3
  65. package/src/client/components/PersonPanel.svelte +134 -26
  66. package/src/client/i18n.ts +5 -927
  67. package/src/client/messages.test.ts +154 -0
  68. package/src/client/messages.ts +3388 -0
  69. package/src/client/mock.ts +1576 -161
  70. package/src/client/module.ts +15 -1
  71. package/src/client/pages/ApprovalsPage.svelte +120 -46
  72. package/src/client/pages/AttendancePage.svelte +106 -20
  73. package/src/client/pages/DirectoryPage.svelte +192 -55
  74. package/src/client/pages/LeavePage.svelte +280 -25
  75. package/src/client/pages/OfficesPage.svelte +26 -8
  76. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  77. package/src/client/query.ts +12 -0
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +14 -7
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/attendance.ts +9 -0
  90. package/src/contract/capabilities.ts +17 -0
@@ -0,0 +1,588 @@
1
+ /**
2
+ * What the two "my own record" pages draw, and what a refused cancellation says.
3
+ *
4
+ * Neither page had an error branch. Attendance fell through to "Nothing recorded yet — clock in to
5
+ * start" when the load *failed*, above three tiles reading 0h / 0h / 0h reduced from `[]` — a
6
+ * confident statement about somebody's month made out of nothing, and the one figure here a person
7
+ * might take to their manager. Time off dropped its balance strip with no message and then said "No
8
+ * time off booked", which reads as a person with nothing rather than as a screen that never loaded.
9
+ * A screenshot of the happy path cannot see any of that, and the mock this module develops against
10
+ * has no way to refuse a call — so the branches are read out of the components themselves.
11
+ *
12
+ * The technique is `ClockControls.test.ts`'s, and the reasons are the same: there is no component
13
+ * renderer in this package, so this parses each page with the compiler Svelte ships and evaluates
14
+ * the `{#if}` chains, the derived flags that steer them, and `cancelFailure` — the file's own
15
+ * source, run, rather than a copy of it restated here.
16
+ *
17
+ * What it cannot see: layout, colour, and anything CSS decides. Those are checked by eye and by
18
+ * `shell`'s `ux.spec.ts`.
19
+ */
20
+ import { readFileSync } from 'node:fs'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { parse } from 'svelte/compiler'
23
+ import ts from 'typescript'
24
+ import { describe, expect, it } from 'vitest'
25
+
26
+ interface Node {
27
+ type: string
28
+ start: number
29
+ end: number
30
+ [key: string]: unknown
31
+ }
32
+ /** An `IfBlock`, with the three children the chain walk reads named rather than fished out. */
33
+ interface IfNode extends Node {
34
+ test: Node
35
+ consequent: { nodes: Node[] }
36
+ alternate?: { nodes: Node[] } | null
37
+ elseif?: boolean
38
+ }
39
+ type Scope = Record<string, unknown>
40
+
41
+ /** One parsed page, with the handful of questions these tests ask of it. */
42
+ function page(file: string) {
43
+ const source = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf8')
44
+ const ast = parse(source, { modern: true })
45
+ const slice = (node: Node) => source.slice(node.start, node.end)
46
+
47
+ /** Every `{#if}` in the markup, innermost included, in source order. */
48
+ const allIfBlocks = (): Node[] => {
49
+ const found: Node[] = []
50
+ const walk = (node: unknown) => {
51
+ if (!node || typeof node !== 'object') return
52
+ if (Array.isArray(node)) return node.forEach(walk)
53
+ const record = node as Record<string, unknown>
54
+ if (record.type === 'IfBlock') found.push(record as Node)
55
+ for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
56
+ }
57
+ walk(ast.fragment)
58
+ return found.sort((a, b) => a.start - b.start)
59
+ }
60
+
61
+ /**
62
+ * The markup between two offsets with every `{#if}` inside it taken or dropped.
63
+ *
64
+ * The nested blocks are the point. Reading a branch's source *text* would put the stale notice and
65
+ * the month in it whatever the query was doing, so an assertion for either passed against a
66
+ * component that draws neither.
67
+ */
68
+ function resolve(from: number, to: number, scope: Scope): string {
69
+ const inside = allIfBlocks().filter((b) => b.start >= from && b.end <= to)
70
+ // Outermost first: a nested block is spliced in by the recursion into whichever side survives,
71
+ // not by this loop, or it would appear twice.
72
+ const blocks = inside.filter((b) => !inside.some((o) => o !== b && o.start <= b.start && o.end >= b.end))
73
+ let out = ''
74
+ let cursor = from
75
+ for (const block of blocks) {
76
+ out += source.slice(cursor, block.start)
77
+ out += resolveIf(block, scope)
78
+ cursor = block.end
79
+ }
80
+ return out + source.slice(cursor, to)
81
+ }
82
+
83
+ /** Resolve one `{#if}` chain against the scope and render whichever side it takes. */
84
+ function resolveIf(block: Node, scope: Scope): string {
85
+ const holds = new Function(...Object.keys(scope), `return (${slice(block.test as Node)})`)(
86
+ ...Object.values(scope),
87
+ )
88
+ const side = holds
89
+ ? (block.consequent as { nodes: Node[] })
90
+ : (block.alternate as { nodes: Node[] } | null)
91
+ if (!side || side.nodes.length === 0) return ''
92
+ const nodes = side.nodes
93
+ return resolve(nodes[0]!.start, nodes[nodes.length - 1]!.end, scope)
94
+ }
95
+
96
+ /** The `{#if}` chain whose first test is written exactly like this, found by that test. */
97
+ const chain = (test: string): IfNode => {
98
+ const found = allIfBlocks().filter((b) => slice(b.test as Node) === test && !b.elseif)
99
+ expect({ test, matches: found.length }).toEqual({ test, matches: 1 })
100
+ return found[0] as IfNode
101
+ }
102
+
103
+ /**
104
+ * That chain's tests, in the order the renderer tries them, with `null` for the closing `{:else}`.
105
+ *
106
+ * The order is the whole assertion these support: every branch of these chains can draw
107
+ * *something*, so "renders an error state" holds just as well of a page that draws it above the
108
+ * data and blanks a working screen on a failed background refetch.
109
+ */
110
+ function branches(test: string): Array<string | null> {
111
+ const out: Array<string | null> = []
112
+ let block: IfNode | undefined = chain(test)
113
+ while (block) {
114
+ out.push(slice(block.test))
115
+ const alternate = block.alternate
116
+ if (!alternate) break
117
+ const elseif = alternate.nodes.find((n) => n.type === 'IfBlock' && n.elseif) as IfNode | undefined
118
+ if (elseif) {
119
+ block = elseif
120
+ continue
121
+ }
122
+ out.push(null)
123
+ break
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * What that chain draws for this scope.
130
+ *
131
+ * Comments are stripped: a branch that *explains* in prose why it does not blank the page would
132
+ * otherwise satisfy an assertion about what it renders.
133
+ */
134
+ const render = (test: string, scope: Scope): string =>
135
+ resolveIf(chain(test), scope).replace(/<!--[\s\S]*?-->/g, '')
136
+
137
+ /** The initialiser of one top-level `const` in the instance script. */
138
+ function init(name: string): string {
139
+ const found: Node[] = []
140
+ const walk = (node: unknown) => {
141
+ if (!node || typeof node !== 'object') return
142
+ if (Array.isArray(node)) return node.forEach(walk)
143
+ const record = node as Record<string, unknown>
144
+ if (record.type === 'VariableDeclarator' && (record.id as { name?: string })?.name === name)
145
+ found.push(record.init as Node)
146
+ for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
147
+ }
148
+ walk(ast.instance?.content)
149
+ expect({ name, matches: found.length }).toEqual({ name, matches: 1 })
150
+ return slice(found[0]!)
151
+ }
152
+
153
+ /**
154
+ * The expression inside a `const x = $derived(…)`, so the flag under test is the file's own.
155
+ *
156
+ * The trailing comma matters: biome wraps a long `$derived(…)` across lines and leaves one, and
157
+ * `return ((…),)` is a syntax error rather than a failing assertion — which reads like the
158
+ * component is broken when it is this helper that is.
159
+ */
160
+ function derived(name: string): string {
161
+ const source = init(name)
162
+ expect(source.startsWith('$derived(')).toBe(true)
163
+ return source.slice('$derived('.length, -1).trim().replace(/,$/, '')
164
+ }
165
+
166
+ /** Evaluate one of the page's `$derived` flags against a scope. */
167
+ const flag = (name: string, scope: Scope): unknown =>
168
+ new Function(...Object.keys(scope), `return (${derived(name)})`)(...Object.values(scope))
169
+
170
+ /** A `const` holding an arrow function, transpiled so its annotations do not reach `Function`. */
171
+ const arrow = (name: string): string =>
172
+ ts.transpileModule(`const ${name} = ${init(name)}`, {
173
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
174
+ }).outputText
175
+
176
+ /** A named `function` declaration from the instance script, with its types stripped. */
177
+ function fn(name: string): string {
178
+ const found: Node[] = []
179
+ const walk = (node: unknown) => {
180
+ if (!node || typeof node !== 'object') return
181
+ if (Array.isArray(node)) return node.forEach(walk)
182
+ const record = node as Record<string, unknown>
183
+ if (record.type === 'FunctionDeclaration' && (record.id as { name?: string })?.name === name)
184
+ found.push(record as Node)
185
+ for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
186
+ }
187
+ walk(ast.instance?.content)
188
+ expect({ name, matches: found.length }).toEqual({ name, matches: 1 })
189
+ return ts.transpileModule(slice(found[0]!), {
190
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
191
+ }).outputText
192
+ }
193
+
194
+ return { source, branches, render, flag, arrow, fn }
195
+ }
196
+
197
+ const attendance = page('./AttendancePage.svelte')
198
+ const leave = page('./LeavePage.svelte')
199
+
200
+ /** A day sheet row, shaped enough for the `{#if}`s inside the month list. */
201
+ const day = { overtimeMinutes: 0, anomalies: [] as unknown[] }
202
+
203
+ describe('the month on the attendance page', () => {
204
+ /**
205
+ * The order is the assertion. Every one of these branches can draw *something*, so "renders a
206
+ * skeleton when loading" was true of a page that asked `isError` first and blanked a working
207
+ * month on a failed background refetch.
208
+ */
209
+ it('asks loading first, then the month it holds, then the failure, then the empty month', () => {
210
+ expect(attendance.branches('loading')).toEqual(['loading', 'days.length', 'daysQuery.isError', null])
211
+ })
212
+
213
+ it('draws a day-shaped skeleton while the first fetch is in flight', () => {
214
+ const body = attendance.render('loading', { loading: true, days: [], daysQuery: {}, day })
215
+ expect(body).toContain('Skeleton')
216
+ expect(body).not.toContain('EmptyState')
217
+ })
218
+
219
+ /**
220
+ * The defect this file was written for. `days` is `[]` before an answer and `[]` after a refused
221
+ * one, so the page said "Nothing recorded yet — clock in to start" to somebody whose month simply
222
+ * had not loaded.
223
+ */
224
+ it('says the month failed and offers a retry rather than claiming nothing is recorded', () => {
225
+ const body = attendance.render('loading', {
226
+ loading: false,
227
+ days: [],
228
+ daysQuery: { isError: true },
229
+ day,
230
+ })
231
+ expect(body).toContain("t('attendance_error')")
232
+ expect(body).toContain("t('retry')")
233
+ expect(body).not.toContain("t('attendance_none')")
234
+ })
235
+
236
+ it('still says nothing is recorded when the answer arrived and the month is genuinely empty', () => {
237
+ const body = attendance.render('loading', {
238
+ loading: false,
239
+ days: [],
240
+ daysQuery: { isError: false },
241
+ day,
242
+ })
243
+ expect(body).toContain("t('attendance_none')")
244
+ expect(body).not.toContain("t('attendance_error')")
245
+ })
246
+
247
+ /**
248
+ * A punch invalidates the whole module, so a refetch that fails while the last good month is
249
+ * still in `data` is the ordinary case — an error branch above the month would blank the page,
250
+ * tiles included, for as long as core takes to come back.
251
+ */
252
+ it('keeps a held month when a refetch fails, and says it may be out of date', () => {
253
+ const body = attendance.render('loading', {
254
+ loading: false,
255
+ days: [{ id: 'a' }],
256
+ daysQuery: { isError: true },
257
+ day,
258
+ })
259
+ expect(body).toContain('{#each days as day (day.id)}')
260
+ expect(body).toContain("t('attendance_stale')")
261
+ expect(body).toContain("t('retry')")
262
+ expect(body).not.toContain("t('attendance_error')")
263
+ })
264
+
265
+ it('does not say the month may be out of date while the refetch is succeeding', () => {
266
+ const body = attendance.render('loading', {
267
+ loading: false,
268
+ days: [{ id: 'a' }],
269
+ daysQuery: { isError: false },
270
+ day,
271
+ })
272
+ expect(body).toContain('{#each days as day (day.id)}')
273
+ expect(body).not.toContain("t('attendance_stale')")
274
+ })
275
+ })
276
+
277
+ describe('the attendance tiles', () => {
278
+ /**
279
+ * Worked, scheduled and overtime are `days.reduce(…)`, so an unloaded month adds up to "0h worked,
280
+ * 0h scheduled, 0h overtime" — three confident falsehoods about somebody's hours. There is nothing
281
+ * to say until there is a day sheet to say it from.
282
+ */
283
+ it('is unknown, not zero, when the load failed with nothing held', () => {
284
+ expect(attendance.flag('totalsUnknown', { days: [], loading: false, daysQuery: { isError: true } })).toBe(
285
+ true,
286
+ )
287
+ })
288
+
289
+ it('is unknown while the first fetch is in flight', () => {
290
+ expect(attendance.flag('totalsUnknown', { days: [], loading: true, daysQuery: { isError: false } })).toBe(
291
+ true,
292
+ )
293
+ })
294
+
295
+ /** A month that really is empty is worth 0h, and saying so is the truth rather than a guess. */
296
+ it('is known when the answer arrived and the month is empty', () => {
297
+ expect(
298
+ attendance.flag('totalsUnknown', { days: [], loading: false, daysQuery: { isError: false } }),
299
+ ).toBe(false)
300
+ })
301
+
302
+ /** The held totals outrank a failed refetch, for the same reason the held month does. */
303
+ it('is known when a refetch failed but the month is still held', () => {
304
+ expect(
305
+ attendance.flag('totalsUnknown', { days: [{ id: 'a' }], loading: false, daysQuery: { isError: true } }),
306
+ ).toBe(false)
307
+ })
308
+
309
+ it('draws skeletons in place of the tiles rather than reducing an empty month', () => {
310
+ expect(attendance.render('totalsUnknown', { totalsUnknown: true })).toContain('tilesUnknown()')
311
+ expect(attendance.render('totalsUnknown', { totalsUnknown: false })).toContain('tiles()')
312
+ expect(attendance.source).toContain('<Skeleton height="86px" />')
313
+ })
314
+
315
+ /**
316
+ * A disabled query is `pending` and not fetching, so it is not "loading" — without the workspace
317
+ * test the first frame told somebody with a full month that nothing was recorded.
318
+ */
319
+ it('counts the frame before a workspace arrives as loading', () => {
320
+ expect(attendance.flag('loading', { workspaceId: '', daysQuery: { isLoading: false } })).toBe(true)
321
+ })
322
+ })
323
+
324
+ describe('the balance on the time-off page', () => {
325
+ it('asks loading first, then the balance it holds, then the failure, then the unconfigured workspace', () => {
326
+ expect(leave.branches('balanceLoading')).toEqual([
327
+ 'balanceLoading',
328
+ 'balances.length',
329
+ 'balanceQuery.isError',
330
+ null,
331
+ ])
332
+ })
333
+
334
+ /**
335
+ * The defect. A refused balance took the strip off the page with no message at all, leaving "No
336
+ * time off booked" underneath it — which reads as a person with nothing left and nothing booked.
337
+ */
338
+ it('says the balance failed and offers a retry rather than dropping the strip', () => {
339
+ const body = leave.render('balanceLoading', {
340
+ balanceLoading: false,
341
+ balances: [],
342
+ balanceQuery: { isError: true },
343
+ canHr: () => true,
344
+ })
345
+ expect(body).toContain("t('balance_error')")
346
+ expect(body).toContain("t('retry')")
347
+ expect(body).not.toContain("t('leave_types_none')")
348
+ })
349
+
350
+ /**
351
+ * An empty answer is one row per leave type and no leave types — a workspace nobody has set up,
352
+ * not a person with nothing. The way out is only offered to somebody who has it: a member cannot
353
+ * create a leave type, and a button that 404s is worse than none.
354
+ */
355
+ it('names the unconfigured workspace when the answer arrived empty, and offers the fix only to an admin', () => {
356
+ const scope = { balanceLoading: false, balances: [], balanceQuery: { isError: false } }
357
+ const admin = leave.render('balanceLoading', { ...scope, canHr: () => true })
358
+ expect(admin).toContain("t('leave_types_none')")
359
+ expect(admin).toContain("t('leave_types')")
360
+ expect(admin).not.toContain("t('balance_error')")
361
+
362
+ const member = leave.render('balanceLoading', { ...scope, canHr: () => false })
363
+ expect(member).toContain("t('leave_types_none')")
364
+ expect(member).not.toContain("t('leave_types')")
365
+ })
366
+
367
+ it('draws skeletons the shape of the tiles while the first fetch is in flight', () => {
368
+ const body = leave.render('balanceLoading', {
369
+ balanceLoading: true,
370
+ balances: [],
371
+ balanceQuery: {},
372
+ canHr: () => true,
373
+ })
374
+ expect(body).toContain('Skeleton')
375
+ expect(body).not.toContain('EmptyState')
376
+ })
377
+ })
378
+
379
+ describe('the requests on the time-off page', () => {
380
+ const row = { status: 'pending' }
381
+ const scope = { canCancel: () => true, request: row, canHr: () => true }
382
+
383
+ it('asks loading first, then the requests it holds, then the failure, then the empty list', () => {
384
+ expect(leave.branches('requestsLoading')).toEqual([
385
+ 'requestsLoading',
386
+ 'requests.length',
387
+ 'requestsQuery.isError',
388
+ null,
389
+ ])
390
+ })
391
+
392
+ it('says the list failed and offers a retry rather than claiming nothing is booked', () => {
393
+ const body = leave.render('requestsLoading', {
394
+ ...scope,
395
+ requestsLoading: false,
396
+ requests: [],
397
+ requestsQuery: { isError: true },
398
+ })
399
+ expect(body).toContain("t('leave_requests_error')")
400
+ expect(body).toContain("t('retry')")
401
+ expect(body).not.toContain("t('leave_none')")
402
+ })
403
+
404
+ it('still says nothing is booked when the answer arrived and the list is genuinely empty', () => {
405
+ const body = leave.render('requestsLoading', {
406
+ ...scope,
407
+ requestsLoading: false,
408
+ requests: [],
409
+ requestsQuery: { isError: false },
410
+ })
411
+ expect(body).toContain("t('leave_none')")
412
+ expect(body).not.toContain("t('leave_requests_error')")
413
+ })
414
+
415
+ /**
416
+ * One strip for both queries: when core is unreachable both fail, and two identical warnings
417
+ * stacked on one page is noise.
418
+ */
419
+ it('warns that held data may be out of date, for either query, and never with nothing to hold', () => {
420
+ const held = { balances: [{}], requests: [{}] }
421
+ const none = { balances: [], requests: [] }
422
+ const ok = { isError: false }
423
+ const bad = { isError: true }
424
+ expect(leave.flag('stale', { ...held, balanceQuery: bad, requestsQuery: ok })).toBe(true)
425
+ expect(leave.flag('stale', { ...held, balanceQuery: ok, requestsQuery: bad })).toBe(true)
426
+ expect(leave.flag('stale', { ...held, balanceQuery: ok, requestsQuery: ok })).toBe(false)
427
+ // Nothing held is the error branch's job; a warning strip above an error state says it twice.
428
+ expect(leave.flag('stale', { ...none, balanceQuery: bad, requestsQuery: bad })).toBe(false)
429
+ })
430
+ })
431
+
432
+ describe('cancelling a booking', () => {
433
+ /**
434
+ * The defect. A small ghost button beside a status badge was `onclick={() => cancel.mutate(id)}`,
435
+ * and `canCancel` allows an *approved* request — so one misclick destroyed a granted week: days
436
+ * already off the balance, and an absence colleagues had planned around.
437
+ */
438
+ it('opens a confirmation instead of cancelling on the click', () => {
439
+ const row = leave.render('requestsLoading', {
440
+ requestsLoading: false,
441
+ requests: [{}],
442
+ requestsQuery: { isError: false },
443
+ canCancel: () => true,
444
+ request: { status: 'approved' },
445
+ canHr: () => true,
446
+ })
447
+ expect(row).toContain("t('cancel_request')")
448
+ expect(row).toContain('cancelling = request')
449
+ expect(row).not.toContain('cancel.mutate')
450
+ // window.confirm is not a dialog anybody can read, style or translate.
451
+ expect(leave.source).not.toContain('confirm(')
452
+ })
453
+
454
+ /** Hidden from somebody who may never cancel; offered for the two statuses that can be. */
455
+ it('is offered for a pending or approved booking, and only with the permission', () => {
456
+ const build = new Function('canHr', `${leave.arrow('canCancel')}; return canCancel`)
457
+ const allowed = build(() => true)
458
+ expect(['pending', 'approved'].map(allowed)).toEqual([true, true])
459
+ expect(['rejected', 'withdrawn', 'cancelled'].map(allowed)).toEqual([false, false, false])
460
+ expect(allowed('pending')).toBe(true)
461
+ expect(build(() => false)('pending')).toBe(false)
462
+ })
463
+
464
+ /**
465
+ * "Are you sure?" says nothing. The body names the dates and the working days that come back, and
466
+ * an approved booking gets a line of its own — those are the days the team has already planned
467
+ * around, and the person clicking is the only one who can weigh that.
468
+ */
469
+ it('states the dates, the days returned and — for an approved booking — the team calendar', () => {
470
+ const approved = leave.render('cancelling', {
471
+ cancelling: { workingDays: 5, startsOn: '2026-09-09', endsOn: '2026-09-13', status: 'approved' },
472
+ dateRange: () => '',
473
+ })
474
+ expect(approved).toContain("t('leave_cancel_body'")
475
+ expect(approved).toContain('count: cancelling.workingDays')
476
+ expect(approved).toContain('range: dateRange(cancelling.startsOn, cancelling.endsOn)')
477
+ expect(approved).toContain("t('leave_cancel_approved')")
478
+
479
+ const pending = leave.render('cancelling', {
480
+ cancelling: { workingDays: 1, startsOn: '2026-09-09', endsOn: '2026-09-09', status: 'pending' },
481
+ dateRange: () => '',
482
+ })
483
+ expect(pending).toContain("t('leave_cancel_body'")
484
+ expect(pending).not.toContain("t('leave_cancel_approved')")
485
+ })
486
+
487
+ /**
488
+ * `disabled={cancel.isPending}` reaches the button on the *next* render, so two quick clicks both
489
+ * fire — and the second arrives at a request the first already cancelled, which the server refuses
490
+ * for something the person did not do.
491
+ */
492
+ it('guards the second click in the same tick as the first', () => {
493
+ expect(leave.source).toContain('let cancelInFlight = $state(false)')
494
+ expect(leave.source).toMatch(/if \(!cancelling \|\| cancelInFlight\) return\s+cancelInFlight = true/)
495
+ })
496
+ })
497
+
498
+ /**
499
+ * `t()` as the runtime resolves it: this module's prefix on a bare key, and — the half `cancelFailure`
500
+ * turns on — the key itself when nothing defines a string for it. `t()` is tested where it lives
501
+ * (`packages/ui/src/lib/i18n.test.ts`); this is the two lines of it the page reads.
502
+ */
503
+ const translator = (strings: Record<string, string>) => (key: string) =>
504
+ strings[key.includes('.') ? key : `hr.${key}`] ?? (key.includes('.') ? key : `hr.${key}`)
505
+
506
+ /**
507
+ * Markers rather than the real English sentences: an assertion naming the real string could not tell
508
+ * a translation apart from the server's fallback, which is the whole distinction under test.
509
+ */
510
+ const strings: Record<string, string> = { 'hr.leave_cancel_error': '[fallback]' }
511
+
512
+ /** The page's own `cancelFailure`, run, with every free name it uses handed to it. */
513
+ function failureText(
514
+ error: unknown,
515
+ refusals: Record<string, string> = {},
516
+ bundle: Record<string, string> = strings,
517
+ ): string {
518
+ return new Function('t', 'cancelRefusalMessages', `${leave.fn('cancelFailure')}; return cancelFailure`)(
519
+ translator(bundle),
520
+ refusals,
521
+ )(error)
522
+ }
523
+
524
+ /**
525
+ * oRPC hands the client a `KernError` re-thrown as an `ORPCError`: an `Error` with a `code` and, for
526
+ * a refusal, whatever the router attached — `kernErrorToORPC` carries a `reason` across as `data`.
527
+ */
528
+ const refusal = (message: string, reason?: string) =>
529
+ Object.assign(new Error(message), { code: 'CONFLICT', data: reason ? { reason } : undefined })
530
+
531
+ describe('a refused cancellation', () => {
532
+ /**
533
+ * The defect. `onError: (error) => toast.error(error.message)` put the router's English —
534
+ * "That request is already cancelled" — in front of somebody who reads Persian and cannot act on
535
+ * it either way.
536
+ */
537
+ it('translates the reason where the router sends one', () => {
538
+ const text = failureText(
539
+ refusal('That request is already cancelled', 'hr.leave.already_cancelled'),
540
+ { 'hr.leave.already_cancelled': 'hr.leave_cancel_refused_gone' },
541
+ { ...strings, 'hr.leave_cancel_refused_gone': '[translated: already cancelled]' },
542
+ )
543
+ expect(text).toBe('[translated: already cancelled]')
544
+ })
545
+
546
+ /**
547
+ * `leave.requests.cancel` refuses through `KernError.conflict(message)` with no reason today, so
548
+ * the router's sentence is the only thing that says *which* refusal this is. Losing it for a
549
+ * generic string would tell the reader less than the English does.
550
+ */
551
+ it('falls back to the router’s sentence for a refusal with no reason of its own', () => {
552
+ expect(failureText(refusal('That request is already cancelled'))).toBe(
553
+ 'That request is already cancelled',
554
+ )
555
+ })
556
+
557
+ it('falls back to the router’s sentence for a reason no key covers yet', () => {
558
+ expect(failureText(refusal('Something new happened', 'hr.leave.brand_new'))).toBe(
559
+ 'Something new happened',
560
+ )
561
+ })
562
+
563
+ /**
564
+ * `t()` answers a key it has no string for with the key itself, so a mapping whose translation has
565
+ * not been merged must not put `hr.leave_cancel_refused_gone` in front of anybody.
566
+ */
567
+ it('never shows a message key when the mapped string has not been merged', () => {
568
+ const text = failureText(refusal('That request is already cancelled', 'hr.leave.already_cancelled'), {
569
+ 'hr.leave.already_cancelled': 'hr.leave_cancel_refused_gone',
570
+ })
571
+ expect(text).toBe('That request is already cancelled')
572
+ expect(text).not.toContain('hr.')
573
+ })
574
+
575
+ /**
576
+ * A network drop, a 500 or a gateway carry machine text, in English, written for nobody — the
577
+ * refusal is the only failure here that has a sentence meant for a reader.
578
+ */
579
+ it('uses this module’s own string for a failure that is not a refusal', () => {
580
+ expect(failureText(Object.assign(new Error('Failed to fetch'), { code: 'INTERNAL' }))).toBe('[fallback]')
581
+ expect(failureText(new TypeError('Failed to fetch'))).toBe('[fallback]')
582
+ })
583
+
584
+ /** A refusal with an empty sentence must still say something. */
585
+ it('falls back to this module’s string when the refusal carries no sentence', () => {
586
+ expect(failureText(Object.assign(new Error(''), { code: 'CONFLICT' }))).toBe('[fallback]')
587
+ })
588
+ })
@@ -38,6 +38,18 @@ export const hrKeys = {
38
38
  approvalInbox: (ws: string, includeDecided = false) =>
39
39
  ['hr', 'approvals', ws, includeDecided ? 'decided' : 'waiting'] as const,
40
40
  delegations: (ws: string) => ['hr', 'delegations', ws] as const,
41
+ calendar: (ws: string, id: string) => ['hr', 'calendar', ws, id] as const,
42
+ calendarWorkingDays: (ws: string, calendarId: string, from: string, to: string) =>
43
+ ['hr', 'calendar-working-days', ws, calendarId, from, to] as const,
44
+ /**
45
+ * The pack diff is keyed by the pack and the year as well as the calendar: an admin comparing two
46
+ * years must not be shown the first one's answer while the second is still in flight.
47
+ */
48
+ calendarPackPreview: (ws: string, calendarId: string, packKey: string, year: number) =>
49
+ ['hr', 'calendar-pack-preview', ws, calendarId, packKey, year] as const,
50
+ entities: (ws: string) => ['hr', 'entities', ws] as const,
51
+ officePeople: (ws: string, officeId: string, primaryOnly: boolean) =>
52
+ ['hr', 'office-people', ws, officeId, primaryOnly ? 'primary' : 'all'] as const,
41
53
  } as const
42
54
 
43
55
  /** `YYYY-MM-DD` for a date, in the viewer's own zone rather than UTC. */