diff --git a/.env.dev.example b/.env.dev.example
index 30b68b8..225c07d 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -28,7 +28,7 @@ DEV_BIND_ADDRESS=127.0.0.1
API_PORT=3001
# Server timezone — core-time-window and off-hours logic depends on this.
-TZ=Europe/Berlin
+TZ=UTC
# JWT signing secret for user sessions. Rotate per deployment!
JWT_SECRET=change-me-jwt-secret
diff --git a/.env.example b/.env.example
index c8f3f6c..82ba07a 100644
--- a/.env.example
+++ b/.env.example
@@ -9,8 +9,9 @@ API_PORT=3000
# Server wall-clock timezone. Core-time-window detection and the
# 07:00/23:00 off-hours approval threshold reason in local time, so the
-# API process must run in the deployment's timezone. Default: Europe/Berlin.
-TZ=Europe/Berlin
+# API process must run in the deployment's IANA timezone. Default: UTC.
+# Choose the organisation's working timezone before recording real data.
+TZ=UTC
# Allowed CORS origins for the API (comma-separated). The Vite dev server
# runs on 4200; the full Docker stack exposes the web container on 8080.
diff --git a/.env.ipad.example b/.env.ipad.example
index 1872f10..7c579ac 100644
--- a/.env.ipad.example
+++ b/.env.ipad.example
@@ -38,7 +38,7 @@ SUPPORT_URL=https://github.com/sponsors/patrickschiller
POSTGRES_USER=openclockwork
POSTGRES_PASSWORD=openclockwork
POSTGRES_DB=openclockwork
-TZ=Europe/Berlin
+TZ=UTC
JWT_SECRET=change-me-jwt-secret
TERMINAL_QR_SECRET=change-me-independent-terminal-qr-secret
TERMINAL_CHALLENGE_TTL_SECONDS=45
diff --git a/.env.prod.example b/.env.prod.example
index 96f8f3e..df0e08d 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -3,7 +3,7 @@
# Pin deployments to an immutable OpenClockwork release. Do not use `latest`
# for production upgrades.
-OPENCLOCKWORK_VERSION=1.3.0
+OPENCLOCKWORK_VERSION=1.4.0
# Keep these names unchanged across upgrades.
OPENCLOCKWORK_DB_VOLUME=openclockwork-db-data-prod
OPENCLOCKWORK_ATTACHMENTS_VOLUME=openclockwork-attachments-prod
@@ -13,7 +13,8 @@ POSTGRES_PASSWORD=change-me-database-password
POSTGRES_DB=openclockwork
DATABASE_URL=postgresql://openclockwork:change-me-database-password@db:5432/openclockwork?schema=public
-TZ=Europe/Berlin
+# IANA working timezone for this installation; configure before recording data.
+TZ=UTC
JWT_SECRET=change-me-long-random-jwt-secret
TERMINAL_QR_SECRET=change-me-independent-terminal-qr-secret
TERMINAL_CHALLENGE_TTL_SECONDS=45
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 49f9594..cfaf35b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -122,7 +122,7 @@ jobs:
DATABASE_URL: postgresql://openclockwork:openclockwork@localhost:5433/openclockwork_test?schema=public
JWT_SECRET: ci-jwt-secret-do-not-use-in-prod
ERP_API_KEY: ci-erp-key
- # Match production — core-time + off-hours logic is timezone-sensitive.
+ # Pin the legacy fixtures to an explicit zone, independent of deployment defaults.
TZ: Europe/Berlin
steps:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e5847ac..7470d4e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,13 +19,13 @@ If you forgot to sign off, fix the latest commit with `git commit --amend -s` or
## Reporting issues
- **Bugs:** open a GitHub issue with reproduction steps, expected vs. actual behaviour, environment details (OS, Node version, browser).
-- **Security vulnerabilities:** do *not* open a public issue. See [SECURITY.md](SECURITY.md).
+- **Security vulnerabilities:** do _not_ open a public issue. See [SECURITY.md](SECURITY.md).
- **Feature ideas:** open a GitHub Discussion or a "proposal" issue first. We prefer to discuss design before code.
## Working on a change
1. Fork the repo and create a feature branch from `main`.
-2. Review the [README](README.md), [feature overview](FEATURES.md), and existing code before changing domain rules — OpenClockwork models a real working-time-tracking system, and the rules around break deduction, approval thresholds, etc. are not invented.
+2. Review the [README](README.md), [feature overview](FEATURES.md), [roadmap](ROADMAP.md), and existing domain tests before changing working-time rules. Country-specific policies must be explicit configuration or optional presets, with existing installations preserved by migrations.
3. Keep new dependencies aligned with the existing TypeScript, Nx, NestJS, React, Prisma, and Tailwind stack unless the pull request clearly explains the reason for a change.
4. Run `pnpm install` at the repo root, then use `pnpm nx run :` (e.g. `pnpm nx serve api`) for local dev.
5. Add tests. New endpoints, business rules, or UI flows without tests will not be merged.
@@ -34,8 +34,8 @@ If you forgot to sign off, fix the latest commit with `git commit --amend -s` or
## Pull request expectations
-- One logical change per PR. If your branch fixes a bug *and* refactors something, split it.
-- The PR description should explain *why*, not just *what*. Link to the issue or discussion.
+- One logical change per PR. If your branch fixes a bug _and_ refactors something, split it.
+- The PR description should explain _why_, not just _what_. Link to the issue or discussion.
- All checks (lint, type-check, tests, DCO) must be green before review.
- A reviewer will respond within a few days. Larger changes may take longer; please be patient.
- We reserve the right to decline contributions that do not align with the project goals stated in the README.
@@ -47,6 +47,7 @@ If you forgot to sign off, fix the latest commit with `git commit --amend -s` or
- Frontend: Tailwind for styling. Component-local state via React; cross-cutting state via the patterns established in `apps/web`.
- Database: schema changes go through Prisma migrations. Never edit a migration after it has been merged to `main`.
- All public APIs are documented through their OpenAPI spec (NestJS Swagger). UI uses the generated client.
+- Keep user-facing text in the English/German translation catalogue. Do not infer holiday calendars, working-time rules, currencies, or tax settings from the UI language.
## Code of Conduct
diff --git a/Dockerfile.api b/Dockerfile.api
index ac362c2..3702b6a 100644
--- a/Dockerfile.api
+++ b/Dockerfile.api
@@ -52,12 +52,12 @@ RUN mkdir -p /app/data/attachments && chown -R app:app /app/data
USER app
EXPOSE 3000
-# Default timezone — German Zeiterfassung. Overridable per deployment
+# Neutral default timezone. Configure the organisation's working timezone
# (docker-compose / Container App env). Drives core-time-window and
# 07:00/23:00 off-hours reasoning, which is all wall-clock.
ENV NODE_ENV=production \
API_PORT=3000 \
- TZ=Europe/Berlin
+ TZ=UTC
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
diff --git a/FEATURES.md b/FEATURES.md
index 48baf06..0bc8397 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -2,7 +2,7 @@
OpenClockwork is a responsive, self-hostable time-and-attendance system for
employees, managers, HR administrators, and paired tablet terminals. Its domain
-model focuses on real German working-time workflows while keeping deployment,
+model supports configurable working-time workflows across countries while keeping deployment,
data, and integrations under the operator's control.
> **Project status:** Stable. Published versions follow semantic versioning and
@@ -10,6 +10,9 @@ data, and integrations under the operator's control.
> upgrade steps. The capabilities below are implemented and covered by
> automated tests.
+For planned Solo mode, invoice creation, and CAUR-based agent billing, see the
+[project roadmap](ROADMAP.md).
+
@@ -71,7 +74,7 @@ device never acts as an employee and cannot call normal HR or time-entry APIs.
| Kiosk PWA | Dedicated manifest and full-screen view with time, date, location, custom branding, connection state, and automatic challenge refresh |
| Employee scanner | Explicit clock-in or clock-out choice, camera preview, local QR decoding, and GPS only when the terminal requires it |
| Device operations | Last-seen monitoring, re-pairing, immediate device revocation, terminal deactivation, and permanent deletion |
-| Time-zone handling | IANA drop-down with `Europe/Berlin` as the default and server-side validation |
+| Time-zone handling | IANA selection with the browser timezone suggested, UTC fallback, and server-side validation |
| Durable audit trail | Historical bookings retain terminal/GPS evidence snapshots even when kiosk-only records are removed |
The local iPad pilot includes a trusted-HTTPS Compose overlay, generated test
@@ -93,7 +96,7 @@ See [the German iPad setup guide](docs/IPAD_TERMINAL_SETUP.de.md).
| Retroactive booking changes | Change project/service-order/activity on completed and approved entries |
| Entry splitting | Split a closed entry at a chosen time when work changes between projects |
| Retroactive range booking | Assign a past interval to a project; coverage is validated and existing entries are split as required |
-| Automatic break accounting | Statutory deduction after six and nine hours |
+| Automatic break accounting | Configurable schedule thresholds and deductions; no automatic deduction for new schedules by default |
| Time accounts | Calculated target hours, actual hours, overtime, and opening balances |
| Annual calendar | Year view for vacation, home office, special leave, sickness, training, and flextime |
| Requests | Vacation, home-office, special-leave, and time-adjustment workflows |
@@ -138,7 +141,7 @@ Employee submits
| Work schedules | Working-day masks, permitted frames, and multiple named core-time windows |
| Schedule assignment | Assign individual schedules or bulk-assign by time model |
| Leave allowances | Base leave, carry-over, adjustments, expiry dates, and adjustment reasons |
-| German public holidays | State-specific holiday calendars used in target hours and vacation calculations |
+| Holiday calendars | Optional regional presets and custom dates used in target hours and vacation calculations |
| Absence administration | Record and review sickness, training, and flextime entries |
| Approval operations | Manager/HR inboxes, bulk actions, correction loops, and workflow history |
| Terminal administration | Configure, activate, pair, monitor, revoke, re-pair, deactivate, or permanently delete tablet kiosks |
@@ -175,12 +178,12 @@ clocking locations are excluded by default and require an explicit HR action;
operators must document a lawful purpose and suitable retention period before
using them.
-## Compliance-oriented domain logic
+## Configurable working-time rules
Working-time rules are visible in code and covered by focused tests. Operators
remain responsible for validating their organisation's exact policies.
-- Statutory break calculation
+- Configurable break calculation with explicit thresholds and deduction minutes
- Target/actual accounting derived from weekly hours and working-day masks
- Opening overtime balances and employee start dates
- Configurable working frames and multiple core-time windows
@@ -188,15 +191,26 @@ remain responsible for validating their organisation's exact policies.
entries, permitted frames, and automatic breaks
- Detailed core-time violation detection
- Special approval handling for out-of-frame entries
-- Working-day and German state-holiday aware vacation calculation
+- Working-day and selected-holiday aware vacation calculation
- Half-day leave and carry-over expiry processing
- Multi-stage request workflows and workflow events
+New employees start with no regional holiday preset and no assumed annual leave
+entitlement. German state calendars remain optional presets; custom dates
+support other national, regional, or company calendars. New schedules start
+without automatic break deduction. Upgrade migrations preserve existing
+employee calendars and break policies.
+
+Working-day and schedule calculations use the deployment's configured `TZ`
+(UTC by default). Per-employee working timezones and more built-in regional
+calendars are planned; see [ROADMAP.md](ROADMAP.md). Operators must validate
+local rules and calendar coverage for each period in use.
+
## Languages, accessibility, and responsive design
- German and English UI across login, employee, manager, HR, and kiosk routes
- Central translation catalogue for labels, validation, states, and empty views
-- Locale-aware dates and timestamps
+- Browser-language detection with English fallback and regional date formatting
- Persistent language and theme preferences
- Keyboard-operable account and mobile overflow menus
- Semantic labels for navigation, forms, buttons, progress indicators, and QR
@@ -266,6 +280,7 @@ installation identity is sent back to OpenClockwork.
## Explore the project
- [Main README and installation guide](README.md)
+- [Roadmap: Solo mode, invoices, and CAUR-based agent billing](ROADMAP.md)
- [Set up and test an iPad terminal](docs/IPAD_TERMINAL_SETUP.de.md)
- [Upgrade an existing installation](UPGRADING.md)
- [Review published releases](https://github.com/patrickschiller/openclockwork/releases)
diff --git a/README.md b/README.md
index f47f81f..b8a1248 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- Open-source time and attendance for teams that want trustworthy rules, modern self-hosting, and no proprietary punch-clock hardware.
+ Open-source time and attendance with configurable work rules, modern self-hosting, and no proprietary punch-clock hardware.
@@ -15,15 +15,20 @@
OpenClockwork is a mobile-first, self-hostable working-time system for small and
-mid-sized organisations. Employees can clock in and out, scan a rotating QR
-code from a wall-mounted tablet, or—when HR explicitly enables it—book their
-contractual daily target as one completed block.
+mid-sized organisations in any country. Employees can clock in and out, scan a
+rotating QR code from a wall-mounted tablet, or—when HR explicitly enables
+it—book their contractual daily target as one completed block.
-The domain model covers real working-time behaviour: statutory break deduction,
-target/actual accounts, configurable schedules and core hours, German public
-holidays, leave balances, multi-stage approvals, projects, service orders, and
-auditable reporting. The UI ships in German and English and works as an
-installable PWA on phones, tablets, and desktops.
+The domain model covers real working-time behaviour: configurable break deduction,
+target/actual accounts, schedules and core hours, selectable holiday calendars
+and custom holiday dates, leave balances, multi-stage approvals, projects,
+service orders, and auditable reporting. The UI ships in German and English and
+works as an installable PWA on phones, tablets, and desktops.
+
+> **Where OpenClockwork is heading:** Read the [project roadmap](ROADMAP.md) for
+> the complete **Solo mode**, **invoice creation**, and **CAUR-based agent usage
+> accounting and billing** plans. These are planned capabilities; the current
+> application provides team time tracking and attendance.
@@ -50,7 +55,7 @@ installable PWA on phones, tablets, and desktops.
- **Optional geofencing.** A terminal can work entirely without GPS or require a
fresh employee position inside a server-validated radius with a configured
accuracy limit.
-- **Compliance-oriented domain logic.** Break deduction, working frames, core
+- **Configurable working-time rules.** Break deduction, working frames, core
hours, target/actual balances, public holidays, and leave calculations live in
tested backend/shared-domain code.
- **Real approval workflows.** Vacation, home office, special leave, time
@@ -61,10 +66,26 @@ installable PWA on phones, tablets, and desktops.
export customer or working-time reports with optional clock-in/out locations.
- **Self-hosted and API-first.** PostgreSQL, NestJS, React, OpenAPI, Socket.IO,
Docker, and an Azure reference deployment—without SaaS lock-in.
-- **German and English.** Centralised translations, locale-aware dates, and a
- persistent language switcher across the login, employee, manager, HR, and
+- **Localised interface.** German and English translations, browser-aware dates,
+ and a persistent language switcher across the login, employee, manager, HR, and
kiosk experiences.
+## Use in any country
+
+Language does not select a country's work rules. Configure the installation's
+IANA working timezone, employee holiday calendars, leave allowances, working
+days, and schedule break rules for your organisation. New employee records
+start without a regional holiday preset or assumed annual leave entitlement;
+new schedules start without automatic break deduction. Existing German state
+calendars remain available as optional presets, and custom holiday dates can
+represent other countries, regions, and company closures.
+
+The API currently evaluates working days and schedule boundaries in one
+deployment timezone (`TZ`, default `UTC`). Set this explicitly before recording
+real data. A kiosk's display timezone does not change payroll or working-day
+boundaries. Multiple simultaneous employee working timezones and additional
+maintained regional calendars are covered in the [roadmap](ROADMAP.md).
+
## Tablet terminal
An HR administrator creates a terminal with an internal name, visible location,
@@ -162,8 +183,9 @@ pnpm nx run-many -t serve -p api,web
```
Open `http://localhost:4200`. Vite proxies API calls to
-`http://localhost:3000`. The interface starts in German; use the language menu
-on the login screen or in the application header to switch to English.
+`http://localhost:3000`. The interface uses a supported browser language, falling
+back to English. Use the language menu on the login screen or in the application
+header to choose English or German; your choice is saved.
### Full local Docker stack
@@ -344,7 +366,7 @@ docker compose -f docker-compose.prod.yml --env-file .env.prod \
```
Enter the administrator's personnel number, name, email address, time model,
-weekly hours, annual leave, start date, and German state. Defaults are shown in
+weekly hours, annual leave, start date, and optional holiday calendar. Defaults are shown in
square brackets and can be accepted with Enter.
The command creates exactly one active `HRAdmin` and prints a random initial
@@ -417,6 +439,7 @@ You can also support the project through
## Documentation
- [Complete feature overview](FEATURES.md)
+- [Roadmap: Solo mode, invoices, and CAUR-based agent billing](ROADMAP.md)
- [iPad terminal setup and operations (German)](docs/IPAD_TERMINAL_SETUP.de.md)
- [Upgrade procedure](UPGRADING.md)
- [Release process](RELEASING.md)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 58b4ef4..8dc3ae2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,52 +1,82 @@
-# OpenClockwork v1.3.0
+# OpenClockwork v1.4.0
## Highlights
-- Adds an HR opt-in for clock-in and clock-out locations in the
- project-independent working-time report and its CSV export. Location data is
- excluded from report responses by default.
-- Combines available GPS coordinates and accuracy with durable terminal
- location labels. Terminal labels are captured at booking time so historical
- reports remain stable after a terminal is renamed or deleted.
-- Secures employee-directory and request-workflow APIs with bearer
- authentication. Request actors and employees are derived from the
- authenticated session instead of client-supplied identity fields.
-- Extends regression coverage for location reporting, terminal deletion,
- authenticated workflow transitions, database upgrades, and generated API
- contracts.
+- Makes work policies country-neutral: employees can use no holiday calendar,
+ an optional German state preset, or explicit custom holiday dates. New employee
+ forms and bootstrap accounts start with an explicit zero-day leave allowance.
+- Adds configurable automatic break deductions to work schedules. New schedules
+ start without deduction rules; time entries capture the active rules so later
+ schedule edits do not change their historical break deductions.
+- Preserves existing installations through a forward migration of employee
+ calendars, schedule policies, and time-entry break snapshots.
+- Detects the browser's English or German language, falls back to English, and
+ respects supported regional date formats. Fixes date-only leave displays and
+ upcoming-leave filtering across timezone and year boundaries.
+- Establishes UTC as the default deployment and new-terminal timezone, with an
+ explicit timezone setting for Docker and Azure installations.
+- Adds a prominent [roadmap](https://github.com/patrickschiller/openclockwork/blob/v1.4.0/ROADMAP.md)
+ for complete Solo mode, customer invoicing, CAUR usage accounting, and combined
+ human/agent billing. These capabilities are planned, not implemented in 1.4.0.
## Upgrade notes
-- Back up PostgreSQL and request attachments before upgrading.
-- Set `OPENCLOCKWORK_VERSION=1.3.0` and follow the
- [upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v1.3.0/UPGRADING.md).
-- Existing production volumes are retained; do not use `docker compose down -v`.
-- Update external integrations that call employee or request endpoints: they
- must send a valid JWT bearer token and must not rely on request-body
- `employeeId` or `actorId` values to select the acting user.
-- When API and web are deployed separately, deploy the `1.3.0` API first and
- wait for `/api/health` to report version `1.3.0`; then deploy the web image.
- Docker Compose performs this ordering automatically.
+- Back up PostgreSQL and request attachments before upgrading. Keep existing
+ data and attachment volume names; do not use `docker compose down -v`.
+- **Set `TZ` explicitly to the installation's existing working timezone before
+ upgrading.** Use `Europe/Berlin` if that was the previous implicit default.
+ Azure deployments must likewise set the `timeZone` parameter used by the API
+ and scheduled job. Existing terminal timezones remain unchanged.
+- Set `OPENCLOCKWORK_VERSION=1.4.0` and follow the
+ [upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v1.4.0/UPGRADING.md),
+ including the notes for any skipped releases.
+- Review calendars, contractual leave allowances, and schedule break rules after
+ upgrading. Existing allowances and German calendar behaviour are retained;
+ neutral defaults apply when creating new records.
+- Update external clients to use `holidayCalendar`, `holidayDates`, and
+ `breakRules` from the regenerated OpenAPI contract. The deprecated `bundesland`
+ alias remains supported for German state selections, but responses can now
+ contain `null`. Conflicting legacy and canonical calendar selections are rejected.
+- When API and web are deployed separately, deploy the `1.4.0` API first and
+ wait for `/api/health` to report version `1.4.0`; then deploy the web image.
+ Docker Compose performs this ordering automatically. Close and reopen existing
+ PWA/browser tabs if they continue displaying a cached application version.
## Database migrations
-- `20260901120000_working_time_report_locations` adds nullable terminal location
- label snapshots for clock-in and clock-out and backfills existing
- terminal-linked entries where possible.
-- The migration is additive and contains no destructive schema change.
+- `20260907120000_international_work_policies` adds employee holiday calendars
+ and custom dates, schedule break rules, and time-entry break-rule snapshots.
+- Existing state selections become their equivalent `DE-XX` preset. Legacy
+ invalid state values retain the previous resolver's `DE-NW` fallback.
+- Existing schedules and entries retain the previous 360-minute/30-minute and
+ 540-minute/45-minute deduction thresholds. Employees who relied on an implicit
+ schedule receive an explicit preserving schedule when no default schedule exists.
+- The migration allows nullable legacy `bundesland` values and changes only the
+ default for newly created terminals to UTC. It retains existing rows and runs
+ atomically during normal API startup; no manual SQL is required.
-## Breaking changes
+## Compatibility and changed defaults
-- All `/api/employees` and `/api/requests` endpoints now require bearer
- authentication. Workflow identity is taken from the authenticated token;
- unauthenticated calls and attempts to select another actor through request
- bodies are rejected or ignored as appropriate.
+- New employees default to `holidayCalendar: "NONE"` and `holidayDates: []`;
+ new schedules default to `breakRules: []`. Clients and setup procedures that
+ relied on automatic German holidays, break deductions, or a 30-day allowance
+ must now configure their intended policies explicitly.
+- API clients must tolerate nullable `bundesland` responses. Persisted time-model
+ enum identifiers remain compatible; their UI labels are translated.
+- UTC replaces the implicit Europe/Berlin deployment fallback. Explicitly retaining
+ the existing working timezone is required to preserve day and schedule boundaries.
## Docker images
-- `ghcr.io/patrickschiller/openclockwork-api:1.3.0`
-- `ghcr.io/patrickschiller/openclockwork-web:1.3.0`
+- `ghcr.io/patrickschiller/openclockwork-api:1.4.0`
+- `ghcr.io/patrickschiller/openclockwork-web:1.4.0`
-## Known issues
+## Known limitations
-None known.
+- The API still uses one installation working timezone for day and schedule
+ boundaries. Per-workspace and per-employee working timezones remain planned.
+- Maintained holiday presets currently cover German states. Other calendars can
+ use explicit custom dates, supplied separately for each relevant year.
+- The UI currently supports English and German. Country-neutral configuration
+ does not imply automatic compliance with a jurisdiction's work or billing rules.
+- Solo mode, invoice creation, and CAUR-based agent billing remain roadmap items.
diff --git a/RELEASING.md b/RELEASING.md
index 88d3641..e4457d0 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -30,8 +30,8 @@ annotated tag on the exact merge commit and push it:
```bash
git switch main
git pull --ff-only
-git tag -a v1.3.0 -m "OpenClockwork v1.3.0"
-git push origin v1.3.0
+git tag -a v1.4.0 -m "OpenClockwork v1.4.0"
+git push origin v1.4.0
```
The release workflow verifies the tag and notes, runs Nx and API end-to-end
@@ -46,8 +46,8 @@ pulled anonymously by self-hosted installations.
## Verify the published release
```bash
-docker pull ghcr.io/patrickschiller/openclockwork-api:1.3.0
-docker pull ghcr.io/patrickschiller/openclockwork-web:1.3.0
+docker pull ghcr.io/patrickschiller/openclockwork-api:1.4.0
+docker pull ghcr.io/patrickschiller/openclockwork-web:1.4.0
```
Confirm that the GitHub Release is marked latest, contains the curated upgrade
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..db41bb4
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,201 @@
+# OpenClockwork Roadmap
+
+OpenClockwork is evolving from team time and attendance into a self-hosted
+workspace for personal work, customer billing, and coding-agent usage accounting.
+The project remains public and Apache-2.0 licensed.
+
+This roadmap describes intended scope and delivery order, not a release-date
+commitment. **Solo mode, invoice creation, and CAUR integration are planned and
+are not implemented yet.** See [FEATURES.md](FEATURES.md) for the current
+application and [README.md](README.md) for installation.
+
+## Delivery order
+
+| Stage | Scope | Status / dependency |
+| ----- | -------------------------------------------- | ---------------------------------------------- |
+| 1 | Country-neutral configuration and defaults | Included in v1.4.0 |
+| 2 | Complete Solo mode | Planned; builds on stage 1 |
+| 3 | Customer billing and invoice creation | Planned; available to Solo and team workspaces |
+| 4 | CAUR usage import, attribution, and review | Planned; can progress alongside stage 3 |
+| 5 | Agent billing and combined customer invoices | Planned; requires stages 3 and 4 |
+
+## 1. Country-neutral foundation
+
+The application must not infer a country's work rules from the user's language.
+The initial foundation includes:
+
+- English and German UI, browser-language detection, English fallback, and
+ regional date formatting.
+- Explicit installation and terminal timezones, with UTC as the neutral
+ deployment fallback.
+- Optional holiday calendars and custom holiday dates for national, regional,
+ and company calendars. Existing German state calendars remain optional presets.
+- Configurable schedule break thresholds and deductions, without automatically
+ imposing the previous German policy on new schedules.
+- Explicit leave allowances and working-day masks. New employee forms and
+ bootstrap no longer assume a 30-day entitlement.
+- Forward migration of existing calendars and schedules, plus stored break
+ policy snapshots so later policy edits do not rewrite historical deductions.
+
+Further internationalisation work remains planned:
+
+- [ ] Per-workspace and per-employee working timezones, including midnight,
+ daylight-saving changes, overnight shifts, and cross-zone reporting. The
+ current API uses one deployment timezone for day and schedule boundaries.
+- [ ] Reusable, maintained regional calendars with import/update workflows and
+ visible coverage years; custom date lists currently require each year's dates.
+- [ ] Additional UI translations, regional week-start and number preferences,
+ and an English equivalent of the German iPad operations guide.
+- [ ] Versioned schedule/leave policies where effective dates are needed for
+ historical target hours and leave calculations, beyond break snapshots.
+- [ ] Currency, address, tax, and invoice-format settings selected independently
+ from language as part of billing. No country-wide compliance claim is implied.
+
+## 2. Complete Solo mode
+
+Solo mode is a first-class workflow for freelancers, independent consultants,
+and people tracking their own work. It must work without creating a fictional
+manager, HR department, or employee approval chain.
+
+- [ ] **Setup and identity:** choose Solo or Team during setup; create one owner
+ account and a personal workspace with timezone, language, working days, optional
+ weekly target, holidays, and optional leave tracking. Reuse secure login,
+ password management, backup, and self-hosting workflows.
+- [ ] **Focused navigation:** a personal dashboard, timer/bookings, calendar,
+ customers, projects, reports, and settings. Show billing and agent usage when
+ those capabilities become available. Team administration, substitutes, approval
+ inboxes, and kiosk setup should not be required in the Solo workflow.
+- [ ] **Time capture:** start/stop a timer, add and correct manual entries, split
+ work between projects, edit activity descriptions, and review daily/weekly
+ totals. Keep the mobile PWA experience. GPS and the existing daily-target block
+ remain optional and explicitly configured.
+- [ ] **Personal policies:** optional work targets, break deduction, time-off and
+ overtime tracking; support working without attendance obligations or a fixed
+ schedule. Use direct owner actions for personal bookings and corrections,
+ preserving an audit history without routing them through employee approvals.
+- [ ] **Customer and project work:** customer records, projects/service orders,
+ billable versus non-billable entries, estimates/budgets, and customer-facing
+ activity descriptions. Rates will feed the shared invoicing foundation.
+- [ ] **Reports and exports:** personal productivity and project totals, customer
+ timesheets, period filters, and exportable billing evidence. Distinguish raw
+ tracked time, deductible breaks, and billable time.
+- [ ] **Transition to a team:** invite additional people and enable Team mode
+ without losing customers, projects, bookings, settings, or invoice references.
+ Map the Solo owner to an explicit administrative role; define who can see and
+ approve existing records. Returning to Solo must require resolving active team
+ members and pending workflows first.
+- [ ] **Acceptance coverage:** complete setup → track work → assign to a customer
+ → review/export flow in both UI languages, optional-policy behaviour, owner
+ access boundaries, and migration of an existing one-person installation.
+
+**Done when:** one person can operate the application end to end without an HR
+workflow, and can later enable team collaboration while preserving their data.
+
+## 3. Customer billing and invoice creation
+
+Invoices should be available in both Solo and Team mode, using the same customer,
+project, permission, and audit model.
+
+- [ ] **Billing profiles:** issuer and customer details, addresses, tax identifiers,
+ invoice language, ISO currency, payment terms, payment instructions, and
+ jurisdiction-specific invoice/tax settings. Do not assume German addresses,
+ EUR, a particular tax rate, or a single national invoice format.
+- [ ] **Rates and charge rules:** customer/project/service-order rates, hourly and
+ fixed-fee lines, discounts, expenses, rounding, and effective dates. Preserve
+ rate/currency snapshots and use decimal arithmetic for money.
+- [ ] **From work to invoice:** select reviewed billable entries by customer and
+ period, preview descriptions/quantities/rates, add manual lines, and attach an
+ activity report. Track uninvoiced, reserved-for-draft, and invoiced work so the
+ same entry cannot be charged twice.
+- [ ] **Invoice lifecycle:** draft, review, issue with a unique configured number,
+ due dates, payment status including partial payments, and overdue overview.
+ Issued invoices retain their original contents; corrections and credits link
+ to the affected invoice and keep their own history.
+- [ ] **Documents and exchange:** printable/downloadable PDF, CSV/accounting
+ export, and documented APIs. Add jurisdiction-specific structured invoice
+ formats through explicit adapters and validation. Sending invoices or payment
+ reminders requires a deliberate action or configured automation.
+- [ ] **Permissions and data:** authorised billing roles, customer separation,
+ backups and retention for documents, and an auditable connection from every
+ invoice line to its time entries or other evidence.
+- [ ] **Acceptance coverage:** tracked work → draft → issued document → payment
+ or correction; rate changes, decimal rounding, currencies, access boundaries,
+ and duplicate-invoicing prevention.
+
+**Done when:** a Solo owner or authorised team member can create a reproducible
+customer invoice from tracked work and follow it through payment or correction.
+
+## 4. CAUR coding-agent usage accounting
+
+[CAUR — Coding Agent Usage Record](https://github.com/patrickschiller/caur)
+defines a vendor-neutral usage record for a completed or interrupted coding-agent
+run. It is currently a **v0.1 draft**, not an invoice format or pricing catalogue.
+OpenClockwork will consume these records and provide attribution, review, and
+billing around them. The integration must follow the
+[CAUR specification](https://github.com/patrickschiller/caur/blob/main/SPEC.md)
+and [JSON Schema](https://github.com/patrickschiller/caur/blob/main/schema/caur-v0.1.schema.json).
+
+- [ ] **Ingestion:** validated file import and authenticated API ingestion, explicit
+ supported schema versions, import diagnostics, and deduplication by `record_id`.
+ Preserve original immutable records; corrections use new records linked by
+ `supersedes`, with review of their impact on previously billed work.
+- [ ] **Attribution:** assign each run/session to a workspace, customer, project,
+ service order, and responsible person. Retain producer/harness versions, run
+ identity, parent-run relationships, outcomes, and optional external trace
+ references without importing trace payloads.
+- [ ] **Measured usage:** preserve per-request provider/model identity, requested
+ aliases, input/output/cache/reasoning token quantities, tool aggregates, and
+ measurement provenance. Keep estimates visibly marked and missing quantities
+ unknown. Reconcile equivalent measurements using CAUR's source precedence
+ rather than adding them together.
+- [ ] **Time and nested agents:** distinguish wall time, active/model/tool time,
+ approval waits, and queue time. Overlapping intervals are unions, not sums;
+ never blindly add child durations to parent time or child usage to an existing
+ roll-up. Reject or flag records that cannot support a reproducible aggregation.
+- [ ] **Cost evidence:** preserve list, contract, and effective cost interpretations,
+ decimal amount strings, ISO currencies, measurement sources, and price
+ references/versions. A supplied cost is evidence, not automatically a verified
+ payable amount. Keep currencies separate until an explicit, recorded conversion.
+- [ ] **Privacy:** store no prompts, responses, source code, patches, tool arguments,
+ tool output, environment variables, or credentials. Minimise identities and
+ protect accounting records with appropriate access, export, and retention rules.
+ Ignore unknown extensions in calculations and enforce the same privacy boundary
+ for any stored extension data.
+- [ ] **Acceptance coverage:** duplicate delivery, out-of-order replacements,
+ parallel/nested agents, mixed measured/estimated data, unknown quantities,
+ failed/cancelled/timed-out runs, currency separation, and privacy violations.
+
+**Done when:** records from independent agent harnesses can be imported, attributed,
+and reviewed with reproducible totals and no duplicated usage or sensitive payloads.
+
+## 5. Agent billing and combined invoices
+
+- [ ] **Commercial rules:** explicitly configure whether to pass through verified
+ costs, apply a markup, bill usage units or agent time, or use an agreed fixed
+ fee. Separate actual resource cost, the customer's price, and human tracked
+ time. Define how failures, retries, waits, and estimates affect billability.
+- [ ] **Review and budgets:** show agent usage and costs by customer/project/run,
+ budget progress, unresolved attribution, missing pricing, and estimates before
+ charges are approved for invoicing. Unknown cost must not silently become zero.
+- [ ] **Invoice integration:** combine human services and separately identifiable
+ agent charges on one customer invoice, with clear units, quantities, rates,
+ currencies, and optional usage evidence. Preserve links to contributing CAUR
+ record IDs and the exact pricing/billing rule version.
+- [ ] **Corrections and reconciliation:** prevent repeated imports or replacement
+ records from generating duplicate charges. Reconcile usage totals with cost
+ evidence; post-issue changes create explicit adjustments or credits instead of
+ rewriting issued invoices.
+- [ ] **Acceptance coverage:** customer/project → human work + agent runs → reviewed
+ charges → invoice → payment/correction, including a nested-agent example and a
+ corrected CAUR record that arrives after invoicing.
+
+**Done when:** human work and coding-agent usage can be billed together with a
+clear, reproducible trail from invoice line to source record and agreed price.
+
+## Contributing and priorities
+
+Delivery should remain incremental: focused domain tests, API/client consistency,
+forward-only migrations, and upgrade verification for existing installations.
+Proposals and implementation feedback are welcome through
+[GitHub issues](https://github.com/patrickschiller/openclockwork/issues).
+Contributions follow [CONTRIBUTING.md](CONTRIBUTING.md), including DCO sign-off.
diff --git a/UPGRADING.md b/UPGRADING.md
index 570ebb8..1d4216e 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -68,10 +68,47 @@ to `TimeEntry`. The forward migration backfills labels for entries that still
reference a terminal. No manual SQL is required; normal API startup applies the
migration before serving traffic.
+## Required for 1.4.0
+
+The migration `20260907120000_international_work_policies` introduces explicit
+holiday calendars, custom holiday dates, and configurable automatic break
+deductions. Normal API startup applies it before serving traffic. Back up the
+installation and explicitly retain its working timezone before deploying 1.4.0.
+
+- Existing employee state selections become the equivalent `DE-XX` calendar.
+ Existing German calendar behaviour remains in place. New employees default
+ to `holidayCalendar: "NONE"` and an empty `holidayDates` list.
+- Existing schedules retain the previous 360-minute/30-minute and
+ 540-minute/45-minute deduction thresholds. If employees previously relied on
+ an implicit schedule and no default schedule exists, the migration assigns
+ them an explicit schedule preserving that policy. New schedules default to
+ an empty `breakRules` list.
+- Existing time entries receive a snapshot of the previous deduction policy.
+ New entries capture the active schedule policy; changing a schedule later
+ does not recalculate those stored entries' break deductions.
+- External clients should use `holidayCalendar` and `holidayDates`. The
+ deprecated `bundesland` field remains accepted for German state selections;
+ responses can now return `null` there. Conflicting legacy and canonical calendar
+ selections are rejected. Regenerate clients from `apps/api/openapi.json` and
+ configure new schedule `breakRules` explicitly where deductions are required.
+ Persisted time-model enum identifiers remain compatible; the UI translates
+ their labels.
+- Bootstrap and employee forms no longer assume a 30-day leave entitlement.
+ Enter the contractual allowance explicitly. Existing allowances are retained.
+- Docker and Azure defaults become `UTC`. **Set `TZ` explicitly to your existing
+ working timezone before upgrading**, especially if you previously relied on
+ the implicit `Europe/Berlin` default. For Azure, set the `timeZone` parameter
+ for both the API and scheduled job. Existing terminal display timezones stay
+ unchanged.
+
+Review calendars and break policies after the upgrade. Custom holidays are
+explicit dates rather than recurring rules: supply each relevant year. The API
+still uses one deployment working timezone for day and schedule boundaries.
+
## 2. Select and pull the release
Set `OPENCLOCKWORK_VERSION` in `.env.prod` to the exact version from the GitHub
-Release, for example `1.3.0`. Do not use `latest` for a controlled production
+Release, for example `1.4.0`. Do not use `latest` for a controlled production
upgrade.
```bash
diff --git a/apps/api-e2e/src/api/bundesland-workdays.e2e.spec.ts b/apps/api-e2e/src/api/bundesland-workdays.e2e.spec.ts
index f7ed90b..a3935ab 100644
--- a/apps/api-e2e/src/api/bundesland-workdays.e2e.spec.ts
+++ b/apps/api-e2e/src/api/bundesland-workdays.e2e.spec.ts
@@ -43,7 +43,7 @@ describe('Bundesland + workingDays — affect Soll calculation', () => {
});
await ctx.prisma.employee.update({
where: { id: by.id },
- data: { bundesland: 'BY' },
+ data: { bundesland: 'BY', holidayCalendar: 'DE-BY' },
});
await seedLeaveAllowance(ctx.prisma, nw.id, YEAR, 30);
await seedLeaveAllowance(ctx.prisma, by.id, YEAR, 30);
diff --git a/apps/api-e2e/src/api/create-admin.e2e.spec.ts b/apps/api-e2e/src/api/create-admin.e2e.spec.ts
index a2bcb03..f9bcec6 100644
--- a/apps/api-e2e/src/api/create-admin.e2e.spec.ts
+++ b/apps/api-e2e/src/api/create-admin.e2e.spec.ts
@@ -47,11 +47,13 @@ describe('Initial administrator command', () => {
lastName: 'Lovelace',
role: 'HRAdmin',
timeModel: 'Vollzeit',
- bundesland: 'NW',
+ bundesland: null,
+ holidayCalendar: 'NONE',
+ holidayDates: [],
isActive: true,
});
expect(Number(created.weeklyHours)).toBe(40);
- expect(Number(created.annualLeaveDays)).toBe(30);
+ expect(Number(created.annualLeaveDays)).toBe(0);
expect(await bcrypt.compare(initialPassword, created.passwordHash)).toBe(
true,
);
diff --git a/apps/api-e2e/src/api/daily-block-booking.e2e.spec.ts b/apps/api-e2e/src/api/daily-block-booking.e2e.spec.ts
index 33f5415..0d99bdb 100644
--- a/apps/api-e2e/src/api/daily-block-booking.e2e.spec.ts
+++ b/apps/api-e2e/src/api/daily-block-booking.e2e.spec.ts
@@ -41,6 +41,10 @@ describe('TimeEntries — direct daily-block booking', () => {
frameStart: '07:00',
frameEnd: '23:00',
workingDays: 15, // Monday through Thursday
+ breakRules: [
+ { afterMinutes: 360, breakMinutes: 30 },
+ { afterMinutes: 540, breakMinutes: 45 },
+ ],
isDefault: true,
},
});
diff --git a/apps/api-e2e/src/api/international-policies.e2e.spec.ts b/apps/api-e2e/src/api/international-policies.e2e.spec.ts
new file mode 100644
index 0000000..078ad3c
--- /dev/null
+++ b/apps/api-e2e/src/api/international-policies.e2e.spec.ts
@@ -0,0 +1,275 @@
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ seedLeaveAllowance,
+ type TestContext,
+} from '../support/test-app';
+
+function dateOnly(date: Date): string {
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+describe('International calendars and configurable break policies', () => {
+ let ctx: TestContext;
+ let hrToken: string;
+
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ await seedEmployee(ctx.prisma, {
+ personalNo: '9000',
+ firstName: 'Admin',
+ lastName: 'Example',
+ email: 'admin@test.local',
+ role: 'HRAdmin',
+ });
+ hrToken = await login(ctx.http, 'admin@test.local');
+ });
+
+ async function createEmployee(extra: Record = {}) {
+ const response = await ctx.http
+ .post('/api/employees')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({
+ personalNo: '1001',
+ firstName: 'Alex',
+ lastName: 'Example',
+ email: 'alex@test.local',
+ password: 'test1234',
+ role: 'Employee',
+ timeModel: 'Vollzeit',
+ weeklyHours: 40,
+ annualLeaveDays: 20,
+ startDate: '2020-01-01',
+ allowDailyBlockBooking: true,
+ ...extra,
+ })
+ .expect(201);
+ return response.body;
+ }
+
+ const schedulePayload = {
+ name: 'Configured policy',
+ frameStart: '00:00',
+ frameEnd: '23:59',
+ workingDays: 127,
+ coreTimes: [],
+ };
+
+ it('creates employees and schedules with neutral defaults', async () => {
+ const employee = await createEmployee();
+ expect(employee).toMatchObject({
+ holidayCalendar: 'NONE',
+ holidayDates: [],
+ bundesland: null,
+ });
+ const schedule = await ctx.http
+ .post('/api/work-schedules')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send(schedulePayload)
+ .expect(201);
+ expect(schedule.body.breakRules).toEqual([]);
+ const token = await login(ctx.http, employee.email);
+ const option = await ctx.http
+ .get('/api/timeentries/daily-block/option')
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(option.body).toMatchObject({
+ dailyNetMinutes: 480,
+ grossMinutes: 480,
+ breakMinutes: 0,
+ });
+ });
+
+ it('accepts legacy state aliases and can explicitly clear the German preset', async () => {
+ const employee = await createEmployee({ bundesland: 'BY' });
+ expect(employee).toMatchObject({
+ holidayCalendar: 'DE-BY',
+ bundesland: 'BY',
+ });
+ const updated = await ctx.http
+ .put(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ bundesland: 'NW' })
+ .expect(200);
+ expect(updated.body.holidayCalendar).toBe('DE-NW');
+ await ctx.http
+ .put(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ bundesland: 'NW', holidayCalendar: 'NONE' })
+ .expect(400);
+ const cleared = await ctx.http
+ .put(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ holidayCalendar: 'NONE' })
+ .expect(200);
+ expect(cleared.body).toMatchObject({
+ holidayCalendar: 'NONE',
+ bundesland: null,
+ });
+ });
+
+ it('uses custom holidays for leave and daily blocks, with strict date validation', async () => {
+ const day = new Date();
+ day.setDate(day.getDate() - 7);
+ while (day.getDay() !== 1) day.setDate(day.getDate() - 1);
+ const holiday = dateOnly(day);
+ const friday = new Date(day);
+ friday.setDate(friday.getDate() + 4);
+ const employee = await createEmployee({ holidayDates: [holiday] });
+ for (const invalid of [
+ { holidayCalendar: null },
+ { holidayCalendar: 'UNKNOWN' },
+ { holidayDates: null },
+ { holidayDates: [null] },
+ { holidayDates: [holiday, holiday] },
+ ]) {
+ await ctx.http
+ .put(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send(invalid)
+ .expect(400);
+ }
+ const token = await login(ctx.http, employee.email);
+ await seedLeaveAllowance(ctx.prisma, employee.id, day.getFullYear(), 20);
+
+ await ctx.http
+ .put(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ holidayDates: ['2026-02-30'] })
+ .expect(400);
+ await ctx.http
+ .put(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ holidayDates: ['2026-07-01T00:00:00Z'] })
+ .expect(400);
+
+ const blocked = await ctx.http
+ .post('/api/timeentries/daily-block')
+ .set('Authorization', `Bearer ${token}`)
+ .send({ date: holiday, start: '08:00' })
+ .expect(400);
+ expect(blocked.body.code).toBe('DAILY_BLOCK_PUBLIC_HOLIDAY');
+ const vacation = await ctx.http
+ .post('/api/requests/vacation')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ employeeId: employee.id,
+ from: `${holiday}T00:00:00.000Z`,
+ to: `${dateOnly(friday)}T00:00:00.000Z`,
+ })
+ .expect(201);
+ expect(vacation.body.calculatedDays).toBe(4);
+ });
+
+ it('preserves a live entry policy across schedule changes and uses its snapshot in every summary', async () => {
+ const rules = [{ afterMinutes: 300, breakMinutes: 20 }];
+ const schedule = await ctx.http
+ .post('/api/work-schedules')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ ...schedulePayload, breakRules: rules })
+ .expect(201);
+ const employee = await createEmployee({ workScheduleId: schedule.body.id });
+ const token = await login(ctx.http, employee.email);
+ const opened = await ctx.http
+ .post('/api/timeentries/clock-in')
+ .set('Authorization', `Bearer ${token}`)
+ .send({})
+ .expect(201);
+ const clockIn = new Date(Date.now() - 480 * 60_000);
+ await ctx.prisma.timeEntry.update({
+ where: { id: opened.body.id },
+ data: { clockIn },
+ });
+ await ctx.http
+ .put(`/api/work-schedules/${schedule.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ ...schedulePayload, breakRules: [] })
+ .expect(200);
+
+ const closed = await ctx.http
+ .post('/api/timeentries/clock-out')
+ .set('Authorization', `Bearer ${token}`)
+ .send({})
+ .expect(201);
+ expect(closed.body.summary).toMatchObject({
+ grossMinutes: 480,
+ breakMinutes: 20,
+ netMinutes: 460,
+ });
+ const persisted = await ctx.prisma.timeEntry.update({
+ where: { id: opened.body.id },
+ data: { status: 'Approved' },
+ });
+ expect(persisted.breakRules).toEqual(rules);
+ const list = await ctx.http
+ .get(`/api/timeentries?employeeId=${employee.id}`)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(list.body[0].summary).toEqual(closed.body.summary);
+
+ const report = await ctx.http
+ .get(
+ `/api/reports/working-times?from=${dateOnly(clockIn)}&to=${dateOnly(new Date())}&employeeId=${employee.id}`,
+ )
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(report.body.totals).toEqual(closed.body.summary);
+ const exported = await ctx.http
+ .get('/api/erp/timeentries')
+ .set('X-API-Key', process.env.ERP_API_KEY ?? 'e2e-erp-key')
+ .expect(200);
+ expect(
+ exported.body.find((row: { id: string }) => row.id === opened.body.id)
+ ?.netMinutes,
+ ).toBe(460);
+
+ const before = await ctx.http
+ .get(`/api/accounts/${employee.id}`)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ await ctx.http
+ .put(`/api/work-schedules/${schedule.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({
+ ...schedulePayload,
+ breakRules: [{ afterMinutes: 300, breakMinutes: 90 }],
+ })
+ .expect(200);
+ const after = await ctx.http
+ .get(`/api/accounts/${employee.id}`)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(after.body.overtimeMinutes).toBe(before.body.overtimeMinutes);
+ });
+
+ it('keeps omitted policies on updates and rejects deductions exceeding attendance thresholds', async () => {
+ const rules = [{ afterMinutes: 300, breakMinutes: 20 }];
+ const schedule = await ctx.http
+ .post('/api/work-schedules')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ ...schedulePayload, breakRules: rules })
+ .expect(201);
+ const unchanged = await ctx.http
+ .put(`/api/work-schedules/${schedule.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send(schedulePayload)
+ .expect(200);
+ expect(unchanged.body.breakRules).toEqual(rules);
+ await ctx.http
+ .put(`/api/work-schedules/${schedule.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({
+ ...schedulePayload,
+ breakRules: [{ afterMinutes: 10, breakMinutes: 20 }],
+ })
+ .expect(400);
+ });
+});
diff --git a/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
index 62714d4..107943e 100644
--- a/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
+++ b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
@@ -65,6 +65,10 @@ describe('Project-independent working-time reports', () => {
projectId: project.id,
clockIn: new Date('2026-08-10T07:00:00.000Z'),
clockOut: new Date('2026-08-10T15:00:00.000Z'),
+ breakRules: [
+ { afterMinutes: 360, breakMinutes: 30 },
+ { afterMinutes: 540, breakMinutes: 45 },
+ ],
status: 'Approved',
terminalLocationLabel: 'Büro Würzburg',
latitude: 49.791304,
diff --git a/apps/api-e2e/src/support/test-app.ts b/apps/api-e2e/src/support/test-app.ts
index 8664cc0..fb33e96 100644
--- a/apps/api-e2e/src/support/test-app.ts
+++ b/apps/api-e2e/src/support/test-app.ts
@@ -81,6 +81,8 @@ export interface SeedEmployeeInput {
startDate?: Date;
overtimeOpeningBalanceMinutes?: number;
bundesland?: string;
+ holidayCalendar?: string;
+ holidayDates?: string[];
allowDailyBlockBooking?: boolean;
managerId?: string | null;
workScheduleId?: string | null;
@@ -110,6 +112,11 @@ export async function seedEmployee(
startDate: input.startDate ?? defaultStart,
overtimeOpeningBalanceMinutes: input.overtimeOpeningBalanceMinutes ?? 0,
bundesland: input.bundesland ?? 'NW',
+ // Existing domain fixtures deliberately exercise German regional calendars.
+ // Production/new employee defaults are country-neutral.
+ holidayCalendar:
+ input.holidayCalendar ?? `DE-${input.bundesland ?? 'NW'}`,
+ holidayDates: input.holidayDates ?? [],
allowDailyBlockBooking: input.allowDailyBlockBooking ?? false,
isActive: true,
managerId: input.managerId ?? null,
diff --git a/apps/api-e2e/src/support/test-setup.ts b/apps/api-e2e/src/support/test-setup.ts
index 5c9a4d2..1b3c9d1 100644
--- a/apps/api-e2e/src/support/test-setup.ts
+++ b/apps/api-e2e/src/support/test-setup.ts
@@ -7,8 +7,8 @@ process.env.DATABASE_URL =
process.env.DATABASE_URL ??
process.env.E2E_DATABASE_URL ??
'postgresql://openclockwork:openclockwork@localhost:5433/openclockwork_test?schema=public';
-// Pin the timezone so the e2e suite reasons in the same wall-clock zone
-// as production (core-time + off-hours logic is timezone-sensitive).
+// Pin the fixture timezone independently of the deployment default.
+// Core-time and off-hours assertions use these explicit wall-clock times.
process.env.TZ = 'Europe/Berlin';
process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'e2e-test-secret-change-me';
process.env.ERP_API_KEY = 'e2e-erp-key'; // force — the e2e suite hard-codes this
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 25a6899..88d879e 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -2669,7 +2669,7 @@
"info": {
"title": "OpenClockwork API",
"description": "Self-hostable working-time tracker — REST + WebSocket surface.",
- "version": "1.3.0",
+ "version": "1.4.0",
"contact": {}
},
"tags": [],
@@ -2739,7 +2739,7 @@
},
"version": {
"type": "string",
- "example": "1.3.0"
+ "example": "1.4.0"
},
"utcTimestamp": {
"type": "string",
@@ -2805,7 +2805,7 @@
"startDate": {
"type": "string",
"example": "2026-04-01",
- "description": "ISO date when the employee starts; Soll-Stunden are counted from here."
+ "description": "ISO date when the employee starts; target working hours are counted from here."
},
"overtimeOpeningBalanceMinutes": {
"type": "number",
@@ -2832,8 +2832,40 @@
"SH",
"TH"
],
- "default": "NW",
- "description": "ISO-3166-2 code of the German state — drives the holiday calendar."
+ "deprecated": true,
+ "description": "Legacy alias for a DE-XX holidayCalendar; use holidayCalendar for new clients."
+ },
+ "holidayCalendar": {
+ "type": "string",
+ "enum": [
+ "NONE",
+ "DE-BW",
+ "DE-BY",
+ "DE-BE",
+ "DE-BB",
+ "DE-HB",
+ "DE-HH",
+ "DE-HE",
+ "DE-MV",
+ "DE-NI",
+ "DE-NW",
+ "DE-RP",
+ "DE-SL",
+ "DE-SN",
+ "DE-ST",
+ "DE-SH",
+ "DE-TH"
+ ],
+ "default": "NONE",
+ "description": "Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country."
+ },
+ "holidayDates": {
+ "example": ["2026-07-01"],
+ "description": "Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
"allowDailyBlockBooking": {
"type": "boolean",
@@ -2930,7 +2962,40 @@
"ST",
"SH",
"TH"
- ]
+ ],
+ "deprecated": true
+ },
+ "holidayCalendar": {
+ "type": "string",
+ "enum": [
+ "NONE",
+ "DE-BW",
+ "DE-BY",
+ "DE-BE",
+ "DE-BB",
+ "DE-HB",
+ "DE-HH",
+ "DE-HE",
+ "DE-MV",
+ "DE-NI",
+ "DE-NW",
+ "DE-RP",
+ "DE-SL",
+ "DE-SN",
+ "DE-ST",
+ "DE-SH",
+ "DE-TH"
+ ],
+ "default": "NONE",
+ "description": "Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country."
+ },
+ "holidayDates": {
+ "example": ["2026-07-01"],
+ "description": "Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
"allowDailyBlockBooking": {
"type": "boolean",
@@ -2962,6 +3027,24 @@
},
"required": ["password"]
},
+ "BreakRuleDto": {
+ "type": "object",
+ "properties": {
+ "afterMinutes": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1440,
+ "description": "Inclusive attendance threshold in minutes."
+ },
+ "breakMinutes": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1440,
+ "description": "Total deduction in minutes; must not exceed afterMinutes."
+ }
+ },
+ "required": ["afterMinutes", "breakMinutes"]
+ },
"CoreTimeWindowDto": {
"type": "object",
"properties": {
@@ -3023,6 +3106,14 @@
"default": 31,
"description": "Working-day bitmask. Mon=1, Tue=2, …, Sun=64. Mo–Fr = 31, Mo–Sa = 63."
},
+ "breakRules": {
+ "default": [],
+ "description": "Automatic break deductions. Empty means none. The largest matching total applies; not a legal compliance guarantee. Omission on update preserves the policy.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BreakRuleDto"
+ }
+ },
"coreTimes": {
"type": "array",
"items": {
@@ -3355,7 +3446,7 @@
},
"grossMinutes": {
"type": "number",
- "description": "Attendance minutes including the automatic statutory break."
+ "description": "Attendance minutes including the configured automatic break deduction."
},
"breakMinutes": {
"type": "number"
@@ -4029,7 +4120,7 @@
},
"timeZone": {
"type": "string",
- "default": "Europe/Berlin"
+ "default": "UTC"
},
"isActive": {
"type": "boolean",
@@ -4096,7 +4187,7 @@
},
"timeZone": {
"type": "string",
- "default": "Europe/Berlin"
+ "default": "UTC"
},
"isActive": {
"type": "boolean",
diff --git a/apps/api/src/app/accounts/accounts.service.ts b/apps/api/src/app/accounts/accounts.service.ts
index d999325..fe290fa 100644
--- a/apps/api/src/app/accounts/accounts.service.ts
+++ b/apps/api/src/app/accounts/accounts.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import {
calculateNetMinutes,
+ parseBreakRules,
calculateOvertimeMinutes,
calculateVacationDays,
calculateWorkingDays,
@@ -30,27 +31,39 @@ export class AccountsService {
// Sum net minutes from completed time entries YTD.
const entries = await this.prisma.timeEntry.findMany({
- where: { employeeId, clockIn: { gte: yearStart }, clockOut: { not: null } },
- select: { clockIn: true, clockOut: true },
+ where: {
+ employeeId,
+ clockIn: { gte: yearStart },
+ clockOut: { not: null },
+ },
+ select: { clockIn: true, clockOut: true, breakRules: true },
});
let netMinutesYtd = 0;
for (const e of entries) {
if (!e.clockOut) continue;
- const gross = Math.floor((e.clockOut.getTime() - e.clockIn.getTime()) / 60_000);
- netMinutesYtd += calculateNetMinutes(gross);
+ const gross = Math.floor(
+ (e.clockOut.getTime() - e.clockIn.getTime()) / 60_000,
+ );
+ netMinutesYtd += calculateNetMinutes(
+ gross,
+ parseBreakRules(e.breakRules),
+ );
}
// Soll-Befreiung: Vacation (approved) + Sickness + Training count as
// excused working days — their absence on a workday is not a deficit.
// Flextime/Gleittage are intentionally NOT excused: that's what makes
// them drain the overtime account.
- const sollFrom = employee.startDate.getTime() > yearStart.getTime()
- ? new Date(Date.UTC(
- employee.startDate.getUTCFullYear(),
- employee.startDate.getUTCMonth(),
- employee.startDate.getUTCDate(),
- ))
- : yearStart;
+ const sollFrom =
+ employee.startDate.getTime() > yearStart.getTime()
+ ? new Date(
+ Date.UTC(
+ employee.startDate.getUTCFullYear(),
+ employee.startDate.getUTCMonth(),
+ employee.startDate.getUTCDate(),
+ ),
+ )
+ : yearStart;
const excusedDays = await this.excusedWorkingDays(
employeeId,
sollFrom,
@@ -60,7 +73,7 @@ export class AccountsService {
);
// Soll counts from the employee's startDate using their working-day mask
- // and Bundesland-specific holiday calendar.
+ // and configured holiday calendar.
const overtime = calculateOvertimeMinutes({
startDate: employee.startDate,
year,
@@ -123,15 +136,20 @@ export class AccountsService {
for (const a of absences) {
const start = a.from.getTime() < from.getTime() ? from : a.from;
const end = a.to.getTime() > to.getTime() ? to : a.to;
- total += calculateWorkingDays(start, end, { holidayProvider, workingDays });
+ total += calculateWorkingDays(start, end, {
+ holidayProvider,
+ workingDays,
+ });
}
for (const v of vacationRequests) {
// Only credit half a day when the half-day boundary falls inside our
// window — if we clipped that end off, it would otherwise be lost.
const clippedStart = v.from.getTime() < from.getTime() ? from : v.from;
const clippedEnd = v.to.getTime() > to.getTime() ? to : v.to;
- const halfDayStart = v.halfDayStart && clippedStart.getTime() === v.from.getTime();
- const halfDayEnd = v.halfDayEnd && clippedEnd.getTime() === v.to.getTime();
+ const halfDayStart =
+ v.halfDayStart && clippedStart.getTime() === v.from.getTime();
+ const halfDayEnd =
+ v.halfDayEnd && clippedEnd.getTime() === v.to.getTime();
total += calculateVacationDays(clippedStart, clippedEnd, {
holidayProvider,
workingDays,
diff --git a/apps/api/src/app/accounts/vacation-balance.service.ts b/apps/api/src/app/accounts/vacation-balance.service.ts
index 2e2b057..61bc2b7 100644
--- a/apps/api/src/app/accounts/vacation-balance.service.ts
+++ b/apps/api/src/app/accounts/vacation-balance.service.ts
@@ -5,7 +5,12 @@ import { PrismaService } from '../prisma/prisma.service';
import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
import type { VacationBalanceDto } from './accounts.dto';
-const PENDING_STATES = ['Submitted', 'PendingSubstitute', 'PendingManager', 'PendingHr'] as const;
+const PENDING_STATES = [
+ 'Submitted',
+ 'PendingSubstitute',
+ 'PendingManager',
+ 'PendingHr',
+] as const;
@Injectable()
export class VacationBalanceService {
@@ -28,32 +33,41 @@ export class VacationBalanceService {
from: { lte: yearEnd },
to: { gte: yearStart },
},
- select: { workflowState: true, from: true, to: true, calculatedDays: true },
+ select: {
+ workflowState: true,
+ from: true,
+ to: true,
+ calculatedDays: true,
+ },
});
let approvedDays = 0;
let pendingDays = 0;
for (const r of requests) {
- const days = Number(r.calculatedDays) > 0
- ? Number(r.calculatedDays)
- : calculateWorkingDays(r.from, r.to, {
- workingDays: schedule.workingDays,
- holidayProvider: schedule.holidayProvider,
- });
+ const days =
+ Number(r.calculatedDays) > 0
+ ? Number(r.calculatedDays)
+ : calculateWorkingDays(r.from, r.to, {
+ workingDays: schedule.workingDays,
+ holidayProvider: schedule.holidayProvider,
+ });
if (r.workflowState === 'Approved') approvedDays += days;
- else if ((PENDING_STATES as readonly string[]).includes(r.workflowState)) pendingDays += days;
+ else if ((PENDING_STATES as readonly string[]).includes(r.workflowState))
+ pendingDays += days;
}
const baseDays = Number(allowance.baseDays);
// Carry-over from the prior year forfeits once `carryOverExpiresOn`
- // passes (German default = 31.03. of the following year). We surface
+ // passes (when configured by the employer). We surface
// the forfeiture immediately, even if the nightly cleanup job has not
// yet written zero back to the row — the displayed balance stays
- // legally correct.
+ // consistent with the configured entitlement.
const today = new Date();
const carryOverExpired =
!!allowance.carryOverExpiresOn && allowance.carryOverExpiresOn < today;
- const carryOverDays = carryOverExpired ? 0 : Number(allowance.carryOverDays);
+ const carryOverDays = carryOverExpired
+ ? 0
+ : Number(allowance.carryOverDays);
const adjustmentDays = Number(allowance.adjustmentDays);
const totalEntitlement = baseDays + carryOverDays + adjustmentDays;
diff --git a/apps/api/src/app/employees/employees.dto.ts b/apps/api/src/app/employees/employees.dto.ts
index 5d0ac7e..32bcede 100644
--- a/apps/api/src/app/employees/employees.dto.ts
+++ b/apps/api/src/app/employees/employees.dto.ts
@@ -1,5 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
+ IsArray,
+ ArrayMaxSize,
+ ArrayUnique,
+ Matches,
+ ValidateIf,
IsBoolean,
IsEmail,
IsEnum,
@@ -14,6 +19,7 @@ import {
MinLength,
} from 'class-validator';
import type { Employee, WorkSchedule } from '@prisma/client';
+import { HOLIDAY_CALENDARS, type HolidayCalendar } from 'shared';
const ROLES = ['Employee', 'Manager', 'HRAdmin'] as const;
const TIME_MODELS = [
@@ -57,7 +63,9 @@ export interface EmployeeDto {
annualLeaveDays: number;
startDate: string; // YYYY-MM-DD
overtimeOpeningBalanceMinutes: number;
- bundesland: string;
+ bundesland: string | null;
+ holidayCalendar: string;
+ holidayDates: string[];
allowDailyBlockBooking: boolean;
managerId: string | null;
workScheduleId: string | null;
@@ -84,6 +92,8 @@ export function toEmployeeDto(e: EmployeeWithSchedule | Employee): EmployeeDto {
startDate: dateOnly(e.startDate),
overtimeOpeningBalanceMinutes: e.overtimeOpeningBalanceMinutes,
bundesland: e.bundesland,
+ holidayCalendar: e.holidayCalendar,
+ holidayDates: e.holidayDates,
allowDailyBlockBooking: e.allowDailyBlockBooking,
managerId: e.managerId,
workScheduleId: e.workScheduleId,
@@ -144,7 +154,7 @@ export class CreateEmployeeDto {
@ApiProperty({
example: '2026-04-01',
description:
- 'ISO date when the employee starts; Soll-Stunden are counted from here.',
+ 'ISO date when the employee starts; target working hours are counted from here.',
})
@IsISO8601({ strict: true })
startDate!: string;
@@ -159,14 +169,38 @@ export class CreateEmployeeDto {
@ApiPropertyOptional({
enum: BUNDESLAENDER,
- default: 'NW',
+ deprecated: true,
description:
- 'ISO-3166-2 code of the German state — drives the holiday calendar.',
+ 'Legacy alias for a DE-XX holidayCalendar; use holidayCalendar for new clients.',
})
@IsOptional()
@IsEnum(BUNDESLAENDER)
bundesland?: (typeof BUNDESLAENDER)[number];
+ @ApiPropertyOptional({
+ enum: HOLIDAY_CALENDARS,
+ default: 'NONE',
+ description:
+ 'Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country.',
+ })
+ @ValidateIf((_object, value) => value !== undefined)
+ @IsEnum(HOLIDAY_CALENDARS)
+ holidayCalendar?: HolidayCalendar;
+
+ @ApiPropertyOptional({
+ type: [String],
+ example: ['2026-07-01'],
+ description:
+ 'Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.',
+ })
+ @ValidateIf((_object, value) => value !== undefined)
+ @IsArray()
+ @ArrayMaxSize(3660)
+ @ArrayUnique()
+ @Matches(/^\d{4}-\d{2}-\d{2}$/, { each: true })
+ @IsISO8601({ strict: true }, { each: true })
+ holidayDates?: string[];
+
@ApiPropertyOptional({
default: false,
description:
@@ -244,11 +278,35 @@ export class UpdateEmployeeDto {
@IsInt()
overtimeOpeningBalanceMinutes?: number;
- @ApiPropertyOptional({ enum: BUNDESLAENDER })
+ @ApiPropertyOptional({ enum: BUNDESLAENDER, deprecated: true })
@IsOptional()
@IsEnum(BUNDESLAENDER)
bundesland?: (typeof BUNDESLAENDER)[number];
+ @ApiPropertyOptional({
+ enum: HOLIDAY_CALENDARS,
+ default: 'NONE',
+ description:
+ 'Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country.',
+ })
+ @ValidateIf((_object, value) => value !== undefined)
+ @IsEnum(HOLIDAY_CALENDARS)
+ holidayCalendar?: HolidayCalendar;
+
+ @ApiPropertyOptional({
+ type: [String],
+ example: ['2026-07-01'],
+ description:
+ 'Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.',
+ })
+ @ValidateIf((_object, value) => value !== undefined)
+ @IsArray()
+ @ArrayMaxSize(3660)
+ @ArrayUnique()
+ @Matches(/^\d{4}-\d{2}-\d{2}$/, { each: true })
+ @IsISO8601({ strict: true }, { each: true })
+ holidayDates?: string[];
+
@ApiPropertyOptional({
description:
'Allow one self-approved fixed-duration block on a configured working day.',
diff --git a/apps/api/src/app/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts
index e5206fc..8d1b906 100644
--- a/apps/api/src/app/employees/employees.service.ts
+++ b/apps/api/src/app/employees/employees.service.ts
@@ -64,7 +64,8 @@ export class EmployeesService {
annualLeaveDays: dto.annualLeaveDays,
startDate: new Date(dto.startDate),
overtimeOpeningBalanceMinutes: dto.overtimeOpeningBalanceMinutes ?? 0,
- bundesland: dto.bundesland ?? 'NW',
+ ...holidaySettings(dto),
+ holidayDates: dto.holidayDates ?? [],
allowDailyBlockBooking: dto.allowDailyBlockBooking ?? false,
isActive: true,
managerId: dto.managerId ?? null,
@@ -111,7 +112,10 @@ export class EmployeesService {
if (dto.overtimeOpeningBalanceMinutes !== undefined) {
data.overtimeOpeningBalanceMinutes = dto.overtimeOpeningBalanceMinutes;
}
- if (dto.bundesland !== undefined) data.bundesland = dto.bundesland;
+ if (dto.bundesland !== undefined || dto.holidayCalendar !== undefined) {
+ Object.assign(data, holidaySettings(dto));
+ }
+ if (dto.holidayDates !== undefined) data.holidayDates = dto.holidayDates;
if (dto.allowDailyBlockBooking !== undefined) {
data.allowDailyBlockBooking = dto.allowDailyBlockBooking;
}
@@ -186,3 +190,24 @@ function mapPrismaConflict(err: unknown): Error {
}
return err as Error;
}
+
+/** Canonical calendars take precedence; reject contradictory old/new fields. */
+function holidaySettings(dto: CreateEmployeeDto | UpdateEmployeeDto) {
+ if (
+ dto.bundesland &&
+ dto.holidayCalendar &&
+ dto.holidayCalendar !== `DE-${dto.bundesland}`
+ ) {
+ throw new BadRequestException(
+ 'bundesland and holidayCalendar must identify the same calendar',
+ );
+ }
+ const holidayCalendar =
+ dto.holidayCalendar ?? (dto.bundesland ? `DE-${dto.bundesland}` : 'NONE');
+ return {
+ holidayCalendar,
+ bundesland: holidayCalendar.startsWith('DE-')
+ ? holidayCalendar.slice(3)
+ : null,
+ };
+}
diff --git a/apps/api/src/app/erp-export/erp-export.service.ts b/apps/api/src/app/erp-export/erp-export.service.ts
index ff3c345..759ae8f 100644
--- a/apps/api/src/app/erp-export/erp-export.service.ts
+++ b/apps/api/src/app/erp-export/erp-export.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
-import { summarize } from 'shared';
+import { summarize, parseBreakRules } from 'shared';
import { PrismaService } from '../prisma/prisma.service';
export interface ErpTimeEntryDto {
@@ -21,10 +21,18 @@ export interface ErpTimeEntryDto {
export class ErpExportService {
constructor(private readonly prisma: PrismaService) {}
- async list(from?: Date, to?: Date, page = 1, pageSize = 100): Promise {
+ async list(
+ from?: Date,
+ to?: Date,
+ page = 1,
+ pageSize = 100,
+ ): Promise {
const take = Math.min(Math.max(pageSize, 1), 500);
const skip = Math.max(0, page - 1) * take;
- const where: Prisma.TimeEntryWhereInput = { status: 'Approved', clockOut: { not: null } };
+ const where: Prisma.TimeEntryWhereInput = {
+ status: 'Approved',
+ clockOut: { not: null },
+ };
if (from || to) {
where.clockIn = {};
if (from) (where.clockIn as Prisma.DateTimeFilter).gte = from;
@@ -44,7 +52,11 @@ export class ErpExportService {
return rows
.filter((r) => r.clockOut !== null)
.map((r) => {
- const summary = summarize(r.clockIn, r.clockOut);
+ const summary = summarize(
+ r.clockIn,
+ r.clockOut,
+ parseBreakRules(r.breakRules),
+ );
return {
id: r.id,
employeeId: r.employeeId,
diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts
index f84bb76..e857dfe 100644
--- a/apps/api/src/app/health/health.controller.ts
+++ b/apps/api/src/app/health/health.controller.ts
@@ -9,7 +9,7 @@ export class HealthResponseDto {
@ApiProperty({ example: 'openclockwork-api' })
service!: string;
- @ApiProperty({ example: '1.3.0' })
+ @ApiProperty({ example: '1.4.0' })
version!: string;
@ApiProperty({ format: 'date-time' })
diff --git a/apps/api/src/app/projects/projects.dto.ts b/apps/api/src/app/projects/projects.dto.ts
index 3ad6e70..947b5bf 100644
--- a/apps/api/src/app/projects/projects.dto.ts
+++ b/apps/api/src/app/projects/projects.dto.ts
@@ -1,5 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
+import {
+ IsBoolean,
+ IsNumber,
+ IsOptional,
+ IsString,
+ MaxLength,
+ Min,
+} from 'class-validator';
import type { Project, ServiceOrder } from '@prisma/client';
export class UpsertProjectDto {
@@ -102,7 +109,7 @@ export interface BookableProjectDto {
}
export interface ProjectReportRow {
- /** Booking day, YYYY-MM-DD in server-local time (Europe/Berlin). */
+ /** Booking day, YYYY-MM-DD in the deployment’s configured local timezone. */
date: string;
employeeName: string;
orderNo: string | null;
@@ -126,13 +133,19 @@ export interface ProjectIstStats {
byOrder: ReadonlyMap;
}
-export const EMPTY_IST_STATS: ProjectIstStats = { totalMinutes: 0, byOrder: new Map() };
+export const EMPTY_IST_STATS: ProjectIstStats = {
+ totalMinutes: 0,
+ byOrder: new Map(),
+};
function decimalToNumber(value: unknown): number | null {
return value === null || value === undefined ? null : Number(value);
}
-export function toServiceOrderDto(o: ServiceOrder, bookedMinutes: number): ServiceOrderDto {
+export function toServiceOrderDto(
+ o: ServiceOrder,
+ bookedMinutes: number,
+): ServiceOrderDto {
return {
id: o.id,
projectId: o.projectId,
diff --git a/apps/api/src/app/projects/projects.service.ts b/apps/api/src/app/projects/projects.service.ts
index 8290510..8e9baaa 100644
--- a/apps/api/src/app/projects/projects.service.ts
+++ b/apps/api/src/app/projects/projects.service.ts
@@ -29,7 +29,7 @@ interface IstRow {
minutes: number;
}
-/** Booking day in server-local time (Europe/Berlin per deployment). */
+/** Booking day in the deployment’s configured local timezone. */
function localDate(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
@@ -62,7 +62,11 @@ export class ProjectsService {
async getById(id: string): Promise {
const row = await this.findOrThrow(id);
const stats = await this.loadIstStats(id);
- return toProjectDto(row, row._count.assignments, stats.get(id) ?? EMPTY_IST_STATS);
+ return toProjectDto(
+ row,
+ row._count.assignments,
+ stats.get(id) ?? EMPTY_IST_STATS,
+ );
}
async create(dto: UpsertProjectDto): Promise {
@@ -81,7 +85,9 @@ export class ProjectsService {
return toProjectDto(created, 0);
} catch (err) {
if (isUniqueViolation(err)) {
- throw new ConflictException(`A project with code "${dto.code}" already exists`);
+ throw new ConflictException(
+ `A project with code "${dto.code}" already exists`,
+ );
}
throw err;
}
@@ -116,10 +122,16 @@ export class ProjectsService {
});
this.broadcast(id);
const stats = await this.loadIstStats(id);
- return toProjectDto(updated, updated._count.assignments, stats.get(id) ?? EMPTY_IST_STATS);
+ return toProjectDto(
+ updated,
+ updated._count.assignments,
+ stats.get(id) ?? EMPTY_IST_STATS,
+ );
} catch (err) {
if (isUniqueViolation(err)) {
- throw new ConflictException(`A project with code "${dto.code}" already exists`);
+ throw new ConflictException(
+ `A project with code "${dto.code}" already exists`,
+ );
}
throw err;
}
@@ -127,7 +139,9 @@ export class ProjectsService {
async remove(id: string): Promise {
await this.findOrThrow(id);
- const bookedEntries = await this.prisma.timeEntry.count({ where: { projectId: id } });
+ const bookedEntries = await this.prisma.timeEntry.count({
+ where: { projectId: id },
+ });
if (bookedEntries > 0) {
throw new ConflictException(
'Project has booked time entries and cannot be deleted — deactivate it instead',
@@ -142,7 +156,11 @@ export class ProjectsService {
dto: UpsertServiceOrderDto,
): Promise {
const project = await this.findOrThrow(projectId);
- this.assertOrderPlanFits(project, project.serviceOrders, dto.planHours ?? null);
+ this.assertOrderPlanFits(
+ project,
+ project.serviceOrders,
+ dto.planHours ?? null,
+ );
try {
const created = await this.prisma.serviceOrder.create({
data: {
@@ -218,8 +236,11 @@ export class ProjectsService {
/** Idempotent: assigning an already-assigned employee is a no-op success. */
async assign(projectId: string, employeeId: string): Promise {
await this.findOrThrow(projectId);
- const employee = await this.prisma.employee.findUnique({ where: { id: employeeId } });
- if (!employee) throw new NotFoundException(`Employee ${employeeId} not found`);
+ const employee = await this.prisma.employee.findUnique({
+ where: { id: employeeId },
+ });
+ if (!employee)
+ throw new NotFoundException(`Employee ${employeeId} not found`);
await this.prisma.projectAssignment.upsert({
where: { employeeId_projectId: { employeeId, projectId } },
create: { employeeId, projectId },
@@ -231,7 +252,9 @@ export class ProjectsService {
/** Idempotent: removing a non-existent assignment is a no-op success. */
async unassign(projectId: string, employeeId: string): Promise {
await this.findOrThrow(projectId);
- await this.prisma.projectAssignment.deleteMany({ where: { employeeId, projectId } });
+ await this.prisma.projectAssignment.deleteMany({
+ where: { employeeId, projectId },
+ });
this.broadcast(projectId);
}
@@ -268,7 +291,9 @@ export class ProjectsService {
* assigned via the admin matrix (403).
*/
async assertBookable(employeeId: string, projectId: string): Promise {
- const project = await this.prisma.project.findUnique({ where: { id: projectId } });
+ const project = await this.prisma.project.findUnique({
+ where: { id: projectId },
+ });
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
if (!project.isActive) {
throw new BadRequestException(`Project "${project.code}" is inactive`);
@@ -277,7 +302,9 @@ export class ProjectsService {
where: { employeeId_projectId: { employeeId, projectId } },
});
if (!assignment) {
- throw new ForbiddenException(`Employee is not assigned to project "${project.code}"`);
+ throw new ForbiddenException(
+ `Employee is not assigned to project "${project.code}"`,
+ );
}
}
@@ -301,7 +328,9 @@ export class ProjectsService {
);
}
if (!order.isActive) {
- throw new BadRequestException(`Service order "${order.orderNo}" is inactive`);
+ throw new BadRequestException(
+ `Service order "${order.orderNo}" is inactive`,
+ );
}
return order;
}
@@ -363,7 +392,9 @@ export class ProjectsService {
* Gross booked minutes per project and service order (closed, non-rejected
* entries). FLOOR-per-entry rounding matches summarize() in libs/shared.
*/
- private async loadIstStats(projectId?: string): Promise