@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
@@ -3,7 +3,10 @@ import {
3
3
  Badge,
4
4
  Button,
5
5
  Card,
6
+ Dialog,
6
7
  EmptyState,
8
+ formatDateRange,
9
+ messageLocale,
7
10
  navigation,
8
11
  Page,
9
12
  PageHeader,
@@ -16,6 +19,7 @@ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-qu
16
19
  import { getHrApi } from '../api-instance.js'
17
20
  import LeaveRequestDialog from '../components/LeaveRequestDialog.svelte'
18
21
  import { t } from '../i18n.js'
22
+ import type { LeaveRequest } from '../index.js'
19
23
  import { canHr } from '../permissions.js'
20
24
  import { formatDays, hrKeys } from '../query.js'
21
25
 
@@ -26,6 +30,10 @@ import { formatDays, hrKeys } from '../query.js'
26
30
  * this page with — and the number they need before deciding anything is `available`, not `balance`.
27
31
  * Pending requests are already spoken for; showing the raw balance is how somebody books a week
28
32
  * they do not have and finds out at approval.
33
+ *
34
+ * Neither half may fail quietly. A refused balance used to take the strip off the page with no
35
+ * message and leave "No time off booked" underneath it, which reads as a person with nothing —
36
+ * no days left and none booked — rather than as a screen that never loaded.
29
37
  */
30
38
  const api = getHrApi()
31
39
  const queryClient = useQueryClient()
@@ -49,17 +57,121 @@ const requestsQuery = createQuery(() => ({
49
57
  }))
50
58
  const requests = $derived(requestsQuery.data?.items ?? [])
51
59
 
60
+ /**
61
+ * A disabled query is `pending` and not fetching, so it is not "loading" — without the workspace
62
+ * test the first frame of this page shows the empty state of both halves to somebody who has
63
+ * twenty days left.
64
+ */
65
+ const balanceLoading = $derived(!workspaceId || balanceQuery.isLoading)
66
+ const requestsLoading = $derived(!workspaceId || requestsQuery.isLoading)
67
+
68
+ /**
69
+ * A failed refetch that still has an answer to show.
70
+ *
71
+ * Everything here is invalidated by a cancellation and by any decision an approver makes, so a
72
+ * refetch failing while the last good balance is still in `data` is the ordinary case — an error
73
+ * branch above the data would blank a working page for as long as core takes to come back. One
74
+ * strip for both queries rather than two: when core is unreachable both fail, and two identical
75
+ * warnings stacked on one page is noise.
76
+ */
77
+ const stale = $derived(
78
+ (balanceQuery.isError && balances.length > 0) || (requestsQuery.isError && requests.length > 0),
79
+ )
80
+ const refetchAll = () => {
81
+ void balanceQuery.refetch()
82
+ void requestsQuery.refetch()
83
+ }
84
+
85
+ const days = (n: number) => formatDays(n, messageLocale())
86
+
87
+ /**
88
+ * The request waiting on a confirmation, and what the last attempt said.
89
+ *
90
+ * `cancelInFlight` rather than `cancel.isPending`: the disabled attribute only reaches the button
91
+ * on the next render, so two quick clicks both fire — and the second one arrives at a request the
92
+ * first has already cancelled, which the server answers with a refusal the person did nothing to
93
+ * deserve.
94
+ */
95
+ let cancelling = $state<LeaveRequest | null>(null)
96
+ let cancelInFlight = $state(false)
97
+ let cancelError = $state<string | null>(null)
98
+
52
99
  const cancel = createMutation(() => ({
53
100
  mutationFn: (requestId: string) => api.leave.requests.cancel({ workspaceId, requestId }),
54
101
  onSuccess: () => {
102
+ cancelling = null
103
+ cancelError = null
55
104
  toast.success(t('leave_cancelled_toast'))
56
- void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-balance'] })
57
- void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-requests'] })
58
- void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-calendar'] })
105
+ // Cancelling moves a balance and the team calendar as well as this list, so the whole module's
106
+ // cache is invalidated rather than guessing which keys moved.
107
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
108
+ },
109
+ onError: (error) => {
110
+ cancelError = cancelFailure(error)
111
+ // A refusal is the server saying its picture of this request is not the one on screen — most
112
+ // often because somebody decided it, or it was already cancelled from another device. Re-read
113
+ // all of HR exactly as a cancellation that landed does; without this the same dead row sits in
114
+ // the list and every retry earns the same sentence.
115
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
116
+ },
117
+ onSettled: () => {
118
+ cancelInFlight = false
59
119
  },
60
- onError: (error: Error) => toast.error(error.message),
61
120
  }))
62
121
 
122
+ /**
123
+ * The cancellation refusals this module has its own sentence for, keyed by the `reason` the router
124
+ * sends beside the refusal — never by the sentence, because a list of sentences is a list somebody
125
+ * has to keep in sync and the day it drifts the reader is told nothing.
126
+ *
127
+ * Empty on purpose. `leave.requests.cancel` refuses through `KernError.conflict`, whose reason
128
+ * argument reaches the client as `data.reason` — `kernErrorToORPC` serialises it — so a refusal
129
+ * names itself in a form this module can translate. Anything the router refuses without one still
130
+ * arrives as its own sentence, which is the fallback below and not a failure.
131
+ */
132
+ const cancelRefusalMessages: Record<string, string> = {
133
+ 'hr.leave.already_cancelled': 'leave_cancel_refused_cancelled',
134
+ 'hr.leave.already_withdrawn': 'leave_cancel_refused_withdrawn',
135
+ }
136
+
137
+ /**
138
+ * What a refused cancellation says to the person who asked for it.
139
+ *
140
+ * A cancellation is refused when the request is no longer theirs to cancel — it is already
141
+ * cancelled, or withdrawn — and the router's sentence is the only thing that says which. It used to
142
+ * be `error.message` for everything, which puts a gateway's English at somebody who reads Persian
143
+ * and cannot act on it either way. Everything else that can fail here carries machine text, so it
144
+ * falls back to this module's own string.
145
+ *
146
+ * The test is the transport's `code`, never the sentence: `KernError.conflict` is what arrives as
147
+ * CONFLICT, so a refusal added to `cancel()` later reaches the reader without anyone editing this
148
+ * file. The same shape as `ClockControls.svelte` and the approvals inbox.
149
+ */
150
+ function cancelFailure(error: unknown): string {
151
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
152
+ if (failure.code !== 'CONFLICT') return t('leave_cancel_error')
153
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
154
+ const key = reason ? cancelRefusalMessages[reason] : undefined
155
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
156
+ // a reason no key covers, and a key whose string has not been merged — land on the router's
157
+ // sentence rather than putting `hr.leave_cancel_refused_…` in front of somebody.
158
+ const translated = key ? t(key) : undefined
159
+ return (translated && translated !== key ? translated : failure.message) || t('leave_cancel_error')
160
+ }
161
+
162
+ const confirmCancel = () => {
163
+ if (!cancelling || cancelInFlight) return
164
+ cancelInFlight = true
165
+ cancelError = null
166
+ cancel.mutate(cancelling.id)
167
+ }
168
+
169
+ const closeCancel = () => {
170
+ if (cancelInFlight) return
171
+ cancelling = null
172
+ cancelError = null
173
+ }
174
+
63
175
  const statusLabel = (s: string) =>
64
176
  s === 'pending'
65
177
  ? t('leave_pending')
@@ -75,16 +187,17 @@ const statusTone = (s: string) =>
75
187
  s === 'approved' ? 'done' : s === 'pending' ? 'upcoming' : s === 'rejected' ? 'declined' : 'grey'
76
188
 
77
189
  /**
78
- * A date range, through `Intl.formatRange`.
79
- *
80
- * Not two dates and a dash: a hand-built range reads backwards under `dir="rtl"` the earlier date
81
- * ends up on the right — and `formatRange` collapses the parts the two dates share for free.
190
+ * `formatDateRange` rather than `Intl` directly: it formats in the reader's *interface* language,
191
+ * which is the one the rest of the row is written in, and it uses `formatRange` — a hand-built range
192
+ * reads backwards under `dir="rtl"`, with the earlier date on the right.
82
193
  */
83
- function range(from: string, to: string): string {
84
- const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })
85
- return fmt.formatRange(new Date(`${from}T00:00:00`), new Date(`${to}T00:00:00`))
86
- }
194
+ const dateRange = (from: string, to: string) => formatDateRange(`${from}T00:00:00`, `${to}T00:00:00`)
87
195
 
196
+ /**
197
+ * An approved request can still be cancelled: the server reverses the ledger rather than deleting
198
+ * it, so the balance goes back up and the history still says what happened. What it must not be is
199
+ * one click away — see the dialog at the bottom of this file.
200
+ */
88
201
  const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pending' || status === 'approved')
89
202
  </script>
90
203
 
@@ -100,40 +213,87 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
100
213
  </PageHeader>
101
214
 
102
215
  <Page>
103
- {#if balanceQuery.isLoading}
104
- <Skeleton height="88px" />
216
+ {#if stale}
217
+ <p class="stale" role="status">
218
+ <span>{t('leave_stale')}</span>
219
+ <Button size="sm" variant="ghost" onclick={refetchAll}>{t('retry')}</Button>
220
+ </p>
221
+ {/if}
222
+
223
+ {#if balanceLoading}
224
+ <div class="tiles">
225
+ {#each [1, 2, 3] as n (n)}<Skeleton height="96px" />{/each}
226
+ </div>
105
227
  {:else if balances.length}
106
228
  <div class="tiles">
107
229
  {#each balances as balance (balance.leaveTypeId)}
108
230
  <StatTile
109
231
  label={balance.leaveTypeName}
110
- value={formatDays(balance.available)}
111
- note={`${t('available')} · ${t('days')}`}
232
+ value={days(balance.available)}
233
+ note={`${t('available')} · ${t('days', { count: balance.available })}`}
112
234
  />
113
235
  {/each}
114
236
  </div>
237
+ {:else if balanceQuery.isError}
238
+ <div class="tiles-slot">
239
+ <EmptyState icon="triangle-alert" title={t('balance_error')}>
240
+ {#snippet actions()}
241
+ <Button variant="secondary" onclick={() => void balanceQuery.refetch()}>{t('retry')}</Button>
242
+ {/snippet}
243
+ </EmptyState>
244
+ </div>
245
+ {:else}
246
+ <!--
247
+ No balances means no leave types: the server returns one row per type, so an empty answer is a
248
+ workspace nobody has configured yet rather than a person with nothing left. The way out is
249
+ only offered to somebody who has it — a member cannot create a leave type, and a button that
250
+ 404s is worse than none.
251
+ -->
252
+ <div class="tiles-slot">
253
+ <EmptyState
254
+ icon="tree-palm"
255
+ compact
256
+ title={t('leave_types_none')}
257
+ description={canHr('leaveManage') ? t('leave_types_none_desc') : undefined}
258
+ >
259
+ {#snippet actions()}
260
+ {#if canHr('leaveManage')}
261
+ <Button
262
+ size="sm"
263
+ variant="secondary"
264
+ icon="settings"
265
+ href={`/${workspaceSlug}/settings/hr/leave`}
266
+ >
267
+ {t('leave_types')}
268
+ </Button>
269
+ {/if}
270
+ {/snippet}
271
+ </EmptyState>
272
+ </div>
115
273
  {/if}
116
274
 
117
275
  <h2>{t('leave_title')}</h2>
118
- {#if requestsQuery.isLoading}
119
- <Skeleton height="120px" />
120
- {:else if requests.length === 0}
121
- <EmptyState icon="tree-palm" title={t('leave_none')} description={t('leave_none_desc')} />
122
- {:else}
276
+ {#if requestsLoading}
277
+ <div class="rows">
278
+ {#each [1, 2, 3] as n (n)}<Skeleton height="58px" />{/each}
279
+ </div>
280
+ {:else if requests.length}
123
281
  <ul>
124
282
  {#each requests as request (request.id)}
125
283
  <li>
126
284
  <Card>
127
285
  <div class="row">
128
- <span class="dates">{range(request.startsOn, request.endsOn)}</span>
129
- <span class="meta">{formatDays(request.workingDays)} {t('days')}</span>
286
+ <span class="dates">{dateRange(request.startsOn, request.endsOn)}</span>
287
+ <span class="meta">{days(request.workingDays)} {t('days', { count: request.workingDays })}</span>
130
288
  <Badge tone={statusTone(request.status)}>{statusLabel(request.status)}</Badge>
131
289
  {#if canCancel(request.status)}
132
290
  <Button
133
291
  size="sm"
134
292
  variant="ghost"
135
- disabled={cancel.isPending}
136
- onclick={() => cancel.mutate(request.id)}>{t('cancel_request')}</Button
293
+ onclick={() => {
294
+ cancelError = null
295
+ cancelling = request
296
+ }}>{t('cancel_request')}</Button
137
297
  >
138
298
  {/if}
139
299
  </div>
@@ -141,11 +301,63 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
141
301
  </li>
142
302
  {/each}
143
303
  </ul>
304
+ {:else if requestsQuery.isError}
305
+ <EmptyState icon="triangle-alert" title={t('leave_requests_error')}>
306
+ {#snippet actions()}
307
+ <Button variant="secondary" onclick={() => void requestsQuery.refetch()}>{t('retry')}</Button>
308
+ {/snippet}
309
+ </EmptyState>
310
+ {:else}
311
+ <EmptyState icon="tree-palm" title={t('leave_none')} description={t('leave_none_desc')} />
144
312
  {/if}
145
313
  </Page>
146
314
 
147
315
  <LeaveRequestDialog open={requesting} {workspaceId} {workspaceSlug} />
148
316
 
317
+ <!--
318
+ Cancelling is not undoing, and the person clicking has to know what it costs before it happens: a
319
+ small ghost button beside a status badge was wired straight to the mutation, so one misclick threw
320
+ away a granted week. The body names the dates, the days that come back and the fact that the team
321
+ stops seeing the absence — an approved request also says so in its own line, because those are the
322
+ days colleagues have already planned around.
323
+ -->
324
+ <Dialog
325
+ open={cancelling !== null}
326
+ size="sm"
327
+ title={t('leave_cancel_title')}
328
+ onOpenChange={(next) => {
329
+ if (!next) closeCancel()
330
+ }}
331
+ >
332
+ {#if cancelling}
333
+ <p class="body">
334
+ {t('leave_cancel_body', {
335
+ count: cancelling.workingDays,
336
+ range: dateRange(cancelling.startsOn, cancelling.endsOn),
337
+ })}
338
+ </p>
339
+ {#if cancelling.status === 'approved'}
340
+ <p class="body note">{t('leave_cancel_approved')}</p>
341
+ {/if}
342
+ {/if}
343
+ {#if cancelError}
344
+ <p class="body failed" role="alert">{cancelError}</p>
345
+ {/if}
346
+
347
+ {#snippet footer()}
348
+ <!--
349
+ Secondary, as in `DecisionDialog`: on a destructive confirmation the way *out* must not be the
350
+ faintest control on it. And it says "Keep it booked" rather than `cancel` — the shared string
351
+ every other dialog here uses — because "Cancel" beside "Cancel request" asks somebody to work
352
+ out which of two identical words abandons the booking and which destroys it.
353
+ -->
354
+ <Button variant="secondary" onclick={closeCancel} disabled={cancelInFlight}>
355
+ {t('leave_cancel_keep')}
356
+ </Button>
357
+ <Button variant="danger" loading={cancelInFlight} onclick={confirmCancel}>{t('cancel_request')}</Button>
358
+ {/snippet}
359
+ </Dialog>
360
+
149
361
  <style>
150
362
  .tiles {
151
363
  display: grid;
@@ -153,6 +365,33 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
153
365
  gap: 12px;
154
366
  margin-block-end: 20px;
155
367
  }
368
+ /* Keeps an error or empty balance on the same rhythm as the tiles it stands in for. */
369
+ .tiles-slot {
370
+ margin-block-end: 20px;
371
+ }
372
+ .rows {
373
+ display: grid;
374
+ gap: 8px;
375
+ }
376
+ /*
377
+ * The warning ink is 4.37:1 on `--kern-canvas`, which is what this page sits on — under the 4.5 a
378
+ * 12.5px line has to clear. On its own tint it is 4.58:1 in light and 5.28:1 in dark, and the tint
379
+ * is what makes the strip read as a notice rather than as another row.
380
+ */
381
+ .stale {
382
+ display: flex;
383
+ align-items: center;
384
+ justify-content: space-between;
385
+ flex-wrap: wrap;
386
+ gap: 8px;
387
+ margin-block: 0 12px;
388
+ padding-block: 6px;
389
+ padding-inline: 12px 8px;
390
+ border-radius: var(--kern-r-md);
391
+ background: var(--kern-warning-tint);
392
+ color: var(--kern-warning);
393
+ font-size: 12.5px;
394
+ }
156
395
  h2 {
157
396
  font-size: 13.5px;
158
397
  margin: 0 0 12px;
@@ -177,4 +416,20 @@ ul {
177
416
  color: var(--kern-ink-500);
178
417
  font-size: 12px;
179
418
  }
419
+ .body {
420
+ margin: 0;
421
+ font-size: 13.5px;
422
+ line-height: 1.55;
423
+ color: var(--kern-ink-700);
424
+ }
425
+ /* A colour, not opacity: opacity fades the text against the dialog whatever token it names. */
426
+ .note {
427
+ margin-block-start: 8px;
428
+ color: var(--kern-ink-500);
429
+ }
430
+ /* A dialog body sits on --kern-surface-raised, not the page: 6.33:1 there in light, 5.04:1 in dark. */
431
+ .failed {
432
+ margin-block-start: 8px;
433
+ color: var(--kern-danger);
434
+ }
180
435
  </style>
@@ -4,6 +4,8 @@ import {
4
4
  type BadgeTone,
5
5
  Button,
6
6
  EmptyState,
7
+ formatCount,
8
+ messageLocale,
7
9
  navigation,
8
10
  Page,
9
11
  PageHeader,
@@ -43,9 +45,10 @@ const officesQuery = createQuery(() => ({
43
45
  const offices = $derived(officesQuery.data ?? [])
44
46
 
45
47
  const stats = $derived({
46
- offices: offices.length,
47
48
  countries: new Set(offices.map((o) => o.country)).size,
48
49
  people: offices.reduce((sum, o) => sum + o.headcount, 0),
50
+ /** Two offices in one zone is one clock to think about; two zones is a scheduling problem. */
51
+ timezones: new Set(offices.map((o) => o.timezone)).size,
49
52
  })
50
53
 
51
54
  /** Re-renders the clocks once a minute; an office list showing a stale time is worse than none. */
@@ -60,7 +63,10 @@ $effect(() => {
60
63
  function localTime(timezone: string, _tick: number): string {
61
64
  void _tick
62
65
  try {
63
- return new Intl.DateTimeFormat(undefined, {
66
+ // The office's zone, the reader's locale: `localTime` from @kernhq/ui cannot help here because
67
+ // it formats in the reader's own zone, and the whole point of this column is somebody else's.
68
+ // `messageLocale()` is what stops the clocks reading in Latin digits on a Persian screen.
69
+ return new Intl.DateTimeFormat(messageLocale(), {
64
70
  timeZone: timezone,
65
71
  hour: 'numeric',
66
72
  minute: '2-digit',
@@ -74,7 +80,7 @@ function localTime(timezone: string, _tick: number): string {
74
80
  /** The country's own name, rather than a two-letter code nobody reads. */
75
81
  function countryName(code: string): string {
76
82
  try {
77
- return new Intl.DisplayNames(undefined, { type: 'region' }).of(code) ?? code
83
+ return new Intl.DisplayNames(messageLocale(), { type: 'region' }).of(code) ?? code
78
84
  } catch {
79
85
  return code
80
86
  }
@@ -102,20 +108,32 @@ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'gre
102
108
  >
103
109
  {#snippet actions()}
104
110
  {#if canHr('officeManage')}
105
- <Button size="sm" href={`/${workspaceSlug}/settings/hr/offices`}>{t('settings_offices')}</Button>
111
+ <Button size="sm" variant="secondary" icon="settings" href={`/${workspaceSlug}/settings/hr/offices`}>
112
+ {t('offices_manage')}
113
+ </Button>
106
114
  {/if}
107
115
  {/snippet}
108
116
  </PageHeader>
109
117
 
110
118
  <Page>
119
+ <!--
120
+ No "Offices" tile. It said the same word and the same number as the section label directly
121
+ below it, so the page opened with "Offices / Offices / Offices 2 / OFFICES 2" down the left
122
+ edge. A tile has to add a fact the list does not already state: how many countries the company
123
+ operates in, how many people those offices hold, and how many clocks you are working across.
124
+ -->
111
125
  <div class="tiles">
112
- <StatTile size="md" label={t('offices_total')} value={new Intl.NumberFormat().format(stats.offices)} />
113
126
  <StatTile
114
127
  size="md"
115
128
  label={t('offices_countries')}
116
- value={new Intl.NumberFormat().format(stats.countries)}
129
+ value={formatCount(stats.countries, Number.MAX_SAFE_INTEGER)}
130
+ />
131
+ <StatTile size="md" label={t('office_people')} value={formatCount(stats.people, Number.MAX_SAFE_INTEGER)} />
132
+ <StatTile
133
+ size="md"
134
+ label={t('offices_timezones')}
135
+ value={formatCount(stats.timezones, Number.MAX_SAFE_INTEGER)}
117
136
  />
118
- <StatTile size="md" label={t('office_people')} value={new Intl.NumberFormat().format(stats.people)} />
119
137
  </div>
120
138
 
121
139
  <SectionLabel label={t('offices_title')} count={offices.length} />
@@ -154,7 +172,7 @@ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'gre
154
172
  </span>
155
173
  <span class="cell" role="cell"><Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge></span>
156
174
  <span class="cell muted" role="cell">{countryName(office.country)}</span>
157
- <span class="cell num" role="cell">{new Intl.NumberFormat().format(office.headcount)}</span>
175
+ <span class="cell num" role="cell">{formatCount(office.headcount, Number.MAX_SAFE_INTEGER)}</span>
158
176
  <span class="cell num" role="cell" title={office.timezone}>{localTime(office.timezone, tick)}</span>
159
177
  </a>
160
178
  {/each}