iobroker.time-tracker 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -22
- package/build/lib/adapter/commands.js +12 -0
- package/build/lib/adapter/commands.js.map +2 -2
- package/build/lib/adapter/stateNames.js +84 -0
- package/build/lib/adapter/stateNames.js.map +2 -2
- package/build/lib/adapter/states.js +44 -1
- package/build/lib/adapter/states.js.map +2 -2
- package/build/lib/reports/pdf.js +8 -2
- package/build/lib/reports/pdf.js.map +2 -2
- package/build/lib/services/calendar.js +125 -0
- package/build/lib/services/calendar.js.map +7 -0
- package/build/lib/web/api.js +44 -67
- package/build/lib/web/api.js.map +2 -2
- package/build/lib/web/problem.js.map +1 -1
- package/build/main.js +44 -1
- package/build/main.js.map +2 -2
- package/io-package.json +27 -27
- package/package.json +1 -1
- package/src-pwa/src/i18n/de.json +5 -1
- package/src-pwa/src/i18n/en.json +5 -1
- package/src-pwa/src/i18n/es.json +5 -1
- package/src-pwa/src/i18n/fr.json +5 -1
- package/src-pwa/src/i18n/it.json +5 -1
- package/src-pwa/src/i18n/nl.json +5 -1
- package/src-pwa/src/i18n/pl.json +5 -1
- package/src-pwa/src/i18n/pt.json +5 -1
- package/src-pwa/src/i18n/ru.json +5 -1
- package/src-pwa/src/i18n/uk.json +5 -1
- package/src-pwa/src/i18n/zh-cn.json +5 -1
- package/www/assets/{index-D5fSC8CA.js → index-DB_lTeVU.js} +3 -3
- package/www/assets/index-DB_lTeVU.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-D5fSC8CA.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/adapter/states.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * States published by the adapter.\n *\n * The adapter exposes a small, stable state tree so that other adapters (and the user's dashboards) can show\n * and trigger time tracking without speaking to the REST API:\n *\n * ```\n * users.<userId>.displayName / present / hasOpenEntry / lastPunch / todayWorkedMinutes / todayBalanceMinutes\n * users.<userId>.openConflicts\n * commands.punchUserId / punch / quickPunch / closeMonth / recalc\n * ```\n *\n * All objects are created with `setObjectNotExists`, so existing instances keep their settings. The logic works\n * on a tiny port interface (`StatePort`) \u2014 the adapter instance in production, a recorder in the tests.\n */\n\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport type { AggregationService } from \"../services/aggregation\";\nimport type { SyncService } from \"../services/sync\";\nimport { STATE_NAMES, type StateName } from \"./stateNames\";\nimport { localDate as resolveLocalDate } from \"../util/time\";\n\n/** Subset of the adapter API the state layer needs. */\nexport interface StatePort {\n\t/**\n\t * Creates an object when it does not exist yet.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - object definition\n\t */\n\tsetObjectNotExists(id: string, object: ioBroker.SettableObject): Promise<unknown> | void;\n\t/**\n\t * Merges fields into an object that already exists.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - fields to merge\n\t */\n\textendObject(id: string, object: ioBroker.PartialObject): Promise<unknown> | void;\n\t/**\n\t * Writes a state value.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param value - value to write\n\t * @param ack - true for confirmed values\n\t */\n\tsetState(id: string, value: ioBroker.StateValue, ack?: boolean): Promise<unknown> | void;\n}\n\n/** Figures of one employee shown in the state tree. */\nexport interface UserSnapshot {\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** True when the last punch of today has no counterpart */\n\thasOpenEntry: boolean;\n\t/** Instant of the last punch of the day, `null` when there is none */\n\tlastPunchUtc: number | null;\n\t/** Net working time of today in minutes */\n\tworkedMinutes: number;\n\t/** Balance of today in minutes */\n\tbalanceMinutes: number;\n\t/** Punches waiting for a decision */\n\topenConflicts: number;\n\t/** Net working time of the current month in minutes */\n\tmonthWorkedMinutes: number;\n\t/** Balance of the current month in minutes */\n\tmonthBalanceMinutes: number;\n\t/** Balance of the current year in minutes */\n\tyearBalanceMinutes: number;\n}\n\n/** Figures of the whole company, published next to the employees. */\nexport interface CompanySnapshot {\n\t/** Employees that are clocked in right now */\n\tpresentCount: number;\n\t/** Names of those employees, separated by a comma (empty when nobody is present) */\n\tpresent: string;\n\t/** Punches of all employees waiting for a decision */\n\topenConflicts: number;\n\t/** Instant of the newest punch of today, `null` when nobody punched */\n\tlastPunchUtc: number | null;\n}\n\n/** Newest event of the instance, mirrored into the state tree. */\nexport interface EventSnapshot {\n\t/** Kind of the event, e.g. `punch` */\n\ttype: string;\n\t/** Instant the event belongs to */\n\tatUtc: number;\n\t/** Name of the employee the event belongs to, empty for instance wide events */\n\tuserName: string;\n\t/** Direction of a punch (`in`/`out`), empty for other events */\n\tdirection: string;\n\t/** Where the change came from, e.g. `web` or `terminal`, empty when unknown */\n\tsource: string;\n}\n\n/** Names of the writable command states. */\nexport const COMMAND_IDS = {\n\t/** Employee the commands apply to */\n\tpunchUserId: \"commands.punchUserId\",\n\t/** Punch in or out for `punchUserId` */\n\tpunch: \"commands.punch\",\n\t/** Punch in or out with the configured quick rounding */\n\tquickPunch: \"commands.quickPunch\",\n\t/** Close a month, value `YYYY-MM` */\n\tcloseMonth: \"commands.closeMonth\",\n\t/** Recalculate a period, value `YYYY-MM` or `YYYY` */\n\trecalc: \"commands.recalc\",\n\t/** Write a backup of the database */\n\tbackup: \"commands.backup\",\n} as const;\n\n/**\n * Builds a state object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @param type - state type\n * @param role - state role\n * @param options - additional flags\n * @param options.read - value can be read (default true)\n * @param options.write - value can be written (default false)\n * @param options.unit - unit of the value\n * @returns object definition\n */\nfunction stateObject(\n\tname: StateName,\n\ttype: ioBroker.CommonType,\n\trole: string,\n\toptions: { read?: boolean; write?: boolean; unit?: string } = {},\n): ioBroker.SettableObject {\n\treturn {\n\t\ttype: \"state\",\n\t\tcommon: {\n\t\t\tname,\n\t\t\ttype,\n\t\t\trole,\n\t\t\tread: options.read !== false,\n\t\t\twrite: options.write === true,\n\t\t\t...(options.unit ? { unit: options.unit } : {}),\n\t\t},\n\t\tnative: {},\n\t};\n}\n\n/**\n * Builds a channel object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @returns object definition\n */\nfunction channelObject(name: StateName): ioBroker.SettableObject {\n\treturn { type: \"channel\", common: { name }, native: {} };\n}\n\n/**\n * Creates an object when it is missing and keeps the definition of an existing one current.\n *\n * `setObjectNotExists` alone would leave existing installations on the definition they were created with \u2014 a later\n * correction (for example the role of `commands.punchUserId`, checker `E1011`) would never arrive. So the fields the\n * adapter owns are merged on every start; `common.custom` is left out, that is where the user configures `history`\n * and similar integrations.\n *\n * @param port - state port\n * @param id - full state id (including the instance prefix)\n * @param object - object definition\n */\nasync function ensureObject(port: StatePort, id: string, object: ioBroker.SettableObject): Promise<void> {\n\tawait port.setObjectNotExists(id, object);\n\tconst common: Record<string, unknown> = { ...(object.common ?? {}) };\n\tdelete common.custom;\n\tawait port.extendObject(id, { common });\n}\n\n/**\n * Reads the figures of an employee for the state tree.\n *\n * @param args - data sources and employee\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service (open conflicts)\n * @param args.userId - database id of the employee\n * @param args.now - instant of the snapshot\n * @returns snapshot of the figures\n */\nexport function readUserSnapshot(args: {\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tuserId: number;\n\tnow?: number;\n}): UserSnapshot | null {\n\tconst user = args.users.findById(args.userId);\n\tif (!user) {\n\t\treturn null;\n\t}\n\n\tconst timestamp = args.now ?? Math.floor(Date.now() / 1000);\n\tconst today = resolveLocalDate(timestamp, user.timezone);\n\tconst day = args.aggregation.recalculateDay(user.id, today, { now: timestamp });\n\t// the month is recalculated like the month view does it; the year is read, so the publishing stays cheap\n\tconst year = Number(today.slice(0, 4));\n\tconst month = Number(today.slice(5, 7));\n\tconst monthAggregate = args.aggregation.recalculateMonth(user.id, year, month, { now: timestamp });\n\tconst yearAggregate = args.aggregation.year(user.id, year);\n\n\treturn {\n\t\tuserId: user.id,\n\t\tdisplayName: user.displayName,\n\t\thasOpenEntry: day.hasOpenEntry,\n\t\tlastPunchUtc: Math.max(day.firstInUtc ?? 0, day.lastOutUtc ?? 0) || null,\n\t\tworkedMinutes: day.workedMin,\n\t\tbalanceMinutes: day.balanceMin,\n\t\topenConflicts: args.sync.conflicts(user.id).length,\n\t\tmonthWorkedMinutes: monthAggregate.workedMin,\n\t\tmonthBalanceMinutes: monthAggregate.balanceMin,\n\t\tyearBalanceMinutes: yearAggregate ? yearAggregate.workedMin - yearAggregate.targetMin : 0,\n\t};\n}\n\n/**\n * Publishes the figures of one employee.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishUserSnapshot(port: StatePort, snapshot: UserSnapshot): Promise<void> {\n\tconst id = `users.${snapshot.userId}`;\n\tawait port.setState(`${id}.displayName`, snapshot.displayName, true);\n\tawait port.setState(`${id}.present`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.hasOpenEntry`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.lastPunch`, snapshot.lastPunchUtc ?? 0, true);\n\tawait port.setState(`${id}.todayWorkedMinutes`, snapshot.workedMinutes, true);\n\tawait port.setState(`${id}.todayBalanceMinutes`, snapshot.balanceMinutes, true);\n\tawait port.setState(`${id}.monthWorkedMinutes`, snapshot.monthWorkedMinutes, true);\n\tawait port.setState(`${id}.monthBalanceMinutes`, snapshot.monthBalanceMinutes, true);\n\tawait port.setState(`${id}.yearBalanceMinutes`, snapshot.yearBalanceMinutes, true);\n\tawait port.setState(`${id}.openConflicts`, snapshot.openConflicts, true);\n}\n\n/**\n * Creates the channels of all employees and publishes their current figures.\n *\n * @param args - state port and data sources\n * @param args.port - state port\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service\n * @param args.now - instant of the snapshot\n * @returns the published snapshots\n */\nexport async function publishAllUserStates(args: {\n\tport: StatePort;\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tnow?: number;\n}): Promise<UserSnapshot[]> {\n\tconst snapshots: UserSnapshot[] = [];\n\tfor (const user of args.users.list({ includeInactive: true })) {\n\t\tawait createUserChannel(args.port, user.id);\n\t\tconst snapshot = readUserSnapshot({\n\t\t\taggregation: args.aggregation,\n\t\t\tusers: args.users,\n\t\t\tsync: args.sync,\n\t\t\tuserId: user.id,\n\t\t\tnow: args.now,\n\t\t});\n\t\tif (!snapshot) {\n\t\t\tcontinue;\n\t\t}\n\t\tawait publishUserSnapshot(args.port, snapshot);\n\t\tsnapshots.push(snapshot);\n\t}\n\treturn snapshots;\n}\n\n/**\n * Creates the channel of one employee, including the shared parent channel `users`.\n *\n * The parent channel matters: the ioBroker object structure check (`E3009`) requires every state to have its\n * parents, so `users.<userId>.*` below a missing `users` produced 126 findings in the repository check\n * (PR #6697). `setObjectNotExists` keeps the call idempotent.\n *\n * @param port - state port\n * @param userId - database id of the employee\n * @returns the channel id\n */\nexport async function createUserChannel(port: StatePort, userId: number): Promise<string> {\n\tconst id = `users.${userId}`;\n\tawait ensureObject(port, \"users\", channelObject(STATE_NAMES.usersChannel));\n\tawait ensureObject(port, id, channelObject(STATE_NAMES.employeeChannel));\n\tawait ensureObject(port, `${id}.displayName`, stateObject(STATE_NAMES.displayName, \"string\", \"info.name\"));\n\t// the writable twin of `hasOpenEntry`: a script, a fingerprint reader or a dashboard writes it to say that\n\t// somebody arrived (`true`) or left (`false`) \u2014 see `presence.ts` for what the adapter does with it\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.present`,\n\t\tstateObject(STATE_NAMES.userPresent, \"boolean\", \"switch\", { write: true }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.hasOpenEntry`,\n\t\tstateObject(STATE_NAMES.hasOpenEntry, \"boolean\", \"indicator.working\"),\n\t);\n\tawait ensureObject(port, `${id}.lastPunch`, stateObject(STATE_NAMES.lastPunch, \"number\", \"value.time\"));\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.todayWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.todayBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.monthWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.monthBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.yearBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.yearBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(port, `${id}.openConflicts`, stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\treturn id;\n}\n\n/**\n * Creates the writable command states.\n *\n * @param port - state port\n */\nexport async function createCommandStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"commands\", channelObject(STATE_NAMES.commandsChannel));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punchUserId,\n\t\tstateObject(STATE_NAMES.punchUserId, \"number\", \"level\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punch,\n\t\tstateObject(STATE_NAMES.punch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.quickPunch,\n\t\tstateObject(STATE_NAMES.quickPunch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.closeMonth,\n\t\tstateObject(STATE_NAMES.closeMonth, \"string\", \"text\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(port, COMMAND_IDS.recalc, stateObject(STATE_NAMES.recalc, \"string\", \"text\", { write: true }));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.backup,\n\t\tstateObject(STATE_NAMES.backupCommand, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n}\n\n/**\n * Creates the informational states of the instance.\n *\n * @param port - state port\n */\nexport async function createInfoStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"info\", channelObject(STATE_NAMES.infoChannel));\n\tawait ensureObject(port, \"info.version\", stateObject(STATE_NAMES.version, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.schemaVersion\", stateObject(STATE_NAMES.schemaVersion, \"string\", \"text\"));\n\tawait ensureObject(\n\t\tport,\n\t\t\"info.dbSizeBytes\",\n\t\tstateObject(STATE_NAMES.dbSizeBytes, \"number\", \"value\", { unit: \"bytes\" }),\n\t);\n\tawait ensureObject(port, \"info.lastError\", stateObject(STATE_NAMES.lastError, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.lastBackup\", stateObject(STATE_NAMES.lastBackup, \"number\", \"value.time\"));\n}\n\n/**\n * Creates the states of the company.\n *\n * @param port - state port\n */\nexport async function createCompanyStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"company\", channelObject(STATE_NAMES.companyChannel));\n\tawait ensureObject(port, \"company.presentCount\", stateObject(STATE_NAMES.companyPresentCount, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.present\", stateObject(STATE_NAMES.companyPresent, \"string\", \"text\"));\n\tawait ensureObject(port, \"company.openConflicts\", stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.lastPunch\", stateObject(STATE_NAMES.companyLastPunch, \"number\", \"value.time\"));\n}\n\n/**\n * Reads the company figures out of the figures of the employees.\n *\n * @param snapshots - figures of all employees\n * @returns the company figures\n */\nexport function readCompanySnapshot(snapshots: UserSnapshot[]): CompanySnapshot {\n\tconst present = snapshots.filter(snapshot => snapshot.hasOpenEntry);\n\treturn {\n\t\tpresentCount: present.length,\n\t\tpresent: present.map(snapshot => snapshot.displayName).join(\", \"),\n\t\topenConflicts: snapshots.reduce((sum, snapshot) => sum + snapshot.openConflicts, 0),\n\t\tlastPunchUtc: snapshots.reduce<number | null>(\n\t\t\t(newest, snapshot) =>\n\t\t\t\tsnapshot.lastPunchUtc !== null && (newest === null || snapshot.lastPunchUtc > newest)\n\t\t\t\t\t? snapshot.lastPunchUtc\n\t\t\t\t\t: newest,\n\t\t\tnull,\n\t\t),\n\t};\n}\n\n/**\n * Publishes the company figures.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishCompanySnapshot(port: StatePort, snapshot: CompanySnapshot): Promise<void> {\n\tawait port.setState(\"company.presentCount\", snapshot.presentCount, true);\n\tawait port.setState(\"company.present\", snapshot.present, true);\n\tawait port.setState(\"company.openConflicts\", snapshot.openConflicts, true);\n\tawait port.setState(\"company.lastPunch\", snapshot.lastPunchUtc ?? 0, true);\n}\n\n/**\n * Creates the event states.\n *\n * @param port - state port\n */\nexport async function createEventStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"events\", channelObject(STATE_NAMES.eventsChannel));\n\tawait ensureObject(port, \"events.lastAt\", stateObject(STATE_NAMES.lastAt, \"number\", \"value.time\"));\n\tawait ensureObject(port, \"events.lastType\", stateObject(STATE_NAMES.lastType, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastUser\", stateObject(STATE_NAMES.lastUser, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastDirection\", stateObject(STATE_NAMES.lastDirection, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastSource\", stateObject(STATE_NAMES.lastSource, \"string\", \"text\"));\n}\n\n/**\n * Publishes the newest event.\n *\n * @param port - state port\n * @param snapshot - event to publish\n */\nexport async function publishEventSnapshot(port: StatePort, snapshot: EventSnapshot): Promise<void> {\n\tawait port.setState(\"events.lastAt\", snapshot.atUtc, true);\n\tawait port.setState(\"events.lastType\", snapshot.type, true);\n\tawait port.setState(\"events.lastUser\", snapshot.userName, true);\n\tawait port.setState(\"events.lastDirection\", snapshot.direction, true);\n\tawait port.setState(\"events.lastSource\", snapshot.source, true);\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,wBAA4C;AAC5C,kBAA8C;AA+EvC,MAAM,cAAc;AAAA;AAAA,EAE1B,aAAa;AAAA;AAAA,EAEb,OAAO;AAAA;AAAA,EAEP,YAAY;AAAA;AAAA,EAEZ,YAAY;AAAA;AAAA,EAEZ,QAAQ;AAAA;AAAA,EAER,QAAQ;
|
|
4
|
+
"sourcesContent": ["/**\n * States published by the adapter.\n *\n * The adapter exposes a small, stable state tree so that other adapters (and the user's dashboards) can show\n * and trigger time tracking without speaking to the REST API:\n *\n * ```\n * users.<userId>.displayName / present / hasOpenEntry / lastPunch / todayWorkedMinutes / todayBalanceMinutes\n * users.<userId>.openConflicts\n * commands.punchUserId / punch / quickPunch / closeMonth / recalc\n * ```\n *\n * All objects are created with `setObjectNotExists`, so existing instances keep their settings. The logic works\n * on a tiny port interface (`StatePort`) \u2014 the adapter instance in production, a recorder in the tests.\n */\n\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport type { AggregationService } from \"../services/aggregation\";\nimport type { SyncService } from \"../services/sync\";\nimport { STATE_NAMES, type StateName } from \"./stateNames\";\nimport { localDate as resolveLocalDate } from \"../util/time\";\n\n/** Subset of the adapter API the state layer needs. */\nexport interface StatePort {\n\t/**\n\t * Creates an object when it does not exist yet.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - object definition\n\t */\n\tsetObjectNotExists(id: string, object: ioBroker.SettableObject): Promise<unknown> | void;\n\t/**\n\t * Merges fields into an object that already exists.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - fields to merge\n\t */\n\textendObject(id: string, object: ioBroker.PartialObject): Promise<unknown> | void;\n\t/**\n\t * Writes a state value.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param value - value to write\n\t * @param ack - true for confirmed values\n\t */\n\tsetState(id: string, value: ioBroker.StateValue, ack?: boolean): Promise<unknown> | void;\n}\n\n/** Figures of one employee shown in the state tree. */\nexport interface UserSnapshot {\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** True when the last punch of today has no counterpart */\n\thasOpenEntry: boolean;\n\t/** Instant of the last punch of the day, `null` when there is none */\n\tlastPunchUtc: number | null;\n\t/** Net working time of today in minutes */\n\tworkedMinutes: number;\n\t/** Balance of today in minutes */\n\tbalanceMinutes: number;\n\t/** Punches waiting for a decision */\n\topenConflicts: number;\n\t/** Net working time of the current month in minutes */\n\tmonthWorkedMinutes: number;\n\t/** Balance of the current month in minutes */\n\tmonthBalanceMinutes: number;\n\t/** Balance of the current year in minutes */\n\tyearBalanceMinutes: number;\n}\n\n/** Figures of the whole company, published next to the employees. */\nexport interface CompanySnapshot {\n\t/** Employees that are clocked in right now */\n\tpresentCount: number;\n\t/** Names of those employees, separated by a comma (empty when nobody is present) */\n\tpresent: string;\n\t/** Punches of all employees waiting for a decision */\n\topenConflicts: number;\n\t/** Instant of the newest punch of today, `null` when nobody punched */\n\tlastPunchUtc: number | null;\n}\n\n/** Newest event of the instance, mirrored into the state tree. */\nexport interface EventSnapshot {\n\t/** Kind of the event, e.g. `punch` */\n\ttype: string;\n\t/** Instant the event belongs to */\n\tatUtc: number;\n\t/** Name of the employee the event belongs to, empty for instance wide events */\n\tuserName: string;\n\t/** Direction of a punch (`in`/`out`), empty for other events */\n\tdirection: string;\n\t/** Where the change came from, e.g. `web` or `terminal`, empty when unknown */\n\tsource: string;\n}\n\n/** Names of the writable command states. */\nexport const COMMAND_IDS = {\n\t/** Employee the commands apply to */\n\tpunchUserId: \"commands.punchUserId\",\n\t/** Punch in or out for `punchUserId` */\n\tpunch: \"commands.punch\",\n\t/** Punch in or out with the configured quick rounding */\n\tquickPunch: \"commands.quickPunch\",\n\t/** Close a month, value `YYYY-MM` */\n\tcloseMonth: \"commands.closeMonth\",\n\t/** Recalculate a period, value `YYYY-MM` or `YYYY` */\n\trecalc: \"commands.recalc\",\n\t/** Write a backup of the database */\n\tbackup: \"commands.backup\",\n\t/** Create or renew the calendar link of the company */\n\trotateCalendarToken: \"commands.rotateCalendarToken\",\n} as const;\n\n/**\n * Builds a state object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @param type - state type\n * @param role - state role\n * @param options - additional flags\n * @param options.read - value can be read (default true)\n * @param options.write - value can be written (default false)\n * @param options.unit - unit of the value\n * @returns object definition\n */\nfunction stateObject(\n\tname: StateName,\n\ttype: ioBroker.CommonType,\n\trole: string,\n\toptions: { read?: boolean; write?: boolean; unit?: string } = {},\n): ioBroker.SettableObject {\n\treturn {\n\t\ttype: \"state\",\n\t\tcommon: {\n\t\t\tname,\n\t\t\ttype,\n\t\t\trole,\n\t\t\tread: options.read !== false,\n\t\t\twrite: options.write === true,\n\t\t\t...(options.unit ? { unit: options.unit } : {}),\n\t\t},\n\t\tnative: {},\n\t};\n}\n\n/**\n * Builds a channel object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @returns object definition\n */\nfunction channelObject(name: StateName): ioBroker.SettableObject {\n\treturn { type: \"channel\", common: { name }, native: {} };\n}\n\n/**\n * Creates an object when it is missing and keeps the definition of an existing one current.\n *\n * `setObjectNotExists` alone would leave existing installations on the definition they were created with \u2014 a later\n * correction (for example the role of `commands.punchUserId`, checker `E1011`) would never arrive. So the fields the\n * adapter owns are merged on every start; `common.custom` is left out, that is where the user configures `history`\n * and similar integrations.\n *\n * @param port - state port\n * @param id - full state id (including the instance prefix)\n * @param object - object definition\n */\nasync function ensureObject(port: StatePort, id: string, object: ioBroker.SettableObject): Promise<void> {\n\tawait port.setObjectNotExists(id, object);\n\tconst common: Record<string, unknown> = { ...(object.common ?? {}) };\n\tdelete common.custom;\n\tawait port.extendObject(id, { common });\n}\n\n/**\n * Reads the figures of an employee for the state tree.\n *\n * @param args - data sources and employee\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service (open conflicts)\n * @param args.userId - database id of the employee\n * @param args.now - instant of the snapshot\n * @returns snapshot of the figures\n */\nexport function readUserSnapshot(args: {\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tuserId: number;\n\tnow?: number;\n}): UserSnapshot | null {\n\tconst user = args.users.findById(args.userId);\n\tif (!user) {\n\t\treturn null;\n\t}\n\n\tconst timestamp = args.now ?? Math.floor(Date.now() / 1000);\n\tconst today = resolveLocalDate(timestamp, user.timezone);\n\tconst day = args.aggregation.recalculateDay(user.id, today, { now: timestamp });\n\t// the month is recalculated like the month view does it; the year is read, so the publishing stays cheap\n\tconst year = Number(today.slice(0, 4));\n\tconst month = Number(today.slice(5, 7));\n\tconst monthAggregate = args.aggregation.recalculateMonth(user.id, year, month, { now: timestamp });\n\tconst yearAggregate = args.aggregation.year(user.id, year);\n\n\treturn {\n\t\tuserId: user.id,\n\t\tdisplayName: user.displayName,\n\t\thasOpenEntry: day.hasOpenEntry,\n\t\tlastPunchUtc: Math.max(day.firstInUtc ?? 0, day.lastOutUtc ?? 0) || null,\n\t\tworkedMinutes: day.workedMin,\n\t\tbalanceMinutes: day.balanceMin,\n\t\topenConflicts: args.sync.conflicts(user.id).length,\n\t\tmonthWorkedMinutes: monthAggregate.workedMin,\n\t\tmonthBalanceMinutes: monthAggregate.balanceMin,\n\t\tyearBalanceMinutes: yearAggregate ? yearAggregate.workedMin - yearAggregate.targetMin : 0,\n\t};\n}\n\n/**\n * Publishes the figures of one employee.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishUserSnapshot(port: StatePort, snapshot: UserSnapshot): Promise<void> {\n\tconst id = `users.${snapshot.userId}`;\n\tawait port.setState(`${id}.displayName`, snapshot.displayName, true);\n\tawait port.setState(`${id}.present`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.hasOpenEntry`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.lastPunch`, snapshot.lastPunchUtc ?? 0, true);\n\tawait port.setState(`${id}.todayWorkedMinutes`, snapshot.workedMinutes, true);\n\tawait port.setState(`${id}.todayBalanceMinutes`, snapshot.balanceMinutes, true);\n\tawait port.setState(`${id}.monthWorkedMinutes`, snapshot.monthWorkedMinutes, true);\n\tawait port.setState(`${id}.monthBalanceMinutes`, snapshot.monthBalanceMinutes, true);\n\tawait port.setState(`${id}.yearBalanceMinutes`, snapshot.yearBalanceMinutes, true);\n\tawait port.setState(`${id}.openConflicts`, snapshot.openConflicts, true);\n}\n\n/**\n * Creates the channels of all employees and publishes their current figures.\n *\n * @param args - state port and data sources\n * @param args.port - state port\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service\n * @param args.now - instant of the snapshot\n * @returns the published snapshots\n */\nexport async function publishAllUserStates(args: {\n\tport: StatePort;\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tnow?: number;\n}): Promise<UserSnapshot[]> {\n\tconst snapshots: UserSnapshot[] = [];\n\tfor (const user of args.users.list({ includeInactive: true })) {\n\t\tawait createUserChannel(args.port, user.id);\n\t\tconst snapshot = readUserSnapshot({\n\t\t\taggregation: args.aggregation,\n\t\t\tusers: args.users,\n\t\t\tsync: args.sync,\n\t\t\tuserId: user.id,\n\t\t\tnow: args.now,\n\t\t});\n\t\tif (!snapshot) {\n\t\t\tcontinue;\n\t\t}\n\t\tawait publishUserSnapshot(args.port, snapshot);\n\t\tsnapshots.push(snapshot);\n\t}\n\treturn snapshots;\n}\n\n/**\n * Creates the channel of one employee, including the shared parent channel `users`.\n *\n * The parent channel matters: the ioBroker object structure check (`E3009`) requires every state to have its\n * parents, so `users.<userId>.*` below a missing `users` produced 126 findings in the repository check\n * (PR #6697). `setObjectNotExists` keeps the call idempotent.\n *\n * @param port - state port\n * @param userId - database id of the employee\n * @returns the channel id\n */\nexport async function createUserChannel(port: StatePort, userId: number): Promise<string> {\n\tconst id = `users.${userId}`;\n\tawait ensureObject(port, \"users\", channelObject(STATE_NAMES.usersChannel));\n\tawait ensureObject(port, id, channelObject(STATE_NAMES.employeeChannel));\n\tawait ensureObject(port, `${id}.displayName`, stateObject(STATE_NAMES.displayName, \"string\", \"info.name\"));\n\t// the writable twin of `hasOpenEntry`: a script, a fingerprint reader or a dashboard writes it to say that\n\t// somebody arrived (`true`) or left (`false`) \u2014 see `presence.ts` for what the adapter does with it\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.present`,\n\t\tstateObject(STATE_NAMES.userPresent, \"boolean\", \"switch\", { write: true }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.hasOpenEntry`,\n\t\tstateObject(STATE_NAMES.hasOpenEntry, \"boolean\", \"indicator.working\"),\n\t);\n\tawait ensureObject(port, `${id}.lastPunch`, stateObject(STATE_NAMES.lastPunch, \"number\", \"value.time\"));\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.todayWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.todayBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.monthWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.monthBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.yearBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.yearBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(port, `${id}.openConflicts`, stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\treturn id;\n}\n\n/**\n * Creates the writable command states.\n *\n * @param port - state port\n */\nexport async function createCommandStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"commands\", channelObject(STATE_NAMES.commandsChannel));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punchUserId,\n\t\tstateObject(STATE_NAMES.punchUserId, \"number\", \"level\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punch,\n\t\tstateObject(STATE_NAMES.punch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.quickPunch,\n\t\tstateObject(STATE_NAMES.quickPunch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.closeMonth,\n\t\tstateObject(STATE_NAMES.closeMonth, \"string\", \"text\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(port, COMMAND_IDS.recalc, stateObject(STATE_NAMES.recalc, \"string\", \"text\", { write: true }));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.backup,\n\t\tstateObject(STATE_NAMES.backupCommand, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.rotateCalendarToken,\n\t\tstateObject(STATE_NAMES.rotateCalendarToken, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n}\n\n/**\n * Creates the informational states of the instance.\n *\n * @param port - state port\n */\nexport async function createInfoStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"info\", channelObject(STATE_NAMES.infoChannel));\n\tawait ensureObject(port, \"info.version\", stateObject(STATE_NAMES.version, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.schemaVersion\", stateObject(STATE_NAMES.schemaVersion, \"string\", \"text\"));\n\tawait ensureObject(\n\t\tport,\n\t\t\"info.dbSizeBytes\",\n\t\tstateObject(STATE_NAMES.dbSizeBytes, \"number\", \"value\", { unit: \"bytes\" }),\n\t);\n\tawait ensureObject(port, \"info.lastError\", stateObject(STATE_NAMES.lastError, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.lastBackup\", stateObject(STATE_NAMES.lastBackup, \"number\", \"value.time\"));\n}\n\n/**\n * Creates the states of the company.\n *\n * @param port - state port\n */\nexport async function createCompanyStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"company\", channelObject(STATE_NAMES.companyChannel));\n\tawait ensureObject(port, \"company.presentCount\", stateObject(STATE_NAMES.companyPresentCount, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.present\", stateObject(STATE_NAMES.companyPresent, \"string\", \"text\"));\n\tawait ensureObject(port, \"company.openConflicts\", stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.lastPunch\", stateObject(STATE_NAMES.companyLastPunch, \"number\", \"value.time\"));\n}\n\n/**\n * Reads the company figures out of the figures of the employees.\n *\n * @param snapshots - figures of all employees\n * @returns the company figures\n */\nexport function readCompanySnapshot(snapshots: UserSnapshot[]): CompanySnapshot {\n\tconst present = snapshots.filter(snapshot => snapshot.hasOpenEntry);\n\treturn {\n\t\tpresentCount: present.length,\n\t\tpresent: present.map(snapshot => snapshot.displayName).join(\", \"),\n\t\topenConflicts: snapshots.reduce((sum, snapshot) => sum + snapshot.openConflicts, 0),\n\t\tlastPunchUtc: snapshots.reduce<number | null>(\n\t\t\t(newest, snapshot) =>\n\t\t\t\tsnapshot.lastPunchUtc !== null && (newest === null || snapshot.lastPunchUtc > newest)\n\t\t\t\t\t? snapshot.lastPunchUtc\n\t\t\t\t\t: newest,\n\t\t\tnull,\n\t\t),\n\t};\n}\n\n/**\n * Publishes the company figures.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishCompanySnapshot(port: StatePort, snapshot: CompanySnapshot): Promise<void> {\n\tawait port.setState(\"company.presentCount\", snapshot.presentCount, true);\n\tawait port.setState(\"company.present\", snapshot.present, true);\n\tawait port.setState(\"company.openConflicts\", snapshot.openConflicts, true);\n\tawait port.setState(\"company.lastPunch\", snapshot.lastPunchUtc ?? 0, true);\n}\n\n/**\n * Creates the event states.\n *\n * @param port - state port\n */\nexport async function createEventStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"events\", channelObject(STATE_NAMES.eventsChannel));\n\tawait ensureObject(port, \"events.lastAt\", stateObject(STATE_NAMES.lastAt, \"number\", \"value.time\"));\n\tawait ensureObject(port, \"events.lastType\", stateObject(STATE_NAMES.lastType, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastUser\", stateObject(STATE_NAMES.lastUser, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastDirection\", stateObject(STATE_NAMES.lastDirection, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastSource\", stateObject(STATE_NAMES.lastSource, \"string\", \"text\"));\n}\n\n/**\n * Publishes the newest event.\n *\n * @param port - state port\n * @param snapshot - event to publish\n */\nexport async function publishEventSnapshot(port: StatePort, snapshot: EventSnapshot): Promise<void> {\n\tawait port.setState(\"events.lastAt\", snapshot.atUtc, true);\n\tawait port.setState(\"events.lastType\", snapshot.type, true);\n\tawait port.setState(\"events.lastUser\", snapshot.userName, true);\n\tawait port.setState(\"events.lastDirection\", snapshot.direction, true);\n\tawait port.setState(\"events.lastSource\", snapshot.source, true);\n}\n\n/** Ids of the calendar states; the adapter publishes them from `main.ts`. */\nexport const CALENDAR_IDS = {\n\t/** Subscription link of the company calendar */\n\tfeedUrl: \"calendar.feedUrl\",\n\t/** Path of the written `.ics` file */\n\tfeedFile: \"calendar.feedFile\",\n\t/** Instant the calendar was written */\n\tupdatedAt: \"calendar.updatedAt\",\n\t/** The absences as JSON */\n\tabsences: \"calendar.absences\",\n} as const;\n\n/** What the calendar states show. */\nexport interface CalendarSnapshot {\n\t/** Subscription link, empty while no token exists */\n\tfeedUrl: string;\n\t/** Absolute path of the written `.ics` file */\n\tfeedFile: string;\n\t/** Instant the file and the JSON were written */\n\tupdatedAt: number;\n\t/** The absences as JSON text */\n\tabsences: string;\n}\n\n/**\n * Creates the states of the calendar.\n *\n * @param port - state port\n */\nexport async function createCalendarStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"calendar\", channelObject(STATE_NAMES.calendarChannel));\n\tawait ensureObject(port, CALENDAR_IDS.feedUrl, stateObject(STATE_NAMES.calendarFeedUrl, \"string\", \"text\"));\n\tawait ensureObject(port, CALENDAR_IDS.feedFile, stateObject(STATE_NAMES.calendarFeedFile, \"string\", \"text\"));\n\tawait ensureObject(\n\t\tport,\n\t\tCALENDAR_IDS.updatedAt,\n\t\tstateObject(STATE_NAMES.calendarUpdatedAt, \"number\", \"value.time\"),\n\t);\n\tawait ensureObject(port, CALENDAR_IDS.absences, stateObject(STATE_NAMES.calendarAbsences, \"string\", \"text\"));\n}\n\n/**\n * Publishes the calendar of the instance.\n *\n * @param port - state port\n * @param snapshot - data to publish\n */\nexport async function publishCalendarSnapshot(port: StatePort, snapshot: CalendarSnapshot): Promise<void> {\n\tawait port.setState(CALENDAR_IDS.feedUrl, snapshot.feedUrl, true);\n\tawait port.setState(CALENDAR_IDS.feedFile, snapshot.feedFile, true);\n\tawait port.setState(CALENDAR_IDS.updatedAt, snapshot.updatedAt, true);\n\tawait port.setState(CALENDAR_IDS.absences, snapshot.absences, true);\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,wBAA4C;AAC5C,kBAA8C;AA+EvC,MAAM,cAAc;AAAA;AAAA,EAE1B,aAAa;AAAA;AAAA,EAEb,OAAO;AAAA;AAAA,EAEP,YAAY;AAAA;AAAA,EAEZ,YAAY;AAAA;AAAA,EAEZ,QAAQ;AAAA;AAAA,EAER,QAAQ;AAAA;AAAA,EAER,qBAAqB;AACtB;AAgBA,SAAS,YACR,MACA,MACA,MACA,UAA8D,CAAC,GACrC;AAC1B,SAAO;AAAA,IACN,MAAM;AAAA,IACN,QAAQ;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM,QAAQ,SAAS;AAAA,MACvB,OAAO,QAAQ,UAAU;AAAA,MACzB,GAAI,QAAQ,OAAO,EAAE,MAAM,QAAQ,KAAK,IAAI,CAAC;AAAA,IAC9C;AAAA,IACA,QAAQ,CAAC;AAAA,EACV;AACD;AAUA,SAAS,cAAc,MAA0C;AAChE,SAAO,EAAE,MAAM,WAAW,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC,EAAE;AACxD;AAcA,eAAe,aAAa,MAAiB,IAAY,QAAgD;AA9KzG;AA+KC,QAAM,KAAK,mBAAmB,IAAI,MAAM;AACxC,QAAM,SAAkC,EAAE,IAAI,YAAO,WAAP,YAAiB,CAAC,EAAG;AACnE,SAAO,OAAO;AACd,QAAM,KAAK,aAAa,IAAI,EAAE,OAAO,CAAC;AACvC;AAaO,SAAS,iBAAiB,MAMT;AAtMxB;AAuMC,QAAM,OAAO,KAAK,MAAM,SAAS,KAAK,MAAM;AAC5C,MAAI,CAAC,MAAM;AACV,WAAO;AAAA,EACR;AAEA,QAAM,aAAY,UAAK,QAAL,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAC1D,QAAM,YAAQ,YAAAA,WAAiB,WAAW,KAAK,QAAQ;AACvD,QAAM,MAAM,KAAK,YAAY,eAAe,KAAK,IAAI,OAAO,EAAE,KAAK,UAAU,CAAC;AAE9E,QAAM,OAAO,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC;AACrC,QAAM,QAAQ,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC;AACtC,QAAM,iBAAiB,KAAK,YAAY,iBAAiB,KAAK,IAAI,MAAM,OAAO,EAAE,KAAK,UAAU,CAAC;AACjG,QAAM,gBAAgB,KAAK,YAAY,KAAK,KAAK,IAAI,IAAI;AAEzD,SAAO;AAAA,IACN,QAAQ,KAAK;AAAA,IACb,aAAa,KAAK;AAAA,IAClB,cAAc,IAAI;AAAA,IAClB,cAAc,KAAK,KAAI,SAAI,eAAJ,YAAkB,IAAG,SAAI,eAAJ,YAAkB,CAAC,KAAK;AAAA,IACpE,eAAe,IAAI;AAAA,IACnB,gBAAgB,IAAI;AAAA,IACpB,eAAe,KAAK,KAAK,UAAU,KAAK,EAAE,EAAE;AAAA,IAC5C,oBAAoB,eAAe;AAAA,IACnC,qBAAqB,eAAe;AAAA,IACpC,oBAAoB,gBAAgB,cAAc,YAAY,cAAc,YAAY;AAAA,EACzF;AACD;AAQA,eAAsB,oBAAoB,MAAiB,UAAuC;AAzOlG;AA0OC,QAAM,KAAK,SAAS,SAAS,MAAM;AACnC,QAAM,KAAK,SAAS,GAAG,EAAE,gBAAgB,SAAS,aAAa,IAAI;AACnE,QAAM,KAAK,SAAS,GAAG,EAAE,YAAY,SAAS,cAAc,IAAI;AAChE,QAAM,KAAK,SAAS,GAAG,EAAE,iBAAiB,SAAS,cAAc,IAAI;AACrE,QAAM,KAAK,SAAS,GAAG,EAAE,eAAc,cAAS,iBAAT,YAAyB,GAAG,IAAI;AACvE,QAAM,KAAK,SAAS,GAAG,EAAE,uBAAuB,SAAS,eAAe,IAAI;AAC5E,QAAM,KAAK,SAAS,GAAG,EAAE,wBAAwB,SAAS,gBAAgB,IAAI;AAC9E,QAAM,KAAK,SAAS,GAAG,EAAE,uBAAuB,SAAS,oBAAoB,IAAI;AACjF,QAAM,KAAK,SAAS,GAAG,EAAE,wBAAwB,SAAS,qBAAqB,IAAI;AACnF,QAAM,KAAK,SAAS,GAAG,EAAE,uBAAuB,SAAS,oBAAoB,IAAI;AACjF,QAAM,KAAK,SAAS,GAAG,EAAE,kBAAkB,SAAS,eAAe,IAAI;AACxE;AAaA,eAAsB,qBAAqB,MAMf;AAC3B,QAAM,YAA4B,CAAC;AACnC,aAAW,QAAQ,KAAK,MAAM,KAAK,EAAE,iBAAiB,KAAK,CAAC,GAAG;AAC9D,UAAM,kBAAkB,KAAK,MAAM,KAAK,EAAE;AAC1C,UAAM,WAAW,iBAAiB;AAAA,MACjC,aAAa,KAAK;AAAA,MAClB,OAAO,KAAK;AAAA,MACZ,MAAM,KAAK;AAAA,MACX,QAAQ,KAAK;AAAA,MACb,KAAK,KAAK;AAAA,IACX,CAAC;AACD,QAAI,CAAC,UAAU;AACd;AAAA,IACD;AACA,UAAM,oBAAoB,KAAK,MAAM,QAAQ;AAC7C,cAAU,KAAK,QAAQ;AAAA,EACxB;AACA,SAAO;AACR;AAaA,eAAsB,kBAAkB,MAAiB,QAAiC;AACzF,QAAM,KAAK,SAAS,MAAM;AAC1B,QAAM,aAAa,MAAM,SAAS,cAAc,8BAAY,YAAY,CAAC;AACzE,QAAM,aAAa,MAAM,IAAI,cAAc,8BAAY,eAAe,CAAC;AACvE,QAAM,aAAa,MAAM,GAAG,EAAE,gBAAgB,YAAY,8BAAY,aAAa,UAAU,WAAW,CAAC;AAGzG,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,aAAa,WAAW,UAAU,EAAE,OAAO,KAAK,CAAC;AAAA,EAC1E;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,cAAc,WAAW,mBAAmB;AAAA,EACrE;AACA,QAAM,aAAa,MAAM,GAAG,EAAE,cAAc,YAAY,8BAAY,WAAW,UAAU,YAAY,CAAC;AACtG,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,oBAAoB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAC/E;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,qBAAqB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAChF;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,oBAAoB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAC/E;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,qBAAqB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAChF;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,oBAAoB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAC/E;AACA,QAAM,aAAa,MAAM,GAAG,EAAE,kBAAkB,YAAY,8BAAY,eAAe,UAAU,OAAO,CAAC;AACzG,SAAO;AACR;AAOA,eAAsB,oBAAoB,MAAgC;AACzE,QAAM,aAAa,MAAM,YAAY,cAAc,8BAAY,eAAe,CAAC;AAC/E,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,aAAa,UAAU,SAAS;AAAA,MACvD,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,OAAO,WAAW,UAAU;AAAA,MACnD,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,YAAY,WAAW,UAAU;AAAA,MACxD,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,YAAY,UAAU,QAAQ;AAAA,MACrD,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM,aAAa,MAAM,YAAY,QAAQ,YAAY,8BAAY,QAAQ,UAAU,QAAQ,EAAE,OAAO,KAAK,CAAC,CAAC;AAC/G,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,eAAe,WAAW,UAAU;AAAA,MAC3D,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,qBAAqB,WAAW,UAAU;AAAA,MACjE,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACD;AAOA,eAAsB,iBAAiB,MAAgC;AACtE,QAAM,aAAa,MAAM,QAAQ,cAAc,8BAAY,WAAW,CAAC;AACvE,QAAM,aAAa,MAAM,gBAAgB,YAAY,8BAAY,SAAS,UAAU,MAAM,CAAC;AAC3F,QAAM,aAAa,MAAM,sBAAsB,YAAY,8BAAY,eAAe,UAAU,MAAM,CAAC;AACvG,QAAM;AAAA,IACL;AAAA,IACA;AAAA,IACA,YAAY,8BAAY,aAAa,UAAU,SAAS,EAAE,MAAM,QAAQ,CAAC;AAAA,EAC1E;AACA,QAAM,aAAa,MAAM,kBAAkB,YAAY,8BAAY,WAAW,UAAU,MAAM,CAAC;AAC/F,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,YAAY,UAAU,YAAY,CAAC;AACxG;AAOA,eAAsB,oBAAoB,MAAgC;AACzE,QAAM,aAAa,MAAM,WAAW,cAAc,8BAAY,cAAc,CAAC;AAC7E,QAAM,aAAa,MAAM,wBAAwB,YAAY,8BAAY,qBAAqB,UAAU,OAAO,CAAC;AAChH,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,gBAAgB,UAAU,MAAM,CAAC;AACrG,QAAM,aAAa,MAAM,yBAAyB,YAAY,8BAAY,eAAe,UAAU,OAAO,CAAC;AAC3G,QAAM,aAAa,MAAM,qBAAqB,YAAY,8BAAY,kBAAkB,UAAU,YAAY,CAAC;AAChH;AAQO,SAAS,oBAAoB,WAA4C;AAC/E,QAAM,UAAU,UAAU,OAAO,cAAY,SAAS,YAAY;AAClE,SAAO;AAAA,IACN,cAAc,QAAQ;AAAA,IACtB,SAAS,QAAQ,IAAI,cAAY,SAAS,WAAW,EAAE,KAAK,IAAI;AAAA,IAChE,eAAe,UAAU,OAAO,CAAC,KAAK,aAAa,MAAM,SAAS,eAAe,CAAC;AAAA,IAClF,cAAc,UAAU;AAAA,MACvB,CAAC,QAAQ,aACR,SAAS,iBAAiB,SAAS,WAAW,QAAQ,SAAS,eAAe,UAC3E,SAAS,eACT;AAAA,MACJ;AAAA,IACD;AAAA,EACD;AACD;AAQA,eAAsB,uBAAuB,MAAiB,UAA0C;AAzcxG;AA0cC,QAAM,KAAK,SAAS,wBAAwB,SAAS,cAAc,IAAI;AACvE,QAAM,KAAK,SAAS,mBAAmB,SAAS,SAAS,IAAI;AAC7D,QAAM,KAAK,SAAS,yBAAyB,SAAS,eAAe,IAAI;AACzE,QAAM,KAAK,SAAS,sBAAqB,cAAS,iBAAT,YAAyB,GAAG,IAAI;AAC1E;AAOA,eAAsB,kBAAkB,MAAgC;AACvE,QAAM,aAAa,MAAM,UAAU,cAAc,8BAAY,aAAa,CAAC;AAC3E,QAAM,aAAa,MAAM,iBAAiB,YAAY,8BAAY,QAAQ,UAAU,YAAY,CAAC;AACjG,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,UAAU,UAAU,MAAM,CAAC;AAC/F,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,UAAU,UAAU,MAAM,CAAC;AAC/F,QAAM,aAAa,MAAM,wBAAwB,YAAY,8BAAY,eAAe,UAAU,MAAM,CAAC;AACzG,QAAM,aAAa,MAAM,qBAAqB,YAAY,8BAAY,YAAY,UAAU,MAAM,CAAC;AACpG;AAQA,eAAsB,qBAAqB,MAAiB,UAAwC;AACnG,QAAM,KAAK,SAAS,iBAAiB,SAAS,OAAO,IAAI;AACzD,QAAM,KAAK,SAAS,mBAAmB,SAAS,MAAM,IAAI;AAC1D,QAAM,KAAK,SAAS,mBAAmB,SAAS,UAAU,IAAI;AAC9D,QAAM,KAAK,SAAS,wBAAwB,SAAS,WAAW,IAAI;AACpE,QAAM,KAAK,SAAS,qBAAqB,SAAS,QAAQ,IAAI;AAC/D;AAGO,MAAM,eAAe;AAAA;AAAA,EAE3B,SAAS;AAAA;AAAA,EAET,UAAU;AAAA;AAAA,EAEV,WAAW;AAAA;AAAA,EAEX,UAAU;AACX;AAmBA,eAAsB,qBAAqB,MAAgC;AAC1E,QAAM,aAAa,MAAM,YAAY,cAAc,8BAAY,eAAe,CAAC;AAC/E,QAAM,aAAa,MAAM,aAAa,SAAS,YAAY,8BAAY,iBAAiB,UAAU,MAAM,CAAC;AACzG,QAAM,aAAa,MAAM,aAAa,UAAU,YAAY,8BAAY,kBAAkB,UAAU,MAAM,CAAC;AAC3G,QAAM;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,YAAY,8BAAY,mBAAmB,UAAU,YAAY;AAAA,EAClE;AACA,QAAM,aAAa,MAAM,aAAa,UAAU,YAAY,8BAAY,kBAAkB,UAAU,MAAM,CAAC;AAC5G;AAQA,eAAsB,wBAAwB,MAAiB,UAA2C;AACzG,QAAM,KAAK,SAAS,aAAa,SAAS,SAAS,SAAS,IAAI;AAChE,QAAM,KAAK,SAAS,aAAa,UAAU,SAAS,UAAU,IAAI;AAClE,QAAM,KAAK,SAAS,aAAa,WAAW,SAAS,WAAW,IAAI;AACpE,QAAM,KAAK,SAAS,aAAa,UAAU,SAAS,UAAU,IAAI;AACnE;",
|
|
6
6
|
"names": ["resolveLocalDate"]
|
|
7
7
|
}
|
package/build/lib/reports/pdf.js
CHANGED
|
@@ -28,6 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
28
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
29
|
var pdf_exports = {};
|
|
30
30
|
__export(pdf_exports, {
|
|
31
|
+
LAYOUT: () => LAYOUT,
|
|
31
32
|
buildMonthStatement: () => buildMonthStatement
|
|
32
33
|
});
|
|
33
34
|
module.exports = __toCommonJS(pdf_exports);
|
|
@@ -257,8 +258,9 @@ async function buildMonthStatement(input) {
|
|
|
257
258
|
{ bold: true, color: totals.balance < 0 ? "#c62828" : "#000000", topBorder: true }
|
|
258
259
|
);
|
|
259
260
|
if (input.absences.length > 0) {
|
|
261
|
+
const contentWidth = doc.page.width - 2 * LAYOUT.margin;
|
|
260
262
|
doc.moveDown(1);
|
|
261
|
-
doc.font(fonts.bold).fontSize(10).fillColor("#000000").text(labels.absences);
|
|
263
|
+
doc.font(fonts.bold).fontSize(10).fillColor("#000000").text(labels.absences, LAYOUT.margin, doc.y, { width: contentWidth });
|
|
262
264
|
doc.moveDown(0.3);
|
|
263
265
|
for (const absence of input.absences) {
|
|
264
266
|
const portion = absence.dayPortion === 0.5 ? labels.halfDay : labels.fullDay;
|
|
@@ -266,7 +268,10 @@ async function buildMonthStatement(input) {
|
|
|
266
268
|
`${absence.typeCode} - ${absence.typeName}: ${formatDate(absence.dateFrom, locale)} - ${formatDate(
|
|
267
269
|
absence.dateTo,
|
|
268
270
|
locale
|
|
269
|
-
)}, ${portion}${absence.hours === null ? "" : `, ${absence.hours} h`}
|
|
271
|
+
)}, ${portion}${absence.hours === null ? "" : `, ${absence.hours} h`}`,
|
|
272
|
+
LAYOUT.margin,
|
|
273
|
+
doc.y,
|
|
274
|
+
{ width: contentWidth }
|
|
270
275
|
);
|
|
271
276
|
}
|
|
272
277
|
}
|
|
@@ -303,6 +308,7 @@ async function buildMonthStatement(input) {
|
|
|
303
308
|
}
|
|
304
309
|
// Annotate the CommonJS export names for ESM import in node:
|
|
305
310
|
0 && (module.exports = {
|
|
311
|
+
LAYOUT,
|
|
306
312
|
buildMonthStatement
|
|
307
313
|
});
|
|
308
314
|
//# sourceMappingURL=pdf.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/reports/pdf.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Monthly work time statement as a PDF.\n *\n * `/api/reports/pdf` returns this file. It shows the same content as the Excel statement (header block, one row\n * per day, totals, absences, signature lines), drawn with `pdfkit`.\n *\n * **Fonts:** the built-in PDF fonts (`Helvetica`) only cover Latin-1, so Cyrillic (`ru`, `uk`) and Chinese\n * (`zh-cn`) cannot be rendered with them. Instead of producing empty boxes, the renderer refuses those\n * languages with a clear error until a Unicode font is configured (`report_font_path` setting, a `.ttf`/`.otf`\n * that covers the script). Latin statements work without any additional file, which keeps the adapter package\n * free of a multi-megabyte font.\n *\n * Text is written **uncompressed** (`compress: false`): a monthly statement is a few kilobytes, and an\n * uncompressed content stream keeps the file verifiable (and testable) without a PDF library.\n */\n\nimport * as fs from \"node:fs\";\nimport PDFDocument from \"pdfkit\";\nimport { ValidationError } from \"../errors\";\nimport type { ReportLabels } from \"./labels\";\nimport type { ReportInput } from \"./types\";\n\n/** Input of the PDF statement. */\nexport type PdfStatementInput = ReportInput & {\n\t/** Name of the generator, e.g. `time-tracker 0.0.1` */\n\tgenerator: string;\n\t/** Path of a Unicode font (`.ttf`/`.otf`), `null`/empty to use the built-in fonts */\n\tfontPath?: string | null;\n};\n\n/** Languages whose script the built-in PDF fonts cannot display. */\nconst NON_LATIN_LANGUAGES = new Set([\"ru\", \"uk\", \"zh-cn\"]);\n\n/** Characters WinAnsiEncoding knows in addition to Latin-1 \u2014 anything else cannot be drawn by the built-in fonts. */\nconst WIN_ANSI_EXTRAS = \"\u20AC\u201A\u0192\u201E\u2026\u2020\u2021\u02C6\u2030\u0160\u2039\u0152\u017D\u2018\u2019\u201C\u201D\u2022\u2013\u2014\u02DC\u2122\u0161\u203A\u0153\u017E\u0178\";\n\n/**\n * Reports whether the built-in fonts can draw a text.\n *\n * The built-in fonts use WinAnsiEncoding, which covers Latin-1 and a handful of typographic characters. A Polish\n * \"Nieobecno\u015B\u0107\" is outside of it: the renderer would draw nonsense instead of the word, so such a text counts as\n * undrawable.\n *\n * @param text - text to check\n * @returns true when every character is part of WinAnsiEncoding\n */\nfunction fitsBuiltInFont(text: string): boolean {\n\treturn [...text].every(character => character.charCodeAt(0) <= 0xff || WIN_ANSI_EXTRAS.includes(character));\n}\n\n/** Fonts the renderer works with. */\ninterface FontChoice {\n\t/** Font of the regular text */\n\tregular: string;\n\t/** Font of the headings */\n\tbold: string;\n\t/** True when a file of the administrator is used */\n\tembedded: boolean;\n}\n\n/** Margins and text sizes of the statement (A4 portrait, points). */\nconst LAYOUT = {\n\tmargin: 40,\n\tfontSize: 9,\n\tlineHeight: 14,\n} as const;\n\n/** Distance between two columns, so that texts never touch. */\nconst COLUMN_PADDING = 6;\n\n/**\n * Font sizes tried for the column headings, the largest first.\n *\n * A heading has to stay on one line: \"Arbeitszeit\" is about 57 points wide at 9 points, the Ukrainian\n * \"\u0412\u0456\u0434\u043F\u0440\u0430\u0446\u044C\u043E\u0432\u0430\u043D\u043E\" needs even more, and this renderer breaks a word when the column is too narrow for it (the\n * option `lineBreak: false` does not prevent that). The headings are therefore measured and the size steps down\n * until every one of them fits.\n */\nconst HEADER_SIZES = [9, 8, 7, 6.5] as const;\n\n/** The columns of the day table, in the order they are drawn. */\nconst COLUMNS: {\n\t/** Key in the values object and in the labels */\n\tkey: keyof ReportLabels;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n\t/** A typical value: the column is at least wide enough for it */\n\tsample: string;\n\t/** Share of the width left over after each column got what it needs */\n\tgrow: number;\n}[] = [\n\t{ key: \"date\", align: \"left\", sample: \"Mi., 30.09.2026\", grow: 0.35 },\n\t{ key: \"timeIn\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"timeOut\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"worked\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"breaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"paidBreaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"target\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"balance\", align: \"right\", sample: \"-00:00\", grow: 0 },\n\t{ key: \"absence\", align: \"left\", sample: \"F 50 %\", grow: 0 },\n\t{ key: \"note\", align: \"left\", sample: \"Feiertag, offen\", grow: 0.65 },\n];\n\n/** A column with the position and the width the renderer measured. */\ninterface MeasuredColumn {\n\t/** Key in the values object */\n\tkey: keyof ReportLabels;\n\t/** Left edge in points */\n\tx: number;\n\t/** Width in points */\n\twidth: number;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n}\n\n/**\n * Chooses the fonts of a statement.\n *\n * @param language - language key of the labels, e.g. `ru`\n * @param fontPath - configured font file\n * @param labels - labels of the employee's language\n * @returns the fonts to use\n */\nfunction chooseFonts(language: string, fontPath: string | null | undefined, labels: ReportLabels): FontChoice {\n\tconst configured = (fontPath ?? \"\").trim();\n\tif (configured !== \"\" && fs.existsSync(configured)) {\n\t\t// one file is used for regular and bold text: a statement must not depend on a second file being there\n\t\treturn { regular: configured, bold: configured, embedded: true };\n\t}\n\t// the language list is the fast path; the labels catch a language whose letters are outside WinAnsiEncoding\n\t// (Polish \"Nieobecno\u015B\u0107\", \"\u015Bwi\u0119to\" or \"ca\u0142y dzie\u0144\") \u2014 drawing them would produce nonsense, not a statement\n\tconst undrawable = Object.values(labels).filter(text => !fitsBuiltInFont(text));\n\tif (NON_LATIN_LANGUAGES.has(language) || undrawable.length > 0) {\n\t\tthrow new ValidationError(\n\t\t\t`language \"${language}\" needs a Unicode font: set report_font_path to a .ttf/.otf file that covers the script`,\n\t\t);\n\t}\n\treturn { regular: \"Helvetica\", bold: \"Helvetica-Bold\", embedded: false };\n}\n\n/**\n * Measures the columns of the day table.\n *\n * Every column is at least as wide as its heading, so a heading never has to be broken: a column sized for the\n * value \"9:30\" would otherwise split \"Arbeitszeit\" in the middle of the word. The width left over after that\n * goes to the date and to the note \u2014 the two columns that carry free text.\n *\n * @param doc - document the texts are measured on\n * @param fonts - fonts of the statement\n * @param labels - labels of the employee's language\n * @param samples - the texts of a column, so the column is wide enough for every one of them\n * @returns the columns and the font size of the heading row\n */\nfunction measureColumns(\n\tdoc: PDFKit.PDFDocument,\n\tfonts: FontChoice,\n\tlabels: ReportLabels,\n\tsamples: Partial<Record<keyof ReportLabels, string[]>>,\n): { columns: MeasuredColumn[]; headerSize: number } {\n\tconst available = doc.page.width - 2 * LAYOUT.margin;\n\tconst smallest = HEADER_SIZES.length - 1;\n\n\tfor (const [index, size] of HEADER_SIZES.entries()) {\n\t\tconst needed = COLUMNS.map(column => {\n\t\t\tdoc.font(fonts.bold).fontSize(size);\n\t\t\tconst heading = doc.widthOfString(labels[column.key]);\n\t\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize);\n\t\t\t// every value of the column is measured: a total is wider than a single day (\"187:00\" against\n\t\t\t// \"8:00\"), and measuring only a sample would break that number in the middle\n\t\t\tconst texts = samples[column.key] ?? [column.sample];\n\t\t\tconst value = Math.max(\n\t\t\t\t...(texts.length > 0 ? texts : [column.sample]).map(text => doc.widthOfString(text)),\n\t\t\t);\n\t\t\t// two paddings: one is the gap to the next column, the other keeps the text off the edge \u2014 a text that\n\t\t\t// fills its box to the last point is wrapped (or cut) after the last character by the renderer\n\t\t\treturn Math.max(heading, value) + 2 * COLUMN_PADDING;\n\t\t});\n\t\tconst total = needed.reduce((sum, width) => sum + width, 0);\n\t\tif (total > available && index < smallest) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst leftover = Math.max(0, available - total);\n\t\tlet x = LAYOUT.margin;\n\t\tconst columns = COLUMNS.map((column, position) => {\n\t\t\tconst width = needed[position] + leftover * column.grow;\n\t\t\tconst measured: MeasuredColumn = { key: column.key, x, width, align: column.align };\n\t\t\tx += width;\n\t\t\treturn measured;\n\t\t});\n\t\treturn { columns, headerSize: size };\n\t}\n\n\t// not reachable: the smallest size always fits\n\treturn { columns: [], headerSize: HEADER_SIZES[smallest] };\n}\n\n/**\n * Formats a local date for display.\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param locale - locale of the employee\n * @returns formatted date, e.g. `Mo, 14.09.2026`\n */\nfunction formatDate(date: string, locale: string): string {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tweekday: \"short\",\n\t\tday: \"2-digit\",\n\t\tmonth: \"2-digit\",\n\t\tyear: \"numeric\",\n\t\ttimeZone: \"UTC\",\n\t}).format(new Date(`${date}T12:00:00Z`));\n}\n\n/**\n * Formats an instant as time of day in a time zone.\n *\n * @param tsUtc - UTC epoch seconds or `null`\n * @param timeZone - IANA time zone of the employee\n * @param locale - locale of the employee\n * @returns formatted time or an empty string\n */\nfunction formatTime(tsUtc: number | null, timeZone: string, locale: string): string {\n\tif (tsUtc === null) {\n\t\treturn \"\";\n\t}\n\treturn new Intl.DateTimeFormat(locale, { timeZone, hour: \"2-digit\", minute: \"2-digit\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Formats minutes as `H:MM`.\n *\n * @param minutes - duration in minutes\n * @returns formatted duration, e.g. `9:30`\n */\nfunction formatMinutes(minutes: number): string {\n\tconst sign = minutes < 0 ? \"-\" : \"\";\n\tconst absolute = Math.abs(minutes);\n\treturn `${sign}${Math.floor(absolute / 60)}:${String(absolute % 60).padStart(2, \"0\")}`;\n}\n\n/**\n * Formats an instant for the header block.\n *\n * @param tsUtc - UTC epoch seconds\n * @param locale - locale of the employee\n * @param timeZone - IANA time zone of the employee\n * @returns formatted date and time\n */\nfunction formatStamp(tsUtc: number, locale: string, timeZone: string): string {\n\treturn new Intl.DateTimeFormat(locale, { timeZone, dateStyle: \"short\", timeStyle: \"short\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Builds the monthly statement as a PDF.\n *\n * @param input - employee, month, days, absences, labels and font\n * @returns the document as bytes\n */\nexport async function buildMonthStatement(input: PdfStatementInput): Promise<Buffer> {\n\tconst { labels, locale, user } = input;\n\tconst fonts = chooseFonts(input.language, input.fontPath, labels);\n\tconst from = `${input.year}-${String(input.month).padStart(2, \"0\")}-01`;\n\tconst lastDay = new Date(Date.UTC(input.year, input.month, 0)).getUTCDate();\n\tconst to = `${input.year}-${String(input.month).padStart(2, \"0\")}-${String(lastDay).padStart(2, \"0\")}`;\n\tconst generated = formatStamp(input.generatedAt, locale, user.timezone);\n\n\tconst doc = new PDFDocument({\n\t\tsize: \"A4\",\n\t\tmargin: LAYOUT.margin,\n\t\t// an uncompressed stream keeps the statement checkable without a PDF reader\n\t\tcompress: false,\n\t\tinfo: {\n\t\t\tTitle: `${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")}`,\n\t\t\tAuthor: input.generator,\n\t\t\tCreator: input.generator,\n\t\t\tProducer: input.generator,\n\t\t\tSubject: `${user.displayName} (${user.login})`,\n\t\t},\n\t});\n\n\tconst chunks: Buffer[] = [];\n\tdoc.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n\tconst finished = new Promise<void>(resolve => doc.on(\"end\", resolve));\n\n\tconst pageBottom = doc.page.height - LAYOUT.margin - 24;\n\t/**\n\t * Returns the longer of two texts.\n\t *\n\t * @param first - first text\n\t * @param second - second text\n\t * @returns the longer text\n\t */\n\t// The values of every column are summed up here: the totals are drawn in the row below and their width is\n\t// what the columns are measured against, so a total like \"187:00\" gets the room it needs.\n\tconst totals = input.days.reduce(\n\t\t(sum, day) => ({\n\t\t\tworked: sum.worked + day.workedMin,\n\t\t\tbreaks: sum.breaks + day.breakMin,\n\t\t\tpaidBreaks: sum.paidBreaks + day.paidBreakMin,\n\t\t\ttarget: sum.target + day.targetMin,\n\t\t\tbalance: sum.balance + day.balanceMin,\n\t\t}),\n\t\t{ worked: 0, breaks: 0, paidBreaks: 0, target: 0, balance: 0 },\n\t);\n\t/** The widest time of a day: in English a time carries an \"AM\"/\"PM\" marker, so \"11:59 PM\" is what fits. */\n\tconst widestTime = formatTime(Date.UTC(2026, 0, 1, 23, 59) / 1000, \"UTC\", locale);\n\n\tconst { columns, headerSize } = measureColumns(doc, fonts, labels, {\n\t\t// the date column holds the dates of the report: \"czw., 30.09.2026\" is wider than \"Thu, 30.09.2026\"\n\t\tdate: input.days.map(day => formatDate(day.localDate, locale)),\n\t\ttimeIn: [...input.days.map(day => formatTime(day.firstInUtc, user.timezone, locale)), widestTime],\n\t\ttimeOut: [...input.days.map(day => formatTime(day.lastOutUtc, user.timezone, locale)), widestTime],\n\t\tworked: [...input.days.map(day => formatMinutes(day.workedMin)), formatMinutes(totals.worked)],\n\t\tbreaks: [...input.days.map(day => formatMinutes(day.breakMin)), formatMinutes(totals.breaks)],\n\t\tpaidBreaks: [...input.days.map(day => formatMinutes(day.paidBreakMin)), formatMinutes(totals.paidBreaks)],\n\t\ttarget: [...input.days.map(day => formatMinutes(day.targetMin)), formatMinutes(totals.target)],\n\t\tbalance: [...input.days.map(day => formatMinutes(day.balanceMin)), formatMinutes(totals.balance)],\n\t\t// the note column carries the marks of a day: a public holiday and a punch without a counterpart\n\t\tnote: [labels.holiday, labels.openEntry, `${labels.holiday}, ${labels.openEntry}`],\n\t});\n\n\t/**\n\t * Draws one line of the day table.\n\t *\n\t * @param values - column values by key\n\t * @param options - styling\n\t * @param options.bold - true for headings and totals\n\t * @param options.color - text colour\n\t * @param options.topBorder - true to draw a line above the row\n\t * @param options.fontSize - text size, defaults to the size of the rows\n\t */\n\tconst drawRow = (\n\t\tvalues: Partial<Record<keyof ReportLabels, string>>,\n\t\toptions: { bold?: boolean; color?: string; topBorder?: boolean; fontSize?: number } = {},\n\t): void => {\n\t\tconst y = doc.y;\n\t\tif (options.topBorder) {\n\t\t\tdoc.moveTo(LAYOUT.margin, y - 3)\n\t\t\t\t.lineTo(doc.page.width - LAYOUT.margin, y - 3)\n\t\t\t\t.lineWidth(0.5)\n\t\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t\t.stroke();\n\t\t\tdoc.y = y;\n\t\t}\n\t\tdoc.font(options.bold ? fonts.bold : fonts.regular).fontSize(options.fontSize ?? LAYOUT.fontSize);\n\t\tdoc.fillColor(options.color ?? \"#000000\");\n\t\tfor (const column of columns) {\n\t\t\t// the width of the drawn box is one padding narrower than the column: without that gap a right\n\t\t\t// aligned value would end exactly where the next column starts (\"SaldoAbwesenheit\")\n\t\t\tdoc.text(values[column.key] ?? \"\", column.x, y, {\n\t\t\t\twidth: Math.max(1, column.width - COLUMN_PADDING),\n\t\t\t\talign: column.align,\n\t\t\t\tlineBreak: false,\n\t\t\t});\n\t\t}\n\t\tdoc.y = y + LAYOUT.lineHeight;\n\t};\n\n\t/** Draws the column headings and the line below them. */\n\tconst drawHeaderRow = (): void => {\n\t\tconst headings: Partial<Record<keyof ReportLabels, string>> = {};\n\t\tfor (const column of columns) {\n\t\t\theadings[column.key] = labels[column.key];\n\t\t}\n\t\tdrawRow(headings, { bold: true, fontSize: headerSize });\n\t\tdoc.moveTo(LAYOUT.margin, doc.y - 3)\n\t\t\t.lineTo(doc.page.width - LAYOUT.margin, doc.y - 3)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t.stroke();\n\t};\n\n\t// --- header block ---------------------------------------------------------\n\tdoc.font(fonts.bold).fontSize(16).fillColor(\"#000000\").text(labels.title);\n\tdoc.moveDown(0.3);\n\tdoc.font(fonts.regular).fontSize(10);\n\tdoc.text(`${labels.employee}: ${user.displayName} (${user.login})`);\n\tdoc.text(`${labels.period}: ${formatDate(from, locale)} - ${formatDate(to, locale)}`);\n\tdoc.text(`${labels.created}: ${generated}`);\n\tdoc.moveDown(0.8);\n\n\t// --- days -----------------------------------------------------------------\n\tdrawHeaderRow();\n\n\tfor (const day of input.days) {\n\t\t// one more page when the month does not fit (defensive: 31 rows always fit on one A4 page)\n\t\tif (doc.y + LAYOUT.lineHeight > pageBottom) {\n\t\t\tdoc.addPage();\n\t\t\tdoc.font(fonts.regular).fontSize(8).fillColor(\"#757575\").text(labels.title, LAYOUT.margin, LAYOUT.margin);\n\t\t\tdoc.moveDown(0.5);\n\t\t\tdrawHeaderRow();\n\t\t}\n\n\t\tconst notes: string[] = [];\n\t\tif (day.isHoliday) {\n\t\t\tnotes.push(labels.holiday);\n\t\t}\n\t\tif (day.hasOpenEntry) {\n\t\t\tnotes.push(labels.openEntry);\n\t\t}\n\n\t\tdrawRow(\n\t\t\t{\n\t\t\t\tdate: formatDate(day.localDate, locale),\n\t\t\t\ttimeIn: formatTime(day.firstInUtc, user.timezone, locale),\n\t\t\t\ttimeOut: formatTime(day.lastOutUtc, user.timezone, locale),\n\t\t\t\tworked: formatMinutes(day.workedMin),\n\t\t\t\tbreaks: formatMinutes(day.breakMin),\n\t\t\t\tpaidBreaks: formatMinutes(day.paidBreakMin),\n\t\t\t\ttarget: formatMinutes(day.targetMin),\n\t\t\t\tbalance: formatMinutes(day.balanceMin),\n\t\t\t\tabsence: day.absenceCode ?? \"\",\n\t\t\t\tnote: notes.join(\", \"),\n\t\t\t},\n\t\t\t{\n\t\t\t\tcolor: day.balanceMin < 0 ? \"#c62828\" : day.isHoliday || day.workedMin === 0 ? \"#757575\" : \"#000000\",\n\t\t\t},\n\t\t);\n\t}\n\n\tdrawRow(\n\t\t{\n\t\t\tdate: labels.total,\n\t\t\tworked: formatMinutes(totals.worked),\n\t\t\tbreaks: formatMinutes(totals.breaks),\n\t\t\tpaidBreaks: formatMinutes(totals.paidBreaks),\n\t\t\ttarget: formatMinutes(totals.target),\n\t\t\tbalance: formatMinutes(totals.balance),\n\t\t\tabsence: `${labels.days}: ${input.days.length}`,\n\t\t},\n\t\t{ bold: true, color: totals.balance < 0 ? \"#c62828\" : \"#000000\", topBorder: true },\n\t);\n\n\t// --- absences -------------------------------------------------------------\n\tif (input.absences.length > 0) {\n\t\tdoc.moveDown(1);\n\t\tdoc.font(fonts.bold).fontSize(10).fillColor(\"#000000\").text(labels.absences);\n\t\tdoc.moveDown(0.3);\n\t\tfor (const absence of input.absences) {\n\t\t\tconst portion = absence.dayPortion === 0.5 ? labels.halfDay : labels.fullDay;\n\t\t\tdoc.font(fonts.regular)\n\t\t\t\t.fontSize(LAYOUT.fontSize)\n\t\t\t\t.fillColor(\"#000000\")\n\t\t\t\t.text(\n\t\t\t\t\t`${absence.typeCode} - ${absence.typeName}: ${formatDate(absence.dateFrom, locale)} - ${formatDate(\n\t\t\t\t\t\tabsence.dateTo,\n\t\t\t\t\t\tlocale,\n\t\t\t\t\t)}, ${portion}${absence.hours === null ? \"\" : `, ${absence.hours} h`}`,\n\t\t\t\t);\n\t\t}\n\t}\n\n\t// --- signatures -----------------------------------------------------------\n\t// the label is drawn and the line is ruled: a long label plus a string of underscores is wrapped by the\n\t// renderer (the Russian and Ukrainian labels are long), and then the two signature lines are no longer level\n\t//\n\t// The block sits near the bottom of the page: a statement ends with two signature lines, so an empty area\n\t// above them looks like the document was cut off. It stays below the content when that reaches far down.\n\tconst signatureGap = 16;\n\tconst signatureWidth = (doc.page.width - 2 * LAYOUT.margin - signatureGap) / 2;\n\tconst signaturePreferred = doc.page.height - 30 - LAYOUT.lineHeight - 24;\n\tconst signatureY = Math.max(doc.y + 24, Math.min(signaturePreferred, pageBottom - LAYOUT.lineHeight));\n\n\t/**\n\t * Draws one signature line: the label followed by the rule to sign on.\n\t *\n\t * @param label - text before the line\n\t * @param x - left edge of the block\n\t */\n\tconst drawSignature = (label: string, x: number): void => {\n\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize).fillColor(\"#000000\");\n\t\tconst written = `${label}:`;\n\t\t// without a width limit the label is never broken \u2014 the block is wide enough for all eleven languages\n\t\tdoc.text(written, x, signatureY, { lineBreak: false });\n\t\tconst start = Math.min(x + doc.widthOfString(written) + 6, x + signatureWidth - 24);\n\t\tdoc.moveTo(start, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineTo(x + signatureWidth, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#000000\")\n\t\t\t.stroke();\n\t\tdoc.y = signatureY + LAYOUT.lineHeight;\n\t};\n\n\tdrawSignature(labels.signatureEmployee, LAYOUT.margin);\n\tdrawSignature(labels.signatureManager, LAYOUT.margin + signatureWidth + signatureGap);\n\n\t// --- footer on every page -------------------------------------------------\n\t// the footer sits below the writing area on purpose, so the bottom margin is lifted while it is drawn: PDFKit\n\t// starts a new page when a text crosses the margin, and that page would carry nothing but the footer\n\tconst range = doc.bufferedPageRange();\n\tfor (let index = 0; index < range.count; index++) {\n\t\tdoc.switchToPage(range.start + index);\n\t\tconst bottom = doc.page.margins.bottom;\n\t\tdoc.page.margins.bottom = 0;\n\t\tdoc.font(fonts.regular)\n\t\t\t.fontSize(8)\n\t\t\t.fillColor(\"#757575\")\n\t\t\t.text(\n\t\t\t\t`${input.generator} - ${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")} - ${\n\t\t\t\t\tindex + 1\n\t\t\t\t}/${range.count}`,\n\t\t\t\tLAYOUT.margin,\n\t\t\t\tdoc.page.height - 30,\n\t\t\t\t{ width: doc.page.width - 2 * LAYOUT.margin, align: \"center\", lineBreak: false },\n\t\t\t);\n\t\tdoc.page.margins.bottom = bottom;\n\t}\n\n\tdoc.end();\n\tawait finished;\n\treturn Buffer.concat(chunks);\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBA,SAAoB;AACpB,oBAAwB;AACxB,oBAAgC;AAahC,MAAM,sBAAsB,oBAAI,IAAI,CAAC,MAAM,MAAM,OAAO,CAAC;AAGzD,MAAM,kBAAkB;AAYxB,SAAS,gBAAgB,MAAuB;AAC/C,SAAO,CAAC,GAAG,IAAI,EAAE,MAAM,eAAa,UAAU,WAAW,CAAC,KAAK,OAAQ,gBAAgB,SAAS,SAAS,CAAC;AAC3G;
|
|
4
|
+
"sourcesContent": ["/**\n * Monthly work time statement as a PDF.\n *\n * `/api/reports/pdf` returns this file. It shows the same content as the Excel statement (header block, one row\n * per day, totals, absences, signature lines), drawn with `pdfkit`.\n *\n * **Fonts:** the built-in PDF fonts (`Helvetica`) only cover Latin-1, so Cyrillic (`ru`, `uk`) and Chinese\n * (`zh-cn`) cannot be rendered with them. Instead of producing empty boxes, the renderer refuses those\n * languages with a clear error until a Unicode font is configured (`report_font_path` setting, a `.ttf`/`.otf`\n * that covers the script). Latin statements work without any additional file, which keeps the adapter package\n * free of a multi-megabyte font.\n *\n * Text is written **uncompressed** (`compress: false`): a monthly statement is a few kilobytes, and an\n * uncompressed content stream keeps the file verifiable (and testable) without a PDF library.\n */\n\nimport * as fs from \"node:fs\";\nimport PDFDocument from \"pdfkit\";\nimport { ValidationError } from \"../errors\";\nimport type { ReportLabels } from \"./labels\";\nimport type { ReportInput } from \"./types\";\n\n/** Input of the PDF statement. */\nexport type PdfStatementInput = ReportInput & {\n\t/** Name of the generator, e.g. `time-tracker 0.0.1` */\n\tgenerator: string;\n\t/** Path of a Unicode font (`.ttf`/`.otf`), `null`/empty to use the built-in fonts */\n\tfontPath?: string | null;\n};\n\n/** Languages whose script the built-in PDF fonts cannot display. */\nconst NON_LATIN_LANGUAGES = new Set([\"ru\", \"uk\", \"zh-cn\"]);\n\n/** Characters WinAnsiEncoding knows in addition to Latin-1 \u2014 anything else cannot be drawn by the built-in fonts. */\nconst WIN_ANSI_EXTRAS = \"\u20AC\u201A\u0192\u201E\u2026\u2020\u2021\u02C6\u2030\u0160\u2039\u0152\u017D\u2018\u2019\u201C\u201D\u2022\u2013\u2014\u02DC\u2122\u0161\u203A\u0153\u017E\u0178\";\n\n/**\n * Reports whether the built-in fonts can draw a text.\n *\n * The built-in fonts use WinAnsiEncoding, which covers Latin-1 and a handful of typographic characters. A Polish\n * \"Nieobecno\u015B\u0107\" is outside of it: the renderer would draw nonsense instead of the word, so such a text counts as\n * undrawable.\n *\n * @param text - text to check\n * @returns true when every character is part of WinAnsiEncoding\n */\nfunction fitsBuiltInFont(text: string): boolean {\n\treturn [...text].every(character => character.charCodeAt(0) <= 0xff || WIN_ANSI_EXTRAS.includes(character));\n}\n\n/** Fonts the renderer works with. */\ninterface FontChoice {\n\t/** Font of the regular text */\n\tregular: string;\n\t/** Font of the headings */\n\tbold: string;\n\t/** True when a file of the administrator is used */\n\tembedded: boolean;\n}\n\n/** Margins and text sizes of the statement (A4 portrait, points). */\nexport const LAYOUT = {\n\tmargin: 40,\n\tfontSize: 9,\n\tlineHeight: 14,\n} as const;\n\n/** Distance between two columns, so that texts never touch. */\nconst COLUMN_PADDING = 6;\n\n/**\n * Font sizes tried for the column headings, the largest first.\n *\n * A heading has to stay on one line: \"Arbeitszeit\" is about 57 points wide at 9 points, the Ukrainian\n * \"\u0412\u0456\u0434\u043F\u0440\u0430\u0446\u044C\u043E\u0432\u0430\u043D\u043E\" needs even more, and this renderer breaks a word when the column is too narrow for it (the\n * option `lineBreak: false` does not prevent that). The headings are therefore measured and the size steps down\n * until every one of them fits.\n */\nconst HEADER_SIZES = [9, 8, 7, 6.5] as const;\n\n/** The columns of the day table, in the order they are drawn. */\nconst COLUMNS: {\n\t/** Key in the values object and in the labels */\n\tkey: keyof ReportLabels;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n\t/** A typical value: the column is at least wide enough for it */\n\tsample: string;\n\t/** Share of the width left over after each column got what it needs */\n\tgrow: number;\n}[] = [\n\t{ key: \"date\", align: \"left\", sample: \"Mi., 30.09.2026\", grow: 0.35 },\n\t{ key: \"timeIn\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"timeOut\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"worked\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"breaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"paidBreaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"target\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"balance\", align: \"right\", sample: \"-00:00\", grow: 0 },\n\t{ key: \"absence\", align: \"left\", sample: \"F 50 %\", grow: 0 },\n\t{ key: \"note\", align: \"left\", sample: \"Feiertag, offen\", grow: 0.65 },\n];\n\n/** A column with the position and the width the renderer measured. */\ninterface MeasuredColumn {\n\t/** Key in the values object */\n\tkey: keyof ReportLabels;\n\t/** Left edge in points */\n\tx: number;\n\t/** Width in points */\n\twidth: number;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n}\n\n/**\n * Chooses the fonts of a statement.\n *\n * @param language - language key of the labels, e.g. `ru`\n * @param fontPath - configured font file\n * @param labels - labels of the employee's language\n * @returns the fonts to use\n */\nfunction chooseFonts(language: string, fontPath: string | null | undefined, labels: ReportLabels): FontChoice {\n\tconst configured = (fontPath ?? \"\").trim();\n\tif (configured !== \"\" && fs.existsSync(configured)) {\n\t\t// one file is used for regular and bold text: a statement must not depend on a second file being there\n\t\treturn { regular: configured, bold: configured, embedded: true };\n\t}\n\t// the language list is the fast path; the labels catch a language whose letters are outside WinAnsiEncoding\n\t// (Polish \"Nieobecno\u015B\u0107\", \"\u015Bwi\u0119to\" or \"ca\u0142y dzie\u0144\") \u2014 drawing them would produce nonsense, not a statement\n\tconst undrawable = Object.values(labels).filter(text => !fitsBuiltInFont(text));\n\tif (NON_LATIN_LANGUAGES.has(language) || undrawable.length > 0) {\n\t\tthrow new ValidationError(\n\t\t\t`language \"${language}\" needs a Unicode font: set report_font_path to a .ttf/.otf file that covers the script`,\n\t\t);\n\t}\n\treturn { regular: \"Helvetica\", bold: \"Helvetica-Bold\", embedded: false };\n}\n\n/**\n * Measures the columns of the day table.\n *\n * Every column is at least as wide as its heading, so a heading never has to be broken: a column sized for the\n * value \"9:30\" would otherwise split \"Arbeitszeit\" in the middle of the word. The width left over after that\n * goes to the date and to the note \u2014 the two columns that carry free text.\n *\n * @param doc - document the texts are measured on\n * @param fonts - fonts of the statement\n * @param labels - labels of the employee's language\n * @param samples - the texts of a column, so the column is wide enough for every one of them\n * @returns the columns and the font size of the heading row\n */\nfunction measureColumns(\n\tdoc: PDFKit.PDFDocument,\n\tfonts: FontChoice,\n\tlabels: ReportLabels,\n\tsamples: Partial<Record<keyof ReportLabels, string[]>>,\n): { columns: MeasuredColumn[]; headerSize: number } {\n\tconst available = doc.page.width - 2 * LAYOUT.margin;\n\tconst smallest = HEADER_SIZES.length - 1;\n\n\tfor (const [index, size] of HEADER_SIZES.entries()) {\n\t\tconst needed = COLUMNS.map(column => {\n\t\t\tdoc.font(fonts.bold).fontSize(size);\n\t\t\tconst heading = doc.widthOfString(labels[column.key]);\n\t\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize);\n\t\t\t// every value of the column is measured: a total is wider than a single day (\"187:00\" against\n\t\t\t// \"8:00\"), and measuring only a sample would break that number in the middle\n\t\t\tconst texts = samples[column.key] ?? [column.sample];\n\t\t\tconst value = Math.max(\n\t\t\t\t...(texts.length > 0 ? texts : [column.sample]).map(text => doc.widthOfString(text)),\n\t\t\t);\n\t\t\t// two paddings: one is the gap to the next column, the other keeps the text off the edge \u2014 a text that\n\t\t\t// fills its box to the last point is wrapped (or cut) after the last character by the renderer\n\t\t\treturn Math.max(heading, value) + 2 * COLUMN_PADDING;\n\t\t});\n\t\tconst total = needed.reduce((sum, width) => sum + width, 0);\n\t\tif (total > available && index < smallest) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst leftover = Math.max(0, available - total);\n\t\tlet x = LAYOUT.margin;\n\t\tconst columns = COLUMNS.map((column, position) => {\n\t\t\tconst width = needed[position] + leftover * column.grow;\n\t\t\tconst measured: MeasuredColumn = { key: column.key, x, width, align: column.align };\n\t\t\tx += width;\n\t\t\treturn measured;\n\t\t});\n\t\treturn { columns, headerSize: size };\n\t}\n\n\t// not reachable: the smallest size always fits\n\treturn { columns: [], headerSize: HEADER_SIZES[smallest] };\n}\n\n/**\n * Formats a local date for display.\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param locale - locale of the employee\n * @returns formatted date, e.g. `Mo, 14.09.2026`\n */\nfunction formatDate(date: string, locale: string): string {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tweekday: \"short\",\n\t\tday: \"2-digit\",\n\t\tmonth: \"2-digit\",\n\t\tyear: \"numeric\",\n\t\ttimeZone: \"UTC\",\n\t}).format(new Date(`${date}T12:00:00Z`));\n}\n\n/**\n * Formats an instant as time of day in a time zone.\n *\n * @param tsUtc - UTC epoch seconds or `null`\n * @param timeZone - IANA time zone of the employee\n * @param locale - locale of the employee\n * @returns formatted time or an empty string\n */\nfunction formatTime(tsUtc: number | null, timeZone: string, locale: string): string {\n\tif (tsUtc === null) {\n\t\treturn \"\";\n\t}\n\treturn new Intl.DateTimeFormat(locale, { timeZone, hour: \"2-digit\", minute: \"2-digit\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Formats minutes as `H:MM`.\n *\n * @param minutes - duration in minutes\n * @returns formatted duration, e.g. `9:30`\n */\nfunction formatMinutes(minutes: number): string {\n\tconst sign = minutes < 0 ? \"-\" : \"\";\n\tconst absolute = Math.abs(minutes);\n\treturn `${sign}${Math.floor(absolute / 60)}:${String(absolute % 60).padStart(2, \"0\")}`;\n}\n\n/**\n * Formats an instant for the header block.\n *\n * @param tsUtc - UTC epoch seconds\n * @param locale - locale of the employee\n * @param timeZone - IANA time zone of the employee\n * @returns formatted date and time\n */\nfunction formatStamp(tsUtc: number, locale: string, timeZone: string): string {\n\treturn new Intl.DateTimeFormat(locale, { timeZone, dateStyle: \"short\", timeStyle: \"short\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Builds the monthly statement as a PDF.\n *\n * @param input - employee, month, days, absences, labels and font\n * @returns the document as bytes\n */\nexport async function buildMonthStatement(input: PdfStatementInput): Promise<Buffer> {\n\tconst { labels, locale, user } = input;\n\tconst fonts = chooseFonts(input.language, input.fontPath, labels);\n\tconst from = `${input.year}-${String(input.month).padStart(2, \"0\")}-01`;\n\tconst lastDay = new Date(Date.UTC(input.year, input.month, 0)).getUTCDate();\n\tconst to = `${input.year}-${String(input.month).padStart(2, \"0\")}-${String(lastDay).padStart(2, \"0\")}`;\n\tconst generated = formatStamp(input.generatedAt, locale, user.timezone);\n\n\tconst doc = new PDFDocument({\n\t\tsize: \"A4\",\n\t\tmargin: LAYOUT.margin,\n\t\t// an uncompressed stream keeps the statement checkable without a PDF reader\n\t\tcompress: false,\n\t\tinfo: {\n\t\t\tTitle: `${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")}`,\n\t\t\tAuthor: input.generator,\n\t\t\tCreator: input.generator,\n\t\t\tProducer: input.generator,\n\t\t\tSubject: `${user.displayName} (${user.login})`,\n\t\t},\n\t});\n\n\tconst chunks: Buffer[] = [];\n\tdoc.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n\tconst finished = new Promise<void>(resolve => doc.on(\"end\", resolve));\n\n\tconst pageBottom = doc.page.height - LAYOUT.margin - 24;\n\t/**\n\t * Returns the longer of two texts.\n\t *\n\t * @param first - first text\n\t * @param second - second text\n\t * @returns the longer text\n\t */\n\t// The values of every column are summed up here: the totals are drawn in the row below and their width is\n\t// what the columns are measured against, so a total like \"187:00\" gets the room it needs.\n\tconst totals = input.days.reduce(\n\t\t(sum, day) => ({\n\t\t\tworked: sum.worked + day.workedMin,\n\t\t\tbreaks: sum.breaks + day.breakMin,\n\t\t\tpaidBreaks: sum.paidBreaks + day.paidBreakMin,\n\t\t\ttarget: sum.target + day.targetMin,\n\t\t\tbalance: sum.balance + day.balanceMin,\n\t\t}),\n\t\t{ worked: 0, breaks: 0, paidBreaks: 0, target: 0, balance: 0 },\n\t);\n\t/** The widest time of a day: in English a time carries an \"AM\"/\"PM\" marker, so \"11:59 PM\" is what fits. */\n\tconst widestTime = formatTime(Date.UTC(2026, 0, 1, 23, 59) / 1000, \"UTC\", locale);\n\n\tconst { columns, headerSize } = measureColumns(doc, fonts, labels, {\n\t\t// the date column holds the dates of the report: \"czw., 30.09.2026\" is wider than \"Thu, 30.09.2026\"\n\t\tdate: input.days.map(day => formatDate(day.localDate, locale)),\n\t\ttimeIn: [...input.days.map(day => formatTime(day.firstInUtc, user.timezone, locale)), widestTime],\n\t\ttimeOut: [...input.days.map(day => formatTime(day.lastOutUtc, user.timezone, locale)), widestTime],\n\t\tworked: [...input.days.map(day => formatMinutes(day.workedMin)), formatMinutes(totals.worked)],\n\t\tbreaks: [...input.days.map(day => formatMinutes(day.breakMin)), formatMinutes(totals.breaks)],\n\t\tpaidBreaks: [...input.days.map(day => formatMinutes(day.paidBreakMin)), formatMinutes(totals.paidBreaks)],\n\t\ttarget: [...input.days.map(day => formatMinutes(day.targetMin)), formatMinutes(totals.target)],\n\t\tbalance: [...input.days.map(day => formatMinutes(day.balanceMin)), formatMinutes(totals.balance)],\n\t\t// the note column carries the marks of a day: a public holiday and a punch without a counterpart\n\t\tnote: [labels.holiday, labels.openEntry, `${labels.holiday}, ${labels.openEntry}`],\n\t});\n\n\t/**\n\t * Draws one line of the day table.\n\t *\n\t * @param values - column values by key\n\t * @param options - styling\n\t * @param options.bold - true for headings and totals\n\t * @param options.color - text colour\n\t * @param options.topBorder - true to draw a line above the row\n\t * @param options.fontSize - text size, defaults to the size of the rows\n\t */\n\tconst drawRow = (\n\t\tvalues: Partial<Record<keyof ReportLabels, string>>,\n\t\toptions: { bold?: boolean; color?: string; topBorder?: boolean; fontSize?: number } = {},\n\t): void => {\n\t\tconst y = doc.y;\n\t\tif (options.topBorder) {\n\t\t\tdoc.moveTo(LAYOUT.margin, y - 3)\n\t\t\t\t.lineTo(doc.page.width - LAYOUT.margin, y - 3)\n\t\t\t\t.lineWidth(0.5)\n\t\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t\t.stroke();\n\t\t\tdoc.y = y;\n\t\t}\n\t\tdoc.font(options.bold ? fonts.bold : fonts.regular).fontSize(options.fontSize ?? LAYOUT.fontSize);\n\t\tdoc.fillColor(options.color ?? \"#000000\");\n\t\tfor (const column of columns) {\n\t\t\t// the width of the drawn box is one padding narrower than the column: without that gap a right\n\t\t\t// aligned value would end exactly where the next column starts (\"SaldoAbwesenheit\")\n\t\t\tdoc.text(values[column.key] ?? \"\", column.x, y, {\n\t\t\t\twidth: Math.max(1, column.width - COLUMN_PADDING),\n\t\t\t\talign: column.align,\n\t\t\t\tlineBreak: false,\n\t\t\t});\n\t\t}\n\t\tdoc.y = y + LAYOUT.lineHeight;\n\t};\n\n\t/** Draws the column headings and the line below them. */\n\tconst drawHeaderRow = (): void => {\n\t\tconst headings: Partial<Record<keyof ReportLabels, string>> = {};\n\t\tfor (const column of columns) {\n\t\t\theadings[column.key] = labels[column.key];\n\t\t}\n\t\tdrawRow(headings, { bold: true, fontSize: headerSize });\n\t\tdoc.moveTo(LAYOUT.margin, doc.y - 3)\n\t\t\t.lineTo(doc.page.width - LAYOUT.margin, doc.y - 3)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t.stroke();\n\t};\n\n\t// --- header block ---------------------------------------------------------\n\tdoc.font(fonts.bold).fontSize(16).fillColor(\"#000000\").text(labels.title);\n\tdoc.moveDown(0.3);\n\tdoc.font(fonts.regular).fontSize(10);\n\tdoc.text(`${labels.employee}: ${user.displayName} (${user.login})`);\n\tdoc.text(`${labels.period}: ${formatDate(from, locale)} - ${formatDate(to, locale)}`);\n\tdoc.text(`${labels.created}: ${generated}`);\n\tdoc.moveDown(0.8);\n\n\t// --- days -----------------------------------------------------------------\n\tdrawHeaderRow();\n\n\tfor (const day of input.days) {\n\t\t// one more page when the month does not fit (defensive: 31 rows always fit on one A4 page)\n\t\tif (doc.y + LAYOUT.lineHeight > pageBottom) {\n\t\t\tdoc.addPage();\n\t\t\tdoc.font(fonts.regular).fontSize(8).fillColor(\"#757575\").text(labels.title, LAYOUT.margin, LAYOUT.margin);\n\t\t\tdoc.moveDown(0.5);\n\t\t\tdrawHeaderRow();\n\t\t}\n\n\t\tconst notes: string[] = [];\n\t\tif (day.isHoliday) {\n\t\t\tnotes.push(labels.holiday);\n\t\t}\n\t\tif (day.hasOpenEntry) {\n\t\t\tnotes.push(labels.openEntry);\n\t\t}\n\n\t\tdrawRow(\n\t\t\t{\n\t\t\t\tdate: formatDate(day.localDate, locale),\n\t\t\t\ttimeIn: formatTime(day.firstInUtc, user.timezone, locale),\n\t\t\t\ttimeOut: formatTime(day.lastOutUtc, user.timezone, locale),\n\t\t\t\tworked: formatMinutes(day.workedMin),\n\t\t\t\tbreaks: formatMinutes(day.breakMin),\n\t\t\t\tpaidBreaks: formatMinutes(day.paidBreakMin),\n\t\t\t\ttarget: formatMinutes(day.targetMin),\n\t\t\t\tbalance: formatMinutes(day.balanceMin),\n\t\t\t\tabsence: day.absenceCode ?? \"\",\n\t\t\t\tnote: notes.join(\", \"),\n\t\t\t},\n\t\t\t{\n\t\t\t\tcolor: day.balanceMin < 0 ? \"#c62828\" : day.isHoliday || day.workedMin === 0 ? \"#757575\" : \"#000000\",\n\t\t\t},\n\t\t);\n\t}\n\n\tdrawRow(\n\t\t{\n\t\t\tdate: labels.total,\n\t\t\tworked: formatMinutes(totals.worked),\n\t\t\tbreaks: formatMinutes(totals.breaks),\n\t\t\tpaidBreaks: formatMinutes(totals.paidBreaks),\n\t\t\ttarget: formatMinutes(totals.target),\n\t\t\tbalance: formatMinutes(totals.balance),\n\t\t\tabsence: `${labels.days}: ${input.days.length}`,\n\t\t},\n\t\t{ bold: true, color: totals.balance < 0 ? \"#c62828\" : \"#000000\", topBorder: true },\n\t);\n\n\t// --- absences -------------------------------------------------------------\n\tif (input.absences.length > 0) {\n\t\t// The block starts at the left margin over the whole width: after the table the drawing position sits inside\n\t\t// the page (the last cell was written there), and every line would be squeezed into the rest of that row.\n\t\tconst contentWidth = doc.page.width - 2 * LAYOUT.margin;\n\t\tdoc.moveDown(1);\n\t\tdoc.font(fonts.bold)\n\t\t\t.fontSize(10)\n\t\t\t.fillColor(\"#000000\")\n\t\t\t.text(labels.absences, LAYOUT.margin, doc.y, { width: contentWidth });\n\t\tdoc.moveDown(0.3);\n\t\tfor (const absence of input.absences) {\n\t\t\tconst portion = absence.dayPortion === 0.5 ? labels.halfDay : labels.fullDay;\n\t\t\tdoc.font(fonts.regular)\n\t\t\t\t.fontSize(LAYOUT.fontSize)\n\t\t\t\t.fillColor(\"#000000\")\n\t\t\t\t.text(\n\t\t\t\t\t`${absence.typeCode} - ${absence.typeName}: ${formatDate(absence.dateFrom, locale)} - ${formatDate(\n\t\t\t\t\t\tabsence.dateTo,\n\t\t\t\t\t\tlocale,\n\t\t\t\t\t)}, ${portion}${absence.hours === null ? \"\" : `, ${absence.hours} h`}`,\n\t\t\t\t\tLAYOUT.margin,\n\t\t\t\t\tdoc.y,\n\t\t\t\t\t{ width: contentWidth },\n\t\t\t\t);\n\t\t}\n\t}\n\n\t// --- signatures -----------------------------------------------------------\n\t// the label is drawn and the line is ruled: a long label plus a string of underscores is wrapped by the\n\t// renderer (the Russian and Ukrainian labels are long), and then the two signature lines are no longer level\n\t//\n\t// The block sits near the bottom of the page: a statement ends with two signature lines, so an empty area\n\t// above them looks like the document was cut off. It stays below the content when that reaches far down.\n\tconst signatureGap = 16;\n\tconst signatureWidth = (doc.page.width - 2 * LAYOUT.margin - signatureGap) / 2;\n\tconst signaturePreferred = doc.page.height - 30 - LAYOUT.lineHeight - 24;\n\tconst signatureY = Math.max(doc.y + 24, Math.min(signaturePreferred, pageBottom - LAYOUT.lineHeight));\n\n\t/**\n\t * Draws one signature line: the label followed by the rule to sign on.\n\t *\n\t * @param label - text before the line\n\t * @param x - left edge of the block\n\t */\n\tconst drawSignature = (label: string, x: number): void => {\n\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize).fillColor(\"#000000\");\n\t\tconst written = `${label}:`;\n\t\t// without a width limit the label is never broken \u2014 the block is wide enough for all eleven languages\n\t\tdoc.text(written, x, signatureY, { lineBreak: false });\n\t\tconst start = Math.min(x + doc.widthOfString(written) + 6, x + signatureWidth - 24);\n\t\tdoc.moveTo(start, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineTo(x + signatureWidth, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#000000\")\n\t\t\t.stroke();\n\t\tdoc.y = signatureY + LAYOUT.lineHeight;\n\t};\n\n\tdrawSignature(labels.signatureEmployee, LAYOUT.margin);\n\tdrawSignature(labels.signatureManager, LAYOUT.margin + signatureWidth + signatureGap);\n\n\t// --- footer on every page -------------------------------------------------\n\t// the footer sits below the writing area on purpose, so the bottom margin is lifted while it is drawn: PDFKit\n\t// starts a new page when a text crosses the margin, and that page would carry nothing but the footer\n\tconst range = doc.bufferedPageRange();\n\tfor (let index = 0; index < range.count; index++) {\n\t\tdoc.switchToPage(range.start + index);\n\t\tconst bottom = doc.page.margins.bottom;\n\t\tdoc.page.margins.bottom = 0;\n\t\tdoc.font(fonts.regular)\n\t\t\t.fontSize(8)\n\t\t\t.fillColor(\"#757575\")\n\t\t\t.text(\n\t\t\t\t`${input.generator} - ${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")} - ${\n\t\t\t\t\tindex + 1\n\t\t\t\t}/${range.count}`,\n\t\t\t\tLAYOUT.margin,\n\t\t\t\tdoc.page.height - 30,\n\t\t\t\t{ width: doc.page.width - 2 * LAYOUT.margin, align: \"center\", lineBreak: false },\n\t\t\t);\n\t\tdoc.page.margins.bottom = bottom;\n\t}\n\n\tdoc.end();\n\tawait finished;\n\treturn Buffer.concat(chunks);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["PDFDocument", "_a"]
|
|
7
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 calendar_exports = {};
|
|
20
|
+
__export(calendar_exports, {
|
|
21
|
+
CALENDAR_TTL: () => CALENDAR_TTL,
|
|
22
|
+
COMPANY_CALENDAR_NAME: () => COMPANY_CALENDAR_NAME,
|
|
23
|
+
ICAL_CONTENT_TYPE: () => ICAL_CONTENT_TYPE,
|
|
24
|
+
absenceEvents: () => absenceEvents,
|
|
25
|
+
absenceFeed: () => absenceFeed,
|
|
26
|
+
calendarDocument: () => calendarDocument,
|
|
27
|
+
dayAfter: () => dayAfter,
|
|
28
|
+
shiftDate: () => shiftDate
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(calendar_exports);
|
|
31
|
+
const ICAL_CONTENT_TYPE = "text/calendar; charset=utf-8";
|
|
32
|
+
const CALENDAR_TTL = "PT2H";
|
|
33
|
+
const COMPANY_CALENDAR_NAME = "Abwesenheiten (Firma)";
|
|
34
|
+
function icalText(value) {
|
|
35
|
+
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
|
36
|
+
}
|
|
37
|
+
function icalStamp(seconds) {
|
|
38
|
+
return new Date(seconds * 1e3).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
39
|
+
}
|
|
40
|
+
function dayAfter(date) {
|
|
41
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
42
|
+
return new Date(Date.UTC(year, month - 1, day + 1)).toISOString().slice(0, 10);
|
|
43
|
+
}
|
|
44
|
+
function shiftDate(date, days) {
|
|
45
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
46
|
+
return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
function absenceEvents(absences, employees, withEmployee) {
|
|
49
|
+
return absences.map((absence) => {
|
|
50
|
+
const portion = absence.dayPortion < 1 ? ` (${absence.dayPortion})` : "";
|
|
51
|
+
const employee = employees.get(absence.userId);
|
|
52
|
+
const own = `${absence.typeName} (${absence.typeCode})${portion}`;
|
|
53
|
+
return {
|
|
54
|
+
uid: `absence-${absence.id}@time-tracker`,
|
|
55
|
+
start: absence.dateFrom,
|
|
56
|
+
end: absence.dateTo,
|
|
57
|
+
summary: withEmployee && employee ? `${employee.displayName}: ${own}` : own,
|
|
58
|
+
confirmed: absence.approval === "approved",
|
|
59
|
+
description: absence.note
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function calendarDocument(name, events, stamp) {
|
|
64
|
+
const lines = [
|
|
65
|
+
"BEGIN:VCALENDAR",
|
|
66
|
+
"VERSION:2.0",
|
|
67
|
+
"PRODID:-//ioBroker//time-tracker//EN",
|
|
68
|
+
"CALSCALE:GREGORIAN",
|
|
69
|
+
"METHOD:PUBLISH",
|
|
70
|
+
`X-WR-CALNAME:${icalText(name)}`,
|
|
71
|
+
`REFRESH-INTERVAL;VALUE=DURATION:${CALENDAR_TTL}`,
|
|
72
|
+
`X-PUBLISHED-TTL:${CALENDAR_TTL}`
|
|
73
|
+
];
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
lines.push(
|
|
76
|
+
"BEGIN:VEVENT",
|
|
77
|
+
`UID:${event.uid}`,
|
|
78
|
+
`DTSTAMP:${icalStamp(stamp)}`,
|
|
79
|
+
`DTSTART;VALUE=DATE:${event.start.replace(/-/g, "")}`,
|
|
80
|
+
`DTEND;VALUE=DATE:${dayAfter(event.end).replace(/-/g, "")}`,
|
|
81
|
+
`SUMMARY:${icalText(event.summary)}`,
|
|
82
|
+
event.confirmed ? "STATUS:CONFIRMED" : "STATUS:TENTATIVE",
|
|
83
|
+
event.description ? `DESCRIPTION:${icalText(event.description)}` : null,
|
|
84
|
+
"TRANSP:TRANSPARENT",
|
|
85
|
+
"END:VEVENT"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
lines.push("END:VCALENDAR");
|
|
89
|
+
return `${lines.filter((line) => line !== null).join("\r\n")}\r
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
function absenceFeed(absences, employees) {
|
|
93
|
+
return absences.map((absence) => {
|
|
94
|
+
var _a, _b;
|
|
95
|
+
const employee = employees.get(absence.userId);
|
|
96
|
+
return {
|
|
97
|
+
id: absence.id,
|
|
98
|
+
userId: absence.userId,
|
|
99
|
+
login: (_a = employee == null ? void 0 : employee.login) != null ? _a : "",
|
|
100
|
+
name: (_b = employee == null ? void 0 : employee.displayName) != null ? _b : "",
|
|
101
|
+
from: absence.dateFrom,
|
|
102
|
+
to: absence.dateTo,
|
|
103
|
+
code: absence.typeCode,
|
|
104
|
+
type: absence.typeName,
|
|
105
|
+
portion: absence.dayPortion,
|
|
106
|
+
approval: absence.approval,
|
|
107
|
+
status: absence.status,
|
|
108
|
+
note: absence.note
|
|
109
|
+
};
|
|
110
|
+
}).sort(
|
|
111
|
+
(left, right) => left.from.localeCompare(right.from) || left.name.localeCompare(right.name) || left.code.localeCompare(right.code)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
115
|
+
0 && (module.exports = {
|
|
116
|
+
CALENDAR_TTL,
|
|
117
|
+
COMPANY_CALENDAR_NAME,
|
|
118
|
+
ICAL_CONTENT_TYPE,
|
|
119
|
+
absenceEvents,
|
|
120
|
+
absenceFeed,
|
|
121
|
+
calendarDocument,
|
|
122
|
+
dayAfter,
|
|
123
|
+
shiftDate
|
|
124
|
+
});
|
|
125
|
+
//# sourceMappingURL=calendar.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|