@kernhq/module-hr 0.13.0 → 0.13.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.
@@ -8,6 +8,7 @@ import {
8
8
  EmptyState,
9
9
  formatCount,
10
10
  formatDate,
11
+ Icon,
11
12
  Input,
12
13
  messageLocale,
13
14
  navigation,
@@ -48,11 +49,101 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
48
49
  const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
49
50
  const workspaceId = $derived(workspace?.id ?? '')
50
51
 
52
+ /**
53
+ * A query parameter is whatever somebody pasted into the address bar, and the contract types both
54
+ * filters as uuids — so a truncated or hand-edited link would be refused by the server and this
55
+ * screen would draw a red error where a filter was meant. Anything that is not a uuid is no filter.
56
+ */
57
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
58
+ const asId = (value: string | undefined): string | null => (value && UUID.test(value) ? value : null)
59
+
51
60
  let search = $state('')
52
- let officeTab = $state('all')
61
+ /**
62
+ * The filter the directory was opened with.
63
+ *
64
+ * Both are links from somewhere else — an office row on the offices screen, "See the people" on a
65
+ * department — and until this read the query string they landed on the unfiltered company
66
+ * directory, which reads as the link being broken rather than as a filter that never applied.
67
+ *
68
+ * Seeded here rather than in the `$effect` below, and that is load-bearing: an effect runs after
69
+ * the first render, so `people.list` would fetch and draw the whole company for a beat before
70
+ * narrowing to the office somebody actually clicked.
71
+ */
72
+ let officeTab = $state(asId(navigation.search.officeId) ?? 'all')
73
+ let orgUnitId = $state<string | null>(asId(navigation.search.orgUnit))
53
74
  const selected = $derived(navigation.search.person)
54
75
  const creating = $derived(navigation.search.new === '1')
55
76
 
77
+ /**
78
+ * The URL keeps seeding the filter after that first render: arriving from the offices screen while
79
+ * the directory is already mounted changes the query string without remounting anything, and the
80
+ * back button is a filter change too. It only ever writes what it does not read, so there is no loop.
81
+ */
82
+ $effect(() => {
83
+ officeTab = asId(navigation.search.officeId) ?? 'all'
84
+ orgUnitId = asId(navigation.search.orgUnit)
85
+ })
86
+
87
+ /**
88
+ * A filter change is written to the URL as well as to state, so the address bar always describes
89
+ * what is on screen: a reload, a shared link and the back button all land on the same directory.
90
+ * `person` and `new` are carried through rather than rebuilt, or switching office would close a
91
+ * panel somebody has open.
92
+ *
93
+ * `replaceState` because a filter is not a place — the way back from a filtered directory is the
94
+ * screen that linked into it, not one history entry per pill.
95
+ */
96
+ function writeFilter(patch: { officeId?: string | null; orgUnit?: string | null }) {
97
+ const params = new URLSearchParams(navigation.search)
98
+ for (const [key, value] of Object.entries(patch)) {
99
+ if (value) params.set(key, value)
100
+ else params.delete(key)
101
+ }
102
+ const query = params.toString()
103
+ navigation.go(`/${workspaceSlug}/hr${query ? `?${query}` : ''}`, {
104
+ replaceState: true,
105
+ keepFocus: true,
106
+ noScroll: true,
107
+ })
108
+ }
109
+
110
+ /** State first, URL second: the shell's router is asynchronous and the pills must not lag a click. */
111
+ function chooseOffice(value: string) {
112
+ officeTab = value
113
+ writeFilter({ officeId: value === 'all' ? null : value })
114
+ }
115
+
116
+ /**
117
+ * Clears the search along with the office and the department.
118
+ *
119
+ * The empty state that offers this button is reached when a filter is on, which includes the case
120
+ * where a *search* inside that filter is what found nobody. Clearing only the filter there leaves
121
+ * the term in the box, so the table can still be empty after the click — a button that visibly does
122
+ * nothing, on the one screen where the reader has already failed to find someone.
123
+ */
124
+ function clearFilters() {
125
+ officeTab = 'all'
126
+ orgUnitId = null
127
+ search = ''
128
+ debounced = ''
129
+ writeFilter({ officeId: null, orgUnit: null })
130
+ }
131
+
132
+ /**
133
+ * A row link carries the filter it was opened from.
134
+ *
135
+ * Now that the filter is in the query string, a bare `?person=…` is also a request to show the
136
+ * whole company — so opening somebody from an office directory would quietly re-fetch and redraw
137
+ * every person in the workspace behind the panel. `new` is dropped rather than kept: two dialogs
138
+ * over one directory is not a state anything should be able to link to.
139
+ */
140
+ function personHref(personId: string): string {
141
+ const params = new URLSearchParams(navigation.search)
142
+ params.delete('new')
143
+ params.set('person', personId)
144
+ return `/${workspaceSlug}/hr?${params.toString()}`
145
+ }
146
+
56
147
  const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
57
148
  /**
58
149
  * Leave is a capability, and a workspace that never switched it on has no balance to show.
@@ -79,6 +170,23 @@ $effect(() => {
79
170
  return () => clearTimeout(handle)
80
171
  })
81
172
 
173
+ /**
174
+ * What the filter asks the server for, shared by the table and the counts above it.
175
+ *
176
+ * `includeDescendants` is passed rather than left to the contract's default: the number on the
177
+ * "See the people" button that links here is the department's *subtree* total, and a directory
178
+ * holding fewer people than the link promised is the same defect one layer down.
179
+ */
180
+ const scopeArgs = $derived({
181
+ ...(officeTab !== 'all' ? { officeId: officeTab } : {}),
182
+ ...(orgUnitId ? { orgUnitId, includeDescendants: true } : {}),
183
+ })
184
+ /**
185
+ * The same filter as a cache key. `'all'` is spelled out rather than left absent, so that no two
186
+ * scopes ever hash alike and the cache cannot answer a filtered directory with the whole company.
187
+ */
188
+ const scopeKey = $derived({ officeId: officeTab, orgUnitId: orgUnitId ?? 'all' })
189
+
82
190
  const officesQuery = createQuery(() => ({
83
191
  queryKey: hrKeys.offices(workspaceId),
84
192
  enabled: Boolean(workspaceId) && showOffices && canHr('officeView'),
@@ -87,24 +195,77 @@ const officesQuery = createQuery(() => ({
87
195
  const offices = $derived(officesQuery.data ?? [])
88
196
 
89
197
  const peopleQuery = createQuery(() => ({
90
- queryKey: hrKeys.people(workspaceId, { q: debounced, officeId: officeTab }),
198
+ queryKey: hrKeys.people(workspaceId, { ...scopeKey, q: debounced }),
91
199
  enabled: Boolean(workspaceId),
92
200
  queryFn: () =>
93
201
  api.people.list({
94
202
  workspaceId,
95
203
  limit: 100,
96
204
  ...(debounced ? { q: debounced } : {}),
97
- ...(officeTab !== 'all' ? { officeId: officeTab } : {}),
205
+ ...scopeArgs,
98
206
  }),
99
207
  }))
100
208
  const people = $derived(peopleQuery.data?.items ?? [])
101
209
 
210
+ /**
211
+ * The tiles count the company, not the page.
212
+ *
213
+ * `people.list` returns a `total` for the whole filter, so both numbers are asked of the server
214
+ * with `limit: 1` rather than counted off the rows on screen. Counting the rows made "On leave"
215
+ * fall as somebody typed a name — the table narrows on every keystroke and stops at a hundred rows
216
+ * either way — which is a tile reporting the search box while wearing the label of a company fact.
217
+ *
218
+ * The search is deliberately not part of these: a tile describes the scope the directory is
219
+ * showing, and finding one person inside it does not change how many of them are away. The filter
220
+ * *is* part of them, so an office's directory shows that office's numbers.
221
+ */
222
+ const headcountQuery = createQuery(() => ({
223
+ queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'headcount' }),
224
+ enabled: Boolean(workspaceId),
225
+ queryFn: () => api.people.list({ workspaceId, limit: 1, ...scopeArgs }),
226
+ }))
227
+
228
+ const onLeaveQuery = createQuery(() => ({
229
+ queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'on_leave' }),
230
+ enabled: Boolean(workspaceId),
231
+ queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['on_leave'], ...scopeArgs }),
232
+ }))
233
+
234
+ /**
235
+ * Only to name the department in the filter chip, so it is asked for only when there is one to
236
+ * name. It shares `hrKeys.orgUnits` with the org chart, which is where "See the people" is clicked
237
+ * — so arriving from there costs no request at all.
238
+ */
239
+ const orgUnitsQuery = createQuery(() => ({
240
+ queryKey: hrKeys.orgUnits(workspaceId),
241
+ enabled: Boolean(workspaceId) && orgUnitId !== null && canHr('orgView'),
242
+ queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
243
+ }))
244
+ const orgUnitName = $derived(orgUnitsQuery.data?.find((u) => u.id === orgUnitId)?.name ?? null)
245
+
102
246
  /** Office tabs only once there is more than one place of work — otherwise they say nothing. */
103
247
  const tabs = $derived([
104
248
  { value: 'all', label: t('title') },
105
249
  ...offices.map((o) => ({ value: o.id, label: o.name })),
106
250
  ])
107
251
 
252
+ /**
253
+ * Whether the office filter has to be said in words.
254
+ *
255
+ * A selected pill already says it, so this is for what the tabs cannot cover: a workspace with one
256
+ * office (no tabs at all), a viewer without `hr.office.view`, and an id that is not in the list any
257
+ * more — an archived office whose link somebody kept. There is no name to give in any of those
258
+ * cases, which is why the chip's wording does not promise one.
259
+ *
260
+ * While the offices are still loading there is nothing to say yet and the table is drawing
261
+ * skeletons, so it waits rather than flashing a sentence a pill is about to replace.
262
+ */
263
+ const officeNeedsChip = $derived(
264
+ officeTab !== 'all' && !officesQuery.isLoading && !tabs.some((tab) => tab.value === officeTab),
265
+ )
266
+ /** Any filter at all — what decides whether an empty table is "nobody yet" or "nobody here". */
267
+ const filtered = $derived(officeTab !== 'all' || orgUnitId !== null)
268
+
108
269
  const balancesQuery = createQuery(() => ({
109
270
  queryKey: hrKeys.leaveBalance(workspaceId, undefined),
110
271
  enabled: Boolean(workspaceId) && showBalance,
@@ -205,9 +366,9 @@ const SUBJECT_LABELS: Record<string, () => string> = {
205
366
  const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
206
367
 
207
368
  const stats = $derived({
208
- headcount: peopleQuery.data?.total ?? people.length,
369
+ headcount: headcountQuery.data?.total ?? null,
209
370
  offices: offices.length,
210
- away: people.filter((p) => p.status === 'on_leave').length,
371
+ away: onLeaveQuery.data?.total ?? null,
211
372
  balance: balancesQuery.data?.[0]?.available ?? 0,
212
373
  })
213
374
 
@@ -267,6 +428,14 @@ const started = (iso: string | null) =>
267
428
 
268
429
  /** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
269
430
  const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
431
+
432
+ /**
433
+ * A count nobody has yet is an em dash, never a zero.
434
+ *
435
+ * "0 on leave" is a fact about the company, and a tile stating it while the request is still in
436
+ * flight — or after it failed — is the same lie the tile was counting rows to tell.
437
+ */
438
+ const tileCount = (n: number | null) => (n === null ? '—' : count(n))
270
439
  </script>
271
440
 
272
441
  <PageHeader
@@ -283,7 +452,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
283
452
 
284
453
  <Page>
285
454
  <div class="tiles">
286
- <StatTile size="md" label={t('widget_headcount_title')} value={count(stats.headcount)} />
455
+ <StatTile size="md" label={t('widget_headcount_title')} value={tileCount(stats.headcount)} />
287
456
  <!--
288
457
  Only where the workspace has offices. It rendered unconditionally and read "Offices 0" on a
289
458
  single-site workspace — a tile counting a feature nobody switched on, sitting beside three
@@ -292,7 +461,11 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
292
461
  {#if showOffices}
293
462
  <StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
294
463
  {/if}
295
- <StatTile size="md" label={t('status_on_leave')} value={count(stats.away)} />
464
+ <!--
465
+ The number comes from the server, and the note says which number it is: "On leave" over a
466
+ figure could as easily mean this week or this month, and it means neither.
467
+ -->
468
+ <StatTile size="md" label={t('status_on_leave')} value={tileCount(stats.away)} note={t('on_leave_note')} />
296
469
  <!-- Same rule for leave: the balance tile is the surface of a capability, so it goes with it. -->
297
470
  {#if showBalance}
298
471
  <StatTile
@@ -310,7 +483,28 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
310
483
 
311
484
  <div class="filters">
312
485
  {#if tabs.length > 1}
313
- <Tabs items={tabs} value={officeTab} variant="pill" onValueChange={(v) => (officeTab = v)} />
486
+ <Tabs items={tabs} value={officeTab} variant="pill" label={t('office')} onValueChange={chooseOffice} />
487
+ {/if}
488
+ <!--
489
+ What the pills cannot say. A directory that opened filtered and says nothing about it is
490
+ indistinguishable from a directory that lost half the company, so anything the tabs are not
491
+ already showing gets a chip — and one control puts every filter back at once.
492
+ -->
493
+ {#if officeNeedsChip || orgUnitId}
494
+ <div class="active">
495
+ {#if officeNeedsChip}
496
+ <span class="chip">
497
+ <Icon name="building" size={12} strokeWidth={1.8} />{t('filter_office_unnamed')}
498
+ </span>
499
+ {/if}
500
+ {#if orgUnitId}
501
+ <span class="chip">
502
+ <Icon name="git-branch" size={12} strokeWidth={1.8} />
503
+ {orgUnitName ? t('filter_department', { name: orgUnitName }) : t('filter_department_unnamed')}
504
+ </span>
505
+ {/if}
506
+ <Button size="xs" variant="ghost" onclick={clearFilters}>{t('filter_clear')}</Button>
507
+ </div>
314
508
  {/if}
315
509
  <div class="search">
316
510
  <Input bind:value={search} placeholder={t('search_people')} type="search" size="sm" />
@@ -339,7 +533,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
339
533
  </div>
340
534
  {#each people as person (person.id)}
341
535
  {@const time = localTime(person.timezone, tick)}
342
- <a class="trow" role="row" href={`/${workspaceSlug}/hr?person=${person.id}`}>
536
+ <a class="trow" role="row" href={personHref(person.id)}>
343
537
  <span class="cell who" role="cell">
344
538
  <Avatar name={person.displayName} id={person.id} size={28} />
345
539
  <span class="stack">
@@ -363,6 +557,19 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
363
557
  <Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
364
558
  {/snippet}
365
559
  </EmptyState>
560
+ <!--
561
+ "Nobody here yet — add the first person" is the right sentence for an empty company and the
562
+ wrong one for an office with nobody in it: it invites somebody to create a second record for
563
+ a person the filter is simply hiding. A filtered miss offers the way out instead.
564
+ -->
565
+ {:else if filtered}
566
+ <EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_filtered')}>
567
+ {#snippet actions()}
568
+ <Button variant="secondary" onclick={clearFilters}>{t('filter_clear')}</Button>
569
+ {/snippet}
570
+ </EmptyState>
571
+ {:else if debounced}
572
+ <EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_search')} />
366
573
  {:else}
367
574
  <EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
368
575
  {/if}
@@ -458,11 +665,34 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
458
665
  display: flex;
459
666
  align-items: center;
460
667
  gap: 12px;
668
+ /* A department name is as long as somebody named it, and Persian and German run longer than the
669
+ English it was laid out in. The row wraps rather than crushing the search box. */
670
+ flex-wrap: wrap;
461
671
  margin-block: 4px 8px;
462
672
  }
463
673
  .search {
464
674
  margin-inline-start: auto;
465
- width: min(260px, 40%);
675
+ inline-size: min(260px, 40%);
676
+ }
677
+ .active {
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 6px;
681
+ flex-wrap: wrap;
682
+ min-inline-size: 0;
683
+ }
684
+ .chip {
685
+ display: inline-flex;
686
+ align-items: center;
687
+ gap: 5px;
688
+ padding-inline: 9px;
689
+ block-size: 24px;
690
+ border-radius: var(--kern-r-full);
691
+ background: var(--kern-surface-chip);
692
+ font-size: 12px;
693
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
694
+ color: var(--kern-ink-700);
695
+ white-space: nowrap;
466
696
  }
467
697
  .rows {
468
698
  display: grid;
@@ -160,6 +160,12 @@ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'gre
160
160
  <span role="columnheader">{t('local_time')}</span>
161
161
  </div>
162
162
  {#each offices as office (office.id)}
163
+ <!--
164
+ `officeId` is read by the directory, which seeds its office filter from it and shows the
165
+ filter it landed with. The parameter name is the whole contract between the two screens:
166
+ renaming it here alone breaks nothing loudly — the link still resolves, and quietly lands
167
+ on the unfiltered company directory, which is exactly how it shipped.
168
+ -->
163
169
  <a
164
170
  class="trow"
165
171
  role="row"
@@ -871,6 +871,13 @@ const tabs = $derived([
871
871
  </div>
872
872
 
873
873
  {#if selected.total > 0}
874
+ <!--
875
+ `orgUnit` is read by the directory, which seeds an org-unit filter from it and asks
876
+ for the whole subtree — which is why the count here is `total` and not `headcount`.
877
+ The parameter name is the whole contract between the two screens: renaming it here
878
+ alone breaks nothing loudly, it just lands on the unfiltered company directory,
879
+ which is exactly how it shipped.
880
+ -->
874
881
  <Button
875
882
  size="sm"
876
883
  variant="secondary"
@@ -270,6 +270,19 @@ function failureText(error: unknown, fallbackKey: string): string {
270
270
 
271
271
  // ---------------------------------------------------------------- the policy form
272
272
 
273
+ /**
274
+ * What an empty form starts a day at, and the only eight hours on this screen.
275
+ *
276
+ * A seed, never a conversion. `accrueForPeriod` multiplies entitlement days by the policy's own
277
+ * `minutesPerDay`, so anything here that turns time into days — or days into time — reads that
278
+ * figure instead of assuming one: five days of a seven-and-a-half-hour day is 2250 minutes, and at
279
+ * a hardcoded 8 × 60 the same five days read as 2400, which is a cap that never bites and a
280
+ * balance nobody can reconcile against the ledger.
281
+ */
282
+ const DEFAULT_DAY_MINUTES = 8 * 60
283
+ /** The contract's own ceiling on `roundToMinutes`. A whole day exceeds it on any day past eight hours. */
284
+ const ROUND_CAP_MINUTES = 480
285
+
273
286
  let policyDialog = $state<'create' | 'edit' | null>(null)
274
287
  let policyId = $state('')
275
288
  let policyName = $state('')
@@ -278,11 +291,21 @@ let policyTo = $state('')
278
291
  let frequency = $state<Frequency>('monthly')
279
292
  let leaveTypeKey = $state('')
280
293
  let daysPerYear = $state('20')
281
- let minutesPerDay = $state('480')
294
+ let minutesPerDay = $state(String(DEFAULT_DAY_MINUTES))
282
295
  let waitingMonths = $state('0')
283
- let roundTo = $state('0')
296
+ /** A token rather than a minute count — see `roundMinutes`. */
297
+ let roundChoice = $state('0')
284
298
  let tiers = $state<Tier[]>([])
285
299
  let policyError = $state<string | null>(null)
300
+ /**
301
+ * Whether the form was seeded from a config the schema could not read.
302
+ *
303
+ * `configOf` answering null means the stored jsonb is not an accrual config — an older release, or
304
+ * a row edited by hand. Every field below then holds a default, and saving writes those defaults
305
+ * over whatever the engine is actually using, so the dialog says so rather than presenting eight
306
+ * hours and twenty days as though they were the policy.
307
+ */
308
+ let seededFromUnreadable = $state(false)
286
309
 
287
310
  function openCreate() {
288
311
  policyDialog = 'create'
@@ -293,23 +316,30 @@ function openCreate() {
293
316
  frequency = 'monthly'
294
317
  leaveTypeKey = leaveTypes[0]?.key ?? ''
295
318
  daysPerYear = '20'
296
- minutesPerDay = '480'
319
+ minutesPerDay = String(DEFAULT_DAY_MINUTES)
297
320
  waitingMonths = '0'
298
- roundTo = '0'
321
+ roundChoice = '0'
299
322
  tiers = []
300
323
  policyError = null
324
+ seededFromUnreadable = false
301
325
  }
302
326
 
303
327
  function fillFrom(policy: PolicyRow) {
304
328
  const config = configOf(policy)
329
+ seededFromUnreadable = config === null
305
330
  policyName = policy.name
306
331
  policyTo = policy.effectiveTo ?? ''
307
332
  frequency = config?.frequency ?? 'monthly'
308
333
  leaveTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
309
334
  daysPerYear = String(config?.daysPerYear ?? 20)
310
- minutesPerDay = String(config?.minutesPerDay ?? 480)
335
+ const day = config?.minutesPerDay ?? DEFAULT_DAY_MINUTES
336
+ minutesPerDay = String(day)
311
337
  waitingMonths = String(config?.waitingPeriodMonths ?? 0)
312
- roundTo = String(config?.roundToMinutes ?? 0)
338
+ // Back to what the step *meant* under the day it was saved with. Read as a bare number, a policy
339
+ // rounding to a whole 450-minute day would reopen as "450 minutes" and stop following the day the
340
+ // moment somebody lengthened it.
341
+ const step = config?.roundToMinutes ?? 0
342
+ roundChoice = step === 0 ? '0' : step === day ? 'day' : step === Math.round(day / 2) ? 'half' : String(step)
313
343
  tiers = (config?.seniorityTiers ?? []).map((tier) => ({
314
344
  afterYears: String(tier.afterYears),
315
345
  daysPerYear: String(tier.daysPerYear),
@@ -346,33 +376,56 @@ const clampedDays = (value: string, min: number, max: number) => {
346
376
  }
347
377
 
348
378
  /**
349
- * Rounding offered as the steps somebody actually means, half and whole days included those are
350
- * derived from the day length in the same form, because "half a day" is 210 minutes on a
351
- * seven-hour day. The contract caps a step at 480 minutes, so a long day's whole-day option is
352
- * dropped rather than offered and refused.
379
+ * The day this policy is being given, and the figure everything below converts against.
380
+ *
381
+ * The same number the server stores as `minutesPerDay` and `accrueForPeriod` multiplies by, so a
382
+ * length shown here is the length leave is actually earned in.
383
+ */
384
+ const dayMinutes = $derived(clamped(minutesPerDay, 1, 1440))
385
+
386
+ /**
387
+ * Rounding held as what the admin meant, resolved against the day beside it.
388
+ *
389
+ * "A whole day" is 450 minutes on a seven-and-a-half-hour day and 480 on an eight-hour one. Held as
390
+ * a bare number the step stops meaning a day the moment the day length changes — silently, because
391
+ * the select goes on reading "A whole day" while the engine rounds to something else. So the choice
392
+ * is a token and the minutes follow from it.
393
+ */
394
+ const roundMinutes = $derived(
395
+ roundChoice === 'day'
396
+ ? dayMinutes
397
+ : roundChoice === 'half'
398
+ ? Math.round(dayMinutes / 2)
399
+ : Math.max(0, Math.round(Number(roundChoice) || 0)),
400
+ )
401
+
402
+ /**
403
+ * Rounding offered as the steps somebody actually means, half and whole days included.
404
+ *
405
+ * The two day steps are kept beside the fixed ones even where they land on the same number, because
406
+ * they are a different choice: one follows the day length, the other stays where it was typed. A
407
+ * whole day past the contract's cap is offered and refused by `formProblem` with the reason next to
408
+ * the button, rather than dropped from the list with nothing to explain the gap.
353
409
  */
354
410
  const roundOptions = $derived.by(() => {
355
- const dayMinutes = clamped(minutesPerDay, 1, 1440)
356
- const steps = [
357
- { minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
358
- { minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
359
- { minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
360
- { minutes: Math.round(dayMinutes / 2), label: t('accr_round_half_day') },
361
- { minutes: dayMinutes, label: t('accr_round_day') },
362
- ].filter((step) => step.minutes > 0 && step.minutes <= 480)
363
-
364
- const seen = new Set<number>()
365
- const options = [{ value: '0', label: t('accr_round_exact') }]
366
- for (const step of steps) {
367
- if (seen.has(step.minutes)) continue
368
- seen.add(step.minutes)
369
- options.push({ value: String(step.minutes), label: step.label })
370
- }
411
+ const half = Math.round(dayMinutes / 2)
412
+ const options = [
413
+ { value: '0', minutes: 0, label: t('accr_round_exact') },
414
+ { value: '15', minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
415
+ { value: '30', minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
416
+ { value: '60', minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
417
+ { value: 'half', minutes: half, label: t('accr_round_half_day', { length: duration(half) }) },
418
+ { value: 'day', minutes: dayMinutes, label: t('accr_round_day', { length: duration(dayMinutes) }) },
419
+ ]
371
420
  // A stored step none of these produce would otherwise vanish the moment somebody opened the
372
421
  // policy to change its name.
373
- if (!options.some((option) => option.value === roundTo))
374
- options.push({ value: roundTo, label: t('accr_round_minutes', { count: Number(roundTo) || 0 }) })
375
- return options.sort((a, b) => Number(a.value) - Number(b.value))
422
+ if (!options.some((option) => option.value === roundChoice))
423
+ options.push({
424
+ value: roundChoice,
425
+ minutes: roundMinutes,
426
+ label: t('accr_round_minutes', { count: roundMinutes }),
427
+ })
428
+ return options.sort((a, b) => a.minutes - b.minutes).map(({ value, label }) => ({ value, label }))
376
429
  })
377
430
 
378
431
  const tierYears = $derived(tiers.map((tier) => clamped(tier.afterYears, 0, 60)))
@@ -384,19 +437,23 @@ const formProblem = $derived.by(() => {
384
437
  if (!policyFrom) return t('accr_error_from')
385
438
  if (policyTo && policyTo < policyFrom) return t('accr_error_to_before_from')
386
439
  if (tiersClash) return t('accr_error_tier_clash')
440
+ // Said rather than clamped: a step quietly cut back to eight hours is a policy rounding to
441
+ // something other than the day it names.
442
+ if (roundMinutes > ROUND_CAP_MINUTES) return t('accr_error_round_cap', { max: duration(ROUND_CAP_MINUTES) })
387
443
  return null
388
444
  })
389
445
 
390
446
  const configDraft = $derived({
391
447
  frequency,
392
448
  daysPerYear: clampedDays(daysPerYear, 0, 365),
393
- minutesPerDay: clamped(minutesPerDay, 1, 1440),
449
+ minutesPerDay: dayMinutes,
394
450
  seniorityTiers: tiers.map((tier) => ({
395
451
  afterYears: clamped(tier.afterYears, 0, 60),
396
452
  daysPerYear: clampedDays(tier.daysPerYear, 0, 365),
397
453
  })),
398
454
  waitingPeriodMonths: clamped(waitingMonths, 0, 24),
399
- roundToMinutes: clamped(roundTo, 0, 480),
455
+ // Save is blocked above the cap, so this never silently shortens a step somebody chose.
456
+ roundToMinutes: Math.min(roundMinutes, ROUND_CAP_MINUTES),
400
457
  leaveTypeKey,
401
458
  })
402
459
 
@@ -815,7 +872,11 @@ const HEAD = 10
815
872
  {#if config}
816
873
  <span class="num">{t('accr_days_per_year', { count: config.daysPerYear })}</span>
817
874
  <span class="sub">
818
- {leaveTypeName(config.leaveTypeKey)} · {duration(config.minutesPerDay)}
875
+ <!-- The day length is named rather than left as a bare duration beside a leave
876
+ type, because it is the number those days per year are earned in. -->
877
+ {leaveTypeName(config.leaveTypeKey)} · {t('accr_day_is', {
878
+ length: duration(config.minutesPerDay),
879
+ })}
819
880
  {#if config.seniorityTiers.length > 0}
820
881
  · {t('accr_tiers_count', { count: config.seniorityTiers.length })}
821
882
  {/if}
@@ -1003,6 +1064,13 @@ const HEAD = 10
1003
1064
  <p class="note">{t('accr_edit_retroactive')}</p>
1004
1065
  {/if}
1005
1066
 
1067
+ {#if seededFromUnreadable}
1068
+ <!-- Every field below is a default, the day length included — and the day length is what the
1069
+ engine multiplies entitlement days by. Saying so is the difference between repairing a
1070
+ broken row and overwriting a working one with eight hours and twenty days. -->
1071
+ <p class="note warn">{t('accr_config_unreadable_form')}</p>
1072
+ {/if}
1073
+
1006
1074
  <Field label={t('accr_name')} hint={t('accr_name_hint')} required>
1007
1075
  {#snippet children(id)}
1008
1076
  <Input {id} bind:value={policyName} maxlength={120} />
@@ -1054,10 +1122,7 @@ const HEAD = 10
1054
1122
  <Input {id} type="number" min={0} max={365} step={0.5} bind:value={daysPerYear} />
1055
1123
  {/snippet}
1056
1124
  </Field>
1057
- <Field
1058
- label={t('accr_day_length')}
1059
- hint={t('accr_day_length_hint', { length: duration(clamped(minutesPerDay, 1, 1440)) })}
1060
- >
1125
+ <Field label={t('accr_day_length')} hint={t('accr_day_length_hint', { length: duration(dayMinutes) })}>
1061
1126
  {#snippet children(id)}
1062
1127
  <Input {id} type="number" min={1} max={1440} bind:value={minutesPerDay} />
1063
1128
  {/snippet}
@@ -1072,7 +1137,12 @@ const HEAD = 10
1072
1137
  </Field>
1073
1138
  <Field label={t('accr_rounding')} hint={t('accr_rounding_hint')}>
1074
1139
  {#snippet children(id)}
1075
- <Select {id} value={roundTo} onValueChange={(v) => (roundTo = v)} options={roundOptions} />
1140
+ <Select
1141
+ {id}
1142
+ value={roundChoice}
1143
+ onValueChange={(v) => (roundChoice = v)}
1144
+ options={roundOptions}
1145
+ />
1076
1146
  {/snippet}
1077
1147
  </Field>
1078
1148
  </div>
@@ -1388,7 +1458,18 @@ const HEAD = 10
1388
1458
  {:else if preview}
1389
1459
  <div class="tiles">
1390
1460
  <StatTile size="md" label={t('accr_run_people')} value={formatCount(creditable.length, 999)} />
1391
- <StatTile size="md" label={t('accr_run_total')} value={duration(preview.totalMinutes)} />
1461
+ <!--
1462
+ In time, not days, and deliberately so: one run covers everybody, and the people in it may
1463
+ sit on policies whose days are different lengths, so there is no single day this sum could
1464
+ honestly be divided by. Each row below *is* in days, because the server divided it by that
1465
+ person's own policy. The unit is on the tile rather than left to be inferred.
1466
+ -->
1467
+ <StatTile
1468
+ size="md"
1469
+ label={t('accr_run_total')}
1470
+ value={duration(preview.totalMinutes)}
1471
+ note={t('accr_run_total_note')}
1472
+ />
1392
1473
  <StatTile size="md" label={t('accr_run_skipped')} value={formatCount(preview.skipped.length, 999)} />
1393
1474
  </div>
1394
1475