@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
@@ -43,6 +43,27 @@
43
43
  "when": 1787761769496,
44
44
  "tag": "0005_approval_requester",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1787848169496,
51
+ "tag": "0006_schedule_no_overlap",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1787848170496,
58
+ "tag": "0007_hot_path_indexes",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 9,
63
+ "version": "7",
64
+ "when": 1787759372069,
65
+ "tag": "0009_beyond_cap_minutes",
66
+ "breakpoints": true
46
67
  }
47
68
  ]
48
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -43,10 +43,10 @@
43
43
  "devDependencies": {
44
44
  "@biomejs/biome": "^2.0.0",
45
45
  "@changesets/cli": "^2.27.0",
46
- "@kernhq/contracts": "^0.5.1",
46
+ "@kernhq/contracts": "^0.6.0",
47
47
  "@kernhq/kernel": "^0.7.0",
48
48
  "@kernhq/tsconfig": "^0.1.0",
49
- "@kernhq/ui": "^0.8.0",
49
+ "@kernhq/ui": "^0.10.0",
50
50
  "@tanstack/svelte-query": "^6.1.0",
51
51
  "@types/node": "^24.0.0",
52
52
  "@types/pg": "^8.15.0",
@@ -58,9 +58,9 @@
58
58
  "vitest": "^4.0.0"
59
59
  },
60
60
  "peerDependencies": {
61
- "@kernhq/contracts": "^0.5.1",
61
+ "@kernhq/contracts": "^0.6.0",
62
62
  "@kernhq/kernel": "^0.7.0",
63
- "@kernhq/ui": "^0.8.0",
63
+ "@kernhq/ui": "^0.10.0",
64
64
  "@tanstack/svelte-query": "^6.1.0",
65
65
  "svelte": "^5.46.0"
66
66
  },
@@ -86,10 +86,10 @@
86
86
  "scripts": {
87
87
  "build": "tsc -p tsconfig.json",
88
88
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
89
- "typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
89
+ "typecheck": "tsc -p tsconfig.test.json && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
90
90
  "test": "vitest run",
91
91
  "db:generate": "drizzle-kit generate",
92
- "lint": "biome check . && node scripts/check-ranges.mjs package.json && node scripts/check-icons.mjs",
92
+ "lint": "biome check . && node scripts/check-ranges.mjs package.json && node scripts/check-icons.mjs && node scripts/check-snapshot-drift.mjs",
93
93
  "format": "biome check --write .",
94
94
  "changeset": "changeset"
95
95
  }
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Button, Skeleton } from '@kernhq/ui'
2
+ import { Button, localTime, Skeleton, toast } from '@kernhq/ui'
3
3
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
4
  import { getHrApi } from '../api-instance.js'
5
5
  import { t } from '../i18n.js'
@@ -11,12 +11,18 @@ import { formatDuration, hrKeys } from '../query.js'
11
11
  * Only the transitions that are currently legal are offered, because the server refuses the others
12
12
  * anyway and a button that always errors is worse than no button. Clocked out: one action. Clocked
13
13
  * in: clock out, and start a break. On a break: end it.
14
+ *
15
+ * Every state of the query is drawn, including the two that used to render nothing: a failed state
16
+ * fetch took the clock off the attendance page and off the dashboard card with no message and no
17
+ * way back, and the only thing that could bring it back was a reload.
14
18
  */
15
19
  interface Props {
16
20
  workspaceId: string
17
21
  }
18
22
  const { workspaceId }: Props = $props()
19
23
 
24
+ type Punch = 'in' | 'out' | 'break_start' | 'break_end'
25
+
20
26
  const api = getHrApi()
21
27
  const queryClient = useQueryClient()
22
28
 
@@ -27,15 +33,43 @@ const stateQuery = createQuery(() => ({
27
33
  // The elapsed total is computed server-side from an open span, so it goes stale on its own.
28
34
  refetchInterval: 60_000,
29
35
  }))
30
- const state = $derived(stateQuery.data)
36
+ // Not `state`: a variable of that name turns every `$state(...)` in this file into a store read
37
+ // of it, and the rune stops existing.
38
+ const clock = $derived(stateQuery.data)
31
39
 
32
40
  const words = {
33
41
  hours: (n: string) => t('hours_short', { n }),
34
42
  minutes: (n: string) => t('minutes_short', { n }),
35
43
  }
36
44
 
