iobroker.time-tracker 0.2.0
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/LICENSE +21 -0
- package/README.md +386 -0
- package/admin/i18n/de.json +34 -0
- package/admin/i18n/en.json +1 -0
- package/admin/i18n/es.json +34 -0
- package/admin/i18n/fr.json +34 -0
- package/admin/i18n/it.json +34 -0
- package/admin/i18n/nl.json +34 -0
- package/admin/i18n/pl.json +34 -0
- package/admin/i18n/pt.json +34 -0
- package/admin/i18n/ru.json +34 -0
- package/admin/i18n/uk.json +34 -0
- package/admin/i18n/zh-cn.json +34 -0
- package/admin/jsonConfig.json +266 -0
- package/admin/time-tracker.png +0 -0
- package/build/lib/adapter/automation.js +100 -0
- package/build/lib/adapter/automation.js.map +7 -0
- package/build/lib/adapter/commands.js +160 -0
- package/build/lib/adapter/commands.js.map +7 -0
- package/build/lib/adapter/messages.js +207 -0
- package/build/lib/adapter/messages.js.map +7 -0
- package/build/lib/adapter/presence.js +129 -0
- package/build/lib/adapter/presence.js.map +7 -0
- package/build/lib/adapter/states.js +330 -0
- package/build/lib/adapter/states.js.map +7 -0
- package/build/lib/adapter/triggers.js +92 -0
- package/build/lib/adapter/triggers.js.map +7 -0
- package/build/lib/db/database.js +96 -0
- package/build/lib/db/database.js.map +7 -0
- package/build/lib/db/migrations.js +603 -0
- package/build/lib/db/migrations.js.map +7 -0
- package/build/lib/db/repositories/absences.js +432 -0
- package/build/lib/db/repositories/absences.js.map +7 -0
- package/build/lib/db/repositories/audit.js +90 -0
- package/build/lib/db/repositories/audit.js.map +7 -0
- package/build/lib/db/repositories/automations.js +326 -0
- package/build/lib/db/repositories/automations.js.map +7 -0
- package/build/lib/db/repositories/entries.js +335 -0
- package/build/lib/db/repositories/entries.js.map +7 -0
- package/build/lib/db/repositories/holidays.js +156 -0
- package/build/lib/db/repositories/holidays.js.map +7 -0
- package/build/lib/db/repositories/payouts.js +221 -0
- package/build/lib/db/repositories/payouts.js.map +7 -0
- package/build/lib/db/repositories/rfid.js +264 -0
- package/build/lib/db/repositories/rfid.js.map +7 -0
- package/build/lib/db/repositories/rules.js +356 -0
- package/build/lib/db/repositories/rules.js.map +7 -0
- package/build/lib/db/repositories/settings.js +91 -0
- package/build/lib/db/repositories/settings.js.map +7 -0
- package/build/lib/db/repositories/terminals.js +243 -0
- package/build/lib/db/repositories/terminals.js.map +7 -0
- package/build/lib/db/repositories/triggers.js +240 -0
- package/build/lib/db/repositories/triggers.js.map +7 -0
- package/build/lib/db/repositories/users.js +491 -0
- package/build/lib/db/repositories/users.js.map +7 -0
- package/build/lib/db/seed.js +180 -0
- package/build/lib/db/seed.js.map +7 -0
- package/build/lib/domain/avatar.js +63 -0
- package/build/lib/domain/avatar.js.map +7 -0
- package/build/lib/domain/breaks.js +70 -0
- package/build/lib/domain/breaks.js.map +7 -0
- package/build/lib/domain/calculation.js +150 -0
- package/build/lib/domain/calculation.js.map +7 -0
- package/build/lib/domain/holidays.js +113 -0
- package/build/lib/domain/holidays.js.map +7 -0
- package/build/lib/domain/punch.js +99 -0
- package/build/lib/domain/punch.js.map +7 -0
- package/build/lib/domain/target.js +89 -0
- package/build/lib/domain/target.js.map +7 -0
- package/build/lib/errors.js +56 -0
- package/build/lib/errors.js.map +7 -0
- package/build/lib/reports/labels.js +358 -0
- package/build/lib/reports/labels.js.map +7 -0
- package/build/lib/reports/pdf.js +308 -0
- package/build/lib/reports/pdf.js.map +7 -0
- package/build/lib/reports/types.js +32 -0
- package/build/lib/reports/types.js.map +7 -0
- package/build/lib/reports/xls.js +211 -0
- package/build/lib/reports/xls.js.map +7 -0
- package/build/lib/services/aggregation.js +495 -0
- package/build/lib/services/aggregation.js.map +7 -0
- package/build/lib/services/auth.js +381 -0
- package/build/lib/services/auth.js.map +7 -0
- package/build/lib/services/backup.js +372 -0
- package/build/lib/services/backup.js.map +7 -0
- package/build/lib/services/closing.js +106 -0
- package/build/lib/services/closing.js.map +7 -0
- package/build/lib/services/sessionSecret.js +100 -0
- package/build/lib/services/sessionSecret.js.map +7 -0
- package/build/lib/services/sync.js +278 -0
- package/build/lib/services/sync.js.map +7 -0
- package/build/lib/util/time.js +125 -0
- package/build/lib/util/time.js.map +7 -0
- package/build/lib/web/api.js +2589 -0
- package/build/lib/web/api.js.map +7 -0
- package/build/lib/web/cookies.js +80 -0
- package/build/lib/web/cookies.js.map +7 -0
- package/build/lib/web/events.js +50 -0
- package/build/lib/web/events.js.map +7 -0
- package/build/lib/web/pin-guard.js +86 -0
- package/build/lib/web/pin-guard.js.map +7 -0
- package/build/lib/web/problem.js +129 -0
- package/build/lib/web/problem.js.map +7 -0
- package/build/lib/web/rate-limit.js +65 -0
- package/build/lib/web/rate-limit.js.map +7 -0
- package/build/lib/web/router.js +274 -0
- package/build/lib/web/router.js.map +7 -0
- package/build/lib/web/server.js +189 -0
- package/build/lib/web/server.js.map +7 -0
- package/build/lib/web/static.js +151 -0
- package/build/lib/web/static.js.map +7 -0
- package/build/lib/web/stream.js +135 -0
- package/build/lib/web/stream.js.map +7 -0
- package/build/main.js +986 -0
- package/build/main.js.map +7 -0
- package/io-package.json +221 -0
- package/package.json +131 -0
- package/src-pwa/src/i18n/de.json +359 -0
- package/src-pwa/src/i18n/en.json +359 -0
- package/src-pwa/src/i18n/es.json +359 -0
- package/src-pwa/src/i18n/fr.json +359 -0
- package/src-pwa/src/i18n/index.ts +96 -0
- package/src-pwa/src/i18n/it.json +359 -0
- package/src-pwa/src/i18n/nl.json +359 -0
- package/src-pwa/src/i18n/pl.json +359 -0
- package/src-pwa/src/i18n/pt.json +359 -0
- package/src-pwa/src/i18n/ru.json +359 -0
- package/src-pwa/src/i18n/uk.json +359 -0
- package/src-pwa/src/i18n/zh-cn.json +359 -0
- package/www/assets/index-QI3wrEvW.js +119 -0
- package/www/assets/index-QI3wrEvW.js.map +1 -0
- package/www/assets/workbox-window.prod.es5-Bd17z0YL.js +3 -0
- package/www/assets/workbox-window.prod.es5-Bd17z0YL.js.map +1 -0
- package/www/favicon.svg +16 -0
- package/www/icon-192.png +0 -0
- package/www/icon-512.png +0 -0
- package/www/index.html +38 -0
- package/www/manifest.webmanifest +1 -0
- package/www/person.png +0 -0
- package/www/sw.js +3 -0
- package/www/sw.js.map +1 -0
- package/www/workbox-9c191d2f.js +3 -0
- package/www/workbox-9c191d2f.js.map +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var sessionSecret_exports = {};
|
|
30
|
+
__export(sessionSecret_exports, {
|
|
31
|
+
MIN_SECRET_LENGTH: () => MIN_SECRET_LENGTH,
|
|
32
|
+
SECRET_FILE_NAME: () => SECRET_FILE_NAME,
|
|
33
|
+
generateSecret: () => generateSecret,
|
|
34
|
+
readStoredSecret: () => readStoredSecret,
|
|
35
|
+
resolveSessionSecret: () => resolveSessionSecret,
|
|
36
|
+
writeStoredSecret: () => writeStoredSecret
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(sessionSecret_exports);
|
|
39
|
+
var import_node_crypto = require("node:crypto");
|
|
40
|
+
var fs = __toESM(require("node:fs"));
|
|
41
|
+
var path = __toESM(require("node:path"));
|
|
42
|
+
const SECRET_FILE_NAME = "session-secret";
|
|
43
|
+
const MIN_SECRET_LENGTH = 16;
|
|
44
|
+
function generateSecret() {
|
|
45
|
+
return (0, import_node_crypto.randomBytes)(32).toString("base64url");
|
|
46
|
+
}
|
|
47
|
+
function readStoredSecret(file) {
|
|
48
|
+
let content;
|
|
49
|
+
try {
|
|
50
|
+
content = fs.readFileSync(file, "utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const secret = content.trim();
|
|
55
|
+
return secret.length >= MIN_SECRET_LENGTH ? secret : null;
|
|
56
|
+
}
|
|
57
|
+
function writeStoredSecret(file, secret) {
|
|
58
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
59
|
+
const temporary = `${file}.tmp`;
|
|
60
|
+
fs.writeFileSync(temporary, `${secret}
|
|
61
|
+
`, { encoding: "utf8", mode: 384 });
|
|
62
|
+
fs.renameSync(temporary, file);
|
|
63
|
+
try {
|
|
64
|
+
fs.chmodSync(file, 384);
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function resolveSessionSecret(input) {
|
|
69
|
+
var _a;
|
|
70
|
+
const configured = ((_a = input.configured) != null ? _a : "").trim();
|
|
71
|
+
if (configured) {
|
|
72
|
+
return { secret: configured, source: "configured", file: null };
|
|
73
|
+
}
|
|
74
|
+
const stored = readStoredSecret(input.file);
|
|
75
|
+
if (stored) {
|
|
76
|
+
return { secret: stored, source: "stored", file: input.file };
|
|
77
|
+
}
|
|
78
|
+
const secret = generateSecret();
|
|
79
|
+
try {
|
|
80
|
+
writeStoredSecret(input.file, secret);
|
|
81
|
+
return { secret, source: "generated", file: input.file };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
secret,
|
|
85
|
+
source: "generated",
|
|
86
|
+
file: null,
|
|
87
|
+
error: error instanceof Error ? error.message : String(error)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
92
|
+
0 && (module.exports = {
|
|
93
|
+
MIN_SECRET_LENGTH,
|
|
94
|
+
SECRET_FILE_NAME,
|
|
95
|
+
generateSecret,
|
|
96
|
+
readStoredSecret,
|
|
97
|
+
resolveSessionSecret,
|
|
98
|
+
writeStoredSecret
|
|
99
|
+
});
|
|
100
|
+
//# sourceMappingURL=sessionSecret.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/services/sessionSecret.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Session secret of the instance.\n *\n * The secret signs the CSRF tokens that every write of the web app carries (see `services/auth.ts`). A value\n * configured in the instance settings always wins. Without one the adapter generates a value on the first start and\n * stores it next to the database, so a restart no longer invalidates the CSRF tokens of clients that are already\n * open. The secret itself never leaves the server: a client only ever sees a token derived from it.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\n\n/** Name of the file that holds a generated secret (next to the database). */\nexport const SECRET_FILE_NAME = \"session-secret\";\n\n/** Shortest usable secret: a generated value has 43 characters, so a much shorter file is broken. */\nexport const MIN_SECRET_LENGTH = 16;\n\n/** Source of the secret. */\nexport type SessionSecretSource = \"configured\" | \"stored\" | \"generated\";\n\n/** Result of {@link resolveSessionSecret}. */\nexport interface ResolvedSessionSecret {\n\t/** Secret used to sign CSRF tokens */\n\tsecret: string;\n\t/** `configured` = instance settings, `stored` = generated earlier, `generated` = created by this call */\n\tsource: SessionSecretSource;\n\t/** File a generated secret lives in, `null` when the secret is only valid for this run */\n\tfile: string | null;\n\t/** Set when a generated secret could not be stored (it then only lasts for this run) */\n\terror?: string;\n}\n\n/**\n * Generates a secret.\n *\n * @returns 32 random bytes as base64url (the same shape the device tokens of the terminals have)\n */\nexport function generateSecret(): string {\n\treturn randomBytes(32).toString(\"base64url\");\n}\n\n/**\n * Reads a stored secret.\n *\n * @param file - file that may hold a secret\n * @returns the secret, or `null` when the file is missing, empty or too short\n */\nexport function readStoredSecret(file: string): string | null {\n\tlet content: string;\n\ttry {\n\t\tcontent = fs.readFileSync(file, \"utf8\");\n\t} catch {\n\t\t// a missing file (or a directory the adapter cannot read) simply means \u201Cnothing stored yet\u201D\n\t\treturn null;\n\t}\n\n\tconst secret = content.trim();\n\treturn secret.length >= MIN_SECRET_LENGTH ? secret : null;\n}\n\n/**\n * Stores a secret for the following starts.\n *\n * The file is written through a temporary one, so an interrupted start cannot leave half a secret behind. It is\n * created for the account that runs ioBroker only; on Windows the mode only marks it read-only.\n *\n * @param file - file to write\n * @param secret - secret to store\n */\nexport function writeStoredSecret(file: string, secret: string): void {\n\tfs.mkdirSync(path.dirname(file), { recursive: true });\n\tconst temporary = `${file}.tmp`;\n\tfs.writeFileSync(temporary, `${secret}\\n`, { encoding: \"utf8\", mode: 0o600 });\n\tfs.renameSync(temporary, file);\n\ttry {\n\t\tfs.chmodSync(file, 0o600);\n\t} catch {\n\t\t// Windows does not know POSIX modes; the write above is what matters there\n\t}\n}\n\n/**\n * Resolves the secret of the instance: a configured value wins, then a stored one, otherwise a new one is generated\n * and stored for the next start.\n *\n * @param input - configured value and the file a generated secret is kept in\n * @param input.configured - value from the instance settings (empty = not configured)\n * @param input.file - file used for a generated secret\n * @returns the secret, its source and the file it lives in\n */\nexport function resolveSessionSecret(input: { configured?: string | null; file: string }): ResolvedSessionSecret {\n\tconst configured = (input.configured ?? \"\").trim();\n\tif (configured) {\n\t\treturn { secret: configured, source: \"configured\", file: null };\n\t}\n\n\tconst stored = readStoredSecret(input.file);\n\tif (stored) {\n\t\treturn { secret: stored, source: \"stored\", file: input.file };\n\t}\n\n\tconst secret = generateSecret();\n\ttry {\n\t\twriteStoredSecret(input.file, secret);\n\t\treturn { secret, source: \"generated\", file: input.file };\n\t} catch (error) {\n\t\treturn {\n\t\t\tsecret,\n\t\t\tsource: \"generated\",\n\t\t\tfile: null,\n\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t};\n\t}\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASA,yBAA4B;AAC5B,SAAoB;AACpB,WAAsB;AAGf,MAAM,mBAAmB;AAGzB,MAAM,oBAAoB;AAsB1B,SAAS,iBAAyB;AACxC,aAAO,gCAAY,EAAE,EAAE,SAAS,WAAW;AAC5C;AAQO,SAAS,iBAAiB,MAA6B;AAC7D,MAAI;AACJ,MAAI;AACH,cAAU,GAAG,aAAa,MAAM,MAAM;AAAA,EACvC,QAAQ;AAEP,WAAO;AAAA,EACR;AAEA,QAAM,SAAS,QAAQ,KAAK;AAC5B,SAAO,OAAO,UAAU,oBAAoB,SAAS;AACtD;AAWO,SAAS,kBAAkB,MAAc,QAAsB;AACrE,KAAG,UAAU,KAAK,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AACpD,QAAM,YAAY,GAAG,IAAI;AACzB,KAAG,cAAc,WAAW,GAAG,MAAM;AAAA,GAAM,EAAE,UAAU,QAAQ,MAAM,IAAM,CAAC;AAC5E,KAAG,WAAW,WAAW,IAAI;AAC7B,MAAI;AACH,OAAG,UAAU,MAAM,GAAK;AAAA,EACzB,QAAQ;AAAA,EAER;AACD;AAWO,SAAS,qBAAqB,OAA4E;AA5FjH;AA6FC,QAAM,eAAc,WAAM,eAAN,YAAoB,IAAI,KAAK;AACjD,MAAI,YAAY;AACf,WAAO,EAAE,QAAQ,YAAY,QAAQ,cAAc,MAAM,KAAK;AAAA,EAC/D;AAEA,QAAM,SAAS,iBAAiB,MAAM,IAAI;AAC1C,MAAI,QAAQ;AACX,WAAO,EAAE,QAAQ,QAAQ,QAAQ,UAAU,MAAM,MAAM,KAAK;AAAA,EAC7D;AAEA,QAAM,SAAS,eAAe;AAC9B,MAAI;AACH,sBAAkB,MAAM,MAAM,MAAM;AACpC,WAAO,EAAE,QAAQ,QAAQ,aAAa,MAAM,MAAM,KAAK;AAAA,EACxD,SAAS,OAAO;AACf,WAAO;AAAA,MACN;AAAA,MACA,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAC7D;AAAA,EACD;AACD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
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 sync_exports = {};
|
|
20
|
+
__export(sync_exports, {
|
|
21
|
+
createSyncService: () => createSyncService
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(sync_exports);
|
|
24
|
+
var import_errors = require("../errors");
|
|
25
|
+
var import_audit = require("../db/repositories/audit");
|
|
26
|
+
var import_punch = require("../domain/punch");
|
|
27
|
+
var import_time = require("../util/time");
|
|
28
|
+
function createSyncService(deps) {
|
|
29
|
+
const { db, entries, users, aggregation } = deps;
|
|
30
|
+
const refreshDays = (userId, dates) => {
|
|
31
|
+
const unique = [...new Set(dates)].sort();
|
|
32
|
+
for (const date of unique) {
|
|
33
|
+
aggregation.recalculateDay(userId, date);
|
|
34
|
+
}
|
|
35
|
+
return unique;
|
|
36
|
+
};
|
|
37
|
+
const timeZoneOf = (userId) => {
|
|
38
|
+
var _a, _b;
|
|
39
|
+
return (_b = (_a = users.findById(userId)) == null ? void 0 : _a.timezone) != null ? _b : "UTC";
|
|
40
|
+
};
|
|
41
|
+
const findNearby = (userId, tsUtc, minDistance, timeZone) => {
|
|
42
|
+
var _a;
|
|
43
|
+
const date = (0, import_time.localDate)(tsUtc, timeZone);
|
|
44
|
+
const candidates = [
|
|
45
|
+
...entries.listByDate(userId, (0, import_time.addDays)(date, -1)),
|
|
46
|
+
...entries.listByDate(userId, date),
|
|
47
|
+
...entries.listByDate(userId, (0, import_time.addDays)(date, 1))
|
|
48
|
+
];
|
|
49
|
+
return (_a = candidates.find((entry) => Math.abs(entry.tsUtc - tsUtc) < minDistance)) != null ? _a : null;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
sync(input) {
|
|
53
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
|
|
54
|
+
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
55
|
+
const maxSkew = (_b = input.maxClockSkewSeconds) != null ? _b : 300;
|
|
56
|
+
const minDistance = (_c = input.minDistanceSeconds) != null ? _c : 30;
|
|
57
|
+
const maxFuture = (_d = input.maxFutureSeconds) != null ? _d : 12 * 3600;
|
|
58
|
+
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 accepted = [];
|
|
61
|
+
const conflicts = [];
|
|
62
|
+
const rejected = [];
|
|
63
|
+
const touched = /* @__PURE__ */ new Set();
|
|
64
|
+
const sorted = input.punches.slice().sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));
|
|
65
|
+
for (const punch of sorted) {
|
|
66
|
+
const key = (_h = (_g = punch.idempotencyKey) == null ? void 0 : _g.trim()) != null ? _h : "";
|
|
67
|
+
if (!key) {
|
|
68
|
+
rejected.push({ idempotencyKey: "", reason: "missing_key", detail: "idempotencyKey is required" });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isFinite(punch.tsUtc) || punch.tsUtc <= 0) {
|
|
72
|
+
rejected.push({
|
|
73
|
+
idempotencyKey: key,
|
|
74
|
+
reason: "invalid",
|
|
75
|
+
detail: `tsUtc ${punch.tsUtc} is not a usable instant`
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (punch.tsUtc > now + maxFuture) {
|
|
80
|
+
rejected.push({
|
|
81
|
+
idempotencyKey: key,
|
|
82
|
+
reason: "future",
|
|
83
|
+
detail: `tsUtc ${punch.tsUtc} is more than ${maxFuture} seconds in the future`
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const alreadyStored = entries.findByIdempotencyKey(input.userId, key);
|
|
88
|
+
if (alreadyStored) {
|
|
89
|
+
accepted.push({
|
|
90
|
+
idempotencyKey: key,
|
|
91
|
+
entryId: alreadyStored.id,
|
|
92
|
+
created: false,
|
|
93
|
+
syncState: alreadyStored.syncState,
|
|
94
|
+
localDate: alreadyStored.localDate
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const duplicate = findNearby(input.userId, punch.tsUtc, minDistance, input.timeZone);
|
|
99
|
+
if (duplicate) {
|
|
100
|
+
conflicts.push({ idempotencyKey: key, reason: "duplicate_punch", entryId: null, entry: null });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);
|
|
104
|
+
const windowError = (0, import_punch.checkEmploymentWindow)(punch.tsUtc, window);
|
|
105
|
+
const syncState = skew > maxSkew || windowError ? "conflict" : "synced";
|
|
106
|
+
const stored = entries.insert({
|
|
107
|
+
userId: input.userId,
|
|
108
|
+
tsUtc: punch.tsUtc,
|
|
109
|
+
clientTsUtc: (_i = punch.clientTsUtc) != null ? _i : null,
|
|
110
|
+
timeZone: input.timeZone,
|
|
111
|
+
source: "api",
|
|
112
|
+
direction: (_j = punch.direction) != null ? _j : "auto",
|
|
113
|
+
idempotencyKey: key,
|
|
114
|
+
syncState,
|
|
115
|
+
note: (_k = punch.note) != null ? _k : null,
|
|
116
|
+
actorId: input.actorId,
|
|
117
|
+
actorIp: (_l = input.actorIp) != null ? _l : null,
|
|
118
|
+
now
|
|
119
|
+
});
|
|
120
|
+
touched.add(stored.entry.localDate);
|
|
121
|
+
if (syncState === "conflict") {
|
|
122
|
+
conflicts.push({
|
|
123
|
+
idempotencyKey: key,
|
|
124
|
+
reason: windowError ? "employment_window" : "clock_skew",
|
|
125
|
+
entryId: stored.entry.id,
|
|
126
|
+
entry: stored.entry
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
accepted.push({
|
|
131
|
+
idempotencyKey: key,
|
|
132
|
+
entryId: stored.entry.id,
|
|
133
|
+
created: stored.created,
|
|
134
|
+
syncState: stored.entry.syncState,
|
|
135
|
+
localDate: stored.entry.localDate
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { accepted, conflicts, rejected, recalculated: refreshDays(input.userId, touched) };
|
|
139
|
+
},
|
|
140
|
+
conflicts(userId) {
|
|
141
|
+
return entries.listBySyncState(userId, "conflict");
|
|
142
|
+
},
|
|
143
|
+
resolve(input) {
|
|
144
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
|
|
145
|
+
const current = entries.findById(input.entryId);
|
|
146
|
+
if (!current) {
|
|
147
|
+
throw new import_errors.NotFoundError(`entry ${input.entryId} not found`);
|
|
148
|
+
}
|
|
149
|
+
if (current.syncState !== "conflict") {
|
|
150
|
+
throw new import_errors.ValidationError(`entry ${input.entryId} is not in conflict (state ${current.syncState})`);
|
|
151
|
+
}
|
|
152
|
+
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
153
|
+
const touched = /* @__PURE__ */ new Set([current.localDate]);
|
|
154
|
+
let entry = current;
|
|
155
|
+
if (input.action === "accept") {
|
|
156
|
+
if (input.tsUtc !== void 0 && input.tsUtc !== current.tsUtc) {
|
|
157
|
+
const moved = entries.update({
|
|
158
|
+
id: current.id,
|
|
159
|
+
expectedRevision: current.revision,
|
|
160
|
+
patch: { tsUtc: input.tsUtc },
|
|
161
|
+
reason: (_b = input.reason) != null ? _b : "clock skew corrected",
|
|
162
|
+
actorId: input.actorId,
|
|
163
|
+
actorIp: (_c = input.actorIp) != null ? _c : null,
|
|
164
|
+
timeZone: (_d = input.timeZone) != null ? _d : timeZoneOf(current.userId),
|
|
165
|
+
now
|
|
166
|
+
});
|
|
167
|
+
touched.add(moved.localDate);
|
|
168
|
+
touched.add(current.localDate);
|
|
169
|
+
}
|
|
170
|
+
entry = entries.setSyncState({
|
|
171
|
+
id: current.id,
|
|
172
|
+
syncState: "synced",
|
|
173
|
+
actorId: input.actorId,
|
|
174
|
+
reason: (_e = input.reason) != null ? _e : null,
|
|
175
|
+
actorIp: (_f = input.actorIp) != null ? _f : null,
|
|
176
|
+
now
|
|
177
|
+
});
|
|
178
|
+
(0, import_audit.writeAuditLog)(db, {
|
|
179
|
+
atUtc: now,
|
|
180
|
+
actorId: input.actorId,
|
|
181
|
+
action: "entry.conflict_accept",
|
|
182
|
+
entity: "time_entry",
|
|
183
|
+
entityId: current.id,
|
|
184
|
+
detail: { userId: current.userId, tsUtc: entry.tsUtc, reason: (_g = input.reason) != null ? _g : null },
|
|
185
|
+
ip: (_h = input.actorIp) != null ? _h : null
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
entries.remove({
|
|
189
|
+
id: current.id,
|
|
190
|
+
actorId: input.actorId,
|
|
191
|
+
reason: (_i = input.reason) != null ? _i : "conflict dismissed",
|
|
192
|
+
actorIp: (_j = input.actorIp) != null ? _j : null,
|
|
193
|
+
now
|
|
194
|
+
});
|
|
195
|
+
(0, import_audit.writeAuditLog)(db, {
|
|
196
|
+
atUtc: now,
|
|
197
|
+
actorId: input.actorId,
|
|
198
|
+
action: "entry.conflict_dismiss",
|
|
199
|
+
entity: "time_entry",
|
|
200
|
+
entityId: current.id,
|
|
201
|
+
detail: { userId: current.userId, tsUtc: current.tsUtc, reason: (_k = input.reason) != null ? _k : null },
|
|
202
|
+
ip: (_l = input.actorIp) != null ? _l : null
|
|
203
|
+
});
|
|
204
|
+
entry = null;
|
|
205
|
+
}
|
|
206
|
+
return { action: input.action, entry, recalculated: refreshDays(current.userId, touched) };
|
|
207
|
+
},
|
|
208
|
+
findCancellationPairs(userId, fromDate, toDate, options) {
|
|
209
|
+
var _a;
|
|
210
|
+
const maxDistance = (_a = options == null ? void 0 : options.maxDistanceSeconds) != null ? _a : 120;
|
|
211
|
+
const punches = entries.listByRange(userId, fromDate, toDate).filter((entry) => entry.syncState === "synced").slice().sort((a, b) => a.tsUtc - b.tsUtc || a.id - b.id);
|
|
212
|
+
const pairs = [];
|
|
213
|
+
let index = 0;
|
|
214
|
+
while (index + 1 < punches.length) {
|
|
215
|
+
const first = punches[index];
|
|
216
|
+
const second = punches[index + 1];
|
|
217
|
+
const distanceSeconds = second.tsUtc - first.tsUtc;
|
|
218
|
+
if (distanceSeconds >= 0 && distanceSeconds <= maxDistance) {
|
|
219
|
+
pairs.push({ first, second, distanceSeconds });
|
|
220
|
+
index += 2;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
index++;
|
|
224
|
+
}
|
|
225
|
+
return pairs;
|
|
226
|
+
},
|
|
227
|
+
cancelPair(input) {
|
|
228
|
+
var _a, _b;
|
|
229
|
+
const first = entries.findById(input.entryIds[0]);
|
|
230
|
+
const second = entries.findById(input.entryIds[1]);
|
|
231
|
+
if (!first || !second) {
|
|
232
|
+
throw new import_errors.NotFoundError("both punches of the cancellation pair must exist");
|
|
233
|
+
}
|
|
234
|
+
if (first.userId !== second.userId) {
|
|
235
|
+
throw new import_errors.ValidationError("both punches of the cancellation pair must belong to the same employee");
|
|
236
|
+
}
|
|
237
|
+
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
238
|
+
const reason = (_b = input.reason) != null ? _b : "cancellation pair";
|
|
239
|
+
const touched = /* @__PURE__ */ new Set([first.localDate, second.localDate]);
|
|
240
|
+
let removed = 0;
|
|
241
|
+
const run = db.transaction(() => {
|
|
242
|
+
var _a2, _b2;
|
|
243
|
+
for (const entry of [first, second]) {
|
|
244
|
+
if (entries.remove({
|
|
245
|
+
id: entry.id,
|
|
246
|
+
actorId: input.actorId,
|
|
247
|
+
reason,
|
|
248
|
+
actorIp: (_a2 = input.actorIp) != null ? _a2 : null,
|
|
249
|
+
now
|
|
250
|
+
})) {
|
|
251
|
+
removed++;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
(0, import_audit.writeAuditLog)(db, {
|
|
255
|
+
atUtc: now,
|
|
256
|
+
actorId: input.actorId,
|
|
257
|
+
action: "entry.cancel_pair",
|
|
258
|
+
entity: "time_entry",
|
|
259
|
+
entityId: `${first.id}+${second.id}`,
|
|
260
|
+
detail: {
|
|
261
|
+
userId: first.userId,
|
|
262
|
+
punch: { first: first.tsUtc, second: second.tsUtc },
|
|
263
|
+
distanceSeconds: second.tsUtc - first.tsUtc,
|
|
264
|
+
reason
|
|
265
|
+
},
|
|
266
|
+
ip: (_b2 = input.actorIp) != null ? _b2 : null
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
run();
|
|
270
|
+
return { removed, recalculated: refreshDays(first.userId, touched) };
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
275
|
+
0 && (module.exports = {
|
|
276
|
+
createSyncService
|
|
277
|
+
});
|
|
278
|
+
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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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",
|
|
6
|
+
"names": ["resolveLocalDate", "_a", "_b"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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 time_exports = {};
|
|
20
|
+
__export(time_exports, {
|
|
21
|
+
addDays: () => addDays,
|
|
22
|
+
dateRange: () => dateRange,
|
|
23
|
+
dayOfWeek: () => dayOfWeek,
|
|
24
|
+
daysInMonth: () => daysInMonth,
|
|
25
|
+
isDateString: () => isDateString,
|
|
26
|
+
isValidTimeZone: () => isValidTimeZone,
|
|
27
|
+
localDate: () => localDate,
|
|
28
|
+
localDateTime: () => localDateTime,
|
|
29
|
+
startOfLocalDayUtc: () => startOfLocalDayUtc,
|
|
30
|
+
utcToWallTime: () => utcToWallTime,
|
|
31
|
+
wallTimeToUtc: () => wallTimeToUtc
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(time_exports);
|
|
34
|
+
var import_luxon = require("luxon");
|
|
35
|
+
const UTC = "utc";
|
|
36
|
+
function isValidTimeZone(timeZone) {
|
|
37
|
+
return import_luxon.DateTime.local().setZone(timeZone).isValid;
|
|
38
|
+
}
|
|
39
|
+
function localDate(tsUtc, timeZone) {
|
|
40
|
+
return localDateTime(tsUtc, timeZone).date;
|
|
41
|
+
}
|
|
42
|
+
function localDateTime(tsUtc, timeZone) {
|
|
43
|
+
var _a;
|
|
44
|
+
const local = import_luxon.DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone);
|
|
45
|
+
return {
|
|
46
|
+
date: (_a = local.toISODate()) != null ? _a : "",
|
|
47
|
+
minutes: local.hour * 60 + local.minute
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function dayOfWeek(date, timeZone) {
|
|
51
|
+
return import_luxon.DateTime.fromISO(date, { zone: timeZone }).weekday % 7;
|
|
52
|
+
}
|
|
53
|
+
function addDays(date, days) {
|
|
54
|
+
var _a;
|
|
55
|
+
return (_a = import_luxon.DateTime.fromISO(date, { zone: UTC }).plus({ days }).toISODate()) != null ? _a : date;
|
|
56
|
+
}
|
|
57
|
+
function dateRange(from, to) {
|
|
58
|
+
const dates = [];
|
|
59
|
+
let current = from;
|
|
60
|
+
while (current <= to) {
|
|
61
|
+
dates.push(current);
|
|
62
|
+
current = addDays(current, 1);
|
|
63
|
+
}
|
|
64
|
+
return dates;
|
|
65
|
+
}
|
|
66
|
+
function isDateString(value) {
|
|
67
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return import_luxon.DateTime.fromISO(value, { zone: UTC }).isValid;
|
|
71
|
+
}
|
|
72
|
+
function startOfLocalDayUtc(date, timeZone) {
|
|
73
|
+
return Math.floor(import_luxon.DateTime.fromISO(date, { zone: timeZone }).toSeconds());
|
|
74
|
+
}
|
|
75
|
+
function daysInMonth(year, month) {
|
|
76
|
+
var _a;
|
|
77
|
+
return (_a = import_luxon.DateTime.fromObject({ year, month }, { zone: UTC }).daysInMonth) != null ? _a : 30;
|
|
78
|
+
}
|
|
79
|
+
function wallTimeToUtc(localSeconds, timeZone) {
|
|
80
|
+
const wall = import_luxon.DateTime.fromSeconds(localSeconds, { zone: UTC });
|
|
81
|
+
const fields = {
|
|
82
|
+
year: wall.year,
|
|
83
|
+
month: wall.month,
|
|
84
|
+
day: wall.day,
|
|
85
|
+
hour: wall.hour,
|
|
86
|
+
minute: wall.minute,
|
|
87
|
+
second: wall.second
|
|
88
|
+
};
|
|
89
|
+
const wallSeconds = Math.floor(
|
|
90
|
+
Date.UTC(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second) / 1e3
|
|
91
|
+
);
|
|
92
|
+
const offsetAt = (tsUtc) => import_luxon.DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone).offset;
|
|
93
|
+
const wallTimeOf = (tsUtc) => {
|
|
94
|
+
const local = import_luxon.DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone);
|
|
95
|
+
return Math.floor(
|
|
96
|
+
Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second) / 1e3
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
const offsetBefore = offsetAt(wallSeconds - 86400);
|
|
100
|
+
const offsetAfter = offsetAt(wallSeconds + 86400);
|
|
101
|
+
const candidates = Array.from(/* @__PURE__ */ new Set([wallSeconds - offsetBefore * 60, wallSeconds - offsetAfter * 60])).filter((tsUtc) => wallTimeOf(tsUtc) === wallSeconds).sort((a, b) => a - b);
|
|
102
|
+
if (candidates.length === 0) {
|
|
103
|
+
return { tsUtc: wallSeconds - offsetBefore * 60, ambiguous: false, nonexistent: true };
|
|
104
|
+
}
|
|
105
|
+
return { tsUtc: candidates[0], ambiguous: candidates.length > 1, nonexistent: false };
|
|
106
|
+
}
|
|
107
|
+
function utcToWallTime(tsUtc, timeZone) {
|
|
108
|
+
const local = import_luxon.DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone);
|
|
109
|
+
return Math.floor(Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second) / 1e3);
|
|
110
|
+
}
|
|
111
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
112
|
+
0 && (module.exports = {
|
|
113
|
+
addDays,
|
|
114
|
+
dateRange,
|
|
115
|
+
dayOfWeek,
|
|
116
|
+
daysInMonth,
|
|
117
|
+
isDateString,
|
|
118
|
+
isValidTimeZone,
|
|
119
|
+
localDate,
|
|
120
|
+
localDateTime,
|
|
121
|
+
startOfLocalDayUtc,
|
|
122
|
+
utcToWallTime,
|
|
123
|
+
wallTimeToUtc
|
|
124
|
+
});
|
|
125
|
+
//# sourceMappingURL=time.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/util/time.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Time zone helpers.\n *\n * All timestamps in the database are UTC epoch seconds; local dates and times are derived with these\n * helpers (never with manual offsets), so daylight saving transitions are handled correctly.\n */\n\nimport { DateTime } from \"luxon\";\n\nconst UTC = \"utc\";\n\n/**\n * Checks whether a string is a valid IANA time zone name.\n *\n * @param timeZone - IANA time zone name to validate\n */\nexport function isValidTimeZone(timeZone: string): boolean {\n\treturn DateTime.local().setZone(timeZone).isValid;\n}\n\n/**\n * Converts an instant to the local calendar date (`YYYY-MM-DD`) of a time zone.\n *\n * @param tsUtc - UTC epoch seconds\n * @param timeZone - IANA time zone\n * @returns local date string\n */\nexport function localDate(tsUtc: number, timeZone: string): string {\n\treturn localDateTime(tsUtc, timeZone).date;\n}\n\n/**\n * Converts an instant to the wall clock time of a time zone.\n *\n * @param tsUtc - UTC epoch seconds\n * @param timeZone - IANA time zone\n * @returns local date and minutes since local midnight\n */\nexport function localDateTime(tsUtc: number, timeZone: string): { date: string; minutes: number } {\n\tconst local = DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone);\n\treturn {\n\t\tdate: local.toISODate() ?? \"\",\n\t\tminutes: local.hour * 60 + local.minute,\n\t};\n}\n\n/**\n * Weekday of a local calendar date, 0 = Sunday \u2026 6 = Saturday (same base as `workdays`).\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param timeZone - IANA time zone used to resolve the date\n * @returns weekday number\n */\nexport function dayOfWeek(date: string, timeZone: string): number {\n\treturn DateTime.fromISO(date, { zone: timeZone }).weekday % 7;\n}\n\n/**\n * Adds whole days to a calendar date (calendar arithmetic, no time zone involved).\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param days - days to add, may be negative\n * @returns resulting local date\n */\nexport function addDays(date: string, days: number): string {\n\treturn DateTime.fromISO(date, { zone: UTC }).plus({ days }).toISODate() ?? date;\n}\n\n/**\n * Returns the inclusive range of local dates between two dates.\n *\n * @param from - first local date, `YYYY-MM-DD`\n * @param to - last local date, `YYYY-MM-DD`\n * @returns array of local dates (empty when `to` is before `from`)\n */\nexport function dateRange(from: string, to: string): string[] {\n\tconst dates: string[] = [];\n\tlet current = from;\n\twhile (current <= to) {\n\t\tdates.push(current);\n\t\tcurrent = addDays(current, 1);\n\t}\n\treturn dates;\n}\n\n/**\n * Checks whether a string is a local calendar date in `YYYY-MM-DD` form.\n *\n * @param value - value to check\n * @returns true when the value is a valid date without time\n */\nexport function isDateString(value: string): boolean {\n\tif (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n\t\treturn false;\n\t}\n\treturn DateTime.fromISO(value, { zone: UTC }).isValid;\n}\n\n/**\n * UTC epoch seconds of local midnight of a given date.\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param timeZone - IANA time zone\n * @returns UTC epoch seconds\n */\nexport function startOfLocalDayUtc(date: string, timeZone: string): number {\n\treturn Math.floor(DateTime.fromISO(date, { zone: timeZone }).toSeconds());\n}\n\n/**\n * Number of days in a month.\n *\n * @param year - four digit year\n * @param month - month, 1 to 12\n * @returns day count of that month\n */\nexport function daysInMonth(year: number, month: number): number {\n\treturn DateTime.fromObject({ year, month }, { zone: UTC }).daysInMonth ?? 30;\n}\n\n/** Result of converting a local wall-clock timestamp. */\nexport interface WallTimeConversion {\n\t/** UTC epoch seconds */\n\ttsUtc: number;\n\t/** True when the wall time exists twice (switch back to standard time) */\n\tambiguous: boolean;\n\t/** True when the wall time does not exist (switch to daylight saving time) */\n\tnonexistent: boolean;\n}\n\n/**\n * Converts a local wall-clock timestamp (seconds since epoch *interpreted as local time*) to UTC epoch\n * seconds.\n *\n * During daylight saving transitions wall times can be ambiguous or non-existent; the caller has to\n * report those cases as warnings instead of guessing.\n *\n * @param localSeconds - seconds since epoch in the local frame\n * @param timeZone - IANA time zone of the timestamp\n * @returns UTC instant plus ambiguity flags\n */\nexport function wallTimeToUtc(localSeconds: number, timeZone: string): WallTimeConversion {\n\tconst wall = DateTime.fromSeconds(localSeconds, { zone: UTC });\n\tconst fields = {\n\t\tyear: wall.year,\n\t\tmonth: wall.month,\n\t\tday: wall.day,\n\t\thour: wall.hour,\n\t\tminute: wall.minute,\n\t\tsecond: wall.second,\n\t};\n\tconst wallSeconds = Math.floor(\n\t\tDate.UTC(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second) / 1000,\n\t);\n\n\tconst offsetAt = (tsUtc: number): number => DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone).offset;\n\tconst wallTimeOf = (tsUtc: number): number => {\n\t\tconst local = DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone);\n\t\treturn Math.floor(\n\t\t\tDate.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second) / 1000,\n\t\t);\n\t};\n\n\t// Candidate instants are built from the offsets one day before and one day after the wall time:\n\t// during a switch back both produce a valid instant (ambiguous), during a switch forward none does\n\t// (non-existent).\n\tconst offsetBefore = offsetAt(wallSeconds - 86400);\n\tconst offsetAfter = offsetAt(wallSeconds + 86400);\n\tconst candidates = Array.from(new Set([wallSeconds - offsetBefore * 60, wallSeconds - offsetAfter * 60]))\n\t\t.filter(tsUtc => wallTimeOf(tsUtc) === wallSeconds)\n\t\t.sort((a, b) => a - b);\n\n\tif (candidates.length === 0) {\n\t\t// Non-existent wall time: shift it forward by the gap, which equals the instant computed with\n\t\t// the offset from before the transition.\n\t\treturn { tsUtc: wallSeconds - offsetBefore * 60, ambiguous: false, nonexistent: true };\n\t}\n\treturn { tsUtc: candidates[0], ambiguous: candidates.length > 1, nonexistent: false };\n}\n\n/**\n * Converts an instant into the local wall-clock timestamp (inverse of `wallTimeToUtc`).\n *\n * @param tsUtc - UTC epoch seconds\n * @param timeZone - IANA time zone of the timestamp\n * @returns seconds since epoch in the local frame\n */\nexport function utcToWallTime(tsUtc: number, timeZone: string): number {\n\tconst local = DateTime.fromSeconds(tsUtc, { zone: UTC }).setZone(timeZone);\n\treturn Math.floor(Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second) / 1000);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|