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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +386 -0
  3. package/admin/i18n/de.json +34 -0
  4. package/admin/i18n/en.json +1 -0
  5. package/admin/i18n/es.json +34 -0
  6. package/admin/i18n/fr.json +34 -0
  7. package/admin/i18n/it.json +34 -0
  8. package/admin/i18n/nl.json +34 -0
  9. package/admin/i18n/pl.json +34 -0
  10. package/admin/i18n/pt.json +34 -0
  11. package/admin/i18n/ru.json +34 -0
  12. package/admin/i18n/uk.json +34 -0
  13. package/admin/i18n/zh-cn.json +34 -0
  14. package/admin/jsonConfig.json +266 -0
  15. package/admin/time-tracker.png +0 -0
  16. package/build/lib/adapter/automation.js +100 -0
  17. package/build/lib/adapter/automation.js.map +7 -0
  18. package/build/lib/adapter/commands.js +160 -0
  19. package/build/lib/adapter/commands.js.map +7 -0
  20. package/build/lib/adapter/messages.js +207 -0
  21. package/build/lib/adapter/messages.js.map +7 -0
  22. package/build/lib/adapter/presence.js +129 -0
  23. package/build/lib/adapter/presence.js.map +7 -0
  24. package/build/lib/adapter/states.js +330 -0
  25. package/build/lib/adapter/states.js.map +7 -0
  26. package/build/lib/adapter/triggers.js +92 -0
  27. package/build/lib/adapter/triggers.js.map +7 -0
  28. package/build/lib/db/database.js +96 -0
  29. package/build/lib/db/database.js.map +7 -0
  30. package/build/lib/db/migrations.js +603 -0
  31. package/build/lib/db/migrations.js.map +7 -0
  32. package/build/lib/db/repositories/absences.js +432 -0
  33. package/build/lib/db/repositories/absences.js.map +7 -0
  34. package/build/lib/db/repositories/audit.js +90 -0
  35. package/build/lib/db/repositories/audit.js.map +7 -0
  36. package/build/lib/db/repositories/automations.js +326 -0
  37. package/build/lib/db/repositories/automations.js.map +7 -0
  38. package/build/lib/db/repositories/entries.js +335 -0
  39. package/build/lib/db/repositories/entries.js.map +7 -0
  40. package/build/lib/db/repositories/holidays.js +156 -0
  41. package/build/lib/db/repositories/holidays.js.map +7 -0
  42. package/build/lib/db/repositories/payouts.js +221 -0
  43. package/build/lib/db/repositories/payouts.js.map +7 -0
  44. package/build/lib/db/repositories/rfid.js +264 -0
  45. package/build/lib/db/repositories/rfid.js.map +7 -0
  46. package/build/lib/db/repositories/rules.js +356 -0
  47. package/build/lib/db/repositories/rules.js.map +7 -0
  48. package/build/lib/db/repositories/settings.js +91 -0
  49. package/build/lib/db/repositories/settings.js.map +7 -0
  50. package/build/lib/db/repositories/terminals.js +243 -0
  51. package/build/lib/db/repositories/terminals.js.map +7 -0
  52. package/build/lib/db/repositories/triggers.js +240 -0
  53. package/build/lib/db/repositories/triggers.js.map +7 -0
  54. package/build/lib/db/repositories/users.js +491 -0
  55. package/build/lib/db/repositories/users.js.map +7 -0
  56. package/build/lib/db/seed.js +180 -0
  57. package/build/lib/db/seed.js.map +7 -0
  58. package/build/lib/domain/avatar.js +63 -0
  59. package/build/lib/domain/avatar.js.map +7 -0
  60. package/build/lib/domain/breaks.js +70 -0
  61. package/build/lib/domain/breaks.js.map +7 -0
  62. package/build/lib/domain/calculation.js +150 -0
  63. package/build/lib/domain/calculation.js.map +7 -0
  64. package/build/lib/domain/holidays.js +113 -0
  65. package/build/lib/domain/holidays.js.map +7 -0
  66. package/build/lib/domain/punch.js +99 -0
  67. package/build/lib/domain/punch.js.map +7 -0
  68. package/build/lib/domain/target.js +89 -0
  69. package/build/lib/domain/target.js.map +7 -0
  70. package/build/lib/errors.js +56 -0
  71. package/build/lib/errors.js.map +7 -0
  72. package/build/lib/reports/labels.js +358 -0
  73. package/build/lib/reports/labels.js.map +7 -0
  74. package/build/lib/reports/pdf.js +308 -0
  75. package/build/lib/reports/pdf.js.map +7 -0
  76. package/build/lib/reports/types.js +32 -0
  77. package/build/lib/reports/types.js.map +7 -0
  78. package/build/lib/reports/xls.js +211 -0
  79. package/build/lib/reports/xls.js.map +7 -0
  80. package/build/lib/services/aggregation.js +495 -0
  81. package/build/lib/services/aggregation.js.map +7 -0
  82. package/build/lib/services/auth.js +381 -0
  83. package/build/lib/services/auth.js.map +7 -0
  84. package/build/lib/services/backup.js +372 -0
  85. package/build/lib/services/backup.js.map +7 -0
  86. package/build/lib/services/closing.js +106 -0
  87. package/build/lib/services/closing.js.map +7 -0
  88. package/build/lib/services/sessionSecret.js +100 -0
  89. package/build/lib/services/sessionSecret.js.map +7 -0
  90. package/build/lib/services/sync.js +278 -0
  91. package/build/lib/services/sync.js.map +7 -0
  92. package/build/lib/util/time.js +125 -0
  93. package/build/lib/util/time.js.map +7 -0
  94. package/build/lib/web/api.js +2589 -0
  95. package/build/lib/web/api.js.map +7 -0
  96. package/build/lib/web/cookies.js +80 -0
  97. package/build/lib/web/cookies.js.map +7 -0
  98. package/build/lib/web/events.js +50 -0
  99. package/build/lib/web/events.js.map +7 -0
  100. package/build/lib/web/pin-guard.js +86 -0
  101. package/build/lib/web/pin-guard.js.map +7 -0
  102. package/build/lib/web/problem.js +129 -0
  103. package/build/lib/web/problem.js.map +7 -0
  104. package/build/lib/web/rate-limit.js +65 -0
  105. package/build/lib/web/rate-limit.js.map +7 -0
  106. package/build/lib/web/router.js +274 -0
  107. package/build/lib/web/router.js.map +7 -0
  108. package/build/lib/web/server.js +189 -0
  109. package/build/lib/web/server.js.map +7 -0
  110. package/build/lib/web/static.js +151 -0
  111. package/build/lib/web/static.js.map +7 -0
  112. package/build/lib/web/stream.js +135 -0
  113. package/build/lib/web/stream.js.map +7 -0
  114. package/build/main.js +986 -0
  115. package/build/main.js.map +7 -0
  116. package/io-package.json +221 -0
  117. package/package.json +131 -0
  118. package/src-pwa/src/i18n/de.json +359 -0
  119. package/src-pwa/src/i18n/en.json +359 -0
  120. package/src-pwa/src/i18n/es.json +359 -0
  121. package/src-pwa/src/i18n/fr.json +359 -0
  122. package/src-pwa/src/i18n/index.ts +96 -0
  123. package/src-pwa/src/i18n/it.json +359 -0
  124. package/src-pwa/src/i18n/nl.json +359 -0
  125. package/src-pwa/src/i18n/pl.json +359 -0
  126. package/src-pwa/src/i18n/pt.json +359 -0
  127. package/src-pwa/src/i18n/ru.json +359 -0
  128. package/src-pwa/src/i18n/uk.json +359 -0
  129. package/src-pwa/src/i18n/zh-cn.json +359 -0
  130. package/www/assets/index-QI3wrEvW.js +119 -0
  131. package/www/assets/index-QI3wrEvW.js.map +1 -0
  132. package/www/assets/workbox-window.prod.es5-Bd17z0YL.js +3 -0
  133. package/www/assets/workbox-window.prod.es5-Bd17z0YL.js.map +1 -0
  134. package/www/favicon.svg +16 -0
  135. package/www/icon-192.png +0 -0
  136. package/www/icon-512.png +0 -0
  137. package/www/index.html +38 -0
  138. package/www/manifest.webmanifest +1 -0
  139. package/www/person.png +0 -0
  140. package/www/sw.js +3 -0
  141. package/www/sw.js.map +1 -0
  142. package/www/workbox-9c191d2f.js +3 -0
  143. package/www/workbox-9c191d2f.js.map +1 -0
package/build/main.js ADDED
@@ -0,0 +1,986 @@
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 __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+ var import_node_crypto = require("node:crypto");
25
+ var fs = __toESM(require("node:fs"));
26
+ var path = __toESM(require("node:path"));
27
+ var utils = __toESM(require("@iobroker/adapter-core"));
28
+ var import_database = require("./lib/db/database");
29
+ var import_seed = require("./lib/db/seed");
30
+ var import_absences = require("./lib/db/repositories/absences");
31
+ var import_entries = require("./lib/db/repositories/entries");
32
+ var import_holidays = require("./lib/db/repositories/holidays");
33
+ var import_payouts = require("./lib/db/repositories/payouts");
34
+ var import_rules = require("./lib/db/repositories/rules");
35
+ var import_settings = require("./lib/db/repositories/settings");
36
+ var import_users = require("./lib/db/repositories/users");
37
+ var import_automations = require("./lib/db/repositories/automations");
38
+ var import_terminals = require("./lib/db/repositories/terminals");
39
+ var import_rfid = require("./lib/db/repositories/rfid");
40
+ var import_triggers = require("./lib/db/repositories/triggers");
41
+ var import_aggregation = require("./lib/services/aggregation");
42
+ var import_auth = require("./lib/services/auth");
43
+ var import_sessionSecret = require("./lib/services/sessionSecret");
44
+ var import_closing = require("./lib/services/closing");
45
+ var import_backup = require("./lib/services/backup");
46
+ var import_sync = require("./lib/services/sync");
47
+ var import_states = require("./lib/adapter/states");
48
+ var import_presence = require("./lib/adapter/presence");
49
+ var import_commands = require("./lib/adapter/commands");
50
+ var import_triggers2 = require("./lib/adapter/triggers");
51
+ var import_automation = require("./lib/adapter/automation");
52
+ var import_messages = require("./lib/adapter/messages");
53
+ var import_api = require("./lib/web/api");
54
+ var import_server = require("./lib/web/server");
55
+ var import_time = require("./lib/util/time");
56
+ var import_static = require("./lib/web/static");
57
+ const SUPPORTED_COUNTRIES = ["CH", "DE", "AT"];
58
+ const SESSION_PURGE_MINUTES = 30;
59
+ const STATE_REFRESH_MINUTES = 5;
60
+ const BACKUP_MAX_AGE_HOURS = 20;
61
+ const AUTOMATION_CHECK_MINUTES = 1;
62
+ const TAG_SECRET_FILE_NAME = "tag-secret";
63
+ const FIGURES_EVENT_TYPES = ["punch", "terminal.punch", "rfid.scan", "entry.update", "entry.delete", "absence.change"];
64
+ class TimeTracker extends utils.Adapter {
65
+ db = null;
66
+ webServer = null;
67
+ services = null;
68
+ /** Bus of the API; `null` until the API is created */
69
+ events = null;
70
+ /** Last value seen per watched trigger state: only a change fires a rule */
71
+ triggerValues = /* @__PURE__ */ new Map();
72
+ /** Trigger states the adapter currently subscribes to */
73
+ triggerStates = /* @__PURE__ */ new Set();
74
+ /** Pending refresh of the figures after a change through the API */
75
+ stateRefreshTimer = void 0;
76
+ constructor(options = {}) {
77
+ super({
78
+ ...options,
79
+ name: "time-tracker"
80
+ });
81
+ this.on("ready", this.onReady.bind(this));
82
+ this.on("stateChange", this.onStateChange.bind(this));
83
+ this.on("message", this.onMessage.bind(this));
84
+ this.on("unload", this.onUnload.bind(this));
85
+ }
86
+ /** Database file: configured path or `<adapter instance data dir>/time-tracker.sqlite`. */
87
+ databaseFile() {
88
+ var _a;
89
+ const configured = ((_a = this.config.dbPath) != null ? _a : "").trim();
90
+ if (configured) {
91
+ return path.resolve(configured);
92
+ }
93
+ const dir = utils.getAbsoluteInstanceDataDir(this);
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ return path.join(dir, "time-tracker.sqlite");
96
+ }
97
+ /** Configured holiday country, falling back to Switzerland. */
98
+ holidayCountry() {
99
+ var _a;
100
+ const configured = ((_a = this.config.holidayCountry) != null ? _a : "").toUpperCase();
101
+ return SUPPORTED_COUNTRIES.includes(configured) ? configured : "CH";
102
+ }
103
+ /**
104
+ * Is called when databases are connected and adapter received configuration.
105
+ */
106
+ async onReady() {
107
+ var _a;
108
+ try {
109
+ await this.setState("info.connection", false, true);
110
+ const file = this.databaseFile();
111
+ let applied = null;
112
+ try {
113
+ applied = (0, import_backup.applyPendingRestore)(file);
114
+ if (applied) {
115
+ this.log.info(
116
+ `restored the database from ${applied.restored.name}: ${applied.restored.users} users, ${applied.restored.entries} punches, previous file ${(_a = applied.previous) != null ? _a : "(none)"}`
117
+ );
118
+ }
119
+ } catch (error) {
120
+ this.log.error(`the queued restore was refused: ${error.message}`);
121
+ }
122
+ this.db = (0, import_database.openAndMigrate)(file, (message) => this.log.debug(message));
123
+ if (applied) {
124
+ (0, import_backup.recordRestore)(this.db, applied.restored, applied.actorId);
125
+ }
126
+ const year = (/* @__PURE__ */ new Date()).getUTCFullYear();
127
+ const country = this.holidayCountry();
128
+ const result = (0, import_seed.seed)(this.db, { holidayCountry: country, holidayYears: [year, year + 1] });
129
+ this.log.info(
130
+ `database ready at ${file} (${result.permissions} permissions, ${result.holidays} holidays added for ${country})`
131
+ );
132
+ this.ensureAdministrator(this.db);
133
+ this.applyConfiguration((0, import_settings.createSettingsRepository)(this.db));
134
+ await this.startApi();
135
+ await this.subscribeCommands();
136
+ await this.refreshTriggerSubscriptions();
137
+ this.runAutomationRules();
138
+ this.setInterval(() => this.runAutomationRules(), AUTOMATION_CHECK_MINUTES * 60 * 1e3);
139
+ await this.publishInstanceInfo();
140
+ await this.refreshStates();
141
+ this.setInterval(
142
+ () => {
143
+ void this.publishInstanceInfo();
144
+ void this.refreshStates();
145
+ },
146
+ STATE_REFRESH_MINUTES * 60 * 1e3
147
+ );
148
+ await this.runScheduledBackup();
149
+ this.setInterval(() => void this.runScheduledBackup(), 60 * 60 * 1e3);
150
+ await this.setState("info.connection", true, true);
151
+ } catch (error) {
152
+ const message = error.message;
153
+ this.log.error(`Startup failed: ${message}`);
154
+ await this.setState("info.lastError", message, true);
155
+ }
156
+ }
157
+ /**
158
+ * Creates the first administrator when the instance has none.
159
+ *
160
+ * The account is created with `must_change_pw = 1`, so the start password opens the door exactly once.
161
+ * Login and password can be configured; without a configured password a random one is generated and written
162
+ * to the log, because there is no other way to hand it to the operator.
163
+ *
164
+ * @param db - open database handle
165
+ */
166
+ ensureAdministrator(db) {
167
+ var _a, _b;
168
+ const users = (0, import_users.createUsersRepository)(db);
169
+ const administrators = users.list().filter((user) => users.roles(user.id).includes("admin"));
170
+ if (administrators.length > 0) {
171
+ return;
172
+ }
173
+ const login = ((_a = this.config.adminLogin) != null ? _a : "").trim() || "admin";
174
+ const configured = ((_b = this.config.adminPassword) != null ? _b : "").trim();
175
+ const password = configured || `Zf-${(0, import_node_crypto.randomBytes)(9).toString("base64url")}-7`;
176
+ try {
177
+ users.create({
178
+ login,
179
+ displayName: login,
180
+ passwordHash: (0, import_auth.hashPassword)(password),
181
+ mustChangePw: true,
182
+ timezone: this.config.timezone || "Europe/Berlin",
183
+ roleKeys: ["admin"],
184
+ now: Math.floor(Date.now() / 1e3)
185
+ });
186
+ } catch (error) {
187
+ this.log.warn(`the administrator "${login}" could not be created: ${error.message}`);
188
+ return;
189
+ }
190
+ if (configured) {
191
+ this.log.info(
192
+ `administrator "${login}" created with the configured start password - it has to be changed at the first login`
193
+ );
194
+ } else {
195
+ this.log.warn(
196
+ `administrator "${login}" created with the start password "${password}" - change it at the first login`
197
+ );
198
+ }
199
+ }
200
+ /**
201
+ * Copies the instance configuration into the stored settings and publishes the instance information.
202
+ *
203
+ * The ioBroker admin UI is where an operator configures the instance, so a configured value wins over what
204
+ * is stored in the database; `PUT /api/settings` stays for the keys the admin UI does not offer. Empty
205
+ * fields are ignored, so clearing a field never wipes a stored setting.
206
+ *
207
+ * @param settings - instance settings
208
+ */
209
+ applyConfiguration(settings) {
210
+ var _a, _b;
211
+ const text = (value) => (value == null ? void 0 : value.trim()) ? value.trim() : null;
212
+ const number = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : null;
213
+ const flag = (value) => value === void 0 ? null : value ? "1" : "0";
214
+ const mapped = [
215
+ ["holiday_country", (_b = (_a = text(this.config.holidayCountry)) == null ? void 0 : _a.toUpperCase()) != null ? _b : null],
216
+ ["timezone", text(this.config.timezone)],
217
+ ["default_language", text(this.config.defaultLanguage)],
218
+ ["edit_window_days", number(this.config.editWindowDays)],
219
+ ["quick_round_minutes", number(this.config.quickRoundMinutes)],
220
+ ["session_ttl_minutes", number(this.config.sessionTtlMinutes)],
221
+ ["backup_retention_days", number(this.config.backupRetentionDays)],
222
+ ["absence_calc_until_today", flag(this.config.absenceCalcUntilToday)],
223
+ ["absence_deduct_worktime", flag(this.config.absenceDeductWorktime)]
224
+ ];
225
+ const now = Math.floor(Date.now() / 1e3);
226
+ for (const [key, value] of mapped) {
227
+ if (value !== null && settings.get(key) !== value) {
228
+ settings.set(key, value, null, now);
229
+ }
230
+ }
231
+ }
232
+ /**
233
+ * Publishes version, database size and schema version as states (specification 5.1).
234
+ *
235
+ * The objects are created by `createInfoStates`, so this runs after the command states were set up.
236
+ */
237
+ async publishInstanceInfo() {
238
+ var _a;
239
+ await this.setState("info.version", (_a = this.version) != null ? _a : "0.0.0", true);
240
+ const file = this.db ? this.databaseFile() : "";
241
+ await this.setState("info.dbSizeBytes", file && fs.existsSync(file) ? fs.statSync(file).size : 0, true);
242
+ if (this.db) {
243
+ await this.setState("info.schemaVersion", String((0, import_database.currentSchemaVersion)(this.db)), true);
244
+ }
245
+ }
246
+ /**
247
+ * Secret used to sign CSRF tokens. A value from the instance settings wins; without one the adapter generates a
248
+ * secret on the first start and stores it next to the database, so a restart keeps the CSRF tokens of clients
249
+ * that are already open valid.
250
+ */
251
+ sessionSecret() {
252
+ var _a;
253
+ const resolved = (0, import_sessionSecret.resolveSessionSecret)({
254
+ configured: this.config.sessionSecret,
255
+ file: path.join(path.dirname(this.databaseFile()), import_sessionSecret.SECRET_FILE_NAME)
256
+ });
257
+ if (resolved.source === "configured") {
258
+ this.log.debug("session secret taken from the instance settings");
259
+ } else if (resolved.source === "stored") {
260
+ this.log.debug(`session secret taken from ${resolved.file}`);
261
+ } else if (resolved.file) {
262
+ this.log.info(
263
+ `no session secret configured - generated one and stored it at ${resolved.file}, so sessions and CSRF tokens survive restarts`
264
+ );
265
+ } else {
266
+ this.log.warn(
267
+ `no session secret configured and it could not be stored (${(_a = resolved.error) != null ? _a : "unknown"}) - a temporary one is used, so all sessions end with the next restart`
268
+ );
269
+ }
270
+ return resolved.secret;
271
+ }
272
+ /**
273
+ * Secret that signs the badge/NFC links.
274
+ *
275
+ * A value from the instance settings wins. Without one a secret is generated on the first start and stored next
276
+ * to the database — otherwise a fresh installation could not create a badge at all and the administration would
277
+ * only report “not configured”.
278
+ */
279
+ tagSecret() {
280
+ var _a;
281
+ const resolved = (0, import_sessionSecret.resolveSessionSecret)({
282
+ configured: this.config.hmacSecret,
283
+ file: path.join(path.dirname(this.databaseFile()), TAG_SECRET_FILE_NAME)
284
+ });
285
+ if (resolved.source === "configured") {
286
+ this.log.debug("badge link secret taken from the instance settings");
287
+ } else if (resolved.source === "stored") {
288
+ this.log.debug(`badge link secret taken from ${resolved.file}`);
289
+ } else if (resolved.file) {
290
+ this.log.info(
291
+ `no badge link secret configured - generated one and stored it at ${resolved.file}, so badges stay valid across restarts`
292
+ );
293
+ } else {
294
+ this.log.warn(
295
+ `no badge link secret configured and it could not be stored (${(_a = resolved.error) != null ? _a : "unknown"}) - a temporary one is used, so badges stop working with the next restart`
296
+ );
297
+ }
298
+ return resolved.secret;
299
+ }
300
+ /**
301
+ * Builds the services and starts the HTTP server on the configured port.
302
+ *
303
+ * Binding can fail (port in use); that must never stop the adapter, so the failure is only logged.
304
+ */
305
+ async startApi() {
306
+ var _a;
307
+ const db = this.db;
308
+ if (!db) {
309
+ return;
310
+ }
311
+ const users = (0, import_users.createUsersRepository)(db);
312
+ const entries = (0, import_entries.createEntriesRepository)(db);
313
+ const absences = (0, import_absences.createAbsencesRepository)(db);
314
+ const holidays = (0, import_holidays.createHolidaysRepository)(db);
315
+ const rules = (0, import_rules.createRulesRepository)(db);
316
+ const payouts = (0, import_payouts.createPayoutsRepository)(db);
317
+ const terminals = (0, import_terminals.createTerminalsRepository)(db);
318
+ const rfid = (0, import_rfid.createRfidRepository)(db);
319
+ const settings = (0, import_settings.createSettingsRepository)(db);
320
+ const auth = (0, import_auth.createAuthService)({
321
+ db,
322
+ users,
323
+ settings,
324
+ secret: this.sessionSecret(),
325
+ defaultTtlMinutes: this.config.sessionTtlMinutes || 720
326
+ });
327
+ const aggregation = (0, import_aggregation.createAggregationService)({
328
+ db,
329
+ users,
330
+ entries,
331
+ absences,
332
+ holidays,
333
+ rules,
334
+ settings
335
+ });
336
+ const triggers = (0, import_triggers.createTriggersRepository)(db);
337
+ const automations = (0, import_automations.createAutomationsRepository)(db);
338
+ const sync = (0, import_sync.createSyncService)({ db, entries, users, aggregation });
339
+ const closing = (0, import_closing.createClosingService)({ db, aggregation, payouts });
340
+ const backup = (0, import_backup.createBackupService)({
341
+ db,
342
+ dir: path.join(path.dirname(this.databaseFile()), "backups"),
343
+ retentionDays: Math.max(0, Number((_a = this.config.backupRetentionDays) != null ? _a : 30) || 0)
344
+ });
345
+ const api = (0, import_api.createApi)({
346
+ db,
347
+ auth,
348
+ users,
349
+ entries,
350
+ absences,
351
+ holidays,
352
+ rules,
353
+ payouts,
354
+ terminals,
355
+ rfid,
356
+ triggers,
357
+ automations,
358
+ aggregation,
359
+ sync,
360
+ settings,
361
+ backup,
362
+ kioskEnabled: this.config.kioskEnabled === true,
363
+ // a reverse proxy in front is the normal case for HTTPS; without the switch the forwarded
364
+ // headers are ignored, so a client cannot choose its own address or the HTTPS flag
365
+ trustProxy: this.config.trustProxy === true,
366
+ hmacSecret: this.tagSecret(),
367
+ version: this.version,
368
+ // a saved trigger rule changes the states the adapter has to watch
369
+ onTriggerRulesChanged: () => void this.refreshTriggerSubscriptions()
370
+ });
371
+ this.services = {
372
+ users,
373
+ entries,
374
+ absences,
375
+ settings,
376
+ aggregation,
377
+ sync,
378
+ closing,
379
+ backup,
380
+ triggers,
381
+ automations
382
+ };
383
+ this.events = api.events;
384
+ api.events.subscribe((event) => {
385
+ void this.publishEventState(event);
386
+ if (FIGURES_EVENT_TYPES.includes(event.type)) {
387
+ this.scheduleStateRefresh();
388
+ }
389
+ });
390
+ this.log.debug(`API routes: ${api.routes().length}`);
391
+ const webDir = path.join(__dirname, "..", "www");
392
+ const staticFiles = fs.existsSync(path.join(webDir, "index.html")) ? (0, import_static.createStaticHandler)({ root: webDir }) : void 0;
393
+ this.log.info(
394
+ staticFiles ? `web interface found at ${webDir}` : `no web interface at ${webDir} - only the API (and the terminal) is available`
395
+ );
396
+ try {
397
+ this.webServer = await (0, import_server.startWebServer)({
398
+ router: api.router,
399
+ port: this.config.port || 8092,
400
+ bind: this.config.bind || "127.0.0.1",
401
+ staticFiles,
402
+ // an uploaded backup is bigger than the default limit; the route carries the same bound
403
+ maxBodyBytes: import_api.MAX_BACKUP_UPLOAD_BYTES,
404
+ // live events for the PWA and the terminal: `/api/stream?token=...`
405
+ stream: {
406
+ auth,
407
+ events: api.events,
408
+ version: this.version,
409
+ timers: {
410
+ // the adapter's own timer functions: they are cleared with the adapter on unload
411
+ setInterval: (handler, milliseconds) => this.setInterval(handler, milliseconds),
412
+ clearInterval: (handle) => this.clearInterval(handle)
413
+ }
414
+ },
415
+ log: {
416
+ info: (message) => this.log.info(message),
417
+ warn: (message) => this.log.warn(message),
418
+ error: (message) => this.log.error(message)
419
+ }
420
+ });
421
+ } catch (error) {
422
+ this.log.error(
423
+ `API could not be started on port ${this.config.port}: ${error.message} - the web interface and the terminal stay unavailable`
424
+ );
425
+ this.webServer = null;
426
+ return;
427
+ }
428
+ this.setInterval(
429
+ () => {
430
+ try {
431
+ const removed = auth.purge();
432
+ if (removed > 0) {
433
+ this.log.debug(`removed ${removed} expired session(s)`);
434
+ }
435
+ } catch (error) {
436
+ this.log.warn(`session cleanup failed: ${error.message}`);
437
+ }
438
+ },
439
+ SESSION_PURGE_MINUTES * 60 * 1e3
440
+ );
441
+ }
442
+ /**
443
+ * Creates the command states and subscribes to them.
444
+ */
445
+ async subscribeCommands() {
446
+ try {
447
+ await (0, import_states.createCommandStates)(this);
448
+ await (0, import_states.createInfoStates)(this);
449
+ await (0, import_states.createCompanyStates)(this);
450
+ await (0, import_states.createEventStates)(this);
451
+ await this.subscribeStatesAsync("commands.*");
452
+ await this.subscribeStatesAsync(`users.*.${import_presence.PRESENCE_SUFFIX}`);
453
+ this.log.debug("command states ready");
454
+ } catch (error) {
455
+ this.log.warn(`command states could not be created: ${error.message}`);
456
+ }
457
+ }
458
+ /**
459
+ * Writes a backup unless a recent one exists.
460
+ *
461
+ * The check runs hourly and once at start: a nightly backup therefore happens within an hour at the latest,
462
+ * and an instance that was down for days does not write a burst of copies.
463
+ */
464
+ async runScheduledBackup() {
465
+ var _a, _b;
466
+ const backup = (_a = this.services) == null ? void 0 : _a.backup;
467
+ if (!backup) {
468
+ return;
469
+ }
470
+ try {
471
+ const newest = backup.list()[0];
472
+ const ageHours = newest ? (Date.now() / 1e3 - newest.createdAt) / 3600 : Number.POSITIVE_INFINITY;
473
+ if (ageHours < BACKUP_MAX_AGE_HOURS) {
474
+ this.log.debug(`backup ${(_b = newest == null ? void 0 : newest.name) != null ? _b : ""} is ${ageHours.toFixed(1)} h old, nothing to do`);
475
+ return;
476
+ }
477
+ const created = backup.create({ actorId: null, reason: "scheduled" });
478
+ this.log.info(
479
+ `backup ${created.backup.name} written (${created.backup.sizeBytes} bytes, ${created.backup.users} employees, ${created.backup.entries} punches), ${created.removed.length} old file(s) removed`
480
+ );
481
+ await this.announceBackup(created.backup.name, created.backup.createdAt);
482
+ } catch (error) {
483
+ this.log.warn(`backup failed: ${error.message}`);
484
+ }
485
+ }
486
+ /**
487
+ * Publishes the instant of a written backup.
488
+ *
489
+ * @param name - file name of the backup
490
+ * @param createdAt - instant the backup belongs to
491
+ */
492
+ async announceBackup(name, createdAt) {
493
+ var _a;
494
+ await this.setState("info.lastBackup", createdAt, true);
495
+ (_a = this.events) == null ? void 0 : _a.publish({
496
+ type: "backup.create",
497
+ atUtc: createdAt,
498
+ userId: null,
499
+ data: { backup: name }
500
+ });
501
+ }
502
+ /**
503
+ * Applies a write on the presence state of an employee.
504
+ *
505
+ * `users.<id>.present` is the writable twin of `users.<id>.hasOpenEntry`: `true` opens an entry, `false` closes
506
+ * it. Afterwards the figures of all employees are republished, so a dashboard sees the new state right away.
507
+ *
508
+ * @param id - state id without the instance prefix
509
+ * @param value - value written by the user or a script
510
+ */
511
+ async runPresence(id, value) {
512
+ const services = this.services;
513
+ if (!services) {
514
+ return;
515
+ }
516
+ try {
517
+ const result = (0, import_presence.handlePresenceState)(
518
+ {
519
+ entries: services.entries,
520
+ users: services.users,
521
+ aggregation: services.aggregation
522
+ },
523
+ id,
524
+ value
525
+ );
526
+ if (result.ok) {
527
+ this.log.info(`presence ${id}: ${result.message}`);
528
+ } else {
529
+ this.log.warn(`presence ${id}: ${result.message}`);
530
+ }
531
+ await this.refreshStates();
532
+ } catch (error) {
533
+ this.log.error(`Error in runPresence for ${id}: ${error.message}`);
534
+ }
535
+ }
536
+ /**
537
+ * Publishes the figures of all employees.
538
+ */
539
+ async refreshStates() {
540
+ const services = this.services;
541
+ if (!services) {
542
+ return;
543
+ }
544
+ try {
545
+ const snapshots = await (0, import_states.publishAllUserStates)({
546
+ port: this,
547
+ aggregation: services.aggregation,
548
+ users: services.users,
549
+ sync: services.sync
550
+ });
551
+ this.log.debug(`published ${snapshots.length} employee state(s)`);
552
+ await (0, import_states.publishCompanySnapshot)(this, (0, import_states.readCompanySnapshot)(snapshots));
553
+ } catch (error) {
554
+ this.log.warn(`states could not be published: ${error.message}`);
555
+ }
556
+ }
557
+ /**
558
+ * Runs a command state and reports the outcome.
559
+ *
560
+ * @param id - state id without the instance prefix
561
+ * @param value - value written by the user or a script
562
+ */
563
+ async runCommand(id, value) {
564
+ var _a;
565
+ const services = this.services;
566
+ const db = this.db;
567
+ if (!services || !db) {
568
+ return;
569
+ }
570
+ try {
571
+ const result = (0, import_commands.handleCommand)(
572
+ {
573
+ db,
574
+ entries: services.entries,
575
+ users: services.users,
576
+ settings: services.settings,
577
+ aggregation: services.aggregation,
578
+ closing: services.closing,
579
+ backup: services.backup
580
+ },
581
+ id,
582
+ value
583
+ );
584
+ this.log.info(`command ${id}: ${result.message}`);
585
+ if (id === import_states.COMMAND_IDS.closeMonth) {
586
+ (_a = this.events) == null ? void 0 : _a.publish({
587
+ type: "month.close",
588
+ atUtc: Math.floor(Date.now() / 1e3),
589
+ userId: null,
590
+ data: { period: String(value != null ? value : "") }
591
+ });
592
+ }
593
+ if (id === import_states.COMMAND_IDS.backup && result.ok) {
594
+ const newest = services.backup.list()[0];
595
+ if (newest) {
596
+ await this.announceBackup(newest.name, newest.createdAt);
597
+ }
598
+ }
599
+ } catch (error) {
600
+ this.log.warn(`command ${id} failed: ${error.message}`);
601
+ }
602
+ if (id === import_states.COMMAND_IDS.punch || id === import_states.COMMAND_IDS.quickPunch || id === import_states.COMMAND_IDS.backup) {
603
+ await this.setState(id, false, true);
604
+ }
605
+ await this.refreshStates();
606
+ }
607
+ /**
608
+ * Watches exactly the states the active trigger rules name.
609
+ *
610
+ * Runs at startup and whenever the administration saves the rules, so an added, changed or removed rule takes
611
+ * effect without a restart. A state that no rule names any more is dropped from the watch list.
612
+ */
613
+ async refreshTriggerSubscriptions() {
614
+ const services = this.services;
615
+ if (!services) {
616
+ return;
617
+ }
618
+ let rules = [];
619
+ try {
620
+ rules = services.triggers.list();
621
+ } catch (error) {
622
+ this.log.warn(`trigger rules could not be read: ${error.message}`);
623
+ return;
624
+ }
625
+ const wanted = new Set(rules.map((rule) => rule.sourceState));
626
+ for (const id of [...this.triggerStates]) {
627
+ if (wanted.has(id)) {
628
+ continue;
629
+ }
630
+ try {
631
+ await this.unsubscribeForeignStatesAsync(id);
632
+ } catch (error) {
633
+ this.log.debug(`trigger state ${id} could not be released: ${error.message}`);
634
+ }
635
+ this.triggerStates.delete(id);
636
+ this.triggerValues.delete(id);
637
+ }
638
+ for (const id of wanted) {
639
+ if (this.triggerStates.has(id)) {
640
+ continue;
641
+ }
642
+ try {
643
+ await this.subscribeForeignStatesAsync(id);
644
+ this.triggerStates.add(id);
645
+ } catch (error) {
646
+ this.log.warn(`trigger state ${id} could not be watched: ${error.message}`);
647
+ }
648
+ }
649
+ this.log.debug(`watching ${this.triggerStates.size} trigger state(s) for ${rules.length} active rule(s)`);
650
+ }
651
+ /**
652
+ * Applies a write on a state that trigger rules watch.
653
+ *
654
+ * Such a state belongs to another adapter, so its `ack` says nothing about whether the value was set by a
655
+ * script or by the device. What counts is that the value **changed**: a reader that repeats itself, a
656
+ * dashboard that refreshes or a script that writes the same value twice does not punch twice. Together with
657
+ * the cooldown of a rule even a rapidly blinking state stays harmless.
658
+ *
659
+ * @param id - full state id
660
+ * @param state - state object of the write
661
+ * @returns true when the state belongs to a rule and was handled here
662
+ */
663
+ handleTriggerState(id, state) {
664
+ var _a;
665
+ const services = this.services;
666
+ if (!services) {
667
+ return false;
668
+ }
669
+ const previous = (_a = this.triggerValues.get(id)) != null ? _a : null;
670
+ this.triggerValues.set(id, (0, import_triggers2.triggerText)(state.val));
671
+ const rules = services.triggers.list().filter((rule) => rule.sourceState === id);
672
+ if (rules.length === 0) {
673
+ return false;
674
+ }
675
+ const now = Math.floor(Date.now() / 1e3);
676
+ for (const rule of rules) {
677
+ const decision = (0, import_triggers2.evaluateTrigger)(rule, { value: state.val, previous, now }, services.users);
678
+ if (!decision.fire || decision.userId === null) {
679
+ this.log.debug(`trigger ${rule.id} (${rule.sourceState}) did not fire: ${decision.reason}`);
680
+ continue;
681
+ }
682
+ this.runTrigger(rule, decision.userId);
683
+ }
684
+ return true;
685
+ }
686
+ /**
687
+ * Runs the action of a rule that fired.
688
+ *
689
+ * The punch goes through the same path as the command state and a presence change through the same handler as
690
+ * the `users.<id>.present` state, so direction, rounding, notes and the recalculation behave identically.
691
+ *
692
+ * @param rule - rule that fired
693
+ * @param userId - employee the action applies to
694
+ */
695
+ runTrigger(rule, userId) {
696
+ void (async () => {
697
+ const services = this.services;
698
+ if (!services) {
699
+ return;
700
+ }
701
+ try {
702
+ const result = rule.action === "present" || rule.action === "absent" ? (0, import_presence.handlePresenceState)(
703
+ { entries: services.entries, users: services.users, aggregation: services.aggregation },
704
+ `users.${userId}.${import_presence.PRESENCE_SUFFIX}`,
705
+ rule.action === "present"
706
+ ) : (0, import_commands.punchEmployee)(
707
+ {
708
+ entries: services.entries,
709
+ users: services.users,
710
+ settings: services.settings,
711
+ aggregation: services.aggregation
712
+ },
713
+ { userId, quick: rule.action === "quickPunch", note: `trigger.${rule.id}` }
714
+ );
715
+ this.log.info(`trigger ${rule.id} (${rule.sourceState}) \u2192 ${result.message}`);
716
+ services.triggers.markFired({ id: rule.id });
717
+ await this.refreshStates();
718
+ } catch (error) {
719
+ this.log.warn(`trigger ${rule.id} failed: ${error.message}`);
720
+ }
721
+ })();
722
+ }
723
+ /**
724
+ * Stops the HTTP server.
725
+ */
726
+ async stopApi() {
727
+ const server = this.webServer;
728
+ this.webServer = null;
729
+ if (server) {
730
+ await server.close();
731
+ this.log.debug("API stopped");
732
+ }
733
+ }
734
+ /**
735
+ * Is called when adapter shuts down - callback has to be called under any circumstances!
736
+ *
737
+ * @param callback - Callback function
738
+ */
739
+ onUnload(callback) {
740
+ try {
741
+ void this.stopApi().catch((error) => this.log.warn(`stopping the API failed: ${error.message}`)).finally(() => {
742
+ var _a;
743
+ (_a = this.db) == null ? void 0 : _a.close();
744
+ this.db = null;
745
+ callback();
746
+ });
747
+ } catch (error) {
748
+ this.log.error(`Error during unloading: ${error.message}`);
749
+ callback();
750
+ }
751
+ }
752
+ /**
753
+ * Runs the automation rules that are due.
754
+ *
755
+ * A rule acts at most once per employee and period — the local date, or the ISO week for a weekly rule;
756
+ * `automation_runs` holds that decision, so a restart
757
+ * cannot punch twice. The run is noted **before** the action (a double punch would be worse than a missed
758
+ * reminder) and taken back when the action fails, so the next minute retries it.
759
+ */
760
+ runAutomationRules() {
761
+ var _a;
762
+ const services = this.services;
763
+ if (!services) {
764
+ return;
765
+ }
766
+ let rules = [];
767
+ try {
768
+ rules = services.automations.list();
769
+ } catch (error) {
770
+ this.log.warn(`automation rules could not be read: ${error.message}`);
771
+ return;
772
+ }
773
+ if (rules.length === 0) {
774
+ return;
775
+ }
776
+ const nowUtc = Math.floor(Date.now() / 1e3);
777
+ let acted = false;
778
+ for (const rule of rules) {
779
+ const candidates = rule.userId === null ? services.users.list() : [services.users.findById(rule.userId)].filter((user) => user !== null);
780
+ for (const user of candidates) {
781
+ if (!user.isActive) {
782
+ continue;
783
+ }
784
+ const local = (0, import_time.localDateTime)(nowUtc, user.timezone);
785
+ const period = (0, import_automation.runPeriod)(rule.repeat, local.date);
786
+ if (services.automations.hasRun({ ruleId: rule.id, userId: user.id, period })) {
787
+ continue;
788
+ }
789
+ const punches = services.entries.listByDate(user.id, local.date).map((entry) => ({ tsUtc: entry.tsUtc, direction: entry.direction }));
790
+ const block = (0, import_automation.workBlock)(punches, nowUtc);
791
+ const decision = (0, import_automation.evaluateAutomation)(rule, {
792
+ localDate: local.date,
793
+ weekday: (0, import_automation.isoWeekday)(local.date),
794
+ minuteOfDay: local.minutes,
795
+ hasOpenEntry: block.hasOpenEntry,
796
+ blockMinutes: block.blockMinutes,
797
+ alreadyRan: false
798
+ });
799
+ if (!decision.fire) {
800
+ this.log.debug(`automation ${rule.id} (${user.displayName}): ${decision.reason}`);
801
+ continue;
802
+ }
803
+ const action = this.automationAction(rule, block.blockMinutes);
804
+ if (!services.automations.recordRun({
805
+ ruleId: rule.id,
806
+ userId: user.id,
807
+ period,
808
+ action,
809
+ now: nowUtc
810
+ })) {
811
+ continue;
812
+ }
813
+ try {
814
+ if (rule.kind === "clockOut" || rule.kind === "clockIn") {
815
+ const result = (0, import_commands.punchEmployee)(
816
+ {
817
+ entries: services.entries,
818
+ users: services.users,
819
+ settings: services.settings,
820
+ aggregation: services.aggregation
821
+ },
822
+ { userId: user.id, note: `auto.${rule.kind}`, now: nowUtc }
823
+ );
824
+ this.log.info(`automation ${rule.id} for ${user.displayName}: ${result.message}`);
825
+ } else {
826
+ this.log.info(`automation ${rule.id} for ${user.displayName}: ${action}`);
827
+ }
828
+ acted = true;
829
+ (_a = this.events) == null ? void 0 : _a.publish({
830
+ type: `automation.${rule.kind}`,
831
+ atUtc: nowUtc,
832
+ userId: user.id,
833
+ data: { ruleId: rule.id, action, reason: decision.reason }
834
+ });
835
+ } catch (error) {
836
+ services.automations.forgetRun({ ruleId: rule.id, userId: user.id, period });
837
+ this.log.warn(`automation ${rule.id} for ${user.displayName} failed: ${error.message}`);
838
+ }
839
+ }
840
+ }
841
+ if (acted) {
842
+ this.scheduleStateRefresh();
843
+ }
844
+ }
845
+ /**
846
+ * Short description of what a rule did (the log entry of `automation_runs`).
847
+ *
848
+ * @param rule - rule that fired
849
+ * @param blockMinutes - length of the running work block, `null` when the employee is not clocked in
850
+ * @returns text for the log
851
+ */
852
+ automationAction(rule, blockMinutes) {
853
+ if (rule.kind === "clockOut") {
854
+ return "clocked out";
855
+ }
856
+ if (rule.kind === "clockIn") {
857
+ return "clocked in";
858
+ }
859
+ if (rule.kind === "breakReminder") {
860
+ return `reminded after ${blockMinutes != null ? blockMinutes : 0} min without a break`;
861
+ }
862
+ return "reported a missing punch";
863
+ }
864
+ /**
865
+ * Republishes the figures shortly after data changed.
866
+ *
867
+ * A punch from the web app, the terminal or a badge arrives through the API of this very process, so without
868
+ * this the states would only change with the five minute timer. One action often produces several events, so
869
+ * the refresh is collected for a moment instead of running once per event.
870
+ */
871
+ scheduleStateRefresh() {
872
+ if (this.stateRefreshTimer) {
873
+ return;
874
+ }
875
+ this.stateRefreshTimer = this.setTimeout(() => {
876
+ this.stateRefreshTimer = void 0;
877
+ void this.refreshStates();
878
+ }, 1e3);
879
+ }
880
+ /**
881
+ * Publishes the newest event of the instance into the state tree.
882
+ *
883
+ * A notification adapter, a dashboard or a Blockly script then only has to watch `events.*` instead of
884
+ * following the live stream of the web app.
885
+ *
886
+ * @param event - event published by the API
887
+ */
888
+ async publishEventState(event) {
889
+ var _a, _b;
890
+ const services = this.services;
891
+ if (!services) {
892
+ return;
893
+ }
894
+ try {
895
+ const data = (_a = event.data) != null ? _a : {};
896
+ const user = event.userId === null || event.userId === void 0 ? null : services.users.findById(event.userId);
897
+ await (0, import_states.publishEventSnapshot)(this, {
898
+ type: event.type,
899
+ atUtc: event.atUtc,
900
+ userName: (_b = user == null ? void 0 : user.displayName) != null ? _b : "",
901
+ direction: typeof data.direction === "string" ? data.direction : "",
902
+ source: typeof data.source === "string" ? data.source : ""
903
+ });
904
+ } catch (error) {
905
+ this.log.debug(`event state could not be published: ${error.message}`);
906
+ }
907
+ }
908
+ /**
909
+ * Is called when another adapter sends a message (`sendTo`).
910
+ *
911
+ * @param obj - message object of the adapter framework
912
+ */
913
+ onMessage(obj) {
914
+ void (async () => {
915
+ const services = this.services;
916
+ if (!services) {
917
+ return;
918
+ }
919
+ try {
920
+ const answer = await (0, import_messages.handleMessage)(
921
+ {
922
+ users: services.users,
923
+ entries: services.entries,
924
+ absences: services.absences,
925
+ settings: services.settings,
926
+ aggregation: services.aggregation,
927
+ sync: services.sync,
928
+ backup: services.backup,
929
+ version: this.version
930
+ },
931
+ obj.command,
932
+ obj.message
933
+ );
934
+ this.log.info(`message ${obj.command}: ${answer.message}`);
935
+ if (obj.callback) {
936
+ this.sendTo(obj.from, obj.command, answer, obj.callback);
937
+ }
938
+ await this.refreshStates();
939
+ } catch (error) {
940
+ const message = error.message;
941
+ this.log.warn(`message ${obj.command} failed: ${message}`);
942
+ if (obj.callback) {
943
+ this.sendTo(obj.from, obj.command, { ok: false, message }, obj.callback);
944
+ }
945
+ }
946
+ })();
947
+ }
948
+ /**
949
+ * Is called if a subscribed state changes
950
+ *
951
+ * @param id - State ID
952
+ * @param state - State object
953
+ */
954
+ onStateChange(id, state) {
955
+ try {
956
+ if (!state) {
957
+ return;
958
+ }
959
+ if (this.handleTriggerState(id, state)) {
960
+ return;
961
+ }
962
+ if (state.ack) {
963
+ return;
964
+ }
965
+ const prefix = `${this.namespace}.`;
966
+ const localId = id.startsWith(prefix) ? id.slice(prefix.length) : id;
967
+ if (localId.startsWith("commands.")) {
968
+ void this.runCommand(localId, state.val);
969
+ return;
970
+ }
971
+ if ((0, import_presence.parsePresenceStateId)(localId) !== null) {
972
+ void this.runPresence(localId, state.val);
973
+ return;
974
+ }
975
+ this.log.debug(`Ignored state change: ${localId} = ${state.val}`);
976
+ } catch (error) {
977
+ this.log.error(`Error in onStateChange for ${id}: ${error.message}`);
978
+ }
979
+ }
980
+ }
981
+ if (require.main !== module) {
982
+ module.exports = (options) => new TimeTracker(options);
983
+ } else {
984
+ (() => new TimeTracker())();
985
+ }
986
+ //# sourceMappingURL=main.js.map