iobroker.time-tracker 0.7.3 → 0.7.5
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/README.md +30 -20
- package/build/lib/services/calendar.js +8 -0
- package/build/lib/services/calendar.js.map +2 -2
- package/build/main.js +36 -7
- package/build/main.js.map +3 -3
- package/io-package.json +27 -27
- package/package.json +7 -7
- package/www/assets/index-DB_lTeVU.js.map +1 -1
- package/www/assets/workbox-window.prod.es5-Bd17z0YL.js.map +1 -1
- package/www/sw.js.map +1 -1
- /package/{src-pwa → src-www}/src/i18n/de.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/en.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/es.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/fr.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/index.ts +0 -0
- /package/{src-pwa → src-www}/src/i18n/it.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/nl.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/pl.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/pt.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/ru.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/uk.json +0 -0
- /package/{src-pwa → src-www}/src/i18n/zh-cn.json +0 -0
package/README.md
CHANGED
|
@@ -253,12 +253,16 @@ broken instance.
|
|
|
253
253
|
|
|
254
254
|
The absences of the whole company reach ioBroker in two ways — both without a session and without a token:
|
|
255
255
|
|
|
256
|
-
| What
|
|
257
|
-
|
|
|
258
|
-
| **File**
|
|
259
|
-
| **URL**
|
|
260
|
-
| **Data**
|
|
261
|
-
| **When**
|
|
256
|
+
| What | Where | Who uses it |
|
|
257
|
+
| -------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
|
258
|
+
| **File** | `<iobroker-data>/files/time-tracker.<n>/calendar.ics`, rewritten on every change and every 5 minutes | the `ical` adapter as a **local file**, or a browser through the file server of a `web` instance |
|
|
259
|
+
| **URL** | `calendar.feedUrl` (`http://<host>:<port>/api/calendar.ics?token=…`) | a calendar app, or a script that hands the link to `ical.0.iCalReadTrigger` |
|
|
260
|
+
| **Data** | `calendar.absences` — the same days as JSON (`login`, `name`, `from`, `to`, `code`, `type`, `portion`, `approval`, `status`, `note`) | scripts, Blockly, VIS |
|
|
261
|
+
| **When** | `calendar.updatedAt` | to see how current the three above are |
|
|
262
|
+
|
|
263
|
+
The file lives in the `files` folder of the instance data, because the instance folder next to it can only be read by
|
|
264
|
+
the adapter itself. With a `web` instance on the usual port the same file is downloadable as
|
|
265
|
+
`http://<host>:8081/files/time-tracker.0/calendar.ics`; the `ical` adapter points at the path from `calendar.feedFile`.
|
|
262
266
|
|
|
263
267
|
The window is a year back and to the end of next year. The link of the **company** only exists after somebody asked
|
|
264
268
|
for it: write `true` to `commands.rotateCalendarToken`. The first call creates the token, every further one replaces
|
|
@@ -272,6 +276,9 @@ log(getState("time-tracker.0.calendar.feedUrl").val); // paste it int
|
|
|
272
276
|
setState("ical.0.iCalReadTrigger", "read " + getState("time-tracker.0.calendar.feedUrl").val);
|
|
273
277
|
```
|
|
274
278
|
|
|
279
|
+
`calendar.absences` is JSON, so any script can read it — the field list stands in the table above. T21 in
|
|
280
|
+
[`docs/testplan.md`](docs/testplan.md) carries a small snippet that logs the running absences.
|
|
281
|
+
|
|
275
282
|
### Actions (trigger rules)
|
|
276
283
|
|
|
277
284
|
The ioBroker way of connecting hardware is a state: a fingerprint reader, a button, a door contact or a dashboard
|
|
@@ -405,6 +412,23 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
405
412
|
|
|
406
413
|
### **WORK IN PROGRESS**
|
|
407
414
|
|
|
415
|
+
### 0.7.5 (2026-09-24)
|
|
416
|
+
|
|
417
|
+
- (Alex) change: the sources of the web app moved from `src-pwa/` to `src-www/`. The repository checker reads every folder it does not know by name and had reported five `W5042` for react, MUI and i18next - packages only the web app needs and that an adapter installation should not carry; the new name is on its list, so the warnings are gone. Nothing changes for a running installation: the built app is still served from `www/`
|
|
418
|
+
- (Alex) fix: the periodic refresh and the hourly backup check cannot overlap themselves any more. A tick that finds the previous run still busy is skipped and logged, so a slow run (a big database, a slow disk) can no longer pile a second one on top of itself
|
|
419
|
+
- (Alex) change: `onStateChange` no longer writes a debug line for every state of the instance that it does not handle - on a busy system that filled the log at log level `debug` for nothing
|
|
420
|
+
|
|
421
|
+
### 0.7.4 (2026-09-24)
|
|
422
|
+
|
|
423
|
+
- (Alex) fix: the subscription link of the company carries the API prefix now (`…/api/calendar.ics?token=…`). Without
|
|
424
|
+
it a browser got the web app and its login instead of the calendar
|
|
425
|
+
- (Alex) change: the written calendar file sits in `<iobroker-data>/files/time-tracker.<n>/calendar.ics` instead of the
|
|
426
|
+
instance folder next to it — that one is readable for the adapter alone, while `files/` is handed out by a `web`
|
|
427
|
+
instance as a download (`http://<host>:8081/files/time-tracker.0/calendar.ics`); the `ical` adapter keeps reading it
|
|
428
|
+
as a local file
|
|
429
|
+
- (Alex) docs: the test plan carries a small snippet for the *Scripts* tab that reads `calendar.absences` and logs who
|
|
430
|
+
is away today (for T21, not part of the adapter)
|
|
431
|
+
|
|
408
432
|
### 0.7.3 (2026-09-24)
|
|
409
433
|
|
|
410
434
|
- (Alex) new: the calendar goes to ioBroker. The adapter writes `calendar.ics` into its instance folder — the `ical`
|
|
@@ -448,20 +472,6 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
448
472
|
- (Alex) fix: on a narrow phone the balance of a day (month) and of a month (statements) no longer runs over the
|
|
449
473
|
text — the pencil, the balance and the download buttons stand beside the text instead of being laid over it
|
|
450
474
|
|
|
451
|
-
### 0.7.0 (2026-09-24)
|
|
452
|
-
|
|
453
|
-
- (Alex) new: an absence type can carry a **colour**, and the calendar paints a day with it — vacation and sickness are
|
|
454
|
-
told apart at a glance. The editor offers a colour picker plus “remove the colour”, the list shows a dot per type, and
|
|
455
|
-
the seeded types come with colours (vacation green, sickness red, accident orange, military slate, internal blue,
|
|
456
|
-
training violet, external brown). An open request keeps its colour but is drawn faded
|
|
457
|
-
|
|
458
|
-
### 0.6.0 (2026-09-24)
|
|
459
|
-
|
|
460
|
-
- (Alex) change: the year overview of the absence tab became a **calendar**. The month now stands as a grid (Monday
|
|
461
|
-
first) with the name of every absent employee on the day — approved days green, requested ones grey, numbers of a
|
|
462
|
-
public holiday in bold. A **year dropdown** (three years back and ahead) and arrows for the month make the past and
|
|
463
|
-
the future reachable, so the administration can look ahead and back instead of only seeing the current year
|
|
464
|
-
|
|
465
475
|
Older entries are kept in [`CHANGELOG_OLD.md`](CHANGELOG_OLD.md).
|
|
466
476
|
|
|
467
477
|
## Provenance
|
|
@@ -18,12 +18,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
18
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
19
|
var calendar_exports = {};
|
|
20
20
|
__export(calendar_exports, {
|
|
21
|
+
API_PREFIX: () => API_PREFIX,
|
|
21
22
|
CALENDAR_TTL: () => CALENDAR_TTL,
|
|
22
23
|
COMPANY_CALENDAR_NAME: () => COMPANY_CALENDAR_NAME,
|
|
23
24
|
ICAL_CONTENT_TYPE: () => ICAL_CONTENT_TYPE,
|
|
24
25
|
absenceEvents: () => absenceEvents,
|
|
25
26
|
absenceFeed: () => absenceFeed,
|
|
26
27
|
calendarDocument: () => calendarDocument,
|
|
28
|
+
companyFeedUrl: () => companyFeedUrl,
|
|
27
29
|
dayAfter: () => dayAfter,
|
|
28
30
|
shiftDate: () => shiftDate
|
|
29
31
|
});
|
|
@@ -31,6 +33,10 @@ module.exports = __toCommonJS(calendar_exports);
|
|
|
31
33
|
const ICAL_CONTENT_TYPE = "text/calendar; charset=utf-8";
|
|
32
34
|
const CALENDAR_TTL = "PT2H";
|
|
33
35
|
const COMPANY_CALENDAR_NAME = "Abwesenheiten (Firma)";
|
|
36
|
+
const API_PREFIX = "/api";
|
|
37
|
+
function companyFeedUrl(host, port, token) {
|
|
38
|
+
return `http://${host}:${port}${API_PREFIX}/calendar.ics?token=${encodeURIComponent(token)}`;
|
|
39
|
+
}
|
|
34
40
|
function icalText(value) {
|
|
35
41
|
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
|
36
42
|
}
|
|
@@ -113,12 +119,14 @@ function absenceFeed(absences, employees) {
|
|
|
113
119
|
}
|
|
114
120
|
// Annotate the CommonJS export names for ESM import in node:
|
|
115
121
|
0 && (module.exports = {
|
|
122
|
+
API_PREFIX,
|
|
116
123
|
CALENDAR_TTL,
|
|
117
124
|
COMPANY_CALENDAR_NAME,
|
|
118
125
|
ICAL_CONTENT_TYPE,
|
|
119
126
|
absenceEvents,
|
|
120
127
|
absenceFeed,
|
|
121
128
|
calendarDocument,
|
|
129
|
+
companyFeedUrl,
|
|
122
130
|
dayAfter,
|
|
123
131
|
shiftDate
|
|
124
132
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/services/calendar.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The calendar of the time tracking: absences as iCalendar and as plain data.\n *\n * Two consumers share this module: the **calendar feed** (`GET /calendar.ics`) hands a subscription link to a\n * calendar app \u2014 for one employee or, with the token of the instance, for the whole company \u2014 and the **adapter**\n * publishes the same data to ioBroker (an `.ics` file for the `ical` adapter, `calendar.absences` as JSON for\n * scripts and dashboards).\n *\n * The document is real iCalendar: an all-day event ends **exclusively** (`DTEND` is the day after the last day,\n * otherwise every calendar drops that day), a day that still waits for its decision is `TENTATIVE`, and the `UID` of\n * an absence is derived from its row id \u2014 that is what lets a calendar app update an event instead of adding it\n * again on every refresh.\n */\n\nimport type { AbsenceWithType } from \"../db/repositories/absences\";\n\n/** Media type of a calendar document. */\nexport const ICAL_CONTENT_TYPE = \"text/calendar; charset=utf-8\";\n\n/** How long a calendar app may cache a feed (a hint \u2014 Google caches longer anyway). */\nexport const CALENDAR_TTL = \"PT2H\";\n\n/** Name of the calendar that carries every employee (the feed of the instance and the written file). */\nexport const COMPANY_CALENDAR_NAME = \"Abwesenheiten (Firma)\";\n\n/** The few fields of an employee a calendar needs. */\nexport interface CalendarEmployee {\n\t/** Database id of the employee */\n\tid: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Login, used by the JSON view */\n\tlogin: string;\n}\n\n/** One event of the calendar, independent of how it is rendered. */\nexport interface CalendarEvent {\n\t/** Stable id, so a calendar app updates an event instead of adding it again */\n\tuid: string;\n\t/** First day, `YYYY-MM-DD` */\n\tstart: string;\n\t/** Last day, `YYYY-MM-DD` (included in the event) */\n\tend: string;\n\t/** Title shown in the calendar */\n\tsummary: string;\n\t/** True when the absence is approved and the day is taken */\n\tconfirmed: boolean;\n\t/** Note of the absence, `null` when there is none */\n\tdescription: string | null;\n}\n\n/** One absence in the plain data view (`calendar.absences`). */\nexport interface AbsenceFeedRow {\n\t/** Database id of the absence */\n\tid: number;\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Login of the employee */\n\tlogin: string;\n\t/** Shown name of the employee */\n\tname: string;\n\t/** First day, `YYYY-MM-DD` */\n\tfrom: string;\n\t/** Last day, `YYYY-MM-DD` */\n\tto: string;\n\t/** Code of the absence type, e.g. `U` */\n\tcode: string;\n\t/** Name of the absence type, e.g. `Urlaub` */\n\ttype: string;\n\t/** How much of a day the absence covers (`1` for a whole day) */\n\tportion: number;\n\t/** `approved`, `requested` or `rejected` */\n\tapproval: string;\n\t/** `planned` for a day in the future, `taken` for the past */\n\tstatus: string;\n\t/** Note, `null` when there is none */\n\tnote: string | null;\n}\n\n/**\n * Escapes a text for an iCalendar value.\n *\n * @param value - raw text\n * @returns the text with the characters iCalendar reserves\n */\nfunction icalText(value: string): string {\n\treturn value.replace(/\\\\/g, \"\\\\\\\\\").replace(/;/g, \"\\\\;\").replace(/,/g, \"\\\\,\").replace(/\\r?\\n/g, \"\\\\n\");\n}\n\n/**\n * Formats an instant as a UTC stamp (`YYYYMMDDTHHMMSSZ`).\n *\n * @param seconds - UTC epoch seconds\n * @returns the iCalendar timestamp\n */\nfunction icalStamp(seconds: number): string {\n\treturn new Date(seconds * 1000)\n\t\t.toISOString()\n\t\t.replace(/[-:]/g, \"\")\n\t\t.replace(/\\.\\d{3}/, \"\");\n}\n\n/**\n * The day after a local date.\n *\n * iCalendar ends an all-day event **exclusively**: a vacation from the 5th to the 9th has to carry `DTEND` as the\n * 10th, otherwise every calendar shows it one day short.\n *\n * @param date - local date `YYYY-MM-DD`\n * @returns the following day\n */\nexport function dayAfter(date: string): string {\n\tconst [year, month, day] = date.split(\"-\").map(Number);\n\treturn new Date(Date.UTC(year, month - 1, day + 1)).toISOString().slice(0, 10);\n}\n\n/**\n * Shifts a local date by whole days.\n *\n * @param date - local date `YYYY-MM-DD`\n * @param days - days to add (negative values go back)\n * @returns the shifted date\n */\nexport function shiftDate(date: string, days: number): string {\n\tconst [year, month, day] = date.split(\"-\").map(Number);\n\treturn new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);\n}\n\n/**\n * Turns absences into calendar events.\n *\n * A half day cannot be an all-day event, so its portion travels in the summary. `withEmployee` puts the name of the\n * employee in front \u2014 that is what the calendar of the whole company needs, while the own calendar does not repeat\n * it.\n *\n * @param absences - absences of the period\n * @param employees - the employees the absences belong to\n * @param withEmployee - true to name the employee in the summary\n * @returns the events\n */\nexport function absenceEvents(\n\tabsences: AbsenceWithType[],\n\temployees: Map<number, CalendarEmployee>,\n\twithEmployee: boolean,\n): CalendarEvent[] {\n\treturn absences.map(absence => {\n\t\tconst portion = absence.dayPortion < 1 ? ` (${absence.dayPortion})` : \"\";\n\t\tconst employee = employees.get(absence.userId);\n\t\tconst own = `${absence.typeName} (${absence.typeCode})${portion}`;\n\t\treturn {\n\t\t\tuid: `absence-${absence.id}@time-tracker`,\n\t\t\tstart: absence.dateFrom,\n\t\t\tend: absence.dateTo,\n\t\t\tsummary: withEmployee && employee ? `${employee.displayName}: ${own}` : own,\n\t\t\tconfirmed: absence.approval === \"approved\",\n\t\t\tdescription: absence.note,\n\t\t};\n\t});\n}\n/**\n * Builds a `VCALENDAR` document.\n *\n * @param name - name of the calendar (the employee, or the company)\n * @param events - events to write\n * @param stamp - instant of the answer (`DTSTAMP`)\n * @returns the iCalendar document\n */\nexport function calendarDocument(name: string, events: CalendarEvent[], stamp: number): string {\n\tconst lines: (string | null)[] = [\n\t\t\"BEGIN:VCALENDAR\",\n\t\t\"VERSION:2.0\",\n\t\t\"PRODID:-//ioBroker//time-tracker//EN\",\n\t\t\"CALSCALE:GREGORIAN\",\n\t\t\"METHOD:PUBLISH\",\n\t\t`X-WR-CALNAME:${icalText(name)}`,\n\t\t`REFRESH-INTERVAL;VALUE=DURATION:${CALENDAR_TTL}`,\n\t\t`X-PUBLISHED-TTL:${CALENDAR_TTL}`,\n\t];\n\tfor (const event of events) {\n\t\tlines.push(\n\t\t\t\"BEGIN:VEVENT\",\n\t\t\t`UID:${event.uid}`,\n\t\t\t`DTSTAMP:${icalStamp(stamp)}`,\n\t\t\t`DTSTART;VALUE=DATE:${event.start.replace(/-/g, \"\")}`,\n\t\t\t`DTEND;VALUE=DATE:${dayAfter(event.end).replace(/-/g, \"\")}`,\n\t\t\t`SUMMARY:${icalText(event.summary)}`,\n\t\t\tevent.confirmed ? \"STATUS:CONFIRMED\" : \"STATUS:TENTATIVE\",\n\t\t\tevent.description ? `DESCRIPTION:${icalText(event.description)}` : null,\n\t\t\t\"TRANSP:TRANSPARENT\",\n\t\t\t\"END:VEVENT\",\n\t\t);\n\t}\n\tlines.push(\"END:VCALENDAR\");\n\treturn `${lines.filter(line => line !== null).join(\"\\r\\n\")}\\r\\n`;\n}\n\n/**\n * The plain data view of the absences, for scripts and dashboards.\n *\n * The list is sorted by day, employee name and type, so it reads like the calendar does.\n *\n * @param absences - absences of the period\n * @param employees - the employees the absences belong to\n * @returns the rows\n */\nexport function absenceFeed(absences: AbsenceWithType[], employees: Map<number, CalendarEmployee>): AbsenceFeedRow[] {\n\treturn absences\n\t\t.map(absence => {\n\t\t\tconst employee = employees.get(absence.userId);\n\t\t\treturn {\n\t\t\t\tid: absence.id,\n\t\t\t\tuserId: absence.userId,\n\t\t\t\tlogin: employee?.login ?? \"\",\n\t\t\t\tname: employee?.displayName ?? \"\",\n\t\t\t\tfrom: absence.dateFrom,\n\t\t\t\tto: absence.dateTo,\n\t\t\t\tcode: absence.typeCode,\n\t\t\t\ttype: absence.typeName,\n\t\t\t\tportion: absence.dayPortion,\n\t\t\t\tapproval: absence.approval,\n\t\t\t\tstatus: absence.status,\n\t\t\t\tnote: absence.note,\n\t\t\t};\n\t\t})\n\t\t.sort(\n\t\t\t(left, right) =>\n\t\t\t\tleft.from.localeCompare(right.from) ||\n\t\t\t\tleft.name.localeCompare(right.name) ||\n\t\t\t\tleft.code.localeCompare(right.code),\n\t\t);\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBO,MAAM,oBAAoB;AAG1B,MAAM,eAAe;AAGrB,MAAM,wBAAwB;
|
|
4
|
+
"sourcesContent": ["/**\n * The calendar of the time tracking: absences as iCalendar and as plain data.\n *\n * Two consumers share this module: the **calendar feed** (`GET /calendar.ics`) hands a subscription link to a\n * calendar app \u2014 for one employee or, with the token of the instance, for the whole company \u2014 and the **adapter**\n * publishes the same data to ioBroker (an `.ics` file for the `ical` adapter, `calendar.absences` as JSON for\n * scripts and dashboards).\n *\n * The document is real iCalendar: an all-day event ends **exclusively** (`DTEND` is the day after the last day,\n * otherwise every calendar drops that day), a day that still waits for its decision is `TENTATIVE`, and the `UID` of\n * an absence is derived from its row id \u2014 that is what lets a calendar app update an event instead of adding it\n * again on every refresh.\n */\n\nimport type { AbsenceWithType } from \"../db/repositories/absences\";\n\n/** Media type of a calendar document. */\nexport const ICAL_CONTENT_TYPE = \"text/calendar; charset=utf-8\";\n\n/** How long a calendar app may cache a feed (a hint \u2014 Google caches longer anyway). */\nexport const CALENDAR_TTL = \"PT2H\";\n\n/** Name of the calendar that carries every employee (the feed of the instance and the written file). */\nexport const COMPANY_CALENDAR_NAME = \"Abwesenheiten (Firma)\";\n\n/** Path prefix the API is mounted on (the web server uses the same default). */\nexport const API_PREFIX = \"/api\";\n\n/**\n * The subscription link of the company calendar.\n *\n * The API lives **below the prefix** \u2014 a browser that asks the prefix-less path gets the web app and its login \u2014\n * so the prefix has to be part of the link. The token travels in the query, encoded like any other value.\n *\n * @param host - host name or address the devices can reach\n * @param port - port of the adapter\n * @param token - token of the instance\n * @returns the URL a calendar app subscribes to\n */\nexport function companyFeedUrl(host: string, port: number, token: string): string {\n\treturn `http://${host}:${port}${API_PREFIX}/calendar.ics?token=${encodeURIComponent(token)}`;\n}\n\n/** The few fields of an employee a calendar needs. */\nexport interface CalendarEmployee {\n\t/** Database id of the employee */\n\tid: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Login, used by the JSON view */\n\tlogin: string;\n}\n\n/** One event of the calendar, independent of how it is rendered. */\nexport interface CalendarEvent {\n\t/** Stable id, so a calendar app updates an event instead of adding it again */\n\tuid: string;\n\t/** First day, `YYYY-MM-DD` */\n\tstart: string;\n\t/** Last day, `YYYY-MM-DD` (included in the event) */\n\tend: string;\n\t/** Title shown in the calendar */\n\tsummary: string;\n\t/** True when the absence is approved and the day is taken */\n\tconfirmed: boolean;\n\t/** Note of the absence, `null` when there is none */\n\tdescription: string | null;\n}\n\n/** One absence in the plain data view (`calendar.absences`). */\nexport interface AbsenceFeedRow {\n\t/** Database id of the absence */\n\tid: number;\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Login of the employee */\n\tlogin: string;\n\t/** Shown name of the employee */\n\tname: string;\n\t/** First day, `YYYY-MM-DD` */\n\tfrom: string;\n\t/** Last day, `YYYY-MM-DD` */\n\tto: string;\n\t/** Code of the absence type, e.g. `U` */\n\tcode: string;\n\t/** Name of the absence type, e.g. `Urlaub` */\n\ttype: string;\n\t/** How much of a day the absence covers (`1` for a whole day) */\n\tportion: number;\n\t/** `approved`, `requested` or `rejected` */\n\tapproval: string;\n\t/** `planned` for a day in the future, `taken` for the past */\n\tstatus: string;\n\t/** Note, `null` when there is none */\n\tnote: string | null;\n}\n\n/**\n * Escapes a text for an iCalendar value.\n *\n * @param value - raw text\n * @returns the text with the characters iCalendar reserves\n */\nfunction icalText(value: string): string {\n\treturn value.replace(/\\\\/g, \"\\\\\\\\\").replace(/;/g, \"\\\\;\").replace(/,/g, \"\\\\,\").replace(/\\r?\\n/g, \"\\\\n\");\n}\n\n/**\n * Formats an instant as a UTC stamp (`YYYYMMDDTHHMMSSZ`).\n *\n * @param seconds - UTC epoch seconds\n * @returns the iCalendar timestamp\n */\nfunction icalStamp(seconds: number): string {\n\treturn new Date(seconds * 1000)\n\t\t.toISOString()\n\t\t.replace(/[-:]/g, \"\")\n\t\t.replace(/\\.\\d{3}/, \"\");\n}\n\n/**\n * The day after a local date.\n *\n * iCalendar ends an all-day event **exclusively**: a vacation from the 5th to the 9th has to carry `DTEND` as the\n * 10th, otherwise every calendar shows it one day short.\n *\n * @param date - local date `YYYY-MM-DD`\n * @returns the following day\n */\nexport function dayAfter(date: string): string {\n\tconst [year, month, day] = date.split(\"-\").map(Number);\n\treturn new Date(Date.UTC(year, month - 1, day + 1)).toISOString().slice(0, 10);\n}\n\n/**\n * Shifts a local date by whole days.\n *\n * @param date - local date `YYYY-MM-DD`\n * @param days - days to add (negative values go back)\n * @returns the shifted date\n */\nexport function shiftDate(date: string, days: number): string {\n\tconst [year, month, day] = date.split(\"-\").map(Number);\n\treturn new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);\n}\n\n/**\n * Turns absences into calendar events.\n *\n * A half day cannot be an all-day event, so its portion travels in the summary. `withEmployee` puts the name of the\n * employee in front \u2014 that is what the calendar of the whole company needs, while the own calendar does not repeat\n * it.\n *\n * @param absences - absences of the period\n * @param employees - the employees the absences belong to\n * @param withEmployee - true to name the employee in the summary\n * @returns the events\n */\nexport function absenceEvents(\n\tabsences: AbsenceWithType[],\n\temployees: Map<number, CalendarEmployee>,\n\twithEmployee: boolean,\n): CalendarEvent[] {\n\treturn absences.map(absence => {\n\t\tconst portion = absence.dayPortion < 1 ? ` (${absence.dayPortion})` : \"\";\n\t\tconst employee = employees.get(absence.userId);\n\t\tconst own = `${absence.typeName} (${absence.typeCode})${portion}`;\n\t\treturn {\n\t\t\tuid: `absence-${absence.id}@time-tracker`,\n\t\t\tstart: absence.dateFrom,\n\t\t\tend: absence.dateTo,\n\t\t\tsummary: withEmployee && employee ? `${employee.displayName}: ${own}` : own,\n\t\t\tconfirmed: absence.approval === \"approved\",\n\t\t\tdescription: absence.note,\n\t\t};\n\t});\n}\n/**\n * Builds a `VCALENDAR` document.\n *\n * @param name - name of the calendar (the employee, or the company)\n * @param events - events to write\n * @param stamp - instant of the answer (`DTSTAMP`)\n * @returns the iCalendar document\n */\nexport function calendarDocument(name: string, events: CalendarEvent[], stamp: number): string {\n\tconst lines: (string | null)[] = [\n\t\t\"BEGIN:VCALENDAR\",\n\t\t\"VERSION:2.0\",\n\t\t\"PRODID:-//ioBroker//time-tracker//EN\",\n\t\t\"CALSCALE:GREGORIAN\",\n\t\t\"METHOD:PUBLISH\",\n\t\t`X-WR-CALNAME:${icalText(name)}`,\n\t\t`REFRESH-INTERVAL;VALUE=DURATION:${CALENDAR_TTL}`,\n\t\t`X-PUBLISHED-TTL:${CALENDAR_TTL}`,\n\t];\n\tfor (const event of events) {\n\t\tlines.push(\n\t\t\t\"BEGIN:VEVENT\",\n\t\t\t`UID:${event.uid}`,\n\t\t\t`DTSTAMP:${icalStamp(stamp)}`,\n\t\t\t`DTSTART;VALUE=DATE:${event.start.replace(/-/g, \"\")}`,\n\t\t\t`DTEND;VALUE=DATE:${dayAfter(event.end).replace(/-/g, \"\")}`,\n\t\t\t`SUMMARY:${icalText(event.summary)}`,\n\t\t\tevent.confirmed ? \"STATUS:CONFIRMED\" : \"STATUS:TENTATIVE\",\n\t\t\tevent.description ? `DESCRIPTION:${icalText(event.description)}` : null,\n\t\t\t\"TRANSP:TRANSPARENT\",\n\t\t\t\"END:VEVENT\",\n\t\t);\n\t}\n\tlines.push(\"END:VCALENDAR\");\n\treturn `${lines.filter(line => line !== null).join(\"\\r\\n\")}\\r\\n`;\n}\n\n/**\n * The plain data view of the absences, for scripts and dashboards.\n *\n * The list is sorted by day, employee name and type, so it reads like the calendar does.\n *\n * @param absences - absences of the period\n * @param employees - the employees the absences belong to\n * @returns the rows\n */\nexport function absenceFeed(absences: AbsenceWithType[], employees: Map<number, CalendarEmployee>): AbsenceFeedRow[] {\n\treturn absences\n\t\t.map(absence => {\n\t\t\tconst employee = employees.get(absence.userId);\n\t\t\treturn {\n\t\t\t\tid: absence.id,\n\t\t\t\tuserId: absence.userId,\n\t\t\t\tlogin: employee?.login ?? \"\",\n\t\t\t\tname: employee?.displayName ?? \"\",\n\t\t\t\tfrom: absence.dateFrom,\n\t\t\t\tto: absence.dateTo,\n\t\t\t\tcode: absence.typeCode,\n\t\t\t\ttype: absence.typeName,\n\t\t\t\tportion: absence.dayPortion,\n\t\t\t\tapproval: absence.approval,\n\t\t\t\tstatus: absence.status,\n\t\t\t\tnote: absence.note,\n\t\t\t};\n\t\t})\n\t\t.sort(\n\t\t\t(left, right) =>\n\t\t\t\tleft.from.localeCompare(right.from) ||\n\t\t\t\tleft.name.localeCompare(right.name) ||\n\t\t\t\tleft.code.localeCompare(right.code),\n\t\t);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/main.js
CHANGED
|
@@ -77,6 +77,10 @@ class TimeTracker extends utils.Adapter {
|
|
|
77
77
|
triggerStates = /* @__PURE__ */ new Set();
|
|
78
78
|
/** Pending refresh of the figures after a change through the API */
|
|
79
79
|
stateRefreshTimer = void 0;
|
|
80
|
+
/** A refresh tick that is still running; the next tick is skipped instead of piling up on it. */
|
|
81
|
+
stateRefreshRunning = false;
|
|
82
|
+
/** A backup run that is still running; the hourly check must not start a second one. */
|
|
83
|
+
backupRunning = false;
|
|
80
84
|
constructor(options = {}) {
|
|
81
85
|
super({
|
|
82
86
|
...options,
|
|
@@ -144,13 +148,36 @@ class TimeTracker extends utils.Adapter {
|
|
|
144
148
|
await this.refreshStates();
|
|
145
149
|
this.setInterval(
|
|
146
150
|
() => {
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
if (this.stateRefreshRunning) {
|
|
152
|
+
this.log.debug(`state refresh still running, skipping this ${STATE_REFRESH_MINUTES} min tick`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.stateRefreshRunning = true;
|
|
156
|
+
void Promise.allSettled([this.publishInstanceInfo(), this.refreshStates()]).then((results) => {
|
|
157
|
+
this.stateRefreshRunning = false;
|
|
158
|
+
for (const result2 of results) {
|
|
159
|
+
if (result2.status === "rejected") {
|
|
160
|
+
this.log.warn(`state refresh failed: ${result2.reason.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
149
164
|
},
|
|
150
165
|
STATE_REFRESH_MINUTES * 60 * 1e3
|
|
151
166
|
);
|
|
152
167
|
await this.runScheduledBackup();
|
|
153
|
-
this.setInterval(
|
|
168
|
+
this.setInterval(
|
|
169
|
+
() => {
|
|
170
|
+
if (this.backupRunning) {
|
|
171
|
+
this.log.debug("scheduled backup still running, skipping this hour");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.backupRunning = true;
|
|
175
|
+
void this.runScheduledBackup().catch((error) => this.log.warn(`scheduled backup failed: ${error.message}`)).finally(() => {
|
|
176
|
+
this.backupRunning = false;
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
60 * 60 * 1e3
|
|
180
|
+
);
|
|
154
181
|
await this.setState("info.connection", true, true);
|
|
155
182
|
} catch (error) {
|
|
156
183
|
const message = error.message;
|
|
@@ -405,6 +432,8 @@ class TimeTracker extends utils.Adapter {
|
|
|
405
432
|
router: api.router,
|
|
406
433
|
port: this.config.port || 8092,
|
|
407
434
|
bind: this.config.bind || "127.0.0.1",
|
|
435
|
+
// the same prefix the calendar link carries (`/api`) — the web app and its login live beside it
|
|
436
|
+
apiPrefix: import_calendar.API_PREFIX,
|
|
408
437
|
staticFiles,
|
|
409
438
|
// an uploaded backup is bigger than the default limit; the route carries the same bound
|
|
410
439
|
maxBodyBytes: import_api.MAX_BACKUP_UPLOAD_BYTES,
|
|
@@ -590,8 +619,9 @@ class TimeTracker extends utils.Adapter {
|
|
|
590
619
|
services.users.list().map((user) => [user.id, { id: user.id, displayName: user.displayName, login: user.login }])
|
|
591
620
|
);
|
|
592
621
|
const list = services.absences.allInRange(from, to);
|
|
593
|
-
const
|
|
594
|
-
|
|
622
|
+
const folder = path.join(utils.getAbsoluteDefaultDataDir(), "files", `${this.name}.${this.instance}`);
|
|
623
|
+
const file = path.join(folder, CALENDAR_FILE_NAME);
|
|
624
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
595
625
|
fs.writeFileSync(
|
|
596
626
|
file,
|
|
597
627
|
(0, import_calendar.calendarDocument)(import_calendar.COMPANY_CALENDAR_NAME, (0, import_calendar.absenceEvents)(list, employees, true), stamp),
|
|
@@ -601,7 +631,7 @@ class TimeTracker extends utils.Adapter {
|
|
|
601
631
|
await (0, import_states.publishCalendarSnapshot)(this, {
|
|
602
632
|
// the host part is the name of this machine: an address that is reachable from the LAN and simple enough
|
|
603
633
|
// to correct by hand (a reverse proxy or another address is a matter of the copy in the calendar app)
|
|
604
|
-
feedUrl: token ?
|
|
634
|
+
feedUrl: token ? (0, import_calendar.companyFeedUrl)((0, import_node_os.hostname)(), this.config.port || 8092, token) : "",
|
|
605
635
|
feedFile: file,
|
|
606
636
|
updatedAt: stamp,
|
|
607
637
|
absences: JSON.stringify((0, import_calendar.absenceFeed)(list, employees))
|
|
@@ -1060,7 +1090,6 @@ class TimeTracker extends utils.Adapter {
|
|
|
1060
1090
|
void this.runPresence(localId, state.val);
|
|
1061
1091
|
return;
|
|
1062
1092
|
}
|
|
1063
|
-
this.log.debug(`Ignored state change: ${localId} = ${state.val}`);
|
|
1064
1093
|
} catch (error) {
|
|
1065
1094
|
this.log.error(`Error in onStateChange for ${id}: ${error.message}`);
|
|
1066
1095
|
}
|