45
+ /**
46
+ * `punching` rather than `act.isPending`: the disabled attribute only reaches the button on the
47
+ * next render, so two quick clicks both fire and the day sheet grows a punch nobody made. This is
48
+ * set in the same tick as the first click.
49
+ */
50
+ let punching = $state(false)
51
+
52
+ /**
53
+ * The reasons the server refuses a punch for, and this module's string for each.
54
+ *
55
+ * Keyed by the `reason` the router sends beside the refusal — never by the sentence, because a list
56
+ * of sentences is a list somebody has to keep in sync and the day it drifts the reader is told
57
+ * nothing. A reason missing from here is not a bug; it is the fallback doing its job: a sixth
58
+ * refusal added to `punch()` shows the sentence the router wrote until a string lands here.
59
+ *
60
+ * The message keys on the right are written out in full so `t()` passes them through untouched,
61
+ * which is what lets `onError` tell a resolved string from a key that resolved to itself.
62
+ */
63
+ const punchRefusalMessages: Record<string, string> = {
64
+ 'hr.clock.already_clocked_in': 'hr.clock_refused_already_in',
65
+ 'hr.clock.not_clocked_in': 'hr.clock_refused_not_in',
66
+ 'hr.clock.break_before_clock_in': 'hr.clock_refused_break_needs_in',
67
+ 'hr.clock.already_on_break': 'hr.clock_refused_already_on_break',
68
+ 'hr.clock.not_on_break': 'hr.clock_refused_not_on_break',
69
+ }
70
+
37
71
  const act = createMutation(() => ({
38
- mutationFn: async (action: 'in' | 'out' | 'break_start' | 'break_end') => {
72
+ mutationFn: async (action: Punch) => {
39
73
  if (action === 'in') return api.attendance.clockIn({ workspaceId })
40
74
  if (action === 'out') return api.attendance.clockOut({ workspaceId })
41
75
  if (action === 'break_start') return api.attendance.breakStart({ workspaceId })
@@ -45,55 +79,124 @@ const act = createMutation(() => ({
45
79
  // A punch changes the day sheet as well as the clock, and both are on screen.
46
80
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
47
81
  },
82
+ onError: (error) => {
83
+ // The router refuses an impossible transition — clocking in twice, a break before clocking in —
84
+ // and that refusal is the only thing that says why the button did nothing. It arrives as two
85
+ // pieces: a `reason` this file translates, and the English sentence the router wrote for a
86
+ // reader. Nothing else that can fail here has written anything for a person: a network drop, a
87
+ // 500 or a gateway carry machine text, in English, and a clock is the last place to paste one.
88
+ // So a refusal is explained and everything else falls back to this module's own string.
89
+ //
90
+ // The test is the transport's `code`, never the sentence: a list of sentences is a list
91
+ // somebody has to keep in sync, and the day it drifts the person is told nothing.
92
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
93
+ const refused = failure.code === 'CONFLICT'
94
+ const reason = refused && typeof failure.data?.reason === 'string' ? failure.data.reason : null
95
+ const key = reason ? punchRefusalMessages[reason] : undefined
96
+ // `t()` answers a key it has no string for with the key itself, so a translation is only used
97
+ // when it is one, and both ways of not having one land on the server's sentence: a reason no
98
+ // key covers, and a key whose string has not been merged yet. Neither may put
99
+ // `hr.clock_refused_not_in` — or nothing at all — in front of a person.
100
+ const translated = key ? t(key) : undefined
101
+ const explained = translated && translated !== key ? translated : refused ? failure.message : undefined
102
+ toast.error(explained || t('clock_punch_error'))
103
+ // A refusal is the server saying its picture of the day is not the one on screen — most often
104
+ // because the auto clock-out sweep closed the shift, or a punch arrived from another device.
105
+ // Both of those wrote a punch, so the day sheet beside the clock is as wrong as the clock is:
106
+ // re-read all of HR, exactly as a punch that landed does. Without this the same wrong clock
107
+ // stays up and every retry earns the same toast with no route to the truth.
108
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
109
+ },
110
+ onSettled: () => {
111
+ punching = false
112
+ },
48
113
  }))
49
114
 
50
- const since = $derived(
51
- state?.since
52
- ? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date(state.since))
53
- : null,
54
- )
115
+ const punch = (action: Punch) => {
116
+ if (punching) return
117
+ punching = true
118
+ act.mutate(action)
119
+ }
120
+
121
+ const since = $derived(clock?.since ? localTime(new Date(clock.since)) : null)
55
122
  </script>
56
123
 
124
+ <!--
125
+ Held data outranks the error, because this query polls. A failed background refetch leaves
126
+ TanStack in `error` while `data` is still the last good clock, so an error branch above this one
127
+ took "Working since 09:00" *and the clock-out button* off the screen of somebody mid-shift for a
128
+ minute at a time — every core restart, every rolling deploy. The error is only the whole frame
129
+ when there is nothing else to draw.
130
+ -->
57
131
  {#if stateQuery.isLoading}
58
132
  <Skeleton height="72px" />
59
- {:else if state}
133
+ {:else if clock}
60
134
  <div class="clock">
61
135
  <div class="status">
62
136
  <span class="line">
63
- {#if state.onBreak && since}
137
+ {#if clock.onBreak && since}
64
138
  {t('on_break_since', { time: since })}
65
- {:else if state.clockedIn && since}
139
+ {:else if clock.clockedIn && since}
66
140
  {t('clocked_in_since', { time: since })}
67
141
  {:else}
68
142
  {t('not_clocked_in')}
69
143
  {/if}
70
144
  </span>
71
- <span class="total">
72
- {t('worked_today')}: {formatDuration(state.workedMinutesToday, words)}
73
- </span>
145
+ {#if stateQuery.isError}
146
+ <!--
147
+ The total is the number that drifts — it is counted from an open span, so it is only ever
148
+ as current as the last successful poll. Saying so in its place costs no height (the widget
149
+ body is about 44px) and takes nothing away: the poll is still running, and a punch
150
+ refreshes the clock either way, so there is nothing here for a person to do.
151
+ -->
152
+ <span class="total stale" role="status">{t('clock_stale')}</span>
153
+ {:else}
154
+ <span class="total">
155
+ {t('worked_today')}: {formatDuration(clock.workedMinutesToday, words)}
156
+ </span>
157
+ {/if}
74
158
  </div>
75
159
 
76
160
  <div class="actions">
77
- {#if !state.clockedIn}
78
- <Button size="sm" disabled={act.isPending} onclick={() => act.mutate('in')}>
161
+ {#if !clock.clockedIn}
162
+ <Button size="sm" disabled={punching} onclick={() => punch('in')}>
79
163
  {t('clock_in')}
80
164
  </Button>
81
165
  {:else}
82
- {#if state.onBreak}
83
- <Button size="sm" variant="secondary" disabled={act.isPending} onclick={() => act.mutate('break_end')}>
166
+ {#if clock.onBreak}
167
+ <Button size="sm" variant="secondary" disabled={punching} onclick={() => punch('break_end')}>
84
168
  {t('break_end')}
85
169
  </Button>
86
170
  {:else}
87
- <Button size="sm" variant="secondary" disabled={act.isPending} onclick={() => act.mutate('break_start')}>
171
+ <Button size="sm" variant="secondary" disabled={punching} onclick={() => punch('break_start')}>
88
172
  {t('break_start')}
89
173
  </Button>
90
174
  {/if}
91
- <Button size="sm" disabled={act.isPending} onclick={() => act.mutate('out')}>
175
+ <Button size="sm" disabled={punching} onclick={() => punch('out')}>
92
176
  {t('clock_out')}
93
177
  </Button>
94
178
  {/if}
95
179
  </div>
96
180
  </div>
181
+ {:else if stateQuery.isError}
182
+ <!--
183
+ The same row as the clock, rather than an `EmptyState`: this is read most often inside the clock
184
+ widget, whose body is about one grid row — roughly 44px — and a compact `EmptyState` is more
185
+ than twice that, so its retry button sat below a fold nobody scrolls. Here the button lands
186
+ where the clock-out button would have been.
187
+ -->
188
+ <div class="clock" role="alert">
189
+ <span class="line">{t('clock_error')}</span>
190
+ <Button size="sm" variant="secondary" onclick={() => void stateQuery.refetch()}>
191
+ {t('retry')}
192
+ </Button>
193
+ </div>
194
+ {:else}
195
+ <!--
196
+ No workspace yet. The query is disabled until one arrives, and a disabled query is not
197
+ "loading" — so without this branch the clock is simply absent for that first frame.
198
+ -->
199
+ <Skeleton height="72px" />
97
200
  {/if}
98
201
 
99
202
  <style>
@@ -120,6 +223,10 @@ const since = $derived(
120
223
  font-size: 12px;
121
224
  font-variant-numeric: tabular-nums;
122
225
  }
226
+ /* 5.23:1 on --kern-surface in light, 6.80:1 in dark — this is 12px text, so it has to clear 4.5. */
227
+ .stale {
228
+ color: var(--kern-warning);
229
+ }
123
230
  .actions {
124
231
  display: flex;
125
232
  gap: 8px;