@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
@@ -1,21 +1,1158 @@
1
1
  <script lang="ts">
2
- import { EmptyState, SettingsPage } from '@kernhq/ui'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Field,
9
+ formatCount,
10
+ formatDate,
11
+ formatDateRange,
12
+ IconButton,
13
+ Input,
14
+ messageLocale,
15
+ navigation,
16
+ SectionLabel,
17
+ Select,
18
+ SettingsPage,
19
+ Skeleton,
20
+ Switch,
21
+ session,
22
+ toast,
23
+ } from '@kernhq/ui'
24
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
25
+ import { getHrApi } from '../api-instance.js'
3
26
  import { t } from '../i18n.js'
27
+ import type { Schedule } from '../index.js'
28
+ import { canHr } from '../permissions.js'
29
+ import { formatDuration, hrKeys, isoDate } from '../query.js'
4
30
 
5
31
  /**
6
- * schedules settings.
32
+ * The week the day sheet is measured against.
7
33
  *
8
- * The API behind this is complete the module's server implements the full surface — but the
9
- * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
- * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
- * because the capability is on.
34
+ * Everything attendance computesscheduled minutes, lateness, early leaving, overtime, the
35
+ * automatic close of a forgotten shift is read off a schedule and the person's assignment to it.
36
+ * Until this screen existed there was no way to make one, so every "Scheduled" and "Overtime" tile
37
+ * in the product was zero by construction rather than by fact.
38
+ *
39
+ * **The week is the screen.** `week` is per-weekday start, end and break kept as local wall-clock
40
+ * readings — `09:00` means nine o'clock wherever the person is, not an instant — so a four-day week
41
+ * or a half-day Thursday is an ordinary thing to set up, and a form of scalar fields cannot show
42
+ * it. The editor lays the seven days out as rows, which is also what makes it survive `dir="rtl"`:
43
+ * the week runs down the dialog rather than across it, and the label column lands on whichever side
44
+ * the reader's language starts from without a single `left` or `right` in the stylesheet.
45
+ *
46
+ * No stat tiles. Every number worth one here — how many schedules, how long each week is — is
47
+ * already a column of the table below, and OfficesPage learned the hard way that a tile restating
48
+ * the list is noise. The one figure that would earn a tile, how many people are on no schedule at
49
+ * all, is not answerable: `attendance.schedules.list` returns no assignment count, the way
50
+ * `offices.list` returns `headcount`, and there is no procedure that lists assignments.
51
+ */
52
+ const api = getHrApi()
53
+ const queryClient = useQueryClient()
54
+
55
+ const workspaceSlug = $derived(navigation.workspaceSlug)
56
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
57
+ const workspaceId = $derived(workspace?.id ?? '')
58
+
59
+ /**
60
+ * The settings page is declared with this permission, so the shell only offers it to somebody who
61
+ * has it — but the module cannot assume the shell is the only thing that ever mounts it, and a
62
+ * read-only view of what the workspace has set up is useful in its own right.
63
+ */
64
+ const manage = $derived(canHr('attendanceManage'))
65
+
66
+ // ---------------------------------------------------------------- the week, as data
67
+
68
+ const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
69
+ type DayKey = (typeof DAY_KEYS)[number]
70
+ type Shift = { start: string; end: string; breakMinutes: number }
71
+ type Week = Record<DayKey, Shift | null>
72
+
73
+ const DEFAULT_SHIFT: Shift = { start: '09:00', end: '18:00', breakMinutes: 60 }
74
+
75
+ const defaultWeek = (): Week => ({
76
+ mon: { ...DEFAULT_SHIFT },
77
+ tue: { ...DEFAULT_SHIFT },
78
+ wed: { ...DEFAULT_SHIFT },
79
+ thu: { ...DEFAULT_SHIFT },
80
+ fri: { ...DEFAULT_SHIFT },
81
+ sat: null,
82
+ sun: null,
83
+ })
84
+
85
+ /**
86
+ * Weekday names come from the reader's calendar rather than seven message keys per locale.
87
+ *
88
+ * 2024-01-01 was a Monday, so the first seven days of that January are Monday to Sunday in order.
89
+ * The anchor carries a midday time on purpose: `2024-01-01` alone parses as UTC midnight, which is
90
+ * still 31 December in every zone west of Greenwich — and the column would be headed "Sunday".
12
91
  */
