@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
@@ -223,6 +223,17 @@ export const hrClientModule = defineClientModule({
223
223
  * is no route file in the app to keep in step with it any more.
224
224
  */
225
225
  settingsPages: [
226
+ {
227
+ id: 'general',
228
+ get label() {
229
+ return t('settings_general')
230
+ },
231
+ icon: 'sliders-vertical',
232
+ scope: 'workspace',
233
+ permission: 'core.modules.manage',
234
+ order: 1,
235
+ component: () => import('./settings/GeneralSettings.svelte'),
236
+ },
226
237
  {
227
238
  id: 'capabilities',
228
239
  get label() {
@@ -230,7 +241,10 @@ export const hrClientModule = defineClientModule({
230
241
  },
231
242
  icon: 'toggle-left',
232
243
  scope: 'workspace',
233
- permission: 'core.workspace.manage',
244
+ // `core.modules.manage`, not `core.workspace.manage`: core gates `modules.updateSettings` on
245
+ // the former, and this page's every control is a write to it. Offering the page on the wider
246
+ // permission showed the whole switchboard to somebody the server then refused on each switch.
247
+ permission: 'core.modules.manage',
234
248
  order: 5,
235
249
  component: () => import('./settings/CapabilitiesSettings.svelte'),
236
250
  },
@@ -4,6 +4,8 @@ import {
4
4
  type BadgeTone,
5
5
  Button,
6
6
  EmptyState,
7
+ formatCount,
8
+ formatDateTime,
7
9
  navigation,
8
10
  Page,
9
11
  PageHeader,
@@ -18,9 +20,9 @@ import DecisionDialog from '../components/DecisionDialog.svelte'
18
20
  import DelegationDialog from '../components/DelegationDialog.svelte'
19
21
  import { t } from '../i18n.js'
20
22
  import type { ApprovalRequest } from '../index.js'
21
- import { canHr } from '../permissions.js'
23
+ import { canHr, HR_CAPABILITIES } from '../permissions.js'
22
24
  import { hrKeys } from '../query.js'
23
- import { dateRange, day, summarise } from '../summary.js'
25
+ import { summarise } from '../summary.js'
24
26
 
25
27
  /**
26
28
  * Everything waiting on me, across every kind of request.
@@ -51,6 +53,25 @@ let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject'
51
53
  let delegating = $state(false)
52
54
  let decideError = $state<string | null>(null)
53
55
 
56
+ /**
57
+ * `submitting` rather than `decide.isPending`: the disabled attribute only reaches the confirm
58
+ * button on the next render, so two quick clicks both fire and one request is decided twice. This
59
+ * is set in the same tick as the click.
60
+ */
61
+ let submitting = $state(false)
62
+
63
+ /**
64
+ * Delegation is two questions, and the button was only asking one.
65
+ *
66
+ * `hr.approval.delegate` defaults to owner, admin and member, so every member saw the button — and
67
+ * `DelegationDialog` then calls `approvals.delegations`, which sits behind the `approvals`
68
+ * capability. That capability is off by default, so in a fresh workspace the dialog opened and
69
+ * 404'd on its first query. A capability that is off has no surface at all, not a surface that
70
+ * fails when you touch it.
71
+ */
72
+ const hasChains = $derived(session.hasCapability('hr', HR_CAPABILITIES.approvals))
73
+ const showDelegation = $derived(hasChains && canHr('approvalDelegate'))
74
+
54
75
  const inboxQuery = createQuery(() => ({
55
76
  queryKey: hrKeys.approvalInbox(workspaceId, includeDecided),
56
77
  enabled: Boolean(workspaceId),
@@ -73,11 +94,68 @@ const decide = createMutation(() => ({
73
94
  // is invalidated rather than guessing which keys moved.
74
95
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
75
96
  },
76
- onError: () => {
77
- decideError = t('decide_error')
97
+ onError: (error) => {
98
+ decideError = decideFailure(error)
99
+ // A refusal is the server saying its inbox is not the one on screen, so the row behind the
100
+ // dialog is stale as well as the decision. Re-read all of HR exactly as a decision that landed
101
+ // does — without this the same dead row sits in the table and every retry earns the same
102
+ // sentence.
103
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
104
+ },
105
+ onSettled: () => {
106
+ submitting = false
78
107
  },
79
108
  }))
80
109
 
110
+ /**
111
+ * The decision refusals this module has its own sentence for, keyed by the `reason` the router
112
+ * sends beside the refusal — never by the sentence, because a list of sentences is a list somebody
113
+ * has to keep in sync and the day it drifts the reader is told nothing.
114
+ *
115
+ * Empty on purpose. `approvals.decide` refuses through `KernError.conflict`, whose reason argument
116
+ * is kept on the server and never serialised, so every refusal arrives today as the sentence the
117
+ * router wrote for a person. The day `decide()` sends a reason, its string lands here and nothing
118
+ * else has to change.
119
+ */
120
+ const decideRefusalMessages: Record<string, string> = {}
121
+
122
+ /**
123
+ * What a refused decision says to the person who made it.
124
+ *
125
+ * A decision is refused when the request is no longer theirs to decide — somebody else approved it,
126
+ * the requester cancelled it, or a delegation moved the step — and the router's sentence is the
127
+ * only thing that says which of those happened. It used to be thrown away here for a flat "The
128
+ * decision could not be recorded", which tells a manager staring at a live-looking row nothing at
129
+ * all. Everything else that can fail carries machine text in English, so it falls back to this
130
+ * module's own string.
131
+ *
132
+ * The test is the transport's `code`, never the sentence: `KernError.conflict` is what arrives as
133
+ * CONFLICT, so a refusal added to `decide()` later reaches the reader without anyone editing this
134
+ * file. The same shape as `ClockControls.svelte` and the approvals widget.
135
+ */
136
+ function decideFailure(error: unknown): string {
137
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
138
+ if (failure.code !== 'CONFLICT') return t('decide_error')
139
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
140
+ const key = reason ? decideRefusalMessages[reason] : undefined
141
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
142
+ // a reason no key covers, and a key whose string has not been merged — land on the router's
143
+ // sentence rather than putting `hr.decide_refused_…` in front of somebody.
144
+ const translated = key ? t(key) : undefined
145
+ return (translated && translated !== key ? translated : failure.message) || t('decide_error')
146
+ }
147
+
148
+ const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
149
+ decideError = null
150
+ deciding = { request, decision }
151
+ }
152
+
153
+ const confirmDecision = (comment: string) => {
154
+ if (!deciding || submitting) return
155
+ submitting = true
156
+ decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
157
+ }
158
+
81
159
  const SUBJECT_LABELS: Record<string, () => string> = {
82
160
  leave: () => t('leave_title'),
83
161
  regularization: () => t('attendance_title'),
@@ -100,9 +178,6 @@ const STATUS_LABELS: Record<string, () => string> = {
100
178
  cancelled: () => t('approval_status_cancelled'),
101
179
  }
102
180
 
103
- const when = (iso: string) =>
104
- new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
105
-
106
181
  const stepOf = (request: ApprovalRequest) =>
107
182
  t('approvals_step_of', { n: String(request.currentStep + 1), total: String(request.steps.length) })
108
183
  </script>
@@ -112,7 +187,7 @@ const stepOf = (request: ApprovalRequest) =>
112
187
  title={t('approvals_title')}
113
188
  >
114
189
  {#snippet actions()}
115
- {#if canHr('approvalDelegate')}
190
+ {#if showDelegation}
116
191
  <Button size="sm" variant="secondary" icon="users" onclick={() => (delegating = true)}>
117
192
  {t('delegate')}
118
193
  </Button>
@@ -135,26 +210,20 @@ const stepOf = (request: ApprovalRequest) =>
135
210
 
136
211
  <SectionLabel
137
212
  label={includeDecided ? t('approvals_decided') : t('approvals_waiting')}
138
- count={items.length}
213
+ count={formatCount(items.length, 999)}
139
214
  />
140
215
 
216
+ <!--
217
+ Held rows outrank the error. Every decision taken here invalidates all of `['hr']`, so a failed
218
+ background refetch leaves TanStack in `error` with the last good inbox still in `data` — an
219
+ error branch above this one would blank a working table, and take its approve buttons with it,
220
+ on a transient failure. The error is the whole page only when there is nothing else to draw.
221
+ -->
141
222
  {#if inboxQuery.isLoading}
142
223
  <div class="rows">
143
224
  {#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
144
225
  </div>
145
- {:else if inboxQuery.isError}
146
- <EmptyState icon="triangle-alert" title={t('approvals_error')}>
147
- {#snippet actions()}
148
- <Button variant="secondary" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
149
- {/snippet}
150
- </EmptyState>
151
- {:else if items.length === 0}
152
- <EmptyState
153
- icon="check-check"
154
- title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
155
- description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
156
- />
157
- {:else}
226
+ {:else if items.length > 0}
158
227
  <div class="table" role="table" aria-label={t('approvals_title')}>
159
228
  <div class="thead" role="row">
160
229
  <span role="columnheader">{t('approvals_request')}</span>
@@ -170,7 +239,7 @@ const stepOf = (request: ApprovalRequest) =>
170
239
  <span class="summary">{summarise(item)}</span>
171
240
  </span>
172
241
  <span class="cell muted" role="cell">{item.requesterName ?? '—'}</span>
173
- <span class="cell muted" role="cell">{when(item.requestedAt)}</span>
242
+ <span class="cell muted" role="cell">{formatDateTime(item.requestedAt)}</span>
174
243
  <span class="cell" role="cell">
175
244
  {#if includeDecided}
176
245
  <Badge tone={STATUS_TONES[item.status] ?? 'grey'}>
@@ -182,50 +251,55 @@ const stepOf = (request: ApprovalRequest) =>
182
251
  </span>
183
252
  <span class="cell actions" role="cell">
184
253
  {#if item.status === 'pending'}
185
- <Button
186
- size="sm"
187
- variant="secondary"
188
- onclick={() => {
189
- decideError = null
190
- deciding = { request: item, decision: 'reject' }
191
- }}
192
- >
254
+ <Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>
193
255
  {t('reject')}
194
256
  </Button>
195
- <Button
196
- size="sm"
197
- onclick={() => {
198
- decideError = null
199
- deciding = { request: item, decision: 'approve' }
200
- }}
201
- >
202
- {t('approve')}
203
- </Button>
257
+ <Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
204
258
  {/if}
205
259
  </span>
206
260
  </div>
207
261
  {/each}
208
262
  </div>
263
+ {:else if inboxQuery.isError}
264
+ <EmptyState icon="triangle-alert" title={t('approvals_error')}>
265
+ {#snippet actions()}
266
+ <Button variant="secondary" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
267
+ {/snippet}
268
+ </EmptyState>
269
+ {:else}
270
+ <EmptyState
271
+ icon="check-check"
272
+ title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
273
+ description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
274
+ />
209
275
  {/if}
210
276
 
211
- <p class="hint">{t('approvals_chains_hint')}</p>
277
+ <!--
278
+ Only where the workspace has chains. With the capability off there is no chain to configure —
279
+ the requester's manager approves, implicitly — so this line sent a reader to a settings screen
280
+ that is not there.
281
+ -->
282
+ {#if hasChains}
283
+ <p class="hint">{t('approvals_chains_hint')}</p>
284
+ {/if}
212
285
  </Page>
213
286
 
214
287
  <DecisionDialog
215
288
  request={deciding?.request ?? null}
216
289
  decision={deciding?.decision ?? 'approve'}
217
- pending={decide.isPending}
290
+ pending={submitting}
218
291
  error={decideError}
219
- onConfirm={(comment) => {
220
- if (deciding) decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
221
- }}
292
+ onConfirm={confirmDecision}
222
293
  onCancel={() => {
223
294
  deciding = null
224
295
  decideError = null
225
296
  }}
226
297
  />
227
298
 
228
- <DelegationDialog open={delegating} {workspaceId} onClose={() => (delegating = false)} />
299
+ <!-- Only where the workspace has approval chains: every procedure inside it is behind that one. -->
300
+ {#if showDelegation}
301
+ <DelegationDialog open={delegating} {workspaceId} onClose={() => (delegating = false)} />
302
+ {/if}
229
303
 
230
304
  <style>
231
305
  .filters {
@@ -1,5 +1,17 @@
1
1
  <script lang="ts">
2
- import { Badge, EmptyState, navigation, Page, PageHeader, Skeleton, StatTile, session } from '@kernhq/ui'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ EmptyState,
6
+ formatDate,
7
+ messageLocale,
8
+ navigation,
9
+ Page,
10
+ PageHeader,
11
+ Skeleton,
12
+ StatTile,
13
+ session,
14
+ } from '@kernhq/ui'
3
15
  import { createQuery } from '@tanstack/svelte-query'
4
16
  import { getHrApi } from '../api-instance.js'
5
17
  import ClockControls from '../components/ClockControls.svelte'
@@ -12,6 +24,12 @@ import { formatDuration, hrKeys, monthRange } from '../query.js'
12
24
  * The totals come from the derived day sheet rather than being added up here — the server already
13
25
  * knows what a day is worth, and a second implementation in the browser is how a screen starts
14
26
  * disagreeing with a payslip.
27
+ *
28
+ * Which is also why a failed load may not reach the tiles. `days` is `[]` before an answer and `[]`
29
+ * after a refused one, so reducing it either way produces "0h worked, 0h scheduled, 0h overtime" —
30
+ * a confident statement about somebody's month made out of nothing, and the one figure on this page
31
+ * a person might take to their manager. Until there is a day sheet to add up the tiles are
32
+ * skeletons, and the month underneath says the load failed and offers a way to try again.
15
33
  */
16
34
  const api = getHrApi()
17
35
 
@@ -28,6 +46,16 @@ const daysQuery = createQuery(() => ({
28
46
  }))
29
47
  const days = $derived(daysQuery.data?.items ?? [])
30
48
 
49
+ /**
50
+ * A disabled query is not a loading one — it is `pending` and not fetching — so without the
51
+ * workspace test the first frame of this page falls through to the empty state and tells somebody
52
+ * with a full month that nothing is recorded.
53
+ */
54
+ const loading = $derived(!workspaceId || daysQuery.isLoading)
55
+
56
+ /** Nothing to add up: no answer yet, or an answer that never arrived. */
57
+ const totalsUnknown = $derived(days.length === 0 && (loading || daysQuery.isError))
58
+
31
59
  const words = {
32
60
  hours: (n: string) => t('hours_short', { n }),
33
61
  minutes: (n: string) => t('minutes_short', { n }),
@@ -39,6 +67,8 @@ const totals = $derived({
39
67
  overtime: days.reduce((sum, d) => sum + d.overtimeMinutes, 0),
40
68
  })
41
69
 
70
+ const duration = (minutes: number) => formatDuration(minutes, words, messageLocale())
71
+
42
72
  const statusLabel = (s: string) =>
43
73
  s === 'present'
44
74
  ? t('att_status_present')
@@ -65,10 +95,13 @@ const statusTone = (s: string) =>
65
95
  ? 'urgent'
66
96
  : 'grey'
67
97
 
98
+ /**
99
+ * `formatDate` rather than `Intl` directly: it formats in the reader's *interface* language, which
100
+ * is the one the rest of this row is written in. A bare `Intl` call follows the browser instead, so
101
+ * a Persian interface in an English browser gets "Mon 4 Aug" beside its own digits.
102
+ */
68
103
  const dayLabel = (iso: string) =>
69
- new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(
70
- new Date(`${iso}T00:00:00`),
71
- )
104
+ formatDate(`${iso}T00:00:00`, { weekday: 'short', day: 'numeric', month: 'short' })
72
105
  </script>
73
106
 
74
107
  <PageHeader
@@ -76,31 +109,53 @@ const dayLabel = (iso: string) =>
76
109
  title={t('attendance_title')}
77
110
  />
78
111
 
79
- <Page>
80
- <ClockControls {workspaceId} />
112
+ {#snippet tiles()}
113
+ <div class="tiles">
114
+ <StatTile label={t('att_worked')} value={duration(totals.worked)} />
115
+ <StatTile label={t('att_scheduled')} value={duration(totals.scheduled)} />
116
+ <StatTile label={t('att_overtime')} value={duration(totals.overtime)} />
117
+ </div>
118
+ {/snippet}
81
119
 
120
+ {#snippet tilesUnknown()}
82
121
  <div class="tiles">
83
- <StatTile label={t('att_worked')} value={formatDuration(totals.worked, words)} />
84
- <StatTile label={t('att_scheduled')} value={formatDuration(totals.scheduled, words)} />
85
- <StatTile label={t('att_overtime')} value={formatDuration(totals.overtime, words)} />
122
+ {#each [1, 2, 3] as n (n)}<Skeleton height="86px" />{/each}
86
123
  </div>
124
+ {/snippet}
87
125
 
88
- {#if daysQuery.isLoading}
89
- <Skeleton height="200px" />
90
- {:else if days.length === 0}
91
- <EmptyState
92
- icon="timer"
93
- title={t('attendance_none')}
94
- description={t('attendance_none_desc')}
95
- />
126
+ <Page>
127
+ <ClockControls {workspaceId} />
128
+
129
+ {#if totalsUnknown}
130
+ {@render tilesUnknown()}
96
131
  {:else}
132
+ {@render tiles()}
133
+ {/if}
134
+
135
+ {#if loading}
136
+ <div class="rows">
137
+ {#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="37px" />{/each}
138
+ </div>
139
+ {:else if days.length}
140
+ <!--
141
+ The month a person already has outranks the failure, because everything here is invalidated by
142
+ a punch: clocking out refetches the day sheet, and a refetch that fails leaves the query in
143
+ `error` with the last good month still in `data`. An error branch above this one would blank
144
+ the whole page — tiles included — for as long as core takes to come back.
145
+ -->
146
+ {#if daysQuery.isError}
147
+ <p class="stale" role="status">
148
+ <span>{t('attendance_stale')}</span>
149
+ <Button size="sm" variant="ghost" onclick={() => void daysQuery.refetch()}>{t('retry')}</Button>
150
+ </p>
151
+ {/if}
97
152
  <ul>
98
153
  {#each days as day (day.id)}
99
154
  <li class="row">
100
155
  <span class="date">{dayLabel(day.businessDate)}</span>
101
- <span class="worked">{formatDuration(day.workedMinutes, words)}</span>
156
+ <span class="worked">{duration(day.workedMinutes)}</span>
102
157
  {#if day.overtimeMinutes > 0}
103
- <span class="ot">+{formatDuration(day.overtimeMinutes, words)}</span>
158
+ <span class="ot">+{duration(day.overtimeMinutes)}</span>
104
159
  {/if}
105
160
  <!-- An anomaly is why a day needs a human; saying so beats a silent zero. -->
106
161
  {#if day.anomalies.length}
@@ -110,6 +165,14 @@ const dayLabel = (iso: string) =>
110
165
  </li>
111
166
  {/each}
112
167
  </ul>
168
+ {:else if daysQuery.isError}
169
+ <EmptyState icon="triangle-alert" title={t('attendance_error')}>
170
+ {#snippet actions()}
171
+ <Button variant="secondary" onclick={() => void daysQuery.refetch()}>{t('retry')}</Button>
172
+ {/snippet}
173
+ </EmptyState>
174
+ {:else}
175
+ <EmptyState icon="timer" title={t('attendance_none')} description={t('attendance_none_desc')} />
113
176
  {/if}
114
177
  </Page>
115
178
 
@@ -120,6 +183,29 @@ const dayLabel = (iso: string) =>
120
183
  gap: 12px;
121
184
  margin-block: 16px 20px;
122
185
  }
186
+ .rows {
187
+ display: grid;
188
+ gap: 4px;
189
+ }
190
+ /*
191
+ * The warning ink is 4.37:1 on `--kern-canvas`, which is what this page sits on — under the 4.5 a
192
+ * 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
193
+ * is what makes the strip read as a notice rather than as another row.
194
+ */
195
+ .stale {
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: space-between;
199
+ flex-wrap: wrap;
200
+ gap: 8px;
201
+ margin-block: 0 8px;
202
+ padding-block: 6px;
203
+ padding-inline: 12px 8px;
204
+ border-radius: var(--kern-r-md);
205
+ background: var(--kern-warning-tint);
206
+ color: var(--kern-warning);
207
+ font-size: 12.5px;
208
+ }
123
209
  ul {
124
210
  display: grid;
125
211
  gap: 4px;
@@ -132,7 +218,7 @@ ul {
132
218
  align-items: center;
133
219
  gap: 12px;
134
220
  padding: 8px 12px;
135
- border-bottom: 1px solid var(--kern-border);
221
+ border-block-end: 1px solid var(--kern-border);
136
222
  }
137
223
  .date {
138
224
  flex: 1;