@happyvertical/smrt-timesheets 0.53.1 → 0.53.3
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/AGENTS.md +32 -21
- package/README.md +124 -58
- package/agents/period-rollup.md +130 -0
- package/dist/attendance/models.d.ts +59 -0
- package/dist/attendance/models.d.ts.map +1 -0
- package/dist/attendance/service.d.ts +64 -0
- package/dist/attendance/service.d.ts.map +1 -0
- package/dist/attendance.d.ts +5 -0
- package/dist/attendance.d.ts.map +1 -0
- package/dist/attendance.js +323 -0
- package/dist/attendance.js.map +1 -0
- package/dist/chunks/attendance-Dg_5VkMj.js +60 -0
- package/dist/chunks/attendance-Dg_5VkMj.js.map +1 -0
- package/dist/chunks/models-DRj8BOuG.js +166 -0
- package/dist/chunks/models-DRj8BOuG.js.map +1 -0
- package/dist/chunks/rollup-C_dHTdJs.js +425 -0
- package/dist/chunks/rollup-C_dHTdJs.js.map +1 -0
- package/dist/chunks/service-time-entry-BeOsL7Uv.js +190 -0
- package/dist/chunks/service-time-entry-BeOsL7Uv.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -205
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2553 -652
- package/dist/rollup/attendance.d.ts +8 -0
- package/dist/rollup/attendance.d.ts.map +1 -0
- package/dist/rollup/calculation.d.ts +7 -0
- package/dist/rollup/calculation.d.ts.map +1 -0
- package/dist/rollup/models.d.ts +47 -0
- package/dist/rollup/models.d.ts.map +1 -0
- package/dist/rollup/service.d.ts +37 -0
- package/dist/rollup/service.d.ts.map +1 -0
- package/dist/rollup/types.d.ts +38 -0
- package/dist/rollup/types.d.ts.map +1 -0
- package/dist/rollup/write.d.ts +4 -0
- package/dist/rollup/write.d.ts.map +1 -0
- package/dist/rollup.d.ts +5 -0
- package/dist/rollup.d.ts.map +1 -0
- package/dist/rollup.js +2 -0
- package/dist/smrt-knowledge.json +1319 -433
- package/package.json +19 -10
- package/dist/svelte/components/__tests__/DurationDisplay.test.js +0 -57
- package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +0 -114
- package/dist/svelte/components/__tests__/TimeEntryCard.test.js +0 -45
- package/dist/svelte/components/__tests__/TimeEntryList.test.js +0 -50
- package/dist/svelte/components/__tests__/TimeSummary.test.js +0 -39
package/AGENTS.md
CHANGED
|
@@ -6,9 +6,15 @@ immutable charge / compensation snapshots taken at approval. Moved out of
|
|
|
6
6
|
`smrt-projects` / `smrt-support` in #3288 so shops, builders, and service
|
|
7
7
|
desks share one model instead of re-implementing it.
|
|
8
8
|
|
|
9
|
-
This package
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
This package contains **layer 1 (entry)**, **layer 2 (attendance)**, and
|
|
10
|
+
**layer 3 (period rollup)**.
|
|
11
|
+
`src/attendance/` owns punches, breaks, atomic transitions and durable offline
|
|
12
|
+
receipts; `./attendance` is the public subpath. Period rollup belongs in this
|
|
13
|
+
package too, never projects or support. See the README attendance contract.
|
|
14
|
+
Attendance services accept trusted session actors, serialize mutations per
|
|
15
|
+
actor, and bind every write to one executor. The new tables use non-null unique
|
|
16
|
+
open slots, not nullable unique keys. A linked entry is draft evidence, and
|
|
17
|
+
rollups must deduplicate `AttendancePunch.serviceTimeEntryId`.
|
|
12
18
|
|
|
13
19
|
## Models
|
|
14
20
|
|
|
@@ -57,24 +63,21 @@ through a `ServiceCommercialResolver`), and corrects. `smrt-projects` supplies
|
|
|
57
63
|
|
|
58
64
|
## One table family
|
|
59
65
|
|
|
60
|
-
Classes may share a table only as one inheritance chain
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
the
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
three models. The snapshots' explicit `time_entry_id` conflict key is not
|
|
76
|
-
rewritten when a consumer requires tenancy; widening it is the consumer's
|
|
77
|
-
schema change, never a package default.
|
|
66
|
+
Classes may share a table only as one inheritance chain. Core resolves
|
|
67
|
+
same-named subclasses by constructor ancestry at runtime and qualified manifest
|
|
68
|
+
ancestry at build registration; the deepest subtype wins across N levels.
|
|
69
|
+
Sibling or unrelated types cannot share the table. `smrt-projects` re-exports
|
|
70
|
+
the base; `smrt-support` subclasses it directly.
|
|
71
|
+
|
|
72
|
+
Consumers closing package routes should call
|
|
73
|
+
`ObjectRegistry.registerOverride(qualifiedName, { api: false, mcp: false,
|
|
74
|
+
cli: false, tenancy: { mode: 'required' } })` after importing the model and before
|
|
75
|
+
constructing transports. This tightens the existing registration without a new
|
|
76
|
+
subtype. See core's README for startup order, static route generation, and reset
|
|
77
|
+
semantics. `src/__tests__/consumer-closed-surface.test.ts` also covers existing
|
|
78
|
+
manifest subclass consumers. The snapshots' explicit `time_entry_id` conflict
|
|
79
|
+
key is not rewritten when a consumer requires tenancy; widening it remains a
|
|
80
|
+
consumer schema change.
|
|
78
81
|
|
|
79
82
|
Subclasses restate their fields: package-isolated manifest scanning does not
|
|
80
83
|
read dependency sources, so an unrestated subclass manifest has no columns.
|
|
@@ -103,3 +106,11 @@ pnpm --filter @happyvertical/smrt-support test
|
|
|
103
106
|
Schema-affecting edits must keep `service-time-entry-compat.test.ts` (projects,
|
|
104
107
|
support) and `service-time-entry-migration-plan.test.ts` (support) green: a
|
|
105
108
|
pre-move table must plan no DDL.
|
|
109
|
+
|
|
110
|
+
## Period rollup
|
|
111
|
+
|
|
112
|
+
Layer 3 lives under `src/rollup/` and the `./rollup` subpath. See
|
|
113
|
+
[period-rollup.md](agents/period-rollup.md) for resolver, source clipping,
|
|
114
|
+
confirmation, attributed adjustments, isolation, and transaction contracts.
|
|
115
|
+
Timecards and adjustments have closed generated surfaces and service-only
|
|
116
|
+
model writes; never bypass the instance capability with generic persistence.
|
package/README.md
CHANGED
|
@@ -88,68 +88,45 @@ view objects (`TimeEntry`, `TimeEntryApprovalView`).
|
|
|
88
88
|
|
|
89
89
|
The entry and both snapshots expose generated `list` / `get` on REST, CLI,
|
|
90
90
|
and MCP, with optional tenant scoping. An application that routes all time
|
|
91
|
-
and money through its own permission-gated services
|
|
92
|
-
|
|
91
|
+
and money through its own permission-gated services can close those surfaces
|
|
92
|
+
without declaring replacement models:
|
|
93
93
|
|
|
94
94
|
```ts
|
|
95
|
-
import {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
})
|
|
110
|
-
export class ServiceTimeEntry extends SharedServiceTimeEntry {
|
|
111
|
-
// restate the fields — see AGENTS.md
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
@TenantScoped({ mode: 'required' })
|
|
115
|
-
@smrt({
|
|
116
|
-
tableName: 'service_charge_snapshots',
|
|
117
|
-
conflictColumns: ['time_entry_id'],
|
|
118
|
-
api: CLOSED,
|
|
119
|
-
cli: CLOSED,
|
|
120
|
-
mcp: CLOSED,
|
|
121
|
-
})
|
|
122
|
-
export class ServiceChargeSnapshot extends SharedServiceChargeSnapshot {
|
|
123
|
-
// restate the fields — see AGENTS.md
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
@TenantScoped({ mode: 'required' })
|
|
127
|
-
@smrt({
|
|
128
|
-
tableName: 'service_compensation_snapshots',
|
|
129
|
-
conflictColumns: ['time_entry_id'],
|
|
130
|
-
api: CLOSED,
|
|
131
|
-
cli: CLOSED,
|
|
132
|
-
mcp: CLOSED,
|
|
133
|
-
})
|
|
134
|
-
export class ServiceCompensationSnapshot extends SharedServiceCompensationSnapshot {
|
|
135
|
-
// restate the fields — see AGENTS.md
|
|
95
|
+
import { ObjectRegistry } from '@happyvertical/smrt-core';
|
|
96
|
+
import '@happyvertical/smrt-timesheets';
|
|
97
|
+
|
|
98
|
+
for (const name of [
|
|
99
|
+
'ServiceTimeEntry',
|
|
100
|
+
'ServiceChargeSnapshot',
|
|
101
|
+
'ServiceCompensationSnapshot',
|
|
102
|
+
]) {
|
|
103
|
+
ObjectRegistry.registerOverride(`@happyvertical/smrt-timesheets:${name}`, {
|
|
104
|
+
api: false,
|
|
105
|
+
cli: false,
|
|
106
|
+
mcp: false,
|
|
107
|
+
tenancy: { mode: 'required' },
|
|
108
|
+
});
|
|
136
109
|
}
|
|
137
110
|
```
|
|
138
111
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
smrt-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`
|
|
112
|
+
Register overrides after importing the models and before constructing or
|
|
113
|
+
emitting generated transports. Use the currently registered qualified name:
|
|
114
|
+
when smrt-support has replaced `ServiceTimeEntry`, target
|
|
115
|
+
`@happyvertical/smrt-support:ServiceTimeEntry` instead. Overrides accept only
|
|
116
|
+
closed surfaces and required tenancy; they cannot reopen a surface or weaken
|
|
117
|
+
tenancy. They survive registration replay and are reset by
|
|
118
|
+
`ObjectRegistry.clear()`. Existing static route files must be regenerated with
|
|
119
|
+
the closed configuration; changing the registry does not rewrite deployed files.
|
|
120
|
+
|
|
121
|
+
This preserves the model's fields, indexes, conflict keys, and aliases. Same-named
|
|
122
|
+
subclasses remain available when an application needs to change the model itself;
|
|
123
|
+
see AGENTS.md for field restatement and the alias gap below.
|
|
124
|
+
|
|
125
|
+
The snapshots' conflict key remains `time_entry_id` alone — one snapshot per
|
|
126
|
+
entry — and is never rewritten to lead with `tenant_id` when tenancy becomes
|
|
127
|
+
required. Entry ids are UUIDs unique across tenants. Widening that key to
|
|
128
|
+
`['tenant_id', 'time_entry_id']` is a consumer schema change requiring a new
|
|
129
|
+
unique index through `smrt db:migrate`; runtime overrides do not change schema.
|
|
153
130
|
|
|
154
131
|
## Migrating from smrt-projects / smrt-support
|
|
155
132
|
|
|
@@ -223,7 +200,7 @@ and are unchanged.
|
|
|
223
200
|
|
|
224
201
|
> **Known gap: same-named subtypes drop the alias.** A same-named subclass
|
|
225
202
|
> over a moved table — smrt-support's `ServiceTimeEntry`, or an
|
|
226
|
-
> application's
|
|
203
|
+
> application's replacement subclass —
|
|
227
204
|
> replaces the timesheets class in the registry, and that class's alias does
|
|
228
205
|
> not carry over to it yet. With smrt-support installed,
|
|
229
206
|
> `@happyvertical/smrt-projects:ServiceTimeEntry` therefore does not resolve
|
|
@@ -241,3 +218,92 @@ and are unchanged.
|
|
|
241
218
|
`SubscriptionServiceCommercialResolver` pricing for these entries.
|
|
242
219
|
- [`smrt-support`](../support/README.md) — support-case time on the same table.
|
|
243
220
|
- [`smrt-profiles`](../profiles/README.md) — the participants.
|
|
221
|
+
|
|
222
|
+
## Attendance
|
|
223
|
+
|
|
224
|
+
The `@happyvertical/smrt-timesheets/attendance` subpath adds `AttendancePunch`,
|
|
225
|
+
`AttendanceBreak`, and `AttendanceService`. Apply migrations for the new tables
|
|
226
|
+
before enabling the module. Existing entry and snapshot tables are unchanged.
|
|
227
|
+
Attendance supports SQLite and PostgreSQL. DuckDB cannot enforce the required
|
|
228
|
+
`ON UPDATE CASCADE` foreign-key contract used by attendance and existing entry
|
|
229
|
+
corrections; schema creation deliberately fails rather than omitting constraints.
|
|
230
|
+
|
|
231
|
+
Construct a service with your database and the **authorized session's**
|
|
232
|
+
`{ tenantId, profileId }`. The application resolves Profile membership and
|
|
233
|
+
operation permissions before this boundary; never pass request-supplied actor
|
|
234
|
+
identifiers. A conflicting active tenant is refused. The service cannot read or
|
|
235
|
+
link another profile's punch. Generated punch/break surfaces default to reads
|
|
236
|
+
only; the replay ledger has no generated operations and is excluded from change
|
|
237
|
+
feeds. Before constructing generated transports, consumers can close the remaining
|
|
238
|
+
reads without replacing the models or changing their schema:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { ObjectRegistry } from '@happyvertical/smrt-core';
|
|
242
|
+
import '@happyvertical/smrt-timesheets/attendance';
|
|
243
|
+
|
|
244
|
+
for (const model of ['AttendancePunch', 'AttendanceBreak']) {
|
|
245
|
+
ObjectRegistry.registerOverride(`@happyvertical/smrt-timesheets:${model}`, {
|
|
246
|
+
api: false, cli: false, mcp: false,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Required tenant scoping remains in effect after closing these surfaces.
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { AttendanceService } from '@happyvertical/smrt-timesheets/attendance';
|
|
255
|
+
|
|
256
|
+
const attendance = new AttendanceService(db, { tenantId, profileId });
|
|
257
|
+
await attendance.punchIn(new Date('2026-10-01T08:00:00Z'));
|
|
258
|
+
await attendance.startBreak(new Date('2026-10-01T12:00:00Z')); // unpaid
|
|
259
|
+
await attendance.endBreak(new Date('2026-10-01T12:30:00Z'));
|
|
260
|
+
const punch = await attendance.punchOut(new Date('2026-10-01T16:00:00Z'), {
|
|
261
|
+
workRefType: '@acme/jobs:WorkPackage', workRefId: 'wp-7', description: 'Framing',
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Timestamps retain device milliseconds. `durationSeconds` is elapsed time minus
|
|
266
|
+
unpaid breaks, rounded once to the nearest whole second; paid breaks remain
|
|
267
|
+
included. Local time zone and payroll rounding policy belong to the consumer. Closing also ends an active break. New punches have the
|
|
268
|
+
non-null `openSlot` value `open`, with a unique database index on tenant, profile
|
|
269
|
+
and slot. Closed punches use a slot derived from their id, so PostgreSQL and
|
|
270
|
+
SQLite enforce one open punch without differing nullable-unique semantics.
|
|
271
|
+
The index compares native UUID owner columns directly, including PostgreSQL
|
|
272
|
+
UUID normalization. Breaks use the same
|
|
273
|
+
pattern per punch. Persisted ownership/start and closed intervals are immutable.
|
|
274
|
+
Direct ORM saves serialize against the parent punch and reject a close while a
|
|
275
|
+
break remains open or ends outside the punch.
|
|
276
|
+
|
|
277
|
+
A work reference is optional: attendance alone is not billable evidence. Passing
|
|
278
|
+
one on close, or calling `linkServiceTimeEntry(punch.id, work)` later, creates
|
|
279
|
+
one draft `ServiceTimeEntry` atomically and records `serviceTimeEntryId` on the
|
|
280
|
+
punch. Only the three declared work fields are copied; extra request fields cannot
|
|
281
|
+
replace the trusted tenant or participant. Repeating the same link returns that entry; changing attribution is
|
|
282
|
+
refused. Approval still uses the entry layer. Rollups must deduplicate this link,
|
|
283
|
+
not count both attendance and its entry. Zero-net-duration punches cannot create
|
|
284
|
+
service evidence.
|
|
285
|
+
|
|
286
|
+
`autoClose(now, maxOpenSeconds)` closes a due open punch at its configured
|
|
287
|
+
maximum duration and sets `reviewRequired`. It returns null before the deadline
|
|
288
|
+
or when already closed. If later committed attendance would be truncated it
|
|
289
|
+
refuses the operation for manual review. A scheduler calls this per authorized
|
|
290
|
+
actor; the package does not choose overtime, payroll, or shop-time-zone rules.
|
|
291
|
+
|
|
292
|
+
`replay(taps)` accepts `{ clientId, action, at, paid?, work? }`, where `action` is
|
|
293
|
+
`punchIn`, `punchOut`, `startBreak`, or `endBreak`. It sorts each batch by device
|
|
294
|
+
time, then client id. Client ids are durable and scoped to tenant/profile.
|
|
295
|
+
Successful taps set `recordedOffline` and `reviewRequired`. The conflict policy
|
|
296
|
+
is **committed attendance wins**: an event predating committed activity, or
|
|
297
|
+
invalid for the current state, gets a durable error outcome. Identical retries
|
|
298
|
+
return that outcome even after later shifts; changed payloads using the same id
|
|
299
|
+
are rejected. Database failures roll back the tap and receipt together and can
|
|
300
|
+
be retried. Earlier committed taps in the batch remain committed. Device clocks
|
|
301
|
+
are treated as supplied evidence, not server authentication; consumers should
|
|
302
|
+
apply device drift policy before replay. Replay receipts must be retained for as
|
|
303
|
+
long as clients can retry their ids.
|
|
304
|
+
|
|
305
|
+
## Period timecards
|
|
306
|
+
|
|
307
|
+
`./rollup` provides per-person integer-second regular/overtime rollups, immutable
|
|
308
|
+
confirmation, and attributed append-only adjustments. Consumer resolvers own
|
|
309
|
+
calendar/timezone, holiday and overtime rules. See the [contract and example](agents/period-rollup.md).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Period timecards
|
|
2
|
+
|
|
3
|
+
`@happyvertical/smrt-timesheets/rollup` exports `PeriodRollupService`,
|
|
4
|
+
`PeriodTimecard`, `TimecardAdjustment`, their collections, and resolver types.
|
|
5
|
+
The package root also exports them. SQLite and PostgreSQL are supported.
|
|
6
|
+
Create application tables through normal migrations; neither service provisions
|
|
7
|
+
schema. The attendance option additionally requires the attendance tables.
|
|
8
|
+
|
|
9
|
+
Construct a service only after authenticating and authorizing its trusted
|
|
10
|
+
`{ tenantId, profileId }` scope. A conflicting ambient tenant is rejected.
|
|
11
|
+
`options.actorProfileId` records a separately authorized supervisor; it defaults
|
|
12
|
+
to the subject profile for self confirmation/correction. Never accept this
|
|
13
|
+
trusted scope or attribution unchecked from request input. Profile authorization
|
|
14
|
+
is consumer-owned; the service filters every source/card/adjustment read by its
|
|
15
|
+
trusted subject and tenant. Generic API, CLI and MCP surfaces are closed by
|
|
16
|
+
default; consumer registry overrides may further restrict them (#3318).
|
|
17
|
+
|
|
18
|
+
## Resolver contract
|
|
19
|
+
|
|
20
|
+
`periodFor(at, actor)` supplies half-open UTC `startsAt`/`endsAt`, an IANA
|
|
21
|
+
`timezone`, a nonempty policy `version`, and a JSON `rules` snapshot.
|
|
22
|
+
The consumer owns period length, calendar start day, timezone and DST, overtime
|
|
23
|
+
thresholds, holidays and classification. The requested instant must be inside
|
|
24
|
+
the window. Overlapping windows for existing cards are rejected: introduce a new
|
|
25
|
+
calendar at a boundary after existing windows, and use adjustments for already
|
|
26
|
+
confirmed periods.
|
|
27
|
+
|
|
28
|
+
`classify(period, sources, actor)` returns nonnegative safe integer
|
|
29
|
+
`regularSeconds` and `overtimeSeconds`, whose sum must equal source seconds.
|
|
30
|
+
Inputs are detached so resolver mutation cannot rewrite evidence. Resolver
|
|
31
|
+
exceptions, malformed windows, invalid timezone, fractional/unsafe totals and
|
|
32
|
+
nonconserving classification abort the transaction. No Alberta or other
|
|
33
|
+
jurisdiction rule is assumed, and no pay rate or money is stored.
|
|
34
|
+
|
|
35
|
+
A configurable UTC calendar example (illustrative policy, not payroll advice):
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { PeriodRollupService, type PeriodRulesResolver } from
|
|
39
|
+
'@happyvertical/smrt-timesheets/rollup';
|
|
40
|
+
|
|
41
|
+
const settings = {
|
|
42
|
+
lengthDays: 14, startDay: 1, timezone: 'UTC', // 0 = Sunday
|
|
43
|
+
anchor: '2026-01-05T00:00:00Z', // aligned Monday; anchors fortnight phase
|
|
44
|
+
thresholdSeconds: 80 * 3600,
|
|
45
|
+
holidays: ['2026-12-25'],
|
|
46
|
+
};
|
|
47
|
+
const resolver: PeriodRulesResolver = {
|
|
48
|
+
async periodFor(at) {
|
|
49
|
+
const anchor = new Date(settings.anchor);
|
|
50
|
+
if (anchor.getUTCDay() !== settings.startDay || settings.timezone !== 'UTC')
|
|
51
|
+
throw new Error('Use a timezone-aware calendar resolver for local periods');
|
|
52
|
+
const length = settings.lengthDays * 86400000;
|
|
53
|
+
const start = anchor.getTime() +
|
|
54
|
+
Math.floor((at.getTime() - anchor.getTime()) / length) * length;
|
|
55
|
+
return {
|
|
56
|
+
startsAt: new Date(start), endsAt: new Date(start + length),
|
|
57
|
+
timezone: settings.timezone, version: 'shop-policy-v1', rules: settings,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async classify(period, sources) {
|
|
61
|
+
const rules = period.rules as typeof settings;
|
|
62
|
+
// This example assigns a contribution to its clipped start's UTC date.
|
|
63
|
+
// Local daily rules should split contributions at their own local midnight.
|
|
64
|
+
let regular = 0;
|
|
65
|
+
let holiday = 0;
|
|
66
|
+
for (const source of sources) {
|
|
67
|
+
if (rules.holidays.includes(source.startsAt.slice(0, 10))) holiday += source.seconds;
|
|
68
|
+
else regular += source.seconds;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
regularSeconds: Math.min(regular, rules.thresholdSeconds),
|
|
72
|
+
overtimeSeconds: holiday + Math.max(0, regular - rules.thresholdSeconds),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const rollups = new PeriodRollupService(db, authorizedSubject, resolver,
|
|
77
|
+
{ attendance: true, actorProfileId: authenticatedSupervisorProfileId });
|
|
78
|
+
const card = await rollups.rollup(new Date());
|
|
79
|
+
await rollups.confirm(card.id!);
|
|
80
|
+
await rollups.adjust(card.id!, {
|
|
81
|
+
operationId: requestId, regularSeconds: 900, overtimeSeconds: 0,
|
|
82
|
+
reason: 'Approved correction after confirmation',
|
|
83
|
+
});
|
|
84
|
+
const effectiveSeconds = await rollups.totals(card.id!);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A timezone-aware resolver must compute each local boundary separately: a local
|
|
88
|
+
day crossing DST is not necessarily 86400 seconds. Resolvers can use an app's
|
|
89
|
+
existing calendar library; no new timezone dependency or implicit default is
|
|
90
|
+
introduced here.
|
|
91
|
+
|
|
92
|
+
## Evidence and immutability
|
|
93
|
+
|
|
94
|
+
Only approved human `ServiceTimeEntry` rows for the subject count; corrected
|
|
95
|
+
ancestors do not. Correction chains must remain within that subject/tenant and
|
|
96
|
+
point to corrected parents. Multiple approved descendants of one root are
|
|
97
|
+
rejected as ambiguous rather than double-counted. Entries with both `startedAt`
|
|
98
|
+
and `endedAt` allocate their declared seconds
|
|
99
|
+
proportionally across the period using exact integer prefix division, preserving
|
|
100
|
+
seconds over adjacent periods. Unbounded entries count at `startedAt`, or, when
|
|
101
|
+
absent, `approvedAt`; an end-only timestamp does not create an interval. Missing
|
|
102
|
+
both attribution timestamps is an error. This is time attribution, not a
|
|
103
|
+
claim that a manual entry has a continuous wall-clock duration.
|
|
104
|
+
|
|
105
|
+
With `attendance: true`, closed punches without `reviewRequired` count, excluding
|
|
106
|
+
unpaid breaks and retaining paid breaks. Open/review-required punches do not
|
|
107
|
+
count. Linked attendance is authoritative: its evidence entry and any approved
|
|
108
|
+
correction descendants are excluded, including when the punch is still under
|
|
109
|
+
review or outside the requested period. Unlinked approved work entries and
|
|
110
|
+
attendance both count; consumers must link overlapping evidence explicitly.
|
|
111
|
+
Source snapshots preserve each paid attendance segment and entry identity.
|
|
112
|
+
|
|
113
|
+
Repeated rollup updates one open card per tenant/person/start; a confirmed card
|
|
114
|
+
cannot be recomputed. Confirmation is attributed and idempotent. Model save,
|
|
115
|
+
collection create/update/upsert and object/collection delete cannot bypass the
|
|
116
|
+
service. Model mutation capabilities are per-instance and not exported.
|
|
117
|
+
Direct SQL is a trusted administrative boundary, not an exposed application API.
|
|
118
|
+
Profile/card foreign keys restrict deletion to preserve history.
|
|
119
|
+
|
|
120
|
+
Adjustments contain signed integer regular/overtime deltas, reason, operation
|
|
121
|
+
key and actor attribution. A retry reuses its row only for the exact same
|
|
122
|
+
payload and actor. Effective totals may never become negative or unsafe.
|
|
123
|
+
Adjustments never alter the confirmed row or source snapshot. Undo an adjustment
|
|
124
|
+
with a separately attributed compensating adjustment, never deletion.
|
|
125
|
+
|
|
126
|
+
All service mutations use a root transaction, transaction-bound collections,
|
|
127
|
+
embedded writer serialization or a PostgreSQL tenant/person advisory lock.
|
|
128
|
+
Outputs are reloaded on the root executor after commit. Rollup reads are an
|
|
129
|
+
application snapshot: subsequent source approvals/corrections appear on the
|
|
130
|
+
next open rollup, and confirmed cards require explicit adjustments.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';
|
|
2
|
+
/** A tenant-scoped interval of attendance, independent of billable work. */
|
|
3
|
+
export declare class AttendancePunch extends SmrtObject {
|
|
4
|
+
tenantId: string;
|
|
5
|
+
profileId: string;
|
|
6
|
+
startedAt: Date;
|
|
7
|
+
endedAt: Date | null;
|
|
8
|
+
durationSeconds: number;
|
|
9
|
+
unpaidBreakSeconds: number;
|
|
10
|
+
reviewRequired: boolean;
|
|
11
|
+
recordedOffline: boolean;
|
|
12
|
+
serviceTimeEntryId: string | null;
|
|
13
|
+
/** Unique while open; closed rows retain independent non-null slots on every dialect. */
|
|
14
|
+
openSlot: string;
|
|
15
|
+
save(): Promise<this>;
|
|
16
|
+
private saveConsistent;
|
|
17
|
+
}
|
|
18
|
+
/** A paid or unpaid break inside one attendance punch. */
|
|
19
|
+
export declare class AttendanceBreak extends SmrtObject {
|
|
20
|
+
tenantId: string;
|
|
21
|
+
punchId: string;
|
|
22
|
+
startedAt: Date;
|
|
23
|
+
endedAt: Date | null;
|
|
24
|
+
paid: boolean;
|
|
25
|
+
openSlot: string;
|
|
26
|
+
save(): Promise<this>;
|
|
27
|
+
private saveConsistent;
|
|
28
|
+
}
|
|
29
|
+
/** Durable success or conflict; conflicts do not mutate attendance. */
|
|
30
|
+
export interface AttendanceReplayOutcome {
|
|
31
|
+
clientId: string;
|
|
32
|
+
punchId?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Durable offline-tap outcome; never exposed through generated operations. */
|
|
36
|
+
export declare class AttendanceReplay extends SmrtObject {
|
|
37
|
+
tenantId: string;
|
|
38
|
+
replayKey: string;
|
|
39
|
+
content: string;
|
|
40
|
+
outcome: string;
|
|
41
|
+
/** Read a durable receipt, failing closed rather than re-executing a corrupt tap. */
|
|
42
|
+
getOutcome(): AttendanceReplayOutcome;
|
|
43
|
+
/** Store the JSON representation of a validated success or refusal. */
|
|
44
|
+
setOutcome(value: AttendanceReplayOutcome): void;
|
|
45
|
+
save(): Promise<this>;
|
|
46
|
+
}
|
|
47
|
+
/** Punches for attendance and period rollup. */
|
|
48
|
+
export declare class AttendancePunchCollection extends SmrtCollection<AttendancePunch> {
|
|
49
|
+
static readonly _itemClass: typeof AttendancePunch;
|
|
50
|
+
}
|
|
51
|
+
/** Break intervals for net duration and period clipping. */
|
|
52
|
+
export declare class AttendanceBreakCollection extends SmrtCollection<AttendanceBreak> {
|
|
53
|
+
static readonly _itemClass: typeof AttendanceBreak;
|
|
54
|
+
}
|
|
55
|
+
/** Internal durable replay ledger. */
|
|
56
|
+
export declare class AttendanceReplayCollection extends SmrtCollection<AttendanceReplay> {
|
|
57
|
+
static readonly _itemClass: typeof AttendanceReplay;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=models.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/attendance/models.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,cAAc,EACd,UAAU,EAGX,MAAM,0BAA0B,CAAC;AA4BlC,4EAA4E;AAC5E,qBAca,eAAgB,SAAQ,UAAU;IACjC,QAAQ,SAAM;IAC+B,SAAS,SAAM;IACxE,SAAS,EAAE,IAAI,CAAc;IAC7B,OAAO,EAAE,IAAI,GAAG,IAAI,CAAQ;IAC5B,eAAe,SAAK;IACpB,kBAAkB,SAAK;IACvB,cAAc,UAAS;IACvB,eAAe,UAAS;IAE4B,kBAAkB,EAClE,MAAM,GACN,IAAI,CAAQ;IAChB,yFAAyF;IAC9D,QAAQ,SAAM;IAE1B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAMtB,cAAc;CA0D7B;AAED,0DAA0D;AAC1D,qBAca,eAAgB,SAAQ,UAAU;IACjC,QAAQ,SAAM;IACG,OAAO,SAAM;IAC1C,SAAS,EAAE,IAAI,CAAc;IAC7B,OAAO,EAAE,IAAI,GAAG,IAAI,CAAQ;IAC5B,IAAI,UAAS;IACc,QAAQ,SAAM;IAE1B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAMtB,cAAc;CA6C7B;AAED,uEAAuE;AACvE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiBD,+EAA+E;AAC/E,qBAQa,gBAAiB,SAAQ,UAAU;IAClC,QAAQ,SAAM;IACe,SAAS,SAAM;IAC/B,OAAO,SAAM;IACb,OAAO,SAAQ;IAExC,qFAAqF;IACrF,UAAU,IAAI,uBAAuB;IAOrC,uEAAuE;IACvE,UAAU,CAAC,KAAK,EAAE,uBAAuB,GAAG,IAAI;IAMjC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAIrC;AAED,gDAAgD;AAChD,qBAAa,yBAA0B,SAAQ,cAAc,CAAC,eAAe,CAAC;IAC5E,MAAM,CAAC,QAAQ,CAAC,UAAU,yBAAmB;CAC9C;AACD,4DAA4D;AAC5D,qBAAa,yBAA0B,SAAQ,cAAc,CAAC,eAAe,CAAC;IAC5E,MAAM,CAAC,QAAQ,CAAC,UAAU,yBAAmB;CAC9C;AACD,sCAAsC;AACtC,qBAAa,0BAA2B,SAAQ,cAAc,CAAC,gBAAgB,CAAC;IAC9E,MAAM,CAAC,QAAQ,CAAC,UAAU,0BAAoB;CAC/C"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import { AttendancePunch, type AttendanceReplayOutcome } from './models.js';
|
|
3
|
+
export type { AttendanceReplayOutcome } from './models.js';
|
|
4
|
+
/** Resolve these identifiers from the authenticated session, never from tap input. */
|
|
5
|
+
export interface AttendanceActor {
|
|
6
|
+
tenantId: string;
|
|
7
|
+
profileId: string;
|
|
8
|
+
}
|
|
9
|
+
/** Explicit work attribution for a closed punch's draft evidence entry. */
|
|
10
|
+
export interface AttendanceWork {
|
|
11
|
+
workRefType: string;
|
|
12
|
+
workRefId: string;
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
/** Offline command with a durable client-generated identifier and device timestamp. */
|
|
16
|
+
export interface AttendanceTap {
|
|
17
|
+
clientId: string;
|
|
18
|
+
at: string;
|
|
19
|
+
action: 'punchIn' | 'punchOut' | 'startBreak' | 'endBreak';
|
|
20
|
+
paid?: boolean;
|
|
21
|
+
work?: AttendanceWork;
|
|
22
|
+
}
|
|
23
|
+
/** Expected state conflict, distinct from retryable database failures. */
|
|
24
|
+
export declare class AttendanceConflict extends Error {
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Attendance mutations bound to a trusted tenant/profile. Applications authorize
|
|
28
|
+
* the actor before constructing this service. Each transition is atomic; all
|
|
29
|
+
* participating collections use the transaction executor. PostgreSQL serializes
|
|
30
|
+
* this actor with an advisory lock; embedded adapters serialize writers.
|
|
31
|
+
*/
|
|
32
|
+
export declare class AttendanceService {
|
|
33
|
+
private readonly db;
|
|
34
|
+
private readonly actor;
|
|
35
|
+
constructor(db: DatabaseInterface, actor: AttendanceActor);
|
|
36
|
+
private identity;
|
|
37
|
+
private transact;
|
|
38
|
+
private date;
|
|
39
|
+
private open;
|
|
40
|
+
private chronological;
|
|
41
|
+
/** Open one punch; the database unique slot also protects direct model writers. */
|
|
42
|
+
punchIn(at?: Date): Promise<AttendancePunch>;
|
|
43
|
+
/** Close the punch and any active break; optionally create linked draft evidence. */
|
|
44
|
+
punchOut(at: Date, work?: AttendanceWork): Promise<AttendancePunch>;
|
|
45
|
+
/** Begin a break; paid defaults false and must be explicitly configured by the caller. */
|
|
46
|
+
startBreak(at: Date, paid?: boolean): Promise<AttendancePunch>;
|
|
47
|
+
/** End the current break. */
|
|
48
|
+
endBreak(at: Date): Promise<AttendancePunch>;
|
|
49
|
+
private transition;
|
|
50
|
+
private validateWork;
|
|
51
|
+
private link;
|
|
52
|
+
/** Idempotently link a closed, owned punch to draft service evidence. */
|
|
53
|
+
linkServiceTimeEntry(punchId: string, work: AttendanceWork): Promise<AttendancePunch>;
|
|
54
|
+
/** Close at startedAt + maxOpenSeconds once due, flagging the punch for review. */
|
|
55
|
+
autoClose(now: Date, maxOpenSeconds: number): Promise<AttendancePunch | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Replay device timestamps in order (ties use clientId). Committed attendance
|
|
58
|
+
* wins: stale/state-conflicting taps get durable refusals; identical retries
|
|
59
|
+
* return the same outcome, changed payloads using a key are rejected. A
|
|
60
|
+
* database failure rolls back its tap and receipt and remains retryable.
|
|
61
|
+
*/
|
|
62
|
+
replay(taps: readonly AttendanceTap[]): Promise<AttendanceReplayOutcome[]>;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/attendance/service.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EAEL,eAAe,EAGf,KAAK,uBAAuB,EAC7B,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAE3D,sFAAsF;AACtF,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AACD,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AACD,uFAAuF;AACvF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,YAAY,GAAG,UAAU,CAAC;IAC3D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB;AAED,0EAA0E;AAC1E,qBAAa,kBAAmB,SAAQ,KAAK;CAAG;AAShD;;;;;GAKG;AACH,qBAAa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE,eAAe;IAUzC,OAAO,CAAC,QAAQ;YAQF,QAAQ;IA0CtB,OAAO,CAAC,IAAI;YAQE,IAAI;YASJ,aAAa;IA+B3B,mFAAmF;IAC7E,OAAO,CAAC,EAAE,OAAa,GAAG,OAAO,CAAC,eAAe,CAAC;IAGxD,qFAAqF;IAC/E,QAAQ,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;IAKzE,0FAA0F;IACpF,UAAU,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,UAAQ,GAAG,OAAO,CAAC,eAAe,CAAC;IAKlE,6BAA6B;IACvB,QAAQ,CAAC,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC;YAMpC,UAAU;IAqFxB,OAAO,CAAC,YAAY;YAcN,IAAI;IA6ClB,yEAAyE;IACnE,oBAAoB,CACxB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,eAAe,CAAC;IAY3B,mFAAmF;IAC7E,SAAS,CACb,GAAG,EAAE,IAAI,EACT,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAwBlC;;;;;OAKG;IACG,MAAM,CACV,IAAI,EAAE,SAAS,aAAa,EAAE,GAC7B,OAAO,CAAC,uBAAuB,EAAE,CAAC;CAkGtC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attendance.d.ts","sourceRoot":"","sources":["../src/attendance.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,OAAO,wBAAwB,CAAC;AAEhC,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC"}
|