@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.
- package/dist/contract/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clock, read as data.
|
|
3
|
+
*
|
|
4
|
+
* This package has no component renderer — `@testing-library/svelte` and a Svelte transform are not
|
|
5
|
+
* dependencies here, and adding them to reach one component is a dependency and a lockfile refresh
|
|
6
|
+
* for very little (`messages.test.ts` makes the same trade for `t()`). So this parses the component
|
|
7
|
+
* with the compiler that ships with Svelte and drives the three pieces of it that decide what a
|
|
8
|
+
* person sees: the `{#if}` chains, the mutation's `onError`, and the map from a refusal's reason to
|
|
9
|
+
* a string. All three are *the file's own source*, evaluated — not a copy of it restated here, which
|
|
10
|
+
* is also true of the reason codes: those are read out of the router rather than listed again.
|
|
11
|
+
*
|
|
12
|
+
* What it cannot see: layout, colour and anything CSS decides. Those are checked by eye and by
|
|
13
|
+
* `shell`'s `ux.spec.ts`.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { parse } from 'svelte/compiler'
|
|
18
|
+
import ts from 'typescript'
|
|
19
|
+
import { describe, expect, it } from 'vitest'
|
|
20
|
+
import { ar, de, en, fa, tr } from '../messages.js'
|
|
21
|
+
import { hrKeys } from '../query.js'
|
|
22
|
+
|
|
23
|
+
const source = readFileSync(fileURLToPath(new URL('./ClockControls.svelte', import.meta.url)), 'utf8')
|
|
24
|
+
const ast = parse(source, { modern: true })
|
|
25
|
+
|
|
26
|
+
interface Node {
|
|
27
|
+
type: string
|
|
28
|
+
start: number
|
|
29
|
+
end: number
|
|
30
|
+
[key: string]: unknown
|
|
31
|
+
}
|
|
32
|
+
const slice = (node: Node) => source.slice(node.start, node.end)
|
|
33
|
+
|
|
34
|
+
/** A fragment carries no offsets of its own, so its span is its first node to its last. */
|
|
35
|
+
function fragmentSource(fragment: { nodes: Node[] }): string {
|
|
36
|
+
const nodes = fragment.nodes
|
|
37
|
+
return source.slice(nodes[0]!.start, nodes[nodes.length - 1]!.end)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The top-level `{#if}…{:else if}…{:else}` chain, in the order the renderer tries it. */
|
|
41
|
+
function branches(): Array<{ test: string | null; body: string }> {
|
|
42
|
+
const out: Array<{ test: string | null; body: string }> = []
|
|
43
|
+
let block = ast.fragment.nodes.find((n) => n.type === 'IfBlock') as
|
|
44
|
+
| (Node & { test: Node; consequent: { nodes: Node[] }; alternate?: { nodes: Node[] } })
|
|
45
|
+
| undefined
|
|
46
|
+
while (block) {
|
|
47
|
+
out.push({ test: slice(block.test), body: fragmentSource(block.consequent) })
|
|
48
|
+
const alternate = block.alternate
|
|
49
|
+
if (!alternate) break
|
|
50
|
+
const elseif = alternate.nodes.find((n) => n.type === 'IfBlock' && n.elseif)
|
|
51
|
+
if (elseif) {
|
|
52
|
+
block = elseif as never
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
out.push({ test: null, body: fragmentSource(alternate) })
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface QueryState {
|
|
62
|
+
isLoading: boolean
|
|
63
|
+
isError: boolean
|
|
64
|
+
data?: { clockedIn: boolean; onBreak: boolean; since: string | null; workedMinutesToday: number }
|
|
65
|
+
}
|
|
66
|
+
type Scope = Record<string, unknown>
|
|
67
|
+
|
|
68
|
+
/** Every `{#if}` in the markup, innermost included, in source order. */
|
|
69
|
+
const allIfBlocks = (): Node[] => {
|
|
70
|
+
const found: Node[] = []
|
|
71
|
+
const walk = (node: unknown) => {
|
|
72
|
+
if (!node || typeof node !== 'object') return
|
|
73
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
74
|
+
const record = node as Record<string, unknown>
|
|
75
|
+
if (record.type === 'IfBlock') found.push(record as Node)
|
|
76
|
+
for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
|
|
77
|
+
}
|
|
78
|
+
walk(ast.fragment)
|
|
79
|
+
return found.sort((a, b) => a.start - b.start)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolve one `{#if}` chain against the scope and render whichever side it takes. */
|
|
83
|
+
function resolveIf(block: Node, scope: Scope): string {
|
|
84
|
+
const holds = new Function(...Object.keys(scope), `return (${slice(block.test as Node)})`)(
|
|
85
|
+
...Object.values(scope),
|
|
86
|
+
)
|
|
87
|
+
const side = holds ? (block.consequent as { nodes: Node[] }) : (block.alternate as { nodes: Node[] } | null)
|
|
88
|
+
if (!side || side.nodes.length === 0) return ''
|
|
89
|
+
const nodes = side.nodes
|
|
90
|
+
return resolve(nodes[0]!.start, nodes[nodes.length - 1]!.end, scope)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The markup between two offsets with every `{#if}` inside it taken or dropped.
|
|
95
|
+
*
|
|
96
|
+
* The nested blocks are the point. Reading a branch's source *text* meant the running total and the
|
|
97
|
+
* "may be stale" notice were both in it whatever the query was doing, and so were the clock-in and
|
|
98
|
+
* clock-out buttons — so an assertion for a button passed against a component that never draws it.
|
|
99
|
+
*/
|
|
100
|
+
function resolve(from: number, to: number, scope: Scope): string {
|
|
101
|
+
const inside = allIfBlocks().filter((b) => b.start >= from && b.end <= to)
|
|
102
|
+
// Outermost first: a nested block is rendered by the recursion into the side that survives, not
|
|
103
|
+
// by this loop, or it would be spliced in twice.
|
|
104
|
+
const blocks = inside.filter((b) => !inside.some((o) => o !== b && o.start <= b.start && o.end >= b.end))
|
|
105
|
+
let out = ''
|
|
106
|
+
let cursor = from
|
|
107
|
+
for (const block of blocks) {
|
|
108
|
+
out += source.slice(cursor, block.start)
|
|
109
|
+
out += resolveIf(block, scope)
|
|
110
|
+
cursor = block.end
|
|
111
|
+
}
|
|
112
|
+
return out + source.slice(cursor, to)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What the renderer would draw for that query state — `clock` is `$derived(stateQuery.data)`, and
|
|
117
|
+
* `since` is `$derived` from it the same way the component derives it.
|
|
118
|
+
*
|
|
119
|
+
* Comments are stripped: a branch that *explains* why it does not use `EmptyState` would otherwise
|
|
120
|
+
* satisfy an assertion about what it renders.
|
|
121
|
+
*/
|
|
122
|
+
function rendered(stateQuery: QueryState): string {
|
|
123
|
+
const clock = stateQuery.data
|
|
124
|
+
const since = clock?.since
|
|
125
|
+
? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date(clock.since))
|
|
126
|
+
: null
|
|
127
|
+
const scope: Scope = { stateQuery, clock, since, punching: false }
|
|
128
|
+
const top = ast.fragment.nodes.find((n) => n.type === 'IfBlock')
|
|
129
|
+
if (!top) throw new Error('the markup is no longer one {#if} chain')
|
|
130
|
+
return resolveIf(top as unknown as Node, scope).replace(/<!--[\s\S]*?-->/g, '')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const working = { clockedIn: true, onBreak: false, since: '2026-08-26T09:00:00Z', workedMinutesToday: 200 }
|
|
134
|
+
|
|
135
|
+
/** Find one property by name anywhere in the instance script, and hand back its source. */
|
|
136
|
+
function property(name: string): string {
|
|
137
|
+
const found: Node[] = []
|
|
138
|
+
const walk = (node: unknown) => {
|
|
139
|
+
if (!node || typeof node !== 'object') return
|
|
140
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
141
|
+
const record = node as Record<string, unknown>
|
|
142
|
+
if (record.type === 'Property' && (record.key as { name?: string })?.name === name)
|
|
143
|
+
found.push(record.value as Node)
|
|
144
|
+
for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
|
|
145
|
+
}
|
|
146
|
+
walk(ast.instance?.content)
|
|
147
|
+
expect(found).toHaveLength(1)
|
|
148
|
+
return slice(found[0]!)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Find one top-level `const` by name in the instance script, and hand back the whole declaration. */
|
|
152
|
+
function declaration(name: string): string {
|
|
153
|
+
const found: Node[] = []
|
|
154
|
+
const walk = (node: unknown) => {
|
|
155
|
+
if (!node || typeof node !== 'object') return
|
|
156
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
157
|
+
const record = node as Record<string, unknown>
|
|
158
|
+
if (record.type === 'VariableDeclarator' && (record.id as { name?: string })?.name === name)
|
|
159
|
+
found.push(record.init as Node)
|
|
160
|
+
for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
|
|
161
|
+
}
|
|
162
|
+
walk(ast.instance?.content)
|
|
163
|
+
expect(found).toHaveLength(1)
|
|
164
|
+
return `const ${name} = ${slice(found[0]!)}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The component's own reason → message-key map, evaluated. */
|
|
168
|
+
function refusalMap(): Record<string, string> {
|
|
169
|
+
const js = ts.transpileModule(declaration('punchRefusalMessages'), {
|
|
170
|
+
compilerOptions: { target: ts.ScriptTarget.ESNext },
|
|
171
|
+
}).outputText
|
|
172
|
+
return new Function(`${js}; return punchRefusalMessages`)()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface Recorded {
|
|
176
|
+
toasts: string[]
|
|
177
|
+
invalidated: unknown[][]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `t()` as the runtime resolves it: this module's prefix on a bare key, and — the half that decides
|
|
182
|
+
* what a refused punch says — the key itself when nothing defines a string for it. `t()` is tested
|
|
183
|
+
* where it lives (`packages/ui/src/lib/i18n.test.ts`); this is the two lines of it `onError` reads.
|
|
184
|
+
*/
|
|
185
|
+
const translator = (strings: Record<string, string>) => (key: string) => {
|
|
186
|
+
const full = key.includes('.') ? key : `hr.${key}`
|
|
187
|
+
return strings[full] ?? full
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A bundle standing in for the merged one, so these tests say what a *reader* is shown rather than
|
|
192
|
+
* which key was picked. The values are markers on purpose: an assertion that named the real English
|
|
193
|
+
* sentence could not tell a translation apart from the server's fallback, which is the whole
|
|
194
|
+
* distinction under test.
|
|
195
|
+
*/
|
|
196
|
+
const strings: Record<string, string> = {
|
|
197
|
+
'hr.clock_punch_error': '[fallback]',
|
|
198
|
+
'hr.clock_refused_already_in': '[translated: already clocked in]',
|
|
199
|
+
'hr.clock_refused_not_in': '[translated: not clocked in]',
|
|
200
|
+
'hr.clock_refused_break_needs_in': '[translated: clock in first]',
|
|
201
|
+
'hr.clock_refused_already_on_break': '[translated: already on a break]',
|
|
202
|
+
'hr.clock_refused_not_on_break': '[translated: not on a break]',
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The real `onError`, with types stripped and every free name it uses handed to it. */
|
|
206
|
+
function runOnError(error: unknown, bundle: Record<string, string> = strings): Recorded {
|
|
207
|
+
const js = ts.transpileModule(
|
|
208
|
+
`${declaration('punchRefusalMessages')}\nconst handler = ${property('onError')}`,
|
|
209
|
+
{ compilerOptions: { target: ts.ScriptTarget.ESNext } },
|
|
210
|
+
).outputText
|
|
211
|
+
const recorded: Recorded = { toasts: [], invalidated: [] }
|
|
212
|
+
const handler = new Function('toast', 't', 'queryClient', 'hrKeys', 'workspaceId', `${js}; return handler`)(
|
|
213
|
+
{ error: (message: string) => recorded.toasts.push(message) },
|
|
214
|
+
translator(bundle),
|
|
215
|
+
{ invalidateQueries: ({ queryKey }: { queryKey: unknown[] }) => recorded.invalidated.push(queryKey) },
|
|
216
|
+
hrKeys,
|
|
217
|
+
'ws',
|
|
218
|
+
)
|
|
219
|
+
handler(error)
|
|
220
|
+
return recorded
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* oRPC hands the client a `KernError` re-thrown as an `ORPCError`: an `Error` with a `code` and,
|
|
225
|
+
* for a refusal, the `details` the router attached — `kernErrorToORPC` carries those across as
|
|
226
|
+
* `data`.
|
|
227
|
+
*/
|
|
228
|
+
const refusal = (message: string, reason?: string) =>
|
|
229
|
+
Object.assign(new Error(message), { code: 'CONFLICT', data: reason ? { reason } : undefined })
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Whether invalidating `key` re-reads `target`.
|
|
233
|
+
*
|
|
234
|
+
* TanStack matches by prefix, so a shorter key covers a longer one — but the empty key is not a
|
|
235
|
+
* prefix anybody meant. `[]` matches every query in the app, and `[].every(…)` is true, so an
|
|
236
|
+
* assertion without this guard passed against a component that invalidated nothing in particular.
|
|
237
|
+
*/
|
|
238
|
+
const covers = (key: unknown[], target: readonly unknown[]) =>
|
|
239
|
+
key.length > 0 && key.length <= target.length && key.every((part, index) => part === target[index])
|
|
240
|
+
|
|
241
|
+
describe('what the clock draws', () => {
|
|
242
|
+
it('derives the clock straight from the query, which is what the harness assumes', () => {
|
|
243
|
+
expect(source).toContain('const clock = $derived(stateQuery.data)')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The defect this file was written for. A poll every 60 seconds means a core restart puts the
|
|
248
|
+
* query in `error` while it still holds a good clock — and an error branch that outranks the data
|
|
249
|
+
* took the clock-out button off the screen of somebody mid-shift.
|
|
250
|
+
*/
|
|
251
|
+
it('keeps the punch buttons when a background poll fails and the clock is still held', () => {
|
|
252
|
+
const body = rendered({ isLoading: false, isError: true, data: working })
|
|
253
|
+
expect(body).toContain("punch('out')")
|
|
254
|
+
// Only the legal transitions, which is also what proves the assertion above is reading a
|
|
255
|
+
// *rendered* clock rather than the source of every branch at once.
|
|
256
|
+
expect(body).toContain("punch('break_start')")
|
|
257
|
+
expect(body).not.toContain("punch('in')")
|
|
258
|
+
expect(body).not.toContain("punch('break_end')")
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('offers the break it is in, not the one it is not', () => {
|
|
262
|
+
const body = rendered({ isLoading: false, isError: false, data: { ...working, onBreak: true } })
|
|
263
|
+
expect(body).toContain("punch('break_end')")
|
|
264
|
+
expect(body).not.toContain("punch('break_start')")
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('offers only clocking in when the day has not started', () => {
|
|
268
|
+
const body = rendered({
|
|
269
|
+
isLoading: false,
|
|
270
|
+
isError: false,
|
|
271
|
+
data: { clockedIn: false, onBreak: false, since: null, workedMinutesToday: 0 },
|
|
272
|
+
})
|
|
273
|
+
expect(body).toContain("punch('in')")
|
|
274
|
+
expect(body).not.toContain("punch('out')")
|
|
275
|
+
expect(body).toContain("t('not_clocked_in')")
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Naming the string, not the condition: the branch body *starts* with `stateQuery.isError`, so an
|
|
280
|
+
* assertion for that text held whatever the branch went on to draw — including the running total
|
|
281
|
+
* it is there to replace.
|
|
282
|
+
*/
|
|
283
|
+
it('says the held clock may be stale rather than pretending the poll succeeded', () => {
|
|
284
|
+
const body = rendered({ isLoading: false, isError: true, data: working })
|
|
285
|
+
expect(body).toContain("t('clock_stale')")
|
|
286
|
+
expect(body).not.toContain("t('worked_today')")
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('shows the running total when the poll is succeeding', () => {
|
|
290
|
+
const body = rendered({ isLoading: false, isError: false, data: working })
|
|
291
|
+
expect(body).toContain("t('worked_today')")
|
|
292
|
+
expect(body).not.toContain("t('clock_stale')")
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('gives the whole frame to the error only when there is nothing to show', () => {
|
|
296
|
+
const body = rendered({ isLoading: false, isError: true, data: undefined })
|
|
297
|
+
expect(body).toContain('retry')
|
|
298
|
+
// There is no clock to punch against, so offering a punch button here would be a control that
|
|
299
|
+
// cannot work. This is also what stops the assertion above passing against the clock itself.
|
|
300
|
+
expect(body).not.toContain('punch(')
|
|
301
|
+
// The clock widget's body is about 44px. `EmptyState` is more than twice that, so its retry
|
|
302
|
+
// button sits below the fold in the one place the clock is most often read. Matching the tag
|
|
303
|
+
// rather than the word: the branch's comment says why it is not used, and that is not a use.
|
|
304
|
+
expect(body).not.toContain('<EmptyState')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The last branch draws a skeleton too — for the frame before a workspace arrives — so "renders a
|
|
309
|
+
* skeleton for a loading query" was true of a component with no loading branch at all. What the
|
|
310
|
+
* test is named for is the *order*: loading is asked first, before anything else can answer.
|
|
311
|
+
*/
|
|
312
|
+
it('draws a skeleton while the first fetch is in flight', () => {
|
|
313
|
+
expect(branches()[0]!.test).toBe('stateQuery.isLoading')
|
|
314
|
+
expect(rendered({ isLoading: true, isError: false })).toContain('Skeleton')
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
describe('a refused punch', () => {
|
|
319
|
+
/**
|
|
320
|
+
* The defect this block was rewritten for. `punch()` writes its refusals for a person — "You are
|
|
321
|
+
* not clocked in." — and the widget used to repeat them verbatim, so a Persian, Arabic, Turkish or
|
|
322
|
+
* German reader met English at the one moment they needed the explanation. The router now sends a
|
|
323
|
+
* reason beside the sentence, and the reason is what this file translates.
|
|
324
|
+
*/
|
|
325
|
+
it('translates the reason the server sent rather than repeating its English', () => {
|
|
326
|
+
const { toasts } = runOnError(refusal('You are not clocked in.', 'hr.clock.not_clocked_in'))
|
|
327
|
+
expect(toasts).toEqual(['[translated: not clocked in]'])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('translates every reason the router can refuse a punch with', () => {
|
|
331
|
+
for (const [reason, key] of Object.entries(refusalMap())) {
|
|
332
|
+
expect({ reason, toasts: runOnError(refusal('English sentence.', reason)).toasts }).toEqual({
|
|
333
|
+
reason,
|
|
334
|
+
toasts: [strings[key]],
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The fallback, and why it stays: a sixth refusal added to `punch()` reaches the reader in the
|
|
341
|
+
* router's English rather than not at all, without anyone editing this file first.
|
|
342
|
+
*/
|
|
343
|
+
it('shows the server’s sentence for a reason it has no string for', () => {
|
|
344
|
+
const { toasts } = runOnError(refusal('You are barred from the building.', 'hr.clock.barred'))
|
|
345
|
+
expect(toasts).toEqual(['You are barred from the building.'])
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The same fallback one step further in: the reason is known and the *string* is not, which is
|
|
350
|
+
* what a locale merged half-way looks like. `t()` answers a key it cannot resolve with the key,
|
|
351
|
+
* so without this the toast reads `hr.clock_refused_not_in`.
|
|
352
|
+
*/
|
|
353
|
+
it('shows the server’s sentence rather than a message key nobody merged', () => {
|
|
354
|
+
const { toasts } = runOnError(refusal('You are not clocked in.', 'hr.clock.not_clocked_in'), {
|
|
355
|
+
'hr.clock_punch_error': '[fallback]',
|
|
356
|
+
})
|
|
357
|
+
expect(toasts).toEqual(['You are not clocked in.'])
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
/** An older server, or a refusal from anywhere else on this path: still a sentence, still shown. */
|
|
361
|
+
it('shows the server’s sentence for a refusal that carries no reason at all', () => {
|
|
362
|
+
expect(runOnError(refusal('You are not clocked in.')).toasts).toEqual(['You are not clocked in.'])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Everything that is not a refusal. A network drop, a 500 and a gateway all carry machine text in
|
|
367
|
+
* English, and a clock is the last place to paste one — so the reason is never read off them, and
|
|
368
|
+
* a `CONFLICT` is never assumed from the sentence.
|
|
369
|
+
*/
|
|
370
|
+
it('falls back to the module’s own string for a failure nobody wrote for a reader', () => {
|
|
371
|
+
expect(runOnError(new Error('Failed to fetch')).toasts).toEqual(['[fallback]'])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('ignores a reason on anything that is not a refusal', () => {
|
|
375
|
+
const notRefused = Object.assign(new Error('upstream connect error'), {
|
|
376
|
+
code: 'INTERNAL',
|
|
377
|
+
data: { reason: 'hr.clock.not_clocked_in' },
|
|
378
|
+
})
|
|
379
|
+
expect(runOnError(notRefused).toasts).toEqual(['[fallback]'])
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The commonest reason a punch is refused is that the clock on screen is stale — the auto
|
|
384
|
+
* clock-out sweep closed the shift, or a punch arrived from another device. Leaving the same wrong
|
|
385
|
+
* clock up means every retry earns the same toast and nothing ever corrects it.
|
|
386
|
+
*/
|
|
387
|
+
it('re-reads the clock and the day sheet, because the refusal means what is on screen is wrong', () => {
|
|
388
|
+
const { invalidated } = runOnError(refusal('You are already clocked in.', 'hr.clock.already_clocked_in'))
|
|
389
|
+
expect(invalidated.some((key) => covers(key, hrKeys.clockState('ws')))).toBe(true)
|
|
390
|
+
expect(invalidated.some((key) => covers(key, hrKeys.attendanceDays('ws', undefined, 'a', 'b')))).toBe(
|
|
391
|
+
true,
|
|
392
|
+
)
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The wire between the two halves of this package.
|
|
398
|
+
*
|
|
399
|
+
* A reason code is API: the router writes it, this component reads it, and nothing in between would
|
|
400
|
+
* notice them drifting apart — the widget would quietly go back to English, which is the defect
|
|
401
|
+
* this whole change exists to remove. So the check reads the router's own source, rather than a
|
|
402
|
+
* list of reasons restated here that could go stale with it.
|
|
403
|
+
*
|
|
404
|
+
* It is deliberately one-directional. A reason the router sends and this file does not know is the
|
|
405
|
+
* fallback working as designed; a reason this file translates and the router never sends is a
|
|
406
|
+
* rename nobody noticed.
|
|
407
|
+
*/
|
|
408
|
+
describe('the reasons the router sends', () => {
|
|
409
|
+
const router = readFileSync(fileURLToPath(new URL('../../server/router.ts', import.meta.url)), 'utf8')
|
|
410
|
+
|
|
411
|
+
/** The five guards inside `punch()`, from its refusal comment to the punch it goes on to record. */
|
|
412
|
+
function punchGuards(): string {
|
|
413
|
+
const from = router.indexOf('// Refuse the transitions that make no sense')
|
|
414
|
+
const to = router.indexOf('const punchRow = await attendance.record(', from)
|
|
415
|
+
if (from < 0 || to < 0) throw new Error('punch() no longer has the shape this check reads')
|
|
416
|
+
return router.slice(from, to)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
it('sends a reason with every refusal a punch can earn', () => {
|
|
420
|
+
const guards = punchGuards()
|
|
421
|
+
// A refusal built any other way carries no `details`, so nothing reaches `data.reason` and the
|
|
422
|
+
// reader gets English. `KernError.conflict`'s own `reason` argument is one of those ways: it is
|
|
423
|
+
// kept on the error object and never serialised.
|
|
424
|
+
expect(guards).not.toContain('KernError.conflict(')
|
|
425
|
+
expect(guards.match(/refusePunch\(/g)).toHaveLength(5)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('still sends every reason this component translates', () => {
|
|
429
|
+
const sent = new Set([...punchGuards().matchAll(/refusePunch\('([^']+)'/g)].map((m) => m[1]!))
|
|
430
|
+
for (const reason of Object.keys(refusalMap()))
|
|
431
|
+
expect({ reason, sent: sent.has(reason) }).toEqual({ reason, sent: true })
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The strings those keys name. `t()` renders a key it cannot resolve as itself, and `onError`
|
|
437
|
+
* catches that at runtime by falling back to the server's English — but a locale that never got the
|
|
438
|
+
* string is a defect, not a fallback, and nothing else in this package looks at a key's *use*.
|
|
439
|
+
*/
|
|
440
|
+
describe('the strings those reasons name', () => {
|
|
441
|
+
it('has a string in every locale for every reason the component translates', () => {
|
|
442
|
+
for (const key of Object.values(refusalMap()))
|
|
443
|
+
for (const [locale, bundle] of Object.entries({ en, ar, de, fa, tr }))
|
|
444
|
+
expect({ locale, key, present: key in bundle }).toEqual({ locale, key, present: true })
|
|
445
|
+
})
|
|
446
|
+
})
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Field,
|
|
8
|
+
formatDateRange,
|
|
9
|
+
Input,
|
|
10
|
+
Select,
|
|
11
|
+
Skeleton,
|
|
12
|
+
} from '@kernhq/ui'
|
|
3
13
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
14
|
import { getHrApi } from '../api-instance.js'
|
|
5
15
|
import { t } from '../i18n.js'
|
|
@@ -70,8 +80,7 @@ const datesInvalid = $derived(Boolean(startsOn) && Boolean(endsOn) && endsOn < s
|
|
|
70
80
|
|
|
71
81
|
const nameOf = (personId: string) => people.find((p) => p.id === personId)?.displayName ?? '—'
|
|
72
82
|
|
|
73
|
-
const range = (from: string, to: string) =>
|
|
74
|
-
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).formatRange(new Date(from), new Date(to))
|
|
83
|
+
const range = (from: string, to: string) => formatDateRange(from, to)
|
|
75
84
|
</script>
|
|
76
85
|
|
|
77
86
|
<Dialog
|