iobroker.time-tracker 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +48 -18
  2. package/admin/i18n/de.json +1 -1
  3. package/admin/i18n/en.json +1 -1
  4. package/admin/i18n/es.json +1 -1
  5. package/admin/i18n/fr.json +1 -1
  6. package/admin/i18n/it.json +1 -1
  7. package/admin/i18n/nl.json +1 -1
  8. package/admin/i18n/pl.json +1 -1
  9. package/admin/i18n/pt.json +1 -1
  10. package/admin/i18n/ru.json +1 -1
  11. package/admin/i18n/uk.json +1 -1
  12. package/admin/i18n/zh-cn.json +1 -1
  13. package/admin/jsonConfig.json +1 -1
  14. package/build/lib/db/migrations.js +39 -0
  15. package/build/lib/db/migrations.js.map +2 -2
  16. package/build/lib/db/repositories/dayNotes.js +183 -0
  17. package/build/lib/db/repositories/dayNotes.js.map +7 -0
  18. package/build/lib/db/seed.js +1 -1
  19. package/build/lib/db/seed.js.map +2 -2
  20. package/build/lib/services/sync.js +11 -9
  21. package/build/lib/services/sync.js.map +2 -2
  22. package/build/lib/web/api.js +88 -12
  23. package/build/lib/web/api.js.map +2 -2
  24. package/build/lib/web/events.js.map +2 -2
  25. package/build/main.js +4 -0
  26. package/build/main.js.map +2 -2
  27. package/io-package.json +14 -14
  28. package/package.json +1 -1
  29. package/src-pwa/src/i18n/de.json +11 -1
  30. package/src-pwa/src/i18n/en.json +11 -1
  31. package/src-pwa/src/i18n/es.json +11 -1
  32. package/src-pwa/src/i18n/fr.json +11 -1
  33. package/src-pwa/src/i18n/it.json +11 -1
  34. package/src-pwa/src/i18n/nl.json +11 -1
  35. package/src-pwa/src/i18n/pl.json +11 -1
  36. package/src-pwa/src/i18n/pt.json +11 -1
  37. package/src-pwa/src/i18n/ru.json +11 -1
  38. package/src-pwa/src/i18n/uk.json +11 -1
  39. package/src-pwa/src/i18n/zh-cn.json +11 -1
  40. package/www/assets/index-D5fSC8CA.js +119 -0
  41. package/www/assets/index-D5fSC8CA.js.map +1 -0
  42. package/www/index.html +1 -1
  43. package/www/sw.js +1 -1
  44. package/www/sw.js.map +1 -1
  45. package/www/assets/index-DIM5i7zh.js +0 -119
  46. package/www/assets/index-DIM5i7zh.js.map +0 -1
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var dayNotes_exports = {};
20
+ __export(dayNotes_exports, {
21
+ MAX_DAY_NOTE_LENGTH: () => MAX_DAY_NOTE_LENGTH,
22
+ createDayNotesRepository: () => createDayNotesRepository
23
+ });
24
+ module.exports = __toCommonJS(dayNotes_exports);
25
+ var import_time = require("../../util/time");
26
+ var import_errors = require("../../errors");
27
+ var import_audit = require("./audit");
28
+ const MAX_DAY_NOTE_LENGTH = 500;
29
+ function createDayNotesRepository(db) {
30
+ const COLUMNS = `id, user_id, local_date, note, created_by, created_at, updated_by, updated_at, handled_at,
31
+ handled_by`;
32
+ const selectByRange = db.prepare(
33
+ `SELECT ${COLUMNS} FROM day_notes WHERE user_id = ? AND local_date BETWEEN ? AND ? ORDER BY local_date, id`
34
+ );
35
+ const selectByDate = db.prepare(`SELECT ${COLUMNS} FROM day_notes WHERE user_id = ? AND local_date = ?`);
36
+ const insert = db.prepare(
37
+ `INSERT INTO day_notes (user_id, local_date, note, created_by, created_at, updated_by, updated_at)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
39
+ );
40
+ const updateText = db.prepare(`UPDATE day_notes SET note = ?, updated_by = ?, updated_at = ? WHERE id = ?`);
41
+ const removeRow = db.prepare(`DELETE FROM day_notes WHERE id = ?`);
42
+ const updateHandled = db.prepare(
43
+ `UPDATE day_notes SET handled_at = ?, handled_by = ?, updated_by = ?, updated_at = ? WHERE id = ?`
44
+ );
45
+ const mapRow = (row) => ({
46
+ id: row.id,
47
+ userId: row.user_id,
48
+ localDate: row.local_date,
49
+ note: row.note,
50
+ createdBy: row.created_by,
51
+ createdAt: row.created_at,
52
+ updatedBy: row.updated_by,
53
+ updatedAt: row.updated_at,
54
+ handledAt: row.handled_at,
55
+ handledBy: row.handled_by
56
+ });
57
+ const read = (userId, localDate) => {
58
+ const row = selectByDate.get(userId, localDate);
59
+ return row ? mapRow(row) : null;
60
+ };
61
+ const requireDate = (localDate) => {
62
+ if (!(0, import_time.isDateString)(localDate)) {
63
+ throw new import_errors.ValidationError("localDate must be a date (YYYY-MM-DD)");
64
+ }
65
+ };
66
+ return {
67
+ listByRange(userId, fromDate, toDate) {
68
+ const rows = selectByRange.all(userId, fromDate, toDate);
69
+ return rows.map(mapRow);
70
+ },
71
+ find: read,
72
+ save(input) {
73
+ var _a;
74
+ requireDate(input.localDate);
75
+ const note = input.note.trim();
76
+ if (note.length > MAX_DAY_NOTE_LENGTH) {
77
+ throw new import_errors.ValidationError(
78
+ `note must not be longer than ${MAX_DAY_NOTE_LENGTH} characters (got ${note.length})`
79
+ );
80
+ }
81
+ const atUtc = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
82
+ const existing = read(input.userId, input.localDate);
83
+ if (note === "") {
84
+ if (!existing) {
85
+ return null;
86
+ }
87
+ const run2 = db.transaction(() => {
88
+ var _a2;
89
+ removeRow.run(existing.id);
90
+ (0, import_audit.writeAuditLog)(db, {
91
+ atUtc,
92
+ actorId: input.actorId,
93
+ action: "day_note.delete",
94
+ entity: "day_notes",
95
+ entityId: existing.id,
96
+ detail: { userId: input.userId, localDate: input.localDate, note: existing.note },
97
+ ip: (_a2 = input.actorIp) != null ? _a2 : null
98
+ });
99
+ });
100
+ run2();
101
+ return null;
102
+ }
103
+ if (existing && existing.note === note) {
104
+ return existing;
105
+ }
106
+ const run = db.transaction(() => {
107
+ var _a2, _b;
108
+ if (existing) {
109
+ updateText.run(note, input.actorId, atUtc, existing.id);
110
+ (0, import_audit.writeAuditLog)(db, {
111
+ atUtc,
112
+ actorId: input.actorId,
113
+ action: "day_note.update",
114
+ entity: "day_notes",
115
+ entityId: existing.id,
116
+ detail: {
117
+ userId: input.userId,
118
+ localDate: input.localDate,
119
+ old: existing.note,
120
+ new: note
121
+ },
122
+ ip: (_a2 = input.actorIp) != null ? _a2 : null
123
+ });
124
+ return;
125
+ }
126
+ const result = insert.run(
127
+ input.userId,
128
+ input.localDate,
129
+ note,
130
+ input.actorId,
131
+ atUtc,
132
+ input.actorId,
133
+ atUtc
134
+ );
135
+ (0, import_audit.writeAuditLog)(db, {
136
+ atUtc,
137
+ actorId: input.actorId,
138
+ action: "day_note.create",
139
+ entity: "day_notes",
140
+ entityId: Number(result.lastInsertRowid),
141
+ detail: { userId: input.userId, localDate: input.localDate, note },
142
+ ip: (_b = input.actorIp) != null ? _b : null
143
+ });
144
+ });
145
+ run();
146
+ return read(input.userId, input.localDate);
147
+ },
148
+ setHandled(input) {
149
+ var _a, _b;
150
+ requireDate(input.localDate);
151
+ const existing = read(input.userId, input.localDate);
152
+ if (!existing) {
153
+ throw new import_errors.NotFoundError(`day note of ${input.localDate} not found`);
154
+ }
155
+ const atUtc = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
156
+ const wanted = input.handled ? atUtc : null;
157
+ if (existing.handledAt === wanted) {
158
+ return existing;
159
+ }
160
+ const run = db.transaction(() => {
161
+ var _a2;
162
+ updateHandled.run(wanted, wanted === null ? null : input.actorId, input.actorId, atUtc, existing.id);
163
+ (0, import_audit.writeAuditLog)(db, {
164
+ atUtc,
165
+ actorId: input.actorId,
166
+ action: "day_note.handled",
167
+ entity: "day_notes",
168
+ entityId: existing.id,
169
+ detail: { userId: input.userId, localDate: input.localDate, handled: input.handled },
170
+ ip: (_a2 = input.actorIp) != null ? _a2 : null
171
+ });
172
+ });
173
+ run();
174
+ return (_b = read(input.userId, input.localDate)) != null ? _b : existing;
175
+ }
176
+ };
177
+ }
178
+ // Annotate the CommonJS export names for ESM import in node:
179
+ 0 && (module.exports = {
180
+ MAX_DAY_NOTE_LENGTH,
181
+ createDayNotesRepository
182
+ });
183
+ //# sourceMappingURL=dayNotes.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/lib/db/repositories/dayNotes.ts"],
4
+ "sourcesContent": ["/**\n * Day notes (`day_notes`): what an employee wants the administration to know about one day.\n *\n * An employee does not change punches; the day of a forgotten punch is corrected by the administration. What the\n * employee may do is leave a note like \u201Cforgot to clock in or out\u201D. That message lives outside the punches, because\n * a day without a punch is exactly the case it is meant for. One note per employee and day: writing a second one\n * replaces the first, an empty text takes it back. The administration marks a note as handled and keeps it.\n */\n\nimport type { Db } from \"../database\";\nimport { isDateString } from \"../../util/time\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { writeAuditLog } from \"./audit\";\n\n/** Longest text a note may carry. */\nexport const MAX_DAY_NOTE_LENGTH = 500;\n\n/** A note an employee left for one of his days. */\nexport interface DayNoteRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Owner of the day */\n\tuserId: number;\n\t/** Local date the note belongs to (`YYYY-MM-DD`) */\n\tlocalDate: string;\n\t/** Text of the note */\n\tnote: string;\n\t/** Who wrote it */\n\tcreatedBy: number | null;\n\t/** Instant it was written, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Who changed it last */\n\tupdatedBy: number | null;\n\t/** Instant of the last change, UTC epoch seconds */\n\tupdatedAt: number;\n\t/** Instant the administration marked it as handled, `null` while it waits */\n\thandledAt: number | null;\n\t/** Who marked it as handled */\n\thandledBy: number | null;\n}\n\n/** Input for writing (or clearing) the note of one day. */\nexport interface SaveDayNoteInput {\n\t/** Owner of the day */\n\tuserId: number;\n\t/** Local date the note belongs to */\n\tlocalDate: string;\n\t/** Text of the note; an empty text removes the stored note */\n\tnote: string;\n\t/** Who writes it */\n\tactorId: number;\n\t/** Client IP address */\n\tactorIp?: string | null;\n\t/** Instant of the change, defaults to now */\n\tnow?: number;\n}\n\n/** Input for marking a note as handled (or open again). */\nexport interface HandleDayNoteInput {\n\t/** Owner of the day */\n\tuserId: number;\n\t/** Local date the note belongs to */\n\tlocalDate: string;\n\t/** True marks it as handled, false puts it back into the list of open notes */\n\thandled: boolean;\n\t/** Who decides */\n\tactorId: number;\n\t/** Client IP address */\n\tactorIp?: string | null;\n\t/** Instant of the change, defaults to now */\n\tnow?: number;\n}\n\n/** Typed access to the day notes. */\nexport interface DayNotesRepository {\n\t/** Notes of one employee in a local date range (inclusive), oldest first */\n\tlistByRange(userId: number, fromDate: string, toDate: string): DayNoteRecord[];\n\t/** Note of one day or `null` */\n\tfind(userId: number, localDate: string): DayNoteRecord | null;\n\t/**\n\t * Writes the note of one day.\n\t *\n\t * @param input - owner, day, text and actor\n\t * @returns the stored note, `null` when an empty text removed it\n\t */\n\tsave(input: SaveDayNoteInput): DayNoteRecord | null;\n\t/**\n\t * Marks a note as handled or open again.\n\t *\n\t * @param input - owner, day and decision\n\t * @returns the updated note\n\t */\n\tsetHandled(input: HandleDayNoteInput): DayNoteRecord;\n}\n\n/** One row of `day_notes` as it is stored. */\ninterface DayNoteRow {\n\tid: number;\n\tuser_id: number;\n\tlocal_date: string;\n\tnote: string;\n\tcreated_by: number | null;\n\tcreated_at: number;\n\tupdated_by: number | null;\n\tupdated_at: number;\n\thandled_at: number | null;\n\thandled_by: number | null;\n}\n\n/**\n * Creates the repository of the day notes.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createDayNotesRepository(db: Db): DayNotesRepository {\n\tconst COLUMNS = `id, user_id, local_date, note, created_by, created_at, updated_by, updated_at, handled_at,\n\t\thandled_by`;\n\tconst selectByRange = db.prepare(\n\t\t`SELECT ${COLUMNS} FROM day_notes WHERE user_id = ? AND local_date BETWEEN ? AND ? ORDER BY local_date, id`,\n\t);\n\tconst selectByDate = db.prepare(`SELECT ${COLUMNS} FROM day_notes WHERE user_id = ? AND local_date = ?`);\n\tconst insert = db.prepare(\n\t\t`INSERT INTO day_notes (user_id, local_date, note, created_by, created_at, updated_by, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateText = db.prepare(`UPDATE day_notes SET note = ?, updated_by = ?, updated_at = ? WHERE id = ?`);\n\tconst removeRow = db.prepare(`DELETE FROM day_notes WHERE id = ?`);\n\tconst updateHandled = db.prepare(\n\t\t`UPDATE day_notes SET handled_at = ?, handled_by = ?, updated_by = ?, updated_at = ? WHERE id = ?`,\n\t);\n\n\t/**\n\t * Turns a stored row into the record the API returns.\n\t *\n\t * @param row - stored row\n\t * @returns the record\n\t */\n\tconst mapRow = (row: DayNoteRow): DayNoteRecord => ({\n\t\tid: row.id,\n\t\tuserId: row.user_id,\n\t\tlocalDate: row.local_date,\n\t\tnote: row.note,\n\t\tcreatedBy: row.created_by,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedBy: row.updated_by,\n\t\tupdatedAt: row.updated_at,\n\t\thandledAt: row.handled_at,\n\t\thandledBy: row.handled_by,\n\t});\n\n\t/**\n\t * Reads the note of one day.\n\t *\n\t * @param userId - owner of the day\n\t * @param localDate - the day\n\t * @returns the note or `null`\n\t */\n\tconst read = (userId: number, localDate: string): DayNoteRecord | null => {\n\t\tconst row = selectByDate.get(userId, localDate) as DayNoteRow | undefined;\n\t\treturn row ? mapRow(row) : null;\n\t};\n\n\t/**\n\t * Checks the day a note belongs to.\n\t *\n\t * @param localDate - the day\n\t */\n\tconst requireDate = (localDate: string): void => {\n\t\tif (!isDateString(localDate)) {\n\t\t\tthrow new ValidationError(\"localDate must be a date (YYYY-MM-DD)\");\n\t\t}\n\t};\n\n\treturn {\n\t\tlistByRange(userId: number, fromDate: string, toDate: string): DayNoteRecord[] {\n\t\t\tconst rows = selectByRange.all(userId, fromDate, toDate) as DayNoteRow[];\n\t\t\treturn rows.map(mapRow);\n\t\t},\n\n\t\tfind: read,\n\n\t\tsave(input: SaveDayNoteInput): DayNoteRecord | null {\n\t\t\trequireDate(input.localDate);\n\t\t\tconst note = input.note.trim();\n\t\t\tif (note.length > MAX_DAY_NOTE_LENGTH) {\n\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t`note must not be longer than ${MAX_DAY_NOTE_LENGTH} characters (got ${note.length})`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst atUtc = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst existing = read(input.userId, input.localDate);\n\n\t\t\t// an empty text is how a note is taken back\n\t\t\tif (note === \"\") {\n\t\t\t\tif (!existing) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tconst run = db.transaction((): void => {\n\t\t\t\t\tremoveRow.run(existing.id);\n\t\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\t\tatUtc,\n\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\taction: \"day_note.delete\",\n\t\t\t\t\t\tentity: \"day_notes\",\n\t\t\t\t\t\tentityId: existing.id,\n\t\t\t\t\t\tdetail: { userId: input.userId, localDate: input.localDate, note: existing.note },\n\t\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t\trun();\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tif (existing && existing.note === note) {\n\t\t\t\treturn existing;\n\t\t\t}\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tif (existing) {\n\t\t\t\t\tupdateText.run(note, input.actorId, atUtc, existing.id);\n\t\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\t\tatUtc,\n\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\taction: \"day_note.update\",\n\t\t\t\t\t\tentity: \"day_notes\",\n\t\t\t\t\t\tentityId: existing.id,\n\t\t\t\t\t\tdetail: {\n\t\t\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\t\t\tlocalDate: input.localDate,\n\t\t\t\t\t\t\told: existing.note,\n\t\t\t\t\t\t\tnew: note,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = insert.run(\n\t\t\t\t\tinput.userId,\n\t\t\t\t\tinput.localDate,\n\t\t\t\t\tnote,\n\t\t\t\t\tinput.actorId,\n\t\t\t\t\tatUtc,\n\t\t\t\t\tinput.actorId,\n\t\t\t\t\tatUtc,\n\t\t\t\t);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"day_note.create\",\n\t\t\t\t\tentity: \"day_notes\",\n\t\t\t\t\tentityId: Number(result.lastInsertRowid),\n\t\t\t\t\tdetail: { userId: input.userId, localDate: input.localDate, note },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn read(input.userId, input.localDate);\n\t\t},\n\n\t\tsetHandled(input: HandleDayNoteInput): DayNoteRecord {\n\t\t\trequireDate(input.localDate);\n\t\t\tconst existing = read(input.userId, input.localDate);\n\t\t\tif (!existing) {\n\t\t\t\tthrow new NotFoundError(`day note of ${input.localDate} not found`);\n\t\t\t}\n\t\t\tconst atUtc = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst wanted = input.handled ? atUtc : null;\n\t\t\tif (existing.handledAt === wanted) {\n\t\t\t\treturn existing;\n\t\t\t}\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupdateHandled.run(wanted, wanted === null ? null : input.actorId, input.actorId, atUtc, existing.id);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"day_note.handled\",\n\t\t\t\t\tentity: \"day_notes\",\n\t\t\t\t\tentityId: existing.id,\n\t\t\t\t\tdetail: { userId: input.userId, localDate: input.localDate, handled: input.handled },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn read(input.userId, input.localDate) ?? existing;\n\t\t},\n\t};\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["run", "_a"]
7
+ }
@@ -48,7 +48,7 @@ const PERMISSIONS = {
48
48
  };
49
49
  const ALL_PERMISSIONS = Object.values(PERMISSIONS).flat();
50
50
  const ROLE_PERMISSIONS = {
51
- admin: ALL_PERMISSIONS,
51
+ admin: ALL_PERMISSIONS.filter((permission) => permission !== "time.punch"),
52
52
  manager: [
53
53
  "time.punch",
54
54
  "time.edit_own",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/db/seed.ts"],
4
- "sourcesContent": ["/**\n * Initial data (seeds): roles, permission catalogue, absence types, instance settings and holidays.\n *\n * All seed operations are idempotent (`INSERT OR IGNORE`), so `seed()` can run on every adapter start.\n */\n\nimport type { Db } from \"./database\";\nimport { holidaysForYear, type HolidayCountry } from \"../domain/holidays\";\n\n/** Permission catalogue (keys are stored in `permissions.key`). */\nexport const PERMISSIONS = {\n\ttime: [\"time.punch\", \"time.edit_own\", \"time.edit_other\", \"time.delete\", \"time.import\", \"time.resolve_conflict\"],\n\tabsence: [\"absence.request\", \"absence.approve\", \"absence.edit_other\", \"absence.manage_types\"],\n\tuser: [\"user.view\", \"user.create\", \"user.edit\", \"user.deactivate\", \"user.manage_roles\"],\n\treport: [\"report.view_own\", \"report.view_other\", \"report.export\", \"report.statistics\", \"report.print_locked\"],\n\tpayout: [\"payout.view\", \"payout.create\"],\n\tsystem: [\n\t\t\"settings.view\",\n\t\t\"settings.edit\",\n\t\t\"rfid.manage\",\n\t\t\"terminal.manage\",\n\t\t\"holiday.manage\",\n\t\t\"import.run\",\n\t\t\"audit.view\",\n\t\t\"backup.run\",\n\t],\n\tcommand: [\"command.punch\", \"command.close_month\", \"command.recalc\"],\n} as const;\n\nexport const ALL_PERMISSIONS: string[] = Object.values(PERMISSIONS).flat();\n\n/** Role \u2192 permissions (specification section 4.8). */\nexport const ROLE_PERMISSIONS: Record<string, string[]> = {\n\tadmin: ALL_PERMISSIONS,\n\tmanager: [\n\t\t\"time.punch\",\n\t\t\"time.edit_own\",\n\t\t\"time.edit_other\",\n\t\t\"time.delete\",\n\t\t\"time.resolve_conflict\",\n\t\t\"absence.request\",\n\t\t\"absence.approve\",\n\t\t\"absence.edit_other\",\n\t\t\"absence.manage_types\",\n\t\t\"user.view\",\n\t\t\"report.view_own\",\n\t\t\"report.view_other\",\n\t\t\"report.export\",\n\t\t\"report.statistics\",\n\t\t\"report.print_locked\",\n\t\t\"payout.view\",\n\t\t\"settings.view\",\n\t\t\"holiday.manage\",\n\t\t\"audit.view\",\n\t\t\"command.punch\",\n\t\t\"command.close_month\",\n\t\t\"command.recalc\",\n\t],\n\temployee: [\"time.punch\", \"time.edit_own\", \"absence.request\", \"report.view_own\"],\n};\n\n/**\n * Default absence types of the time tracking: code, name, factor, reduces vacation, visible for everybody.\n *\n * \u201CVisible for everybody\u201D (`active`) says whether an employee may pick the type in the app. Sickness, accident and\n * military service start switched off: a company gets those notes on the same day and books them itself, while\n * vacation, further training and the like are requested by the employee.\n */\nexport const ABSENCE_TYPES: {\n\tcode: string;\n\tname: string;\n\tfactor: number;\n\treduceVacation: boolean;\n\tactive: boolean;\n\tcolor: string;\n}[] = [\n\t{ code: \"F\", name: \"Ferien\", factor: 100, reduceVacation: true, active: true, color: \"#2e7d32\" },\n\t{ code: \"K\", name: \"Krankheit\", factor: 100, reduceVacation: false, active: false, color: \"#c62828\" },\n\t{ code: \"U\", name: \"Unfall\", factor: 100, reduceVacation: false, active: false, color: \"#ef6c00\" },\n\t{ code: \"M\", name: \"Milit\u00E4r\", factor: 100, reduceVacation: false, active: false, color: \"#455a64\" },\n\t{ code: \"I\", name: \"Intern\", factor: 100, reduceVacation: false, active: true, color: \"#1565c0\" },\n\t{ code: \"W\", name: \"Weiterbildung\", factor: 50, reduceVacation: false, active: true, color: \"#6a1b9a\" },\n\t{ code: \"E\", name: \"Extern\", factor: 50, reduceVacation: false, active: true, color: \"#795548\" },\n];\n\n/** Default instance settings (specification section 2.9.9); existing values are never overwritten. */\nexport const SETTING_DEFAULTS: Record<string, string> = {\n\tedit_window_days: \"7\",\n\tquick_round_minutes: \"0\",\n\tabsence_calc_until_today: \"1\",\n\tabsence_deduct_worktime: \"0\",\n\tprint_limit_days: \"0\",\n\tholiday_country: \"DE\",\n\tdefault_language: \"de\",\n\tsession_ttl_minutes: \"720\",\n\tbackup_retention_days: \"30\",\n\tattendance_list_visible: \"0\",\n\t// how the pause of a day is determined: `auto` = the punched break, otherwise the graduated rules\n\tpause_mode: \"auto\",\n\t// path of a Unicode font for the PDF statement; empty uses the built-in Latin fonts\n\treport_font_path: \"\",\n\t// branding of the installation: logo and background as data URLs (empty = not set), accent colour as hex\n\tbrand_logo: \"\",\n\tbrand_background: \"\",\n\tbrand_color: \"\",\n};\n\n/**\n * Options controlling which initial data is written.\n */\nexport interface SeedOptions {\n\t/** Country used to generate public holidays (default `CH`) */\n\tholidayCountry?: HolidayCountry;\n\t/** Years for which holidays are generated (default: current and next year) */\n\tholidayYears?: number[];\n}\n\n/**\n * Writes all initial data. Idempotent: repeated calls change nothing and never overwrite\n * user-modified settings.\n *\n * @param db - open database handle\n * @param options - seed options\n * @returns summary of what is now present\n */\nexport function seed(db: Db, options: SeedOptions = {}): { permissions: number; holidays: number } {\n\tconst country: HolidayCountry = options.holidayCountry ?? \"DE\";\n\tconst currentYear = new Date().getUTCFullYear();\n\tconst years = options.holidayYears ?? [currentYear, currentYear + 1];\n\n\tconst run = db.transaction((): void => {\n\t\tconst now = Math.floor(Date.now() / 1000);\n\n\t\tconst insertRole = db.prepare(\"INSERT OR IGNORE INTO roles (key, name) VALUES (?, ?)\");\n\t\tinsertRole.run(\"admin\", \"Administrator\");\n\t\tinsertRole.run(\"manager\", \"Manager\");\n\t\tinsertRole.run(\"employee\", \"Employee\");\n\n\t\tconst insertPermission = db.prepare(\"INSERT OR IGNORE INTO permissions (key) VALUES (?)\");\n\t\tfor (const permission of ALL_PERMISSIONS) {\n\t\t\tinsertPermission.run(permission);\n\t\t}\n\n\t\tconst findRole = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\t\tconst findPermission = db.prepare(\"SELECT id FROM permissions WHERE key = ?\");\n\t\tconst linkRole = db.prepare(\"INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)\");\n\t\tfor (const [roleKey, permissions] of Object.entries(ROLE_PERMISSIONS)) {\n\t\t\tconst role = findRole.get(roleKey) as { id: number } | undefined;\n\t\t\tif (!role) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const permission of permissions) {\n\t\t\t\tconst row = findPermission.get(permission) as { id: number } | undefined;\n\t\t\t\tif (row) {\n\t\t\t\t\tlinkRole.run(role.id, row.id);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst insertAbsenceType = db.prepare(\n\t\t\t`INSERT OR IGNORE INTO absence_types (user_id, code, name, paid, factor, reduce_vacation, is_active, color)\n\t\t\t VALUES (NULL, ?, ?, 1, ?, ?, ?, ?)`,\n\t\t);\n\t\tfor (const type of ABSENCE_TYPES) {\n\t\t\tinsertAbsenceType.run(\n\t\t\t\ttype.code,\n\t\t\t\ttype.name,\n\t\t\t\ttype.factor,\n\t\t\t\ttype.reduceVacation ? 1 : 0,\n\t\t\t\ttype.active ? 1 : 0,\n\t\t\t\ttype.color,\n\t\t\t);\n\t\t}\n\n\t\tconst insertSetting = db.prepare(\n\t\t\t\"INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)\",\n\t\t);\n\t\tfor (const [key, value] of Object.entries(SETTING_DEFAULTS)) {\n\t\t\tinsertSetting.run(key, key === \"holiday_country\" ? country : value, now);\n\t\t}\n\t});\n\trun();\n\n\tlet holidayCount = 0;\n\tfor (const year of years) {\n\t\tholidayCount += ensureHolidaysForYear(db, country, year);\n\t}\n\n\tconst permissionCount = (db.prepare(\"SELECT COUNT(*) AS c FROM permissions\").get() as { c: number }).c;\n\treturn { permissions: permissionCount, holidays: holidayCount };\n}\n\n/**\n * Generates and stores the public holidays of one year for a country (idempotent).\n *\n * @param db - open database handle\n * @param country - supported country code\n * @param year - four digit year\n * @returns number of inserted holiday rows\n */\nexport function ensureHolidaysForYear(db: Db, country: HolidayCountry, year: number): number {\n\tconst insert = db.prepare(\"INSERT OR IGNORE INTO holidays (region, year, date, name, key) VALUES (?, ?, ?, ?, ?)\");\n\tlet inserted = 0;\n\tconst run = db.transaction((): void => {\n\t\tfor (const holiday of holidaysForYear(year, country)) {\n\t\t\tinserted += insert.run(country, year, holiday.date, holiday.name, holiday.key).changes;\n\t\t}\n\t});\n\trun();\n\treturn inserted;\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Initial data (seeds): roles, permission catalogue, absence types, instance settings and holidays.\n *\n * All seed operations are idempotent (`INSERT OR IGNORE`), so `seed()` can run on every adapter start.\n */\n\nimport type { Db } from \"./database\";\nimport { holidaysForYear, type HolidayCountry } from \"../domain/holidays\";\n\n/** Permission catalogue (keys are stored in `permissions.key`). */\nexport const PERMISSIONS = {\n\ttime: [\"time.punch\", \"time.edit_own\", \"time.edit_other\", \"time.delete\", \"time.import\", \"time.resolve_conflict\"],\n\tabsence: [\"absence.request\", \"absence.approve\", \"absence.edit_other\", \"absence.manage_types\"],\n\tuser: [\"user.view\", \"user.create\", \"user.edit\", \"user.deactivate\", \"user.manage_roles\"],\n\treport: [\"report.view_own\", \"report.view_other\", \"report.export\", \"report.statistics\", \"report.print_locked\"],\n\tpayout: [\"payout.view\", \"payout.create\"],\n\tsystem: [\n\t\t\"settings.view\",\n\t\t\"settings.edit\",\n\t\t\"rfid.manage\",\n\t\t\"terminal.manage\",\n\t\t\"holiday.manage\",\n\t\t\"import.run\",\n\t\t\"audit.view\",\n\t\t\"backup.run\",\n\t],\n\tcommand: [\"command.punch\", \"command.close_month\", \"command.recalc\"],\n} as const;\n\nexport const ALL_PERMISSIONS: string[] = Object.values(PERMISSIONS).flat();\n\n/**\n * Role \u2192 permissions (specification section 4.8).\n *\n * The administrator account is created by the installation and belongs to nobody: it administers the employees\n * instead of working with them and therefore does not punch (migration 26 takes the right away from installations\n * that already exist). Whoever both administers and works carries the `employee` role as well \u2014 the right comes\n * back with it, because permissions are the union of the roles of an account.\n */\nexport const ROLE_PERMISSIONS: Record<string, string[]> = {\n\tadmin: ALL_PERMISSIONS.filter(permission => permission !== \"time.punch\"),\n\tmanager: [\n\t\t\"time.punch\",\n\t\t\"time.edit_own\",\n\t\t\"time.edit_other\",\n\t\t\"time.delete\",\n\t\t\"time.resolve_conflict\",\n\t\t\"absence.request\",\n\t\t\"absence.approve\",\n\t\t\"absence.edit_other\",\n\t\t\"absence.manage_types\",\n\t\t\"user.view\",\n\t\t\"report.view_own\",\n\t\t\"report.view_other\",\n\t\t\"report.export\",\n\t\t\"report.statistics\",\n\t\t\"report.print_locked\",\n\t\t\"payout.view\",\n\t\t\"settings.view\",\n\t\t\"holiday.manage\",\n\t\t\"audit.view\",\n\t\t\"command.punch\",\n\t\t\"command.close_month\",\n\t\t\"command.recalc\",\n\t],\n\temployee: [\"time.punch\", \"time.edit_own\", \"absence.request\", \"report.view_own\"],\n};\n\n/**\n * Default absence types of the time tracking: code, name, factor, reduces vacation, visible for everybody.\n *\n * \u201CVisible for everybody\u201D (`active`) says whether an employee may pick the type in the app. Sickness, accident and\n * military service start switched off: a company gets those notes on the same day and books them itself, while\n * vacation, further training and the like are requested by the employee.\n */\nexport const ABSENCE_TYPES: {\n\tcode: string;\n\tname: string;\n\tfactor: number;\n\treduceVacation: boolean;\n\tactive: boolean;\n\tcolor: string;\n}[] = [\n\t{ code: \"F\", name: \"Ferien\", factor: 100, reduceVacation: true, active: true, color: \"#2e7d32\" },\n\t{ code: \"K\", name: \"Krankheit\", factor: 100, reduceVacation: false, active: false, color: \"#c62828\" },\n\t{ code: \"U\", name: \"Unfall\", factor: 100, reduceVacation: false, active: false, color: \"#ef6c00\" },\n\t{ code: \"M\", name: \"Milit\u00E4r\", factor: 100, reduceVacation: false, active: false, color: \"#455a64\" },\n\t{ code: \"I\", name: \"Intern\", factor: 100, reduceVacation: false, active: true, color: \"#1565c0\" },\n\t{ code: \"W\", name: \"Weiterbildung\", factor: 50, reduceVacation: false, active: true, color: \"#6a1b9a\" },\n\t{ code: \"E\", name: \"Extern\", factor: 50, reduceVacation: false, active: true, color: \"#795548\" },\n];\n\n/** Default instance settings (specification section 2.9.9); existing values are never overwritten. */\nexport const SETTING_DEFAULTS: Record<string, string> = {\n\tedit_window_days: \"7\",\n\tquick_round_minutes: \"0\",\n\tabsence_calc_until_today: \"1\",\n\tabsence_deduct_worktime: \"0\",\n\tprint_limit_days: \"0\",\n\tholiday_country: \"DE\",\n\tdefault_language: \"de\",\n\tsession_ttl_minutes: \"720\",\n\tbackup_retention_days: \"30\",\n\tattendance_list_visible: \"0\",\n\t// how the pause of a day is determined: `auto` = the punched break, otherwise the graduated rules\n\tpause_mode: \"auto\",\n\t// path of a Unicode font for the PDF statement; empty uses the built-in Latin fonts\n\treport_font_path: \"\",\n\t// branding of the installation: logo and background as data URLs (empty = not set), accent colour as hex\n\tbrand_logo: \"\",\n\tbrand_background: \"\",\n\tbrand_color: \"\",\n};\n\n/**\n * Options controlling which initial data is written.\n */\nexport interface SeedOptions {\n\t/** Country used to generate public holidays (default `CH`) */\n\tholidayCountry?: HolidayCountry;\n\t/** Years for which holidays are generated (default: current and next year) */\n\tholidayYears?: number[];\n}\n\n/**\n * Writes all initial data. Idempotent: repeated calls change nothing and never overwrite\n * user-modified settings.\n *\n * @param db - open database handle\n * @param options - seed options\n * @returns summary of what is now present\n */\nexport function seed(db: Db, options: SeedOptions = {}): { permissions: number; holidays: number } {\n\tconst country: HolidayCountry = options.holidayCountry ?? \"DE\";\n\tconst currentYear = new Date().getUTCFullYear();\n\tconst years = options.holidayYears ?? [currentYear, currentYear + 1];\n\n\tconst run = db.transaction((): void => {\n\t\tconst now = Math.floor(Date.now() / 1000);\n\n\t\tconst insertRole = db.prepare(\"INSERT OR IGNORE INTO roles (key, name) VALUES (?, ?)\");\n\t\tinsertRole.run(\"admin\", \"Administrator\");\n\t\tinsertRole.run(\"manager\", \"Manager\");\n\t\tinsertRole.run(\"employee\", \"Employee\");\n\n\t\tconst insertPermission = db.prepare(\"INSERT OR IGNORE INTO permissions (key) VALUES (?)\");\n\t\tfor (const permission of ALL_PERMISSIONS) {\n\t\t\tinsertPermission.run(permission);\n\t\t}\n\n\t\tconst findRole = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\t\tconst findPermission = db.prepare(\"SELECT id FROM permissions WHERE key = ?\");\n\t\tconst linkRole = db.prepare(\"INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)\");\n\t\tfor (const [roleKey, permissions] of Object.entries(ROLE_PERMISSIONS)) {\n\t\t\tconst role = findRole.get(roleKey) as { id: number } | undefined;\n\t\t\tif (!role) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const permission of permissions) {\n\t\t\t\tconst row = findPermission.get(permission) as { id: number } | undefined;\n\t\t\t\tif (row) {\n\t\t\t\t\tlinkRole.run(role.id, row.id);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst insertAbsenceType = db.prepare(\n\t\t\t`INSERT OR IGNORE INTO absence_types (user_id, code, name, paid, factor, reduce_vacation, is_active, color)\n\t\t\t VALUES (NULL, ?, ?, 1, ?, ?, ?, ?)`,\n\t\t);\n\t\tfor (const type of ABSENCE_TYPES) {\n\t\t\tinsertAbsenceType.run(\n\t\t\t\ttype.code,\n\t\t\t\ttype.name,\n\t\t\t\ttype.factor,\n\t\t\t\ttype.reduceVacation ? 1 : 0,\n\t\t\t\ttype.active ? 1 : 0,\n\t\t\t\ttype.color,\n\t\t\t);\n\t\t}\n\n\t\tconst insertSetting = db.prepare(\n\t\t\t\"INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)\",\n\t\t);\n\t\tfor (const [key, value] of Object.entries(SETTING_DEFAULTS)) {\n\t\t\tinsertSetting.run(key, key === \"holiday_country\" ? country : value, now);\n\t\t}\n\t});\n\trun();\n\n\tlet holidayCount = 0;\n\tfor (const year of years) {\n\t\tholidayCount += ensureHolidaysForYear(db, country, year);\n\t}\n\n\tconst permissionCount = (db.prepare(\"SELECT COUNT(*) AS c FROM permissions\").get() as { c: number }).c;\n\treturn { permissions: permissionCount, holidays: holidayCount };\n}\n\n/**\n * Generates and stores the public holidays of one year for a country (idempotent).\n *\n * @param db - open database handle\n * @param country - supported country code\n * @param year - four digit year\n * @returns number of inserted holiday rows\n */\nexport function ensureHolidaysForYear(db: Db, country: HolidayCountry, year: number): number {\n\tconst insert = db.prepare(\"INSERT OR IGNORE INTO holidays (region, year, date, name, key) VALUES (?, ?, ?, ?, ?)\");\n\tlet inserted = 0;\n\tconst run = db.transaction((): void => {\n\t\tfor (const holiday of holidaysForYear(year, country)) {\n\t\t\tinserted += insert.run(country, year, holiday.date, holiday.name, holiday.key).changes;\n\t\t}\n\t});\n\trun();\n\treturn inserted;\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -50,20 +50,21 @@ function createSyncService(deps) {
50
50
  };
51
51
  return {
52
52
  sync(input) {
53
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
53
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m;
54
54
  const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
55
55
  const maxSkew = (_b = input.maxClockSkewSeconds) != null ? _b : 300;
56
56
  const minDistance = (_c = input.minDistanceSeconds) != null ? _c : 30;
57
57
  const maxFuture = (_d = input.maxFutureSeconds) != null ? _d : 12 * 3600;
58
+ const maxPast = (_e = input.maxPastSeconds) != null ? _e : 0;
58
59
  const profile = users.getWorkProfile(input.userId);
59
- const window = { startDate: (_e = profile == null ? void 0 : profile.startDate) != null ? _e : null, endDate: (_f = profile == null ? void 0 : profile.endDate) != null ? _f : null };
60
+ const window = { startDate: (_f = profile == null ? void 0 : profile.startDate) != null ? _f : null, endDate: (_g = profile == null ? void 0 : profile.endDate) != null ? _g : null };
60
61
  const accepted = [];
61
62
  const conflicts = [];
62
63
  const rejected = [];
63
64
  const touched = /* @__PURE__ */ new Set();
64
65
  const sorted = input.punches.slice().sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));
65
66
  for (const punch of sorted) {
66
- const key = (_h = (_g = punch.idempotencyKey) == null ? void 0 : _g.trim()) != null ? _h : "";
67
+ const key = (_i = (_h = punch.idempotencyKey) == null ? void 0 : _h.trim()) != null ? _i : "";
67
68
  if (!key) {
68
69
  rejected.push({ idempotencyKey: "", reason: "missing_key", detail: "idempotencyKey is required" });
69
70
  continue;
@@ -102,26 +103,27 @@ function createSyncService(deps) {
102
103
  }
103
104
  const skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);
104
105
  const windowError = (0, import_punch.checkEmploymentWindow)(punch.tsUtc, window);
105
- const syncState = skew > maxSkew || windowError ? "conflict" : "synced";
106
+ const tooOld = maxPast > 0 && punch.tsUtc < now - maxPast;
107
+ const syncState = skew > maxSkew || windowError || tooOld ? "conflict" : "synced";
106
108
  const stored = entries.insert({
107
109
  userId: input.userId,
108
110
  tsUtc: punch.tsUtc,
109
- clientTsUtc: (_i = punch.clientTsUtc) != null ? _i : null,
111
+ clientTsUtc: (_j = punch.clientTsUtc) != null ? _j : null,
110
112
  timeZone: input.timeZone,
111
113
  source: "api",
112
- direction: (_j = punch.direction) != null ? _j : "auto",
114
+ direction: (_k = punch.direction) != null ? _k : "auto",
113
115
  idempotencyKey: key,
114
116
  syncState,
115
- note: (_k = punch.note) != null ? _k : null,
117
+ note: (_l = punch.note) != null ? _l : null,
116
118
  actorId: input.actorId,
117
- actorIp: (_l = input.actorIp) != null ? _l : null,
119
+ actorIp: (_m = input.actorIp) != null ? _m : null,
118
120
  now
119
121
  });
120
122
  touched.add(stored.entry.localDate);
121
123
  if (syncState === "conflict") {
122
124
  conflicts.push({
123
125
  idempotencyKey: key,
124
- reason: windowError ? "employment_window" : "clock_skew",
126
+ reason: tooOld ? "too_old" : windowError ? "employment_window" : "clock_skew",
125
127
  entryId: stored.entry.id,
126
128
  entry: stored.entry
127
129
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/services/sync.ts"],
4
- "sourcesContent": ["/**\n * Synchronisation service for offline punches.\n *\n * The PWA and the kiosk terminal queue punches locally while they have no connection and send them as a\n * batch afterwards. Every punch carries an idempotency key, so a repeated batch never creates duplicates.\n *\n * Punches that cannot be trusted are **not** discarded: they are stored with `sync_state = conflict` (they do\n * not count in the calculation) and reported back to the caller, so an employee or a manager can resolve them\n * later. Only punches that are clearly invalid (missing key, unusable instant, far in the future) are\n * rejected outright.\n *\n * A \"cancellation pair\" is a pair of neighbouring punches that lie within a tolerance of each other \u2014 usually\n * the result of an accidental double punch. Such pairs can be removed together.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { EntriesRepository, EntryDirection, EntryRecord, EntrySyncState } from \"../db/repositories/entries\";\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport { NotFoundError, ValidationError } from \"../errors\";\nimport { writeAuditLog } from \"../db/repositories/audit\";\nimport { checkEmploymentWindow } from \"../domain/punch\";\nimport { addDays, localDate as resolveLocalDate } from \"../util/time\";\nimport type { AggregationService } from \"./aggregation\";\n\n/** Reason why a punch was not accepted as it came in. */\nexport type SyncConflictReason =\n\t\"missing_key\" | \"invalid\" | \"future\" | \"duplicate_punch\" | \"clock_skew\" | \"employment_window\";\n\n/** A single punch of an offline batch. */\nexport interface SyncPunchInput {\n\t/** Idempotency key generated by the client (mandatory for batches) */\n\tidempotencyKey: string;\n\t/** Punch instant, UTC epoch seconds (device time) */\n\ttsUtc: number;\n\t/** Instant reported by the client clock (used for the skew check) */\n\tclientTsUtc?: number | null;\n\t/** Direction hint for the UI */\n\tdirection?: EntryDirection;\n\t/** Free-form note */\n\tnote?: string | null;\n}\n\n/** Input of a synchronisation batch. */\nexport interface SyncBatchInput {\n\t/** Employee the punches belong to */\n\tuserId: number;\n\t/** Time zone used to derive the local cache fields */\n\ttimeZone: string;\n\t/** Queued punches (order does not matter, they are sorted by instant) */\n\tpunches: SyncPunchInput[];\n\t/** Who triggers the synchronisation (the employee itself or an admin) */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Difference between device and server time tolerated before flagging (`300` seconds) */\n\tmaxClockSkewSeconds?: number;\n\t/** Punches closer than this to an existing punch are duplicates (`30` seconds) */\n\tminDistanceSeconds?: number;\n\t/** Punches further in the future than this are rejected (`12` hours) */\n\tmaxFutureSeconds?: number;\n\t/** Instant of the synchronisation, defaults to now */\n\tnow?: number;\n}\n\n/** Punches that were stored (or already existed). */\nexport interface SyncAccepted {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Id of the stored punch */\n\tentryId: number;\n\t/** False when the punch was already stored earlier */\n\tcreated: boolean;\n\t/** Resulting synchronisation state */\n\tsyncState: EntrySyncState;\n\t/** Local date the punch belongs to */\n\tlocalDate: string;\n}\n\n/** Punches that were stored but need a decision. */\nexport interface SyncConflict {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the conflict */\n\treason: SyncConflictReason;\n\t/** Id of the stored punch, `null` when nothing was stored */\n\tentryId: number | null;\n\t/** Stored punch, `null` when nothing was stored */\n\tentry: EntryRecord | null;\n}\n\n/** Punches that were not stored at all. */\nexport interface SyncRejected {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the rejection */\n\treason: SyncConflictReason;\n\t/** Human readable detail (e.g. the offending instant) */\n\tdetail: string;\n}\n\n/** Result of a synchronisation batch. */\nexport interface SyncBatchResult {\n\t/** Stored punches, sorted by instant */\n\taccepted: SyncAccepted[];\n\t/** Punches stored as conflict */\n\tconflicts: SyncConflict[];\n\t/** Punches that were not stored */\n\trejected: SyncRejected[];\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Input for resolving a conflict. */\nexport interface ResolveConflictInput {\n\t/** Id of the punch in conflict */\n\tentryId: number;\n\t/** `accept` keeps the punch (optionally with a corrected instant), `dismiss` deletes it */\n\taction: \"accept\" | \"dismiss\";\n\t/** Corrected instant (UTC epoch seconds) used when accepting */\n\ttsUtc?: number;\n\t/** Reason recorded in the audit trail */\n\treason?: string | null;\n\t/** Who resolves the conflict */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Time zone used to recompute the local cache fields when the instant changes */\n\ttimeZone?: string;\n\t/** Instant of the decision, defaults to now */\n\tnow?: number;\n}\n\n/** Result of resolving a conflict. */\nexport interface ResolveConflictResult {\n\t/** Applied action */\n\taction: \"accept\" | \"dismiss\";\n\t/** Punch after the decision, `null` after a dismissal */\n\tentry: EntryRecord | null;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Two neighbouring punches that cancel each other out. */\nexport interface CancellationPair {\n\t/** Earlier punch */\n\tfirst: EntryRecord;\n\t/** Later punch */\n\tsecond: EntryRecord;\n\t/** Distance between both punches in seconds */\n\tdistanceSeconds: number;\n}\n\n/** Result of removing a cancellation pair. */\nexport interface CancelPairResult {\n\t/** Number of deleted punches */\n\tremoved: number;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Synchronisation operations. */\nexport interface SyncService {\n\t/** Processes an offline batch */\n\tsync(input: SyncBatchInput): SyncBatchResult;\n\t/** Open conflicts of an employee, newest first */\n\tconflicts(userId: number): EntryRecord[];\n\t/** Accepts or dismisses a conflict */\n\tresolve(input: ResolveConflictInput): ResolveConflictResult;\n\t/** Finds neighbouring punches that cancel each other out */\n\tfindCancellationPairs(\n\t\tuserId: number,\n\t\tfromDate: string,\n\t\ttoDate: string,\n\t\toptions?: { maxDistanceSeconds?: number },\n\t): CancellationPair[];\n\t/** Removes both punches of a cancellation pair */\n\tcancelPair(input: {\n\t\tentryIds: [number, number];\n\t\treason?: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): CancelPairResult;\n}\n\n/** Data sources of the synchronisation service. */\nexport interface SyncDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** Punch storage */\n\tentries: EntriesRepository;\n\t/** User storage (employment window) */\n\tusers: UsersRepository;\n\t/** Aggregation service used to refresh the affected days */\n\taggregation: AggregationService;\n}\n\n/**\n * Creates the synchronisation service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createSyncService(deps: SyncDeps): SyncService {\n\tconst { db, entries, users, aggregation } = deps;\n\n\t/**\n\t * Recalculates the given local dates once.\n\t *\n\t * @param userId - owner of the days\n\t * @param dates - local dates to refresh\n\t * @returns the refreshed dates, sorted\n\t */\n\tconst refreshDays = (userId: number, dates: Iterable<string>): string[] => {\n\t\tconst unique = [...new Set(dates)].sort();\n\t\tfor (const date of unique) {\n\t\t\taggregation.recalculateDay(userId, date);\n\t\t}\n\t\treturn unique;\n\t};\n\n\t/**\n\t * Time zone of an employee (fallback `UTC`).\n\t *\n\t * @param userId - owner of the punches\n\t * @returns IANA time zone\n\t */\n\tconst timeZoneOf = (userId: number): string => users.findById(userId)?.timezone ?? \"UTC\";\n\n\t/**\n\t * Looks for an existing punch close to the given instant.\n\t *\n\t * @param userId - owner of the punches\n\t * @param tsUtc - instant to check\n\t * @param minDistance - tolerated distance in seconds\n\t * @param timeZone - time zone used to find the neighbouring days\n\t * @returns the nearby punch or `null`\n\t */\n\tconst findNearby = (userId: number, tsUtc: number, minDistance: number, timeZone: string): EntryRecord | null => {\n\t\tconst date = resolveLocalDate(tsUtc, timeZone);\n\t\tconst candidates = [\n\t\t\t...entries.listByDate(userId, addDays(date, -1)),\n\t\t\t...entries.listByDate(userId, date),\n\t\t\t...entries.listByDate(userId, addDays(date, 1)),\n\t\t];\n\t\treturn candidates.find(entry => Math.abs(entry.tsUtc - tsUtc) < minDistance) ?? null;\n\t};\n\n\treturn {\n\t\tsync(input: SyncBatchInput): SyncBatchResult {\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst maxSkew = input.maxClockSkewSeconds ?? 300;\n\t\t\tconst minDistance = input.minDistanceSeconds ?? 30;\n\t\t\tconst maxFuture = input.maxFutureSeconds ?? 12 * 3600;\n\t\t\tconst profile = users.getWorkProfile(input.userId);\n\t\t\tconst window = { startDate: profile?.startDate ?? null, endDate: profile?.endDate ?? null };\n\n\t\t\tconst accepted: SyncAccepted[] = [];\n\t\t\tconst conflicts: SyncConflict[] = [];\n\t\t\tconst rejected: SyncRejected[] = [];\n\t\t\tconst touched = new Set<string>();\n\n\t\t\tconst sorted = input.punches\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));\n\n\t\t\tfor (const punch of sorted) {\n\t\t\t\tconst key = punch.idempotencyKey?.trim() ?? \"\";\n\t\t\t\tif (!key) {\n\t\t\t\t\trejected.push({ idempotencyKey: \"\", reason: \"missing_key\", detail: \"idempotencyKey is required\" });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (!Number.isFinite(punch.tsUtc) || punch.tsUtc <= 0) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"invalid\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is not a usable instant`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (punch.tsUtc > now + maxFuture) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"future\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is more than ${maxFuture} seconds in the future`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// a punch that was already transferred is acknowledged without a second write\n\t\t\t\tconst alreadyStored = entries.findByIdempotencyKey(input.userId, key);\n\t\t\t\tif (alreadyStored) {\n\t\t\t\t\taccepted.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\tentryId: alreadyStored.id,\n\t\t\t\t\t\tcreated: false,\n\t\t\t\t\t\tsyncState: alreadyStored.syncState,\n\t\t\t\t\t\tlocalDate: alreadyStored.localDate,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// an unrelated punch at almost the same instant is a duplicate and needs a decision\n\t\t\t\tconst duplicate = findNearby(input.userId, punch.tsUtc, minDistance, input.timeZone);\n\t\t\t\tif (duplicate) {\n\t\t\t\t\tconflicts.push({ idempotencyKey: key, reason: \"duplicate_punch\", entryId: null, entry: null });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);\n\t\t\t\tconst windowError = checkEmploymentWindow(punch.tsUtc, window);\n\t\t\t\tconst syncState: EntrySyncState = skew > maxSkew || windowError ? \"conflict\" : \"synced\";\n\n\t\t\t\tconst stored = entries.insert({\n\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\ttsUtc: punch.tsUtc,\n\t\t\t\t\tclientTsUtc: punch.clientTsUtc ?? null,\n\t\t\t\t\ttimeZone: input.timeZone,\n\t\t\t\t\tsource: \"api\",\n\t\t\t\t\tdirection: punch.direction ?? \"auto\",\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tsyncState,\n\t\t\t\t\tnote: punch.note ?? null,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\ttouched.add(stored.entry.localDate);\n\n\t\t\t\tif (syncState === \"conflict\") {\n\t\t\t\t\tconflicts.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: windowError ? \"employment_window\" : \"clock_skew\",\n\t\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\t\tentry: stored.entry,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\taccepted.push({\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\tcreated: stored.created,\n\t\t\t\t\tsyncState: stored.entry.syncState,\n\t\t\t\t\tlocalDate: stored.entry.localDate,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn { accepted, conflicts, rejected, recalculated: refreshDays(input.userId, touched) };\n\t\t},\n\n\t\tconflicts(userId: number): EntryRecord[] {\n\t\t\treturn entries.listBySyncState(userId, \"conflict\");\n\t\t},\n\n\t\tresolve(input: ResolveConflictInput): ResolveConflictResult {\n\t\t\tconst current = entries.findById(input.entryId);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`entry ${input.entryId} not found`);\n\t\t\t}\n\t\t\tif (current.syncState !== \"conflict\") {\n\t\t\t\tthrow new ValidationError(`entry ${input.entryId} is not in conflict (state ${current.syncState})`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst touched = new Set<string>([current.localDate]);\n\t\t\tlet entry: EntryRecord | null = current;\n\n\t\t\tif (input.action === \"accept\") {\n\t\t\t\tif (input.tsUtc !== undefined && input.tsUtc !== current.tsUtc) {\n\t\t\t\t\tconst moved = entries.update({\n\t\t\t\t\t\tid: current.id,\n\t\t\t\t\t\texpectedRevision: current.revision,\n\t\t\t\t\t\tpatch: { tsUtc: input.tsUtc },\n\t\t\t\t\t\treason: input.reason ?? \"clock skew corrected\",\n\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\ttimeZone: input.timeZone ?? timeZoneOf(current.userId),\n\t\t\t\t\t\tnow,\n\t\t\t\t\t});\n\t\t\t\t\ttouched.add(moved.localDate);\n\t\t\t\t\ttouched.add(current.localDate);\n\t\t\t\t}\n\t\t\t\tentry = entries.setSyncState({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tsyncState: \"synced\",\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? null,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_accept\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: entry.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tentries.remove({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? \"conflict dismissed\",\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_dismiss\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: current.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t\tentry = null;\n\t\t\t}\n\n\t\t\treturn { action: input.action, entry, recalculated: refreshDays(current.userId, touched) };\n\t\t},\n\n\t\tfindCancellationPairs(\n\t\t\tuserId: number,\n\t\t\tfromDate: string,\n\t\t\ttoDate: string,\n\t\t\toptions?: { maxDistanceSeconds?: number },\n\t\t): CancellationPair[] {\n\t\t\tconst maxDistance = options?.maxDistanceSeconds ?? 120;\n\t\t\tconst punches = entries\n\t\t\t\t.listByRange(userId, fromDate, toDate)\n\t\t\t\t.filter(entry => entry.syncState === \"synced\")\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.id - b.id);\n\n\t\t\tconst pairs: CancellationPair[] = [];\n\t\t\tlet index = 0;\n\t\t\twhile (index + 1 < punches.length) {\n\t\t\t\tconst first = punches[index];\n\t\t\t\tconst second = punches[index + 1];\n\t\t\t\tconst distanceSeconds = second.tsUtc - first.tsUtc;\n\t\t\t\tif (distanceSeconds >= 0 && distanceSeconds <= maxDistance) {\n\t\t\t\t\tpairs.push({ first, second, distanceSeconds });\n\t\t\t\t\t// both punches are used, so the next candidate starts after them\n\t\t\t\t\tindex += 2;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tindex++;\n\t\t\t}\n\t\t\treturn pairs;\n\t\t},\n\n\t\tcancelPair(input: {\n\t\t\tentryIds: [number, number];\n\t\t\treason?: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): CancelPairResult {\n\t\t\tconst first = entries.findById(input.entryIds[0]);\n\t\t\tconst second = entries.findById(input.entryIds[1]);\n\t\t\tif (!first || !second) {\n\t\t\t\tthrow new NotFoundError(\"both punches of the cancellation pair must exist\");\n\t\t\t}\n\t\t\tif (first.userId !== second.userId) {\n\t\t\t\tthrow new ValidationError(\"both punches of the cancellation pair must belong to the same employee\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst reason = input.reason ?? \"cancellation pair\";\n\t\t\tconst touched = new Set<string>([first.localDate, second.localDate]);\n\t\t\tlet removed = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const entry of [first, second]) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tentries.remove({\n\t\t\t\t\t\t\tid: entry.id,\n\t\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\t\treason,\n\t\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\t\tnow,\n\t\t\t\t\t\t})\n\t\t\t\t\t) {\n\t\t\t\t\t\tremoved++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.cancel_pair\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: `${first.id}+${second.id}`,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: first.userId,\n\t\t\t\t\t\tpunch: { first: first.tsUtc, second: second.tsUtc },\n\t\t\t\t\t\tdistanceSeconds: second.tsUtc - first.tsUtc,\n\t\t\t\t\t\treason,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn { removed, recalculated: refreshDays(first.userId, touched) };\n\t\t},\n\t};\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Synchronisation service for offline punches.\n *\n * The PWA and the kiosk terminal queue punches locally while they have no connection and send them as a\n * batch afterwards. Every punch carries an idempotency key, so a repeated batch never creates duplicates.\n *\n * Punches that cannot be trusted are **not** discarded: they are stored with `sync_state = conflict` (they do\n * not count in the calculation) and reported back to the caller, so an employee or a manager can resolve them\n * later. Only punches that are clearly invalid (missing key, unusable instant, far in the future) are\n * rejected outright.\n *\n * A \"cancellation pair\" is a pair of neighbouring punches that lie within a tolerance of each other \u2014 usually\n * the result of an accidental double punch. Such pairs can be removed together.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { EntriesRepository, EntryDirection, EntryRecord, EntrySyncState } from \"../db/repositories/entries\";\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport { NotFoundError, ValidationError } from \"../errors\";\nimport { writeAuditLog } from \"../db/repositories/audit\";\nimport { checkEmploymentWindow } from \"../domain/punch\";\nimport { addDays, localDate as resolveLocalDate } from \"../util/time\";\nimport type { AggregationService } from \"./aggregation\";\n\n/** Reason why a punch was not accepted as it came in. */\nexport type SyncConflictReason =\n\t\"missing_key\" | \"invalid\" | \"future\" | \"too_old\" | \"duplicate_punch\" | \"clock_skew\" | \"employment_window\";\n\n/** A single punch of an offline batch. */\nexport interface SyncPunchInput {\n\t/** Idempotency key generated by the client (mandatory for batches) */\n\tidempotencyKey: string;\n\t/** Punch instant, UTC epoch seconds (device time) */\n\ttsUtc: number;\n\t/** Instant reported by the client clock (used for the skew check) */\n\tclientTsUtc?: number | null;\n\t/** Direction hint for the UI */\n\tdirection?: EntryDirection;\n\t/** Free-form note */\n\tnote?: string | null;\n}\n\n/** Input of a synchronisation batch. */\nexport interface SyncBatchInput {\n\t/** Employee the punches belong to */\n\tuserId: number;\n\t/** Time zone used to derive the local cache fields */\n\ttimeZone: string;\n\t/** Queued punches (order does not matter, they are sorted by instant) */\n\tpunches: SyncPunchInput[];\n\t/** Who triggers the synchronisation (the employee itself or an admin) */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Difference between device and server time tolerated before flagging (`300` seconds) */\n\tmaxClockSkewSeconds?: number;\n\t/** Punches closer than this to an existing punch are duplicates (`30` seconds) */\n\tminDistanceSeconds?: number;\n\t/** Punches further in the future than this are rejected (`12` hours) */\n\tmaxFutureSeconds?: number;\n\t/**\n\t * Queued punches older than this are stored as a conflict instead of counting (`0` = no bound).\n\t *\n\t * The offline queue is the only way an employee still writes a punch of his own, so the same window that\n\t * bounds an own change applies here: an older punch reaches the day and waits for the administration.\n\t */\n\tmaxPastSeconds?: number;\n\t/** Instant of the synchronisation, defaults to now */\n\tnow?: number;\n}\n\n/** Punches that were stored (or already existed). */\nexport interface SyncAccepted {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Id of the stored punch */\n\tentryId: number;\n\t/** False when the punch was already stored earlier */\n\tcreated: boolean;\n\t/** Resulting synchronisation state */\n\tsyncState: EntrySyncState;\n\t/** Local date the punch belongs to */\n\tlocalDate: string;\n}\n\n/** Punches that were stored but need a decision. */\nexport interface SyncConflict {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the conflict */\n\treason: SyncConflictReason;\n\t/** Id of the stored punch, `null` when nothing was stored */\n\tentryId: number | null;\n\t/** Stored punch, `null` when nothing was stored */\n\tentry: EntryRecord | null;\n}\n\n/** Punches that were not stored at all. */\nexport interface SyncRejected {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the rejection */\n\treason: SyncConflictReason;\n\t/** Human readable detail (e.g. the offending instant) */\n\tdetail: string;\n}\n\n/** Result of a synchronisation batch. */\nexport interface SyncBatchResult {\n\t/** Stored punches, sorted by instant */\n\taccepted: SyncAccepted[];\n\t/** Punches stored as conflict */\n\tconflicts: SyncConflict[];\n\t/** Punches that were not stored */\n\trejected: SyncRejected[];\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Input for resolving a conflict. */\nexport interface ResolveConflictInput {\n\t/** Id of the punch in conflict */\n\tentryId: number;\n\t/** `accept` keeps the punch (optionally with a corrected instant), `dismiss` deletes it */\n\taction: \"accept\" | \"dismiss\";\n\t/** Corrected instant (UTC epoch seconds) used when accepting */\n\ttsUtc?: number;\n\t/** Reason recorded in the audit trail */\n\treason?: string | null;\n\t/** Who resolves the conflict */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Time zone used to recompute the local cache fields when the instant changes */\n\ttimeZone?: string;\n\t/** Instant of the decision, defaults to now */\n\tnow?: number;\n}\n\n/** Result of resolving a conflict. */\nexport interface ResolveConflictResult {\n\t/** Applied action */\n\taction: \"accept\" | \"dismiss\";\n\t/** Punch after the decision, `null` after a dismissal */\n\tentry: EntryRecord | null;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Two neighbouring punches that cancel each other out. */\nexport interface CancellationPair {\n\t/** Earlier punch */\n\tfirst: EntryRecord;\n\t/** Later punch */\n\tsecond: EntryRecord;\n\t/** Distance between both punches in seconds */\n\tdistanceSeconds: number;\n}\n\n/** Result of removing a cancellation pair. */\nexport interface CancelPairResult {\n\t/** Number of deleted punches */\n\tremoved: number;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Synchronisation operations. */\nexport interface SyncService {\n\t/** Processes an offline batch */\n\tsync(input: SyncBatchInput): SyncBatchResult;\n\t/** Open conflicts of an employee, newest first */\n\tconflicts(userId: number): EntryRecord[];\n\t/** Accepts or dismisses a conflict */\n\tresolve(input: ResolveConflictInput): ResolveConflictResult;\n\t/** Finds neighbouring punches that cancel each other out */\n\tfindCancellationPairs(\n\t\tuserId: number,\n\t\tfromDate: string,\n\t\ttoDate: string,\n\t\toptions?: { maxDistanceSeconds?: number },\n\t): CancellationPair[];\n\t/** Removes both punches of a cancellation pair */\n\tcancelPair(input: {\n\t\tentryIds: [number, number];\n\t\treason?: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): CancelPairResult;\n}\n\n/** Data sources of the synchronisation service. */\nexport interface SyncDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** Punch storage */\n\tentries: EntriesRepository;\n\t/** User storage (employment window) */\n\tusers: UsersRepository;\n\t/** Aggregation service used to refresh the affected days */\n\taggregation: AggregationService;\n}\n\n/**\n * Creates the synchronisation service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createSyncService(deps: SyncDeps): SyncService {\n\tconst { db, entries, users, aggregation } = deps;\n\n\t/**\n\t * Recalculates the given local dates once.\n\t *\n\t * @param userId - owner of the days\n\t * @param dates - local dates to refresh\n\t * @returns the refreshed dates, sorted\n\t */\n\tconst refreshDays = (userId: number, dates: Iterable<string>): string[] => {\n\t\tconst unique = [...new Set(dates)].sort();\n\t\tfor (const date of unique) {\n\t\t\taggregation.recalculateDay(userId, date);\n\t\t}\n\t\treturn unique;\n\t};\n\n\t/**\n\t * Time zone of an employee (fallback `UTC`).\n\t *\n\t * @param userId - owner of the punches\n\t * @returns IANA time zone\n\t */\n\tconst timeZoneOf = (userId: number): string => users.findById(userId)?.timezone ?? \"UTC\";\n\n\t/**\n\t * Looks for an existing punch close to the given instant.\n\t *\n\t * @param userId - owner of the punches\n\t * @param tsUtc - instant to check\n\t * @param minDistance - tolerated distance in seconds\n\t * @param timeZone - time zone used to find the neighbouring days\n\t * @returns the nearby punch or `null`\n\t */\n\tconst findNearby = (userId: number, tsUtc: number, minDistance: number, timeZone: string): EntryRecord | null => {\n\t\tconst date = resolveLocalDate(tsUtc, timeZone);\n\t\tconst candidates = [\n\t\t\t...entries.listByDate(userId, addDays(date, -1)),\n\t\t\t...entries.listByDate(userId, date),\n\t\t\t...entries.listByDate(userId, addDays(date, 1)),\n\t\t];\n\t\treturn candidates.find(entry => Math.abs(entry.tsUtc - tsUtc) < minDistance) ?? null;\n\t};\n\n\treturn {\n\t\tsync(input: SyncBatchInput): SyncBatchResult {\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst maxSkew = input.maxClockSkewSeconds ?? 300;\n\t\t\tconst minDistance = input.minDistanceSeconds ?? 30;\n\t\t\tconst maxFuture = input.maxFutureSeconds ?? 12 * 3600;\n\t\t\tconst maxPast = input.maxPastSeconds ?? 0;\n\t\t\tconst profile = users.getWorkProfile(input.userId);\n\t\t\tconst window = { startDate: profile?.startDate ?? null, endDate: profile?.endDate ?? null };\n\n\t\t\tconst accepted: SyncAccepted[] = [];\n\t\t\tconst conflicts: SyncConflict[] = [];\n\t\t\tconst rejected: SyncRejected[] = [];\n\t\t\tconst touched = new Set<string>();\n\n\t\t\tconst sorted = input.punches\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));\n\n\t\t\tfor (const punch of sorted) {\n\t\t\t\tconst key = punch.idempotencyKey?.trim() ?? \"\";\n\t\t\t\tif (!key) {\n\t\t\t\t\trejected.push({ idempotencyKey: \"\", reason: \"missing_key\", detail: \"idempotencyKey is required\" });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (!Number.isFinite(punch.tsUtc) || punch.tsUtc <= 0) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"invalid\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is not a usable instant`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (punch.tsUtc > now + maxFuture) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"future\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is more than ${maxFuture} seconds in the future`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// a punch that was already transferred is acknowledged without a second write\n\t\t\t\tconst alreadyStored = entries.findByIdempotencyKey(input.userId, key);\n\t\t\t\tif (alreadyStored) {\n\t\t\t\t\taccepted.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\tentryId: alreadyStored.id,\n\t\t\t\t\t\tcreated: false,\n\t\t\t\t\t\tsyncState: alreadyStored.syncState,\n\t\t\t\t\t\tlocalDate: alreadyStored.localDate,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// an unrelated punch at almost the same instant is a duplicate and needs a decision\n\t\t\t\tconst duplicate = findNearby(input.userId, punch.tsUtc, minDistance, input.timeZone);\n\t\t\t\tif (duplicate) {\n\t\t\t\t\tconflicts.push({ idempotencyKey: key, reason: \"duplicate_punch\", entryId: null, entry: null });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);\n\t\t\t\tconst windowError = checkEmploymentWindow(punch.tsUtc, window);\n\t\t\t\t// a queued punch that is older than the window is not lost: it is stored and waits for a decision\n\t\t\t\t// of the administration instead of counting right away\n\t\t\t\tconst tooOld = maxPast > 0 && punch.tsUtc < now - maxPast;\n\t\t\t\tconst syncState: EntrySyncState = skew > maxSkew || windowError || tooOld ? \"conflict\" : \"synced\";\n\n\t\t\t\tconst stored = entries.insert({\n\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\ttsUtc: punch.tsUtc,\n\t\t\t\t\tclientTsUtc: punch.clientTsUtc ?? null,\n\t\t\t\t\ttimeZone: input.timeZone,\n\t\t\t\t\tsource: \"api\",\n\t\t\t\t\tdirection: punch.direction ?? \"auto\",\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tsyncState,\n\t\t\t\t\tnote: punch.note ?? null,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\ttouched.add(stored.entry.localDate);\n\n\t\t\t\tif (syncState === \"conflict\") {\n\t\t\t\t\tconflicts.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: tooOld ? \"too_old\" : windowError ? \"employment_window\" : \"clock_skew\",\n\t\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\t\tentry: stored.entry,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\taccepted.push({\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\tcreated: stored.created,\n\t\t\t\t\tsyncState: stored.entry.syncState,\n\t\t\t\t\tlocalDate: stored.entry.localDate,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn { accepted, conflicts, rejected, recalculated: refreshDays(input.userId, touched) };\n\t\t},\n\n\t\tconflicts(userId: number): EntryRecord[] {\n\t\t\treturn entries.listBySyncState(userId, \"conflict\");\n\t\t},\n\n\t\tresolve(input: ResolveConflictInput): ResolveConflictResult {\n\t\t\tconst current = entries.findById(input.entryId);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`entry ${input.entryId} not found`);\n\t\t\t}\n\t\t\tif (current.syncState !== \"conflict\") {\n\t\t\t\tthrow new ValidationError(`entry ${input.entryId} is not in conflict (state ${current.syncState})`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst touched = new Set<string>([current.localDate]);\n\t\t\tlet entry: EntryRecord | null = current;\n\n\t\t\tif (input.action === \"accept\") {\n\t\t\t\tif (input.tsUtc !== undefined && input.tsUtc !== current.tsUtc) {\n\t\t\t\t\tconst moved = entries.update({\n\t\t\t\t\t\tid: current.id,\n\t\t\t\t\t\texpectedRevision: current.revision,\n\t\t\t\t\t\tpatch: { tsUtc: input.tsUtc },\n\t\t\t\t\t\treason: input.reason ?? \"clock skew corrected\",\n\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\ttimeZone: input.timeZone ?? timeZoneOf(current.userId),\n\t\t\t\t\t\tnow,\n\t\t\t\t\t});\n\t\t\t\t\ttouched.add(moved.localDate);\n\t\t\t\t\ttouched.add(current.localDate);\n\t\t\t\t}\n\t\t\t\tentry = entries.setSyncState({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tsyncState: \"synced\",\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? null,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_accept\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: entry.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tentries.remove({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? \"conflict dismissed\",\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_dismiss\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: current.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t\tentry = null;\n\t\t\t}\n\n\t\t\treturn { action: input.action, entry, recalculated: refreshDays(current.userId, touched) };\n\t\t},\n\n\t\tfindCancellationPairs(\n\t\t\tuserId: number,\n\t\t\tfromDate: string,\n\t\t\ttoDate: string,\n\t\t\toptions?: { maxDistanceSeconds?: number },\n\t\t): CancellationPair[] {\n\t\t\tconst maxDistance = options?.maxDistanceSeconds ?? 120;\n\t\t\tconst punches = entries\n\t\t\t\t.listByRange(userId, fromDate, toDate)\n\t\t\t\t.filter(entry => entry.syncState === \"synced\")\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.id - b.id);\n\n\t\t\tconst pairs: CancellationPair[] = [];\n\t\t\tlet index = 0;\n\t\t\twhile (index + 1 < punches.length) {\n\t\t\t\tconst first = punches[index];\n\t\t\t\tconst second = punches[index + 1];\n\t\t\t\tconst distanceSeconds = second.tsUtc - first.tsUtc;\n\t\t\t\tif (distanceSeconds >= 0 && distanceSeconds <= maxDistance) {\n\t\t\t\t\tpairs.push({ first, second, distanceSeconds });\n\t\t\t\t\t// both punches are used, so the next candidate starts after them\n\t\t\t\t\tindex += 2;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tindex++;\n\t\t\t}\n\t\t\treturn pairs;\n\t\t},\n\n\t\tcancelPair(input: {\n\t\t\tentryIds: [number, number];\n\t\t\treason?: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): CancelPairResult {\n\t\t\tconst first = entries.findById(input.entryIds[0]);\n\t\t\tconst second = entries.findById(input.entryIds[1]);\n\t\t\tif (!first || !second) {\n\t\t\t\tthrow new NotFoundError(\"both punches of the cancellation pair must exist\");\n\t\t\t}\n\t\t\tif (first.userId !== second.userId) {\n\t\t\t\tthrow new ValidationError(\"both punches of the cancellation pair must belong to the same employee\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst reason = input.reason ?? \"cancellation pair\";\n\t\t\tconst touched = new Set<string>([first.localDate, second.localDate]);\n\t\t\tlet removed = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const entry of [first, second]) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tentries.remove({\n\t\t\t\t\t\t\tid: entry.id,\n\t\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\t\treason,\n\t\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\t\tnow,\n\t\t\t\t\t\t})\n\t\t\t\t\t) {\n\t\t\t\t\t\tremoved++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.cancel_pair\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: `${first.id}+${second.id}`,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: first.userId,\n\t\t\t\t\t\tpunch: { first: first.tsUtc, second: second.tsUtc },\n\t\t\t\t\t\tdistanceSeconds: second.tsUtc - first.tsUtc,\n\t\t\t\t\t\treason,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn { removed, recalculated: refreshDays(first.userId, touched) };\n\t\t},\n\t};\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBA,oBAA+C;AAC/C,mBAA8B;AAC9B,mBAAsC;AACtC,kBAAuD;AA6LhD,SAAS,kBAAkB,MAA6B;AAC9D,QAAM,EAAE,IAAI,SAAS,OAAO,YAAY,IAAI;AAS5C,QAAM,cAAc,CAAC,QAAgB,UAAsC;AAC1E,UAAM,SAAS,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC,EAAE,KAAK;AACxC,eAAW,QAAQ,QAAQ;AAC1B,kBAAY,eAAe,QAAQ,IAAI;AAAA,IACxC;AACA,WAAO;AAAA,EACR;AAQA,QAAM,aAAa,CAAC,WAAwB;AA1O7C;AA0OgD,6BAAM,SAAS,MAAM,MAArB,mBAAwB,aAAxB,YAAoC;AAAA;AAWnF,QAAM,aAAa,CAAC,QAAgB,OAAe,aAAqB,aAAyC;AArPlH;AAsPE,UAAM,WAAO,YAAAA,WAAiB,OAAO,QAAQ;AAC7C,UAAM,aAAa;AAAA,MAClB,GAAG,QAAQ,WAAW,YAAQ,qBAAQ,MAAM,EAAE,CAAC;AAAA,MAC/C,GAAG,QAAQ,WAAW,QAAQ,IAAI;AAAA,MAClC,GAAG,QAAQ,WAAW,YAAQ,qBAAQ,MAAM,CAAC,CAAC;AAAA,IAC/C;AACA,YAAO,gBAAW,KAAK,WAAS,KAAK,IAAI,MAAM,QAAQ,KAAK,IAAI,WAAW,MAApE,YAAyE;AAAA,EACjF;AAEA,SAAO;AAAA,IACN,KAAK,OAAwC;AAhQ/C;AAiQG,YAAM,OAAM,WAAM,QAAN,YAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACrD,YAAM,WAAU,WAAM,wBAAN,YAA6B;AAC7C,YAAM,eAAc,WAAM,uBAAN,YAA4B;AAChD,YAAM,aAAY,WAAM,qBAAN,YAA0B,KAAK;AACjD,YAAM,WAAU,WAAM,mBAAN,YAAwB;AACxC,YAAM,UAAU,MAAM,eAAe,MAAM,MAAM;AACjD,YAAM,SAAS,EAAE,YAAW,wCAAS,cAAT,YAAsB,MAAM,UAAS,wCAAS,YAAT,YAAoB,KAAK;AAE1F,YAAM,WAA2B,CAAC;AAClC,YAAM,YAA4B,CAAC;AACnC,YAAM,WAA2B,CAAC;AAClC,YAAM,UAAU,oBAAI,IAAY;AAEhC,YAAM,SAAS,MAAM,QACnB,MAAM,EACN,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,eAAe,cAAc,EAAE,cAAc,CAAC;AAEtF,iBAAW,SAAS,QAAQ;AAC3B,cAAM,OAAM,iBAAM,mBAAN,mBAAsB,WAAtB,YAAgC;AAC5C,YAAI,CAAC,KAAK;AACT,mBAAS,KAAK,EAAE,gBAAgB,IAAI,QAAQ,eAAe,QAAQ,6BAA6B,CAAC;AACjG;AAAA,QACD;AACA,YAAI,CAAC,OAAO,SAAS,MAAM,KAAK,KAAK,MAAM,SAAS,GAAG;AACtD,mBAAS,KAAK;AAAA,YACb,gBAAgB;AAAA,YAChB,QAAQ;AAAA,YACR,QAAQ,SAAS,MAAM,KAAK;AAAA,UAC7B,CAAC;AACD;AAAA,QACD;AACA,YAAI,MAAM,QAAQ,MAAM,WAAW;AAClC,mBAAS,KAAK;AAAA,YACb,gBAAgB;AAAA,YAChB,QAAQ;AAAA,YACR,QAAQ,SAAS,MAAM,KAAK,iBAAiB,SAAS;AAAA,UACvD,CAAC;AACD;AAAA,QACD;AAGA,cAAM,gBAAgB,QAAQ,qBAAqB,MAAM,QAAQ,GAAG;AACpE,YAAI,eAAe;AAClB,mBAAS,KAAK;AAAA,YACb,gBAAgB;AAAA,YAChB,SAAS,cAAc;AAAA,YACvB,SAAS;AAAA,YACT,WAAW,cAAc;AAAA,YACzB,WAAW,cAAc;AAAA,UAC1B,CAAC;AACD;AAAA,QACD;AAGA,cAAM,YAAY,WAAW,MAAM,QAAQ,MAAM,OAAO,aAAa,MAAM,QAAQ;AACnF,YAAI,WAAW;AACd,oBAAU,KAAK,EAAE,gBAAgB,KAAK,QAAQ,mBAAmB,SAAS,MAAM,OAAO,KAAK,CAAC;AAC7F;AAAA,QACD;AAEA,cAAM,OAAO,MAAM,eAAe,OAAO,IAAI,KAAK,IAAI,MAAM,QAAQ,MAAM,WAAW;AACrF,cAAM,kBAAc,oCAAsB,MAAM,OAAO,MAAM;AAG7D,cAAM,SAAS,UAAU,KAAK,MAAM,QAAQ,MAAM;AAClD,cAAM,YAA4B,OAAO,WAAW,eAAe,SAAS,aAAa;AAEzF,cAAM,SAAS,QAAQ,OAAO;AAAA,UAC7B,QAAQ,MAAM;AAAA,UACd,OAAO,MAAM;AAAA,UACb,cAAa,WAAM,gBAAN,YAAqB;AAAA,UAClC,UAAU,MAAM;AAAA,UAChB,QAAQ;AAAA,UACR,YAAW,WAAM,cAAN,YAAmB;AAAA,UAC9B,gBAAgB;AAAA,UAChB;AAAA,UACA,OAAM,WAAM,SAAN,YAAc;AAAA,UACpB,SAAS,MAAM;AAAA,UACf,UAAS,WAAM,YAAN,YAAiB;AAAA,UAC1B;AAAA,QACD,CAAC;AACD,gBAAQ,IAAI,OAAO,MAAM,SAAS;AAElC,YAAI,cAAc,YAAY;AAC7B,oBAAU,KAAK;AAAA,YACd,gBAAgB;AAAA,YAChB,QAAQ,SAAS,YAAY,cAAc,sBAAsB;AAAA,YACjE,SAAS,OAAO,MAAM;AAAA,YACtB,OAAO,OAAO;AAAA,UACf,CAAC;AACD;AAAA,QACD;AACA,iBAAS,KAAK;AAAA,UACb,gBAAgB;AAAA,UAChB,SAAS,OAAO,MAAM;AAAA,UACtB,SAAS,OAAO;AAAA,UAChB,WAAW,OAAO,MAAM;AAAA,UACxB,WAAW,OAAO,MAAM;AAAA,QACzB,CAAC;AAAA,MACF;AAEA,aAAO,EAAE,UAAU,WAAW,UAAU,cAAc,YAAY,MAAM,QAAQ,OAAO,EAAE;AAAA,IAC1F;AAAA,IAEA,UAAU,QAA+B;AACxC,aAAO,QAAQ,gBAAgB,QAAQ,UAAU;AAAA,IAClD;AAAA,IAEA,QAAQ,OAAoD;AA7W9D;AA8WG,YAAM,UAAU,QAAQ,SAAS,MAAM,OAAO;AAC9C,UAAI,CAAC,SAAS;AACb,cAAM,IAAI,4BAAc,SAAS,MAAM,OAAO,YAAY;AAAA,MAC3D;AACA,UAAI,QAAQ,cAAc,YAAY;AACrC,cAAM,IAAI,8BAAgB,SAAS,MAAM,OAAO,8BAA8B,QAAQ,SAAS,GAAG;AAAA,MACnG;AAEA,YAAM,OAAM,WAAM,QAAN,YAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACrD,YAAM,UAAU,oBAAI,IAAY,CAAC,QAAQ,SAAS,CAAC;AACnD,UAAI,QAA4B;AAEhC,UAAI,MAAM,WAAW,UAAU;AAC9B,YAAI,MAAM,UAAU,UAAa,MAAM,UAAU,QAAQ,OAAO;AAC/D,gBAAM,QAAQ,QAAQ,OAAO;AAAA,YAC5B,IAAI,QAAQ;AAAA,YACZ,kBAAkB,QAAQ;AAAA,YAC1B,OAAO,EAAE,OAAO,MAAM,MAAM;AAAA,YAC5B,SAAQ,WAAM,WAAN,YAAgB;AAAA,YACxB,SAAS,MAAM;AAAA,YACf,UAAS,WAAM,YAAN,YAAiB;AAAA,YAC1B,WAAU,WAAM,aAAN,YAAkB,WAAW,QAAQ,MAAM;AAAA,YACrD;AAAA,UACD,CAAC;AACD,kBAAQ,IAAI,MAAM,SAAS;AAC3B,kBAAQ,IAAI,QAAQ,SAAS;AAAA,QAC9B;AACA,gBAAQ,QAAQ,aAAa;AAAA,UAC5B,IAAI,QAAQ;AAAA,UACZ,WAAW;AAAA,UACX,SAAS,MAAM;AAAA,UACf,SAAQ,WAAM,WAAN,YAAgB;AAAA,UACxB,UAAS,WAAM,YAAN,YAAiB;AAAA,UAC1B;AAAA,QACD,CAAC;AACD,wCAAc,IAAI;AAAA,UACjB,OAAO;AAAA,UACP,SAAS,MAAM;AAAA,UACf,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,UAAU,QAAQ;AAAA,UAClB,QAAQ,EAAE,QAAQ,QAAQ,QAAQ,OAAO,MAAM,OAAO,SAAQ,WAAM,WAAN,YAAgB,KAAK;AAAA,UACnF,KAAI,WAAM,YAAN,YAAiB;AAAA,QACtB,CAAC;AAAA,MACF,OAAO;AACN,gBAAQ,OAAO;AAAA,UACd,IAAI,QAAQ;AAAA,UACZ,SAAS,MAAM;AAAA,UACf,SAAQ,WAAM,WAAN,YAAgB;AAAA,UACxB,UAAS,WAAM,YAAN,YAAiB;AAAA,UAC1B;AAAA,QACD,CAAC;AACD,wCAAc,IAAI;AAAA,UACjB,OAAO;AAAA,UACP,SAAS,MAAM;AAAA,UACf,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,UAAU,QAAQ;AAAA,UAClB,QAAQ,EAAE,QAAQ,QAAQ,QAAQ,OAAO,QAAQ,OAAO,SAAQ,WAAM,WAAN,YAAgB,KAAK;AAAA,UACrF,KAAI,WAAM,YAAN,YAAiB;AAAA,QACtB,CAAC;AACD,gBAAQ;AAAA,MACT;AAEA,aAAO,EAAE,QAAQ,MAAM,QAAQ,OAAO,cAAc,YAAY,QAAQ,QAAQ,OAAO,EAAE;AAAA,IAC1F;AAAA,IAEA,sBACC,QACA,UACA,QACA,SACqB;AAtbxB;AAubG,YAAM,eAAc,wCAAS,uBAAT,YAA+B;AACnD,YAAM,UAAU,QACd,YAAY,QAAQ,UAAU,MAAM,EACpC,OAAO,WAAS,MAAM,cAAc,QAAQ,EAC5C,MAAM,EACN,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE;AAEjD,YAAM,QAA4B,CAAC;AACnC,UAAI,QAAQ;AACZ,aAAO,QAAQ,IAAI,QAAQ,QAAQ;AAClC,cAAM,QAAQ,QAAQ,KAAK;AAC3B,cAAM,SAAS,QAAQ,QAAQ,CAAC;AAChC,cAAM,kBAAkB,OAAO,QAAQ,MAAM;AAC7C,YAAI,mBAAmB,KAAK,mBAAmB,aAAa;AAC3D,gBAAM,KAAK,EAAE,OAAO,QAAQ,gBAAgB,CAAC;AAE7C,mBAAS;AACT;AAAA,QACD;AACA;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,IAEA,WAAW,OAMU;AArdvB;AAsdG,YAAM,QAAQ,QAAQ,SAAS,MAAM,SAAS,CAAC,CAAC;AAChD,YAAM,SAAS,QAAQ,SAAS,MAAM,SAAS,CAAC,CAAC;AACjD,UAAI,CAAC,SAAS,CAAC,QAAQ;AACtB,cAAM,IAAI,4BAAc,kDAAkD;AAAA,MAC3E;AACA,UAAI,MAAM,WAAW,OAAO,QAAQ;AACnC,cAAM,IAAI,8BAAgB,wEAAwE;AAAA,MACnG;AAEA,YAAM,OAAM,WAAM,QAAN,YAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACrD,YAAM,UAAS,WAAM,WAAN,YAAgB;AAC/B,YAAM,UAAU,oBAAI,IAAY,CAAC,MAAM,WAAW,OAAO,SAAS,CAAC;AACnE,UAAI,UAAU;AAEd,YAAM,MAAM,GAAG,YAAY,MAAY;AApe1C,YAAAC,KAAAC;AAqeI,mBAAW,SAAS,CAAC,OAAO,MAAM,GAAG;AACpC,cACC,QAAQ,OAAO;AAAA,YACd,IAAI,MAAM;AAAA,YACV,SAAS,MAAM;AAAA,YACf;AAAA,YACA,UAASD,MAAA,MAAM,YAAN,OAAAA,MAAiB;AAAA,YAC1B;AAAA,UACD,CAAC,GACA;AACD;AAAA,UACD;AAAA,QACD;AACA,wCAAc,IAAI;AAAA,UACjB,OAAO;AAAA,UACP,SAAS,MAAM;AAAA,UACf,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,UAAU,GAAG,MAAM,EAAE,IAAI,OAAO,EAAE;AAAA,UAClC,QAAQ;AAAA,YACP,QAAQ,MAAM;AAAA,YACd,OAAO,EAAE,OAAO,MAAM,OAAO,QAAQ,OAAO,MAAM;AAAA,YAClD,iBAAiB,OAAO,QAAQ,MAAM;AAAA,YACtC;AAAA,UACD;AAAA,UACA,KAAIC,MAAA,MAAM,YAAN,OAAAA,MAAiB;AAAA,QACtB,CAAC;AAAA,MACF,CAAC;AACD,UAAI;AAEJ,aAAO,EAAE,SAAS,cAAc,YAAY,MAAM,QAAQ,OAAO,EAAE;AAAA,IACpE;AAAA,EACD;AACD;",
6
6
  "names": ["resolveLocalDate", "_a", "_b"]
7
7
  }