92
+ const anchorDay = (index: number) => `2024-01-0${index + 1}`
93
+ const dayName = (index: number, weekday: 'long' | 'short') =>
94
+ formatDate(`${anchorDay(index)}T12:00:00`, { weekday })
95
+
96
+ const wallToMinutes = (wall: string) => {
97
+ const [h, m] = wall.split(':')
98
+ return Number(h) * 60 + Number(m)
99
+ }
100
+
101
+ /**
102
+ * `working-time.ts` calls a shift overnight when its end is at or before its start, so 22:00–06:00
103
+ * is eight hours and 09:00–09:00 is a full twenty-four. This screen has to agree with it exactly,
104
+ * or the hours it promises are not the hours payroll gets.
105
+ */
106
+ const crossesMidnight = (shift: Shift) => wallToMinutes(shift.end) <= wallToMinutes(shift.start)
107
+
108
+ const netMinutes = (shift: Shift) => {
109
+ const raw = wallToMinutes(shift.end) - wallToMinutes(shift.start)
110
+ return Math.max((raw > 0 ? raw : raw + 1440) - shift.breakMinutes, 0)
111
+ }
112
+
113
+ const weekMinutes = (week: Week) =>
114
+ DAY_KEYS.reduce((sum, key) => {
115
+ const shift = week[key]
116
+ return sum + (shift ? netMinutes(shift) : 0)
117
+ }, 0)
118
+
119
+ const WALL_CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/
120
+ const shiftComplete = (shift: Shift) => WALL_CLOCK.test(shift.start) && WALL_CLOCK.test(shift.end)
121
+
122
+ /** Durations go through the module's own formatter so the digits match the language around them. */
123
+ const DURATION_WORDS = {
124
+ hours: (n: string) => t('hours_short', { n }),
125
+ minutes: (n: string) => t('minutes_short', { n }),
126
+ }
127
+ const duration = (minutes: number) => formatDuration(minutes, DURATION_WORDS, messageLocale())
128
+
129
+ /**
130
+ * A shift as a range rather than two times and a dash.
131
+ *
132
+ * `formatRange` keeps the earlier reading first in Persian and Arabic; a hand-built `09:00–18:00`
133
+ * reads back to front there. The end is anchored to the next day when the shift crosses midnight,
134
+ * which is what the range actually is.
135
+ */
136
+ const shiftRange = (shift: Shift) =>
137
+ formatDateRange(
138
+ `${anchorDay(0)}T${shift.start}:00`,
139
+ `${anchorDay(crossesMidnight(shift) ? 1 : 0)}T${shift.end}:00`,
140
+ { hour: '2-digit', minute: '2-digit' },
141
+ )
142
+
143
+ /** Reading a schedule from the server, which may leave a weekday out entirely. */
144
+ const readWeek = (week: Schedule['week']): Week => {
145
+ const out = {} as Week
146
+ for (const key of DAY_KEYS) {
147
+ const shift = week[key] ?? null
148
+ out[key] = shift ? { start: shift.start, end: shift.end, breakMinutes: shift.breakMinutes } : null
149
+ }
150
+ return out
151
+ }
152
+
153
+ // ---------------------------------------------------------------- the list
154
+
155
+ /**
156
+ * Archived rows are fetched with the rest and hidden here.
157
+ *
158
+ * A workspace has a handful of schedules, so one request answers both views — and the toggle can
159
+ * say whether there is anything archived to look at, which a second query keyed on the flag could
160
+ * not do without asking twice.
161
+ */
162
+ const schedulesQuery = createQuery(() => ({
163
+ queryKey: hrKeys.schedules(workspaceId),
164
+ enabled: Boolean(workspaceId),
165
+ queryFn: () => api.attendance.schedules.list({ workspaceId, includeArchived: true }),
166
+ }))
167
+
168
+ let showArchived = $state(false)
169
+ const allSchedules = $derived(schedulesQuery.data ?? [])
170
+ const archivedCount = $derived(allSchedules.filter((s) => s.archivedAt).length)
171
+ const schedules = $derived(showArchived ? allSchedules : allSchedules.filter((s) => !s.archivedAt))
172
+
173
+ const KIND_LABELS: Record<string, () => string> = {
174
+ fixed: () => t('schedule_kind_fixed'),
175
+ flexible: () => t('schedule_kind_flexible'),
176
+ shift: () => t('schedule_kind_shift'),
177
+ }
178
+ const kindLabel = (kind: string) => KIND_LABELS[kind]?.() ?? kind
179
+
180
+ /**
181
+ * The chips are a picture, so the cell carries the sentence.
182
+ *
183
+ * `title` is not an accessible name a screen reader can rely on, and seven two-letter abbreviations
184
+ * read aloud one after another are noise — the cell says which days are worked and the chips are
185
+ * hidden from the reader that already heard it.
186
+ */
187
+ const workingDaysLabel = (week: Week) => {
188
+ const names = DAY_KEYS.map((key, index) => (week[key] ? dayName(index, 'long') : null)).filter(
189
+ (n): n is string => n !== null,
190
+ )
191
+ return names.length ? names.join(', ') : t('schedule_week_empty')
192
+ }
193
+
194
+ // ---------------------------------------------------------------- the editor
195
+
196
+ /** `null` while closed; `{ schedule: null }` is a new one. */
197
+ let editing = $state<{ schedule: Schedule | null } | null>(null)
198
+
199
+ let name = $state('')
200
+ let kind = $state('fixed')
201
+ let week = $state<Week>(defaultWeek())
202
+ let tzMode = $state('office')
203
+ let tz = $state('')
204
+ let graceIn = $state('0')
205
+ let graceOut = $state('0')
206
+ let roundingStep = $state('0')
207
+ let roundingDirection = $state('nearest')
208
+ let autoClockOut = $state('')
209
+ let formError = $state<string | null>(null)
210
+ let saving = $state(false)
211
+
212
+ function openEditor(schedule: Schedule | null) {
213
+ editing = { schedule }
214
+ formError = null
215
+ name = schedule?.name ?? ''
216
+ kind = schedule?.kind ?? 'fixed'
217
+ week = schedule ? readWeek(schedule.week) : defaultWeek()
218
+ tzMode = schedule?.tzMode ?? 'office'
219
+ tz = schedule?.tz ?? ''
220
+ graceIn = String(schedule?.graceInMinutes ?? 0)
221
+ graceOut = String(schedule?.graceOutMinutes ?? 0)
222
+ roundingStep = String(schedule?.roundingStepMinutes ?? 0)
223
+ roundingDirection = schedule?.roundingDirection ?? 'nearest'
224
+ autoClockOut = schedule?.autoClockOutAfterMinutes ? String(schedule.autoClockOutAfterMinutes) : ''
225
+ }
226
+
227
+ const isNew = $derived(editing?.schedule == null)
228
+
229
+ function setDay(key: DayKey, on: boolean) {
230
+ // A day switched back on takes the hours of the first day already working, so turning Saturday
231
+ // on in a Monday-to-Friday week does not make somebody retype 09:00 for the sixth time.
232
+ const template = DAY_KEYS.map((k) => week[k]).find((shift) => shift !== null)
233
+ week = { ...week, [key]: on ? { ...(template ?? DEFAULT_SHIFT) } : null }
234
+ }
235
+
236
+ function setShift(key: DayKey, patch: Partial<Shift>) {
237
+ const current = week[key]
238
+ if (!current) return
239
+ week = { ...week, [key]: { ...current, ...patch } }
240
+ }
241
+
242
+ const workingDays = $derived(DAY_KEYS.filter((key) => week[key] !== null))
243
+
244
+ function copyFirstDay() {
245
+ const first = workingDays[0]
246
+ if (!first) return
247
+ const template = week[first]
248
+ if (!template) return
249
+ const next = { ...week }
250
+ for (const key of workingDays) next[key] = { ...template }
251
+ week = next
252
+ }
253
+
254
+ const clamp = (value: string, min: number, max: number) =>
255
+ Math.min(Math.max(Math.round(Number(value) || 0), min), max)
256
+
257
+ const timesMissing = $derived(
258
+ workingDays.some((key) => {
259
+ const shift = week[key]
260
+ return shift ? !shiftComplete(shift) : false
261
+ }),
262
+ )
263
+ const noWorkingDays = $derived(workingDays.length === 0)
264
+ const zoneMissing = $derived(tzMode === 'fixed' && !tz)
265
+
266
+ const weekError = $derived(
267
+ noWorkingDays ? t('schedule_week_empty') : timesMissing ? t('schedule_time_missing') : null,
268
+ )
269
+ const canSave = $derived(manage && name.trim().length > 0 && !weekError && !zoneMissing)
270
+
271
+ /** Why Save is off, in the order somebody would fix them. Null when nothing is blocking. */
272
+ const blockedReason = $derived(
273
+ canSave
274
+ ? null
275
+ : !manage
276
+ ? t('schedules_readonly')
277
+ : !name.trim()
278
+ ? t('schedule_name_required')
279
+ : (weekError ?? (zoneMissing ? t('schedule_tz_required') : null)),
280
+ )
281
+
282
+ const save = createMutation(() => ({
283
+ mutationFn: () => {
284
+ const current = editing?.schedule ?? null
285
+ const common = {
286
+ workspaceId,
287
+ name: name.trim(),
288
+ week,
289
+ graceInMinutes: clamp(graceIn, 0, 240),
290
+ graceOutMinutes: clamp(graceOut, 0, 240),
291
+ roundingStepMinutes: clamp(roundingStep, 0, 60),
292
+ roundingDirection: roundingDirection as Schedule['roundingDirection'],
293
+ autoClockOutAfterMinutes: autoClockOut ? Number(autoClockOut) : null,
294
+ }
295
+ // Kind and time zone are create-only in the contract, and deliberately: the days already
296
+ // computed were measured with them, so changing one would rewrite history rather than the rule.
297
+ return current
298
+ ? api.attendance.schedules.update({ ...common, scheduleId: current.id })
299
+ : api.attendance.schedules.create({
300
+ ...common,
301
+ kind: kind as Schedule['kind'],
302
+ tzMode: tzMode as Schedule['tzMode'],
303
+ tz: tzMode === 'fixed' ? tz : null,
304
+ })
305
+ },
306
+ onSuccess: (schedule) => {
307
+ toast.success(isNew ? t('schedule_created', { name: schedule.name }) : t('schedule_saved'))
308
+ editing = null
309
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
310
+ },
311
+ onError: () => {
312
+ formError = t('schedule_save_error')
313
+ },
314
+ onSettled: () => {
315
+ saving = false
316
+ },
317
+ }))
318
+
319
+ function submit() {
320
+ // The disabled attribute lands a render after the click, and two quick clicks are one render
321
+ // apart — so the guard is a flag set in the same tick, not the mutation's own pending state.
322
+ if (saving || !canSave) return
323
+ saving = true
324
+ formError = null
325
+ save.mutate()
326
+ }
327
+
328
+ // ---------------------------------------------------------------- assigning
329
+
330
+ let assigning = $state<Schedule | null>(null)
331
+ let assignPersonId = $state('')
332
+ let assignFrom = $state(isoDate())
333
+ let assignError = $state<string | null>(null)
334
+ let assignBusy = $state(false)
335
+
336
+ const peopleQuery = createQuery(() => ({
337
+ queryKey: hrKeys.people(workspaceId, { forSchedule: true }),
338
+ enabled: Boolean(assigning) && Boolean(workspaceId),
339
+ queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
340
+ }))
341
+ const people = $derived(peopleQuery.data?.items ?? [])
342
+ const personOptions = $derived(people.map((person) => ({ value: person.id, label: person.displayName })))
343
+
344
+ function openAssign(schedule: Schedule) {
345
+ assigning = schedule
346
+ assignPersonId = ''
347
+ assignFrom = isoDate()
348
+ assignError = null
349
+ }
350
+
351
+ const assign = createMutation(() => ({
352
+ mutationFn: () =>
353
+ api.attendance.schedules.assign({
354
+ workspaceId,
355
+ scheduleId: assigning?.id ?? '',
356
+ personId: assignPersonId,
357
+ effectiveFrom: assignFrom,
358
+ }),
359
+ onSuccess: () => {
360
+ const person = people.find((p) => p.id === assignPersonId)
361
+ toast.success(
362
+ t('schedule_assigned', {
363
+ name: person?.displayName ?? '',
364
+ date: formatDate(assignFrom),
365
+ }),
366
+ )
367
+ assigning = null
368
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
369
+ },
370
+ onError: () => {
371
+ assignError = t('schedule_assign_error')
372
+ },
373
+ onSettled: () => {
374
+ assignBusy = false
375
+ },
376
+ }))
377
+
378
+ function submitAssign() {
379
+ if (assignBusy || !assignPersonId || !assignFrom) return
380
+ assignBusy = true
381
+ assignError = null
382
+ assign.mutate()
383
+ }
384
+
385
+ // ---------------------------------------------------------------- archiving
386
+
387
+ let archiving = $state<Schedule | null>(null)
388
+ let archiveError = $state<string | null>(null)
389
+ let archiveBusy = $state(false)
390
+
391
+ const archive = createMutation(() => ({
392
+ mutationFn: () => api.attendance.schedules.archive({ workspaceId, scheduleId: archiving?.id ?? '' }),
393
+ onSuccess: () => {
394
+ toast.success(t('schedule_archived_toast', { name: archiving?.name ?? '' }))
395
+ archiving = null
396
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
397
+ },
398
+ onError: () => {
399
+ archiveError = t('schedule_archive_error')
400
+ },
401
+ onSettled: () => {
402
+ archiveBusy = false
403
+ },
404
+ }))
405
+
406
+ function submitArchive() {
407
+ if (archiveBusy) return
408
+ archiveBusy = true
409
+ archiveError = null
410
+ archive.mutate()
411
+ }
412
+
413
+ // ---------------------------------------------------------------- option sets
414
+
415
+ const kindOptions = $derived([
416
+ { value: 'fixed', label: t('schedule_kind_fixed'), description: t('schedule_kind_fixed_desc') },
417
+ { value: 'flexible', label: t('schedule_kind_flexible'), description: t('schedule_kind_flexible_desc') },
418
+ { value: 'shift', label: t('schedule_kind_shift'), description: t('schedule_kind_shift_desc') },
419
+ ])
420
+
421
+ const tzModeOptions = $derived([
422
+ { value: 'office', label: t('schedule_tz_office') },
423
+ { value: 'person', label: t('schedule_tz_person') },
424
+ { value: 'fixed', label: t('schedule_tz_fixed') },
425
+ ])
426
+
427
+ /**
428
+ * Every zone the runtime knows, grouped by area.
429
+ *
430
+ * `supportedValuesOf` is the only complete list available to a browser; where it is missing there
431
+ * is exactly one zone worth offering, the one this machine is in. Reading it is not formatting, so
432
+ * it does not go through the shared formatters.
433
+ */
434
+ const ZONES: string[] = (() => {
435
+ try {
436
+ const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf
437
+ const list = supported?.call(Intl, 'timeZone') ?? []
438
+ return list.length ? list : [Intl.DateTimeFormat().resolvedOptions().timeZone]
439
+ } catch {
440
+ return [Intl.DateTimeFormat().resolvedOptions().timeZone]
441
+ }
442
+ })()
443
+ const zoneOptions = ZONES.map((zone) => ({
444
+ value: zone,
445
+ label: zone.split('/').slice(1).join(' / ').replace(/_/g, ' ') || zone,
446
+ group: zone.split('/')[0]?.replace(/_/g, ' ') ?? '',
447
+ }))
448
+
449
+ /** A break somebody actually typed into the database keeps its place in the list. */
450
+ const breakOptions = (current: number) =>
451
+ [...new Set([0, 15, 30, 45, 60, 90, current])]
452
+ .sort((a, b) => a - b)
453
+ .map((minutes) => ({
454
+ value: String(minutes),
455
+ label: minutes === 0 ? t('schedule_break_none') : duration(minutes),
456
+ }))
457
+
458
+ const roundingStepOptions = $derived([
459
+ { value: '0', label: t('schedule_rounding_off') },
460
+ ...[5, 6, 10, 15, 30, 60].map((n) => ({ value: String(n), label: duration(n) })),
461
+ ])
462
+
463
+ const roundingDirectionOptions = $derived([
464
+ { value: 'nearest', label: t('schedule_rounding_nearest') },
465
+ { value: 'employee', label: t('schedule_rounding_employee') },
466
+ { value: 'employer', label: t('schedule_rounding_employer') },
467
+ ])
468
+
469
+ const autoClockOutOptions = $derived([
470
+ { value: '', label: t('schedule_auto_out_off') },
471
+ ...[480, 600, 720, 960, 1440].map((n) => ({ value: String(n), label: duration(n) })),
472
+ ])
13
473
  </script>
14
474
 
15
- <SettingsPage title={t('settings_schedules')}>
16
- <EmptyState
17
- icon="clock"
18
- title={t('settings_schedules')}
19
- description={t('settings_not_built')}
20
- />
475
+ <SettingsPage title={t('settings_schedules')} description={t('schedules_desc')}>
476
+ {#snippet actions()}
477
+ {#if manage}
478
+ <Button size="sm" icon="plus" onclick={() => openEditor(null)}>{t('schedule_new')}</Button>
479
+ {/if}
480
+ {/snippet}
481
+
482
+ <SectionLabel label={t('settings_schedules')} count={formatCount(schedules.length)}>
483
+ {#snippet trailing()}
484
+ {#if archivedCount > 0}
485
+ <Switch
486
+ size="sm"
487
+ checked={showArchived}
488
+ onCheckedChange={(on) => (showArchived = on)}
489
+ label={t('schedules_show_archived')}
490
+ />
491
+ {/if}
492
+ {/snippet}
493
+ </SectionLabel>
494
+
495
+ {#if schedulesQuery.isLoading}
496
+ <div class="rows">
497
+ {#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
498
+ </div>
499
+ {:else if schedulesQuery.isError}
500
+ <EmptyState icon="triangle-alert" title={t('schedules_error')}>
501
+ {#snippet actions()}
502
+ <Button variant="secondary" onclick={() => void schedulesQuery.refetch()}>{t('retry')}</Button>
503
+ {/snippet}
504
+ </EmptyState>
505
+ {:else if schedules.length === 0}
506
+ <EmptyState icon="clock" title={t('schedules_none')} description={t('schedules_none_desc')}>
507
+ {#snippet actions()}
508
+ {#if manage}
509
+ <Button size="sm" icon="plus" onclick={() => openEditor(null)}>{t('schedule_new')}</Button>
510
+ {/if}
511
+ {/snippet}
512
+ </EmptyState>
513
+ {:else}
514
+ <div class="table" role="table" aria-label={t('settings_schedules')}>
515
+ <div class="thead" role="row">
516
+ <span role="columnheader">{t('schedule_name')}</span>
517
+ <span role="columnheader">{t('schedule_kind')}</span>
518
+ <span role="columnheader">{t('schedule_week')}</span>
519
+ <span role="columnheader">{t('schedule_weekly')}</span>
520
+ <span class="sr-only" role="columnheader">{t('schedule_actions')}</span>
521
+ </div>
522
+ {#each schedules as schedule (schedule.id)}
523
+ {@const days = readWeek(schedule.week)}
524
+ <div class="trow" role="row">
525
+ <span class="cell title" role="cell">
526
+ <span class="name">{schedule.name}</span>
527
+ {#if schedule.archivedAt}<Badge tone="grey">{t('schedule_archived')}</Badge>{/if}
528
+ </span>
529
+ <span class="cell" role="cell">
530
+ <Badge tone={schedule.kind === 'fixed' ? 'grey' : 'info'}>{kindLabel(schedule.kind)}</Badge>
531
+ </span>
532
+ <span class="cell" role="cell" aria-label={workingDaysLabel(days)}>
533
+ <!-- Seven chips rather than a sentence: which days are worked is the one thing you
534
+ scan a schedule list for, and the row order flips with the language for free. -->
535
+ <span class="chips" aria-hidden="true">
536
+ {#each DAY_KEYS as key, index (key)}
537
+ {@const shift = days[key]}
538
+ <span
539
+ class="chip"
540
+ class:on={shift !== null}
541
+ title={shift
542
+ ? `${dayName(index, 'long')} · ${shiftRange(shift)}`
543
+ : `${dayName(index, 'long')} · ${t('schedule_day_off')}`}
544
+ >
545
+ {dayName(index, 'short')}
546
+ </span>
547
+ {/each}
548
+ </span>
549
+ </span>
550
+ <span class="cell num" role="cell">{duration(weekMinutes(days))}</span>
551
+ <span class="cell actions" role="cell">
552
+ {#if manage && !schedule.archivedAt}
553
+ <DropdownMenu
554
+ items={[
555
+ { label: t('common.edit'), icon: 'pencil', onSelect: () => openEditor(schedule) },
556
+ { label: t('schedule_assign'), icon: 'user-plus', onSelect: () => openAssign(schedule) },
557
+ { type: 'separator' },
558
+ {
559
+ label: t('common.archive'),
560
+ icon: 'archive',
561
+ danger: true,
562
+ onSelect: () => {
563
+ archiveError = null
564
+ archiving = schedule
565
+ },
566
+ },
567
+ ]}
568
+ >
569
+ {#snippet trigger(props)}
570
+ <IconButton
571
+ icon="ellipsis"
572
+ label={t('schedule_actions_for', { name: schedule.name })}
573
+ size={26}
574
+ {...props}
575
+ />
576
+ {/snippet}
577
+ </DropdownMenu>
578
+ {/if}
579
+ </span>
580
+ </div>
581
+ {/each}
582
+ </div>
583
+ {/if}
584
+
585
+ <p class="hint">{manage ? t('schedules_hint') : t('schedules_readonly')}</p>
21
586
  </SettingsPage>
587
+
588
+ <!-- ------------------------------------------------------------------ editor -->
589
+
590
+ <Dialog
591
+ open={editing !== null}
592
+ size="xl"
593
+ title={isNew ? t('schedule_new') : t('schedule_edit_title')}
594
+ description={t('schedule_edit_desc')}
595
+ onOpenChange={(open) => {
596
+ if (!open) editing = null
597
+ }}
598
+ >
599
+ <div class="form">
600
+ <div class="pair">
601
+ <Field label={t('display_name')} required hint={t('schedule_name_hint')}>
602
+ {#snippet children(id)}
603
+ <Input {id} bind:value={name} placeholder={t('schedule_name_placeholder')} />
604
+ {/snippet}
605
+ </Field>
606
+ {#if isNew}
607
+ <Field label={t('schedule_kind')} hint={t('schedule_kind_hint')}>
608
+ {#snippet children(id)}
609
+ <Select {id} bind:value={kind} options={kindOptions} />
610
+ {/snippet}
611
+ </Field>
612
+ {:else}
613
+ <!-- Not a `Field`: it writes a `<label for>`, and `for` pointing at a paragraph names
614
+ nothing. A value nobody can change is a term and its definition. -->
615
+ <div class="ro">
616
+ <span class="rolabel">{t('schedule_kind')}</span>
617
+ <p class="rovalue">{kindLabel(kind)}</p>
618
+ <p class="note">{t('schedule_immutable_hint')}</p>
619
+ </div>
620
+ {/if}
621
+ </div>
622
+
623
+ <!--
624
+ The week, as a week.
625
+
626
+ Rows are weekdays and columns are the three readings a day is made of, so a four-day week and
627
+ a short Friday are both visible at a glance instead of hidden in a field somebody has to open.
628
+ A day that is off shows one line of text rather than three disabled controls: the switch
629
+ beside it is the reason, and three greyed-out boxes only ask the reader to work that out.
630
+ -->
631
+ <section class="weekblock" aria-labelledby="hr-week-label">
632
+ <div class="weekhead">
633
+ <span class="kern-section-label" id="hr-week-label">{t('schedule_week')}</span>
634
+ <span class="total">{t('schedule_week_total', { hours: duration(weekMinutes(week)) })}</span>
635
+ </div>
636
+
637
+ <div class="grid" role="table" aria-labelledby="hr-week-label">
638
+ <div class="ghead" role="row">
639
+ <span role="columnheader">{t('schedule_day')}</span>
640
+ <span role="columnheader">{t('schedule_start')}</span>
641
+ <span role="columnheader">{t('schedule_end')}</span>
642
+ <span role="columnheader">{t('schedule_break')}</span>
643
+ <span role="columnheader">{t('schedule_net')}</span>
644
+ </div>
645
+
646
+ {#each DAY_KEYS as key, index (key)}
647
+ {@const shift = week[key]}
648
+ <div class="grow" role="row" class:off={shift === null}>
649
+ <span class="gcell day" role="cell">
650
+ <Switch
651
+ size="sm"
652
+ checked={shift !== null}
653
+ onCheckedChange={(on) => setDay(key, on)}
654
+ ariaLabel={dayName(index, 'long')}
655
+ />
656
+ <span class="dayname">{dayName(index, 'long')}</span>
657
+ </span>
658
+
659
+ {#if shift}
660
+ <span class="gcell" role="cell">
661
+ <Input
662
+ size="sm"
663
+ type="time"
664
+ value={shift.start}
665
+ aria-label={`${dayName(index, 'long')} — ${t('schedule_start')}`}
666
+ oninput={(event) => setShift(key, { start: event.currentTarget.value })}
667
+ />
668
+ </span>
669
+ <span class="gcell" role="cell">
670
+ <Input
671
+ size="sm"
672
+ type="time"
673
+ value={shift.end}
674
+ aria-label={`${dayName(index, 'long')} — ${t('schedule_end')}`}
675
+ oninput={(event) => setShift(key, { end: event.currentTarget.value })}
676
+ />
677
+ </span>
678
+ <span class="gcell" role="cell">
679
+ <Select
680
+ size="sm"
681
+ value={String(shift.breakMinutes)}
682
+ options={breakOptions(shift.breakMinutes)}
683
+ placeholder={`${dayName(index, 'long')} — ${t('schedule_break')}`}
684
+ onValueChange={(value) => setShift(key, { breakMinutes: Number(value) })}
685
+ />
686
+ </span>
687
+ <span class="gcell net" role="cell">
688
+ {#if shiftComplete(shift)}
689
+ {duration(netMinutes(shift))}
690
+ {#if crossesMidnight(shift)}
691
+ <span class="overnight" title={t('schedule_overnight')} aria-hidden="true">+1</span>
692
+ <span class="sr-only">{t('schedule_overnight')}</span>
693
+ {/if}
694
+ {:else}
695
+
696
+ {/if}
697
+ </span>
698
+ {:else}
699
+ <span class="gcell dayoff" role="cell">{t('schedule_day_off')}</span>
700
+ {/if}
701
+ </div>
702
+ {/each}
703
+ </div>
704
+
705
+ <div class="weekfoot">
706
+ {#if weekError}
707
+ <p class="err" role="alert">{weekError}</p>
708
+ {:else}
709
+ <p class="note">{t('schedule_break_hint')}</p>
710
+ {/if}
711
+ {#if workingDays.length > 1}
712
+ <Button size="xs" variant="ghost" icon="copy" onclick={copyFirstDay}>
713
+ {t('schedule_copy_first')}
714
+ </Button>
715
+ {/if}
716
+ </div>
717
+ </section>
718
+
719
+ <!-- Time zone -->
720
+ {#if isNew}
721
+ <div class="pair">
722
+ <Field label={t('schedule_tz_mode')} hint={t('schedule_tz_hint')}>
723
+ {#snippet children(id)}
724
+ <Select {id} bind:value={tzMode} options={tzModeOptions} />
725
+ {/snippet}
726
+ </Field>
727
+ {#if tzMode === 'fixed'}
728
+ <Field
729
+ label={t('schedule_tz_label')}
730
+ required
731
+ error={zoneMissing ? t('schedule_tz_required') : null}
732
+ >
733
+ {#snippet children(id)}
734
+ <Select {id} bind:value={tz} options={zoneOptions} placeholder={t('schedule_tz_label')} />
735
+ {/snippet}
736
+ </Field>
737
+ {/if}
738
+ </div>
739
+ {:else}
740
+ <div class="ro">
741
+ <span class="rolabel">{t('schedule_tz_mode')}</span>
742
+ <p class="rovalue">
743
+ {tzModeOptions.find((option) => option.value === tzMode)?.label ?? tzMode}{#if tz}&nbsp;· {tz}{/if}
744
+ </p>
745
+ <p class="note">{t('schedule_immutable_hint')}</p>
746
+ </div>
747
+ {/if}
748
+
749
+ <!-- Grace -->
750
+ <div class="pair">
751
+ <Field label={t('schedule_grace_in')}>
752
+ {#snippet children(id)}
753
+ <Input {id} type="number" min={0} max={240} bind:value={graceIn} />
754
+ {/snippet}
755
+ </Field>
756
+ <Field label={t('schedule_grace_out')}>
757
+ {#snippet children(id)}
758
+ <Input {id} type="number" min={0} max={240} bind:value={graceOut} />
759
+ {/snippet}
760
+ </Field>
761
+ </div>
762
+ <p class="note">{t('schedule_grace_hint')}</p>
763
+
764
+ <!-- Rounding -->
765
+ <div class="pair">
766
+ <Field label={t('schedule_rounding_step')}>
767
+ {#snippet children(id)}
768
+ <Select {id} bind:value={roundingStep} options={roundingStepOptions} />
769
+ {/snippet}
770
+ </Field>
771
+ {#if roundingStep !== '0'}
772
+ <Field label={t('schedule_rounding_direction')}>
773
+ {#snippet children(id)}
774
+ <Select {id} bind:value={roundingDirection} options={roundingDirectionOptions} />
775
+ {/snippet}
776
+ </Field>
777
+ {/if}
778
+ </div>
779
+ {#if roundingStep !== '0'}
780
+ <p class="note">{t('schedule_rounding_hint')}</p>
781
+ {/if}
782
+
783
+ <!-- Automatic clock-out -->
784
+ <Field label={t('schedule_auto_out')} hint={t('schedule_auto_out_hint')}>
785
+ {#snippet children(id)}
786
+ <Select {id} bind:value={autoClockOut} options={autoClockOutOptions} />
787
+ {/snippet}
788
+ </Field>
789
+
790
+ {#if formError}<p class="err" role="alert">{formError}</p>{/if}
791
+ <!-- A disabled control with no reason is a bug, so the reason sits next to the control. -->
792
+ {#if blockedReason}<p class="note blocked">{blockedReason}</p>{/if}
793
+ </div>
794
+
795
+ {#snippet footer()}
796
+ <Button variant="ghost" onclick={() => (editing = null)} disabled={saving}>{t('cancel')}</Button>
797
+ <Button onclick={submit} disabled={!canSave} loading={saving}>
798
+ {isNew ? t('common.create') : t('common.save')}
799
+ </Button>
800
+ {/snippet}
801
+ </Dialog>
802
+
803
+ <!-- ------------------------------------------------------------------ assign -->
804
+
805
+ <Dialog
806
+ open={assigning !== null}
807
+ size="md"
808
+ title={t('schedule_assign_title', { name: assigning?.name ?? '' })}
809
+ description={t('schedule_assign_desc')}
810
+ onOpenChange={(open) => {
811
+ if (!open) assigning = null
812
+ }}
813
+ >
814
+ {#if peopleQuery.isLoading}
815
+ <Skeleton height="120px" />
816
+ {:else if peopleQuery.isError}
817
+ <EmptyState compact icon="triangle-alert" title={t('people_error')}>
818
+ {#snippet actions()}
819
+ <Button size="sm" variant="secondary" onclick={() => void peopleQuery.refetch()}>
820
+ {t('retry')}
821
+ </Button>
822
+ {/snippet}
823
+ </EmptyState>
824
+ {:else if personOptions.length === 0}
825
+ <EmptyState compact icon="user" title={t('no_people')} description={t('no_people_desc')} />
826
+ {:else}
827
+ <div class="form">
828
+ <Field label={t('schedule_assign_person')} required>
829
+ {#snippet children(id)}
830
+ <Select
831
+ {id}
832
+ bind:value={assignPersonId}
833
+ options={personOptions}
834
+ placeholder={t('schedule_assign_person')}
835
+ />
836
+ {/snippet}
837
+ </Field>
838
+ <Field
839
+ label={t('schedule_effective_from')}
840
+ hint={t('schedule_effective_hint')}
841
+ error={assignError}
842
+ >
843
+ {#snippet children(id)}
844
+ <Input {id} type="date" bind:value={assignFrom} />
845
+ {/snippet}
846
+ </Field>
847
+ <!-- Same rule as the editor: the button is off, so it says why. -->
848
+ {#if !assignPersonId}<p class="note">{t('schedule_assign_required')}</p>{/if}
849
+ </div>
850
+ {/if}
851
+
852
+ {#snippet footer()}
853
+ <Button variant="ghost" onclick={() => (assigning = null)} disabled={assignBusy}>
854
+ {t('cancel')}
855
+ </Button>
856
+ <Button
857
+ onclick={submitAssign}
858
+ disabled={!assignPersonId || !assignFrom || personOptions.length === 0}
859
+ loading={assignBusy}
860
+ >
861
+ {t('schedule_assign')}
862
+ </Button>
863
+ {/snippet}
864
+ </Dialog>
865
+
866
+ <!-- ------------------------------------------------------------------ archive -->
867
+
868
+ <Dialog
869
+ open={archiving !== null}
870
+ size="sm"
871
+ title={t('schedule_archive_title', { name: archiving?.name ?? '' })}
872
+ description={t('schedule_archive_body')}
873
+ onOpenChange={(open) => {
874
+ if (!open) archiving = null
875
+ }}
876
+ >
877
+ <p class="note">{t('schedule_archive_people')}</p>
878
+ {#if archiveError}<p class="err" role="alert">{archiveError}</p>{/if}
879
+
880
+ {#snippet footer()}
881
+ <Button variant="secondary" onclick={() => (archiving = null)} disabled={archiveBusy}>
882
+ {t('cancel')}
883
+ </Button>
884
+ <Button variant="danger" onclick={submitArchive} loading={archiveBusy}>{t('common.archive')}</Button>
885
+ {/snippet}
886
+ </Dialog>
887
+
888
+ <style>
889
+ .rows {
890
+ display: grid;
891
+ gap: 4px;
892
+ }
893
+
894
+ /* One grid for the header and every row, so the columns line up down the page. */
895
+ .table {
896
+ --hr-schedule-cols: minmax(150px, 1.3fr) 104px minmax(168px, auto) 84px max-content;
897
+ width: 100%;
898
+ }
899
+ .thead,
900
+ .trow {
901
+ display: grid;
902
+ grid-template-columns: var(--hr-schedule-cols);
903
+ gap: 12px;
904
+ align-items: center;
905
+ padding-inline: 10px;
906
+ }
907
+ .thead {
908
+ height: 34px;
909
+ border-block-end: 1px solid var(--kern-border);
910
+ font-size: 11px;
911
+ font-weight: 600;
912
+ letter-spacing: 0.06em;
913
+ text-transform: uppercase;
914
+ color: var(--kern-ink-500);
915
+ }
916
+ .trow {
917
+ min-height: 52px;
918
+ border-block-end: 1px solid var(--kern-border-hairline);
919
+ border-radius: var(--kern-r-md);
920
+ }
921
+ .trow:hover {
922
+ background: var(--kern-surface-raised);
923
+ }
924
+ .cell {
925
+ min-width: 0;
926
+ overflow: hidden;
927
+ text-overflow: ellipsis;
928
+ white-space: nowrap;
929
+ }
930
+ .title {
931
+ display: flex;
932
+ align-items: center;
933
+ gap: 8px;
934
+ }
935
+ .name {
936
+ min-width: 0;
937
+ overflow: hidden;
938
+ text-overflow: ellipsis;
939
+ font-size: 13.5px;
940
+ font-weight: 500;
941
+ }
942
+ .num {
943
+ font-size: 13px;
944
+ color: var(--kern-ink-500);
945
+ font-variant-numeric: tabular-nums;
946
+ }
947
+ .actions {
948
+ display: flex;
949
+ justify-content: flex-end;
950
+ overflow: visible;
951
+ }
952
+
953
+ /* The week at a glance: worked days filled, days off outlined. */
954
+ .chips {
955
+ display: flex;
956
+ gap: 3px;
957
+ }
958
+ .chip {
959
+ display: inline-flex;
960
+ align-items: center;
961
+ justify-content: center;
962
+ min-width: 26px;
963
+ height: 20px;
964
+ padding-inline: 5px;
965
+ border-radius: var(--kern-r-sm);
966
+ border: 1px solid var(--kern-border);
967
+ background: transparent;
968
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
969
+ color: var(--kern-ink-500);
970
+ font-size: 10.5px;
971
+ font-weight: 600;
972
+ letter-spacing: 0.02em;
973
+ }
974
+ .chip.on {
975
+ border-color: transparent;
976
+ background: var(--kern-accent-tint);
977
+ color: var(--kern-accent-text);
978
+ }
979
+
980
+ .hint {
981
+ margin-block-start: 16px;
982
+ font-size: 12px;
983
+ line-height: 1.55;
984
+ color: var(--kern-ink-500);
985
+ }
986
+ .sr-only {
987
+ position: absolute;
988
+ width: 1px;
989
+ height: 1px;
990
+ overflow: hidden;
991
+ clip-path: inset(50%);
992
+ white-space: nowrap;
993
+ }
994
+
995
+ /* ---- dialogs ---- */
996
+ .form {
997
+ display: grid;
998
+ gap: 14px;
999
+ }
1000
+ .pair {
1001
+ display: grid;
1002
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1003
+ gap: 14px;
1004
+ }
1005
+ .ro {
1006
+ display: flex;
1007
+ flex-direction: column;
1008
+ gap: 4px;
1009
+ min-width: 0;
1010
+ }
1011
+ .rolabel {
1012
+ font-size: 12.5px;
1013
+ font-weight: 500;
1014
+ color: var(--kern-ink-700);
1015
+ }
1016
+ .rovalue {
1017
+ margin: 0;
1018
+ padding-block: 5px;
1019
+ font-size: 13.5px;
1020
+ color: var(--kern-ink-900);
1021
+ }
1022
+ .blocked {
1023
+ color: var(--kern-ink-700);
1024
+ }
1025
+ .note {
1026
+ margin: 0;
1027
+ font-size: 12px;
1028
+ line-height: 1.55;
1029
+ color: var(--kern-ink-500);
1030
+ }
1031
+ .err {
1032
+ margin: 0;
1033
+ font-size: 12px;
1034
+ color: var(--kern-danger);
1035
+ }
1036
+
1037
+ .weekblock {
1038
+ display: grid;
1039
+ gap: 10px;
1040
+ padding: 14px;
1041
+ border: 1px solid var(--kern-border);
1042
+ border-radius: var(--kern-r-2xl);
1043
+ background: var(--kern-surface);
1044
+ }
1045
+ .weekhead {
1046
+ display: flex;
1047
+ align-items: baseline;
1048
+ justify-content: space-between;
1049
+ gap: 12px;
1050
+ }
1051
+ .total {
1052
+ font-size: 13px;
1053
+ font-weight: 500;
1054
+ color: var(--kern-ink-700);
1055
+ font-variant-numeric: tabular-nums;
1056
+ }
1057
+ .grid {
1058
+ display: grid;
1059
+ gap: 2px;
1060
+ }
1061
+ .ghead,
1062
+ .grow {
1063
+ display: grid;
1064
+ grid-template-columns: minmax(132px, 1.1fr) 118px 118px 118px 84px;
1065
+ gap: 8px;
1066
+ align-items: center;
1067
+ }
1068
+ .ghead {
1069
+ height: 26px;
1070
+ font-size: 10.5px;
1071
+ font-weight: 600;
1072
+ letter-spacing: 0.06em;
1073
+ text-transform: uppercase;
1074
+ color: var(--kern-ink-500);
1075
+ }
1076
+ .grow {
1077
+ min-height: 40px;
1078
+ padding-block: 2px;
1079
+ border-block-start: 1px solid var(--kern-border-hairline);
1080
+ }
1081
+ .gcell {
1082
+ min-width: 0;
1083
+ }
1084
+ .day {
1085
+ display: flex;
1086
+ align-items: center;
1087
+ gap: 10px;
1088
+ }
1089
+ .dayname {
1090
+ min-width: 0;
1091
+ overflow: hidden;
1092
+ text-overflow: ellipsis;
1093
+ white-space: nowrap;
1094
+ font-size: 13px;
1095
+ font-weight: 500;
1096
+ }
1097
+ .grow.off .dayname {
1098
+ font-weight: 400;
1099
+ color: var(--kern-ink-500);
1100
+ }
1101
+ .dayoff {
1102
+ grid-column: 2 / -1;
1103
+ font-size: 12.5px;
1104
+ color: var(--kern-ink-500);
1105
+ }
1106
+ .net {
1107
+ display: flex;
1108
+ align-items: baseline;
1109
+ gap: 4px;
1110
+ font-size: 12.5px;
1111
+ color: var(--kern-ink-700);
1112
+ font-variant-numeric: tabular-nums;
1113
+ }
1114
+ .overnight {
1115
+ font-size: 10.5px;
1116
+ font-weight: 600;
1117
+ color: var(--kern-accent-text);
1118
+ }
1119
+ .weekfoot {
1120
+ display: flex;
1121
+ align-items: center;
1122
+ justify-content: space-between;
1123
+ gap: 12px;
1124
+ flex-wrap: wrap;
1125
+ }
1126
+
1127
+ @media (max-width: 760px) {
1128
+ .table {
1129
+ --hr-schedule-cols: minmax(120px, 1.3fr) minmax(168px, auto) 84px max-content;
1130
+ }
1131
+ /* Type is the column a narrow screen can lose: nearly every schedule is a fixed one. */
1132
+ .thead > :nth-child(2),
1133
+ .trow > :nth-child(2) {
1134
+ display: none;
1135
+ }
1136
+ }
1137
+
1138
+ @media (max-width: 620px) {
1139
+ /* Two controls per line rather than five columns squeezed to nothing. The header row goes with
1140
+ them — every control keeps its own accessible name, which is what the header was standing in
1141
+ for. */
1142
+ .ghead {
1143
+ display: none;
1144
+ }
1145
+ .ghead,
1146
+ .grow {
1147
+ grid-template-columns: 1fr 1fr;
1148
+ }
1149
+ .grow {
1150
+ padding-block: 8px;
1151
+ }
1152
+ .day,
1153
+ .net,
1154
+ .dayoff {
1155
+ grid-column: 1 / -1;
1156
+ }
1157
+ }
1158
+ </style>