App ID: absence
Namespace: OCA\Absence
Target platform: Nextcloud Server 34 (min-version="34" max-version="36")
License: AGPL-3.0-or-later
Category: organization / tools
This document is the implementation specification. It is intended to be handed to Claude Code (or a developer) as the single source of truth for building the app. It is deliberately prescriptive about data model, API, behavior, and the frontend design system (§15) — the UI must be built from
@nextcloud/vueand feel native, elegant, and playful.
Absence is a self-service leave-management app for a mid-size company (roughly 50–500 employees). It covers the full lifecycle of a leave request:
- Employees apply for leave (vacation, sick, unpaid, or special leave).
- Line managers approve or reject requests from their direct reports.
- HR gets a company-wide overview, statistics, exports, and the ability to override/escalate, plus management of yearly entitlements and the public-holiday calendar.
The app is a standard Nextcloud app: PHP backend on the App Framework, Vue 3 +
@nextcloud/vue frontend, database migrations, background jobs, notifications,
activity entries, email, and CalDAV integration.
- Payroll integration (beyond CSV/Excel export).
- Time tracking / attendance / clock-in.
- Integration with the built-in
user_statusout-of-office / auto-responder feature (deliberately kept independent — see §12). - Hour-level or half-day granularity (full days only — see §3.3).
- Shift planning.
There are four effective roles. A single user may hold several simultaneously (e.g. a manager is also an employee; an HR member may also manage a team).
| Role | How assigned | Capabilities |
|---|---|---|
| Employee | Every logged-in user except guest accounts (§2.2) | Create/edit/cancel own requests, view own balance, view own history, see team who's-off calendar. |
| Line manager | Derived from the LDAP manager attribute (see §2.1) — a user is a manager of everyone whose manager attribute points to them |
Approve/reject/comment on direct reports' requests, view direct reports' calendars and balances, receive coverage-conflict warnings. |
| HR | Membership of a configurable Nextcloud group (default group id hr, set in admin settings) |
Company-wide overview, statistics, exports, manage entitlements, manage public-holiday calendar, override any decision, act on escalated requests, edit/adjust any request and balance. |
| App admin | Nextcloud server admins | Configure app settings (§11): HR group, leave types, escalation window, default entitlements, CalDAV target. |
- The manager relationship is read from the user backend's
managerattribute (LDAPmanagerDN, resolved to a Nextcloud user id). - Implementation: read the manager via
OCP\IUser::getManagerUids()(the canonical NC 34 API — a user may have several configured managers; the first valid one is used). Nextcloud populates this field from LDAP mappings where configured, and it can also be set directly on the account for non-LDAP setups. (Note: there is noIAccountManager::PROPERTY_MANAGERconstant in NC 34 — the manager relationship lives onIUser, not in the account-properties list.) - Resolution is cached per-request. The resolved manager user id is denormalized
onto each leave request at submission time (
manager_uid) so historical requests remain stable even if the org chart changes later. - No manager found: the request is created with
manager_uid = NULLand is routed directly to HR (treated as immediately escalated — see §5.4).
Not every account on an instance is a member of staff. Guest accounts — users created by the Guests app — are external people invited to collaborate on files. They have no entitlement and take no leave, so the app does not treat them as employees.
Without this rule every guest would sit in the balances report and the who's-off calendar forever, with an empty allowance and nothing to show.
- One definition, one place.
EmployeeDirectoryis the only component that enumerates users;ReportService,EntitlementService,CoverageServiceandManagerResolverall ask it rather than walkingIUserManagerthemselves. A rule stated in four copies is a rule that holds in three. - Detection. A guest is a user in the Guests app's own user backend, i.e.
IUser::getBackendClassName() === 'Guests'— the same thingOCA\Guests\GuestManager::isGuest()checks. Read this way the app needs no dependency on the Guests app: where it is absent or disabled, no account has that backend and the rule is simply never true. - Consequences. Guests do not appear in balances, statistics, the sick-leave overview, exports, the who's-off calendar, the HR absence list or any people picker; they are nobody's direct report or peer, and cannot be resolved as a line manager (a request routed to one could never be approved).
- Enforced, not just hidden. The API rejects creating leave for a guest — including by HR, who may otherwise record for anyone — nominating a guest as a replacement, and setting a guest's entitlement. Filtering only the UI would leave the rule one crafted request away from being bypassed.
- Pickers. The people pickers call the app's own
GET /api/employees/searchrather than core's autocomplete, because only the server can tell a guest from a colleague. That endpoint wraps the same collaborator search, so the admin's user-enumeration settings still apply exactly as elsewhere; guests are removed from what it returns. It does add one person that search always withholds — the searching user themselves, whom the collaborator search drops because it was built to answer "who can I share with". Here the question is "whose absence is this?", and there you are a valid answer: without it an HR member cannot record their own sick leave, since the dialog will not submit without an employee and the self-service route offers only the self-requestable types (§5.6). Returning you to yourself discloses nothing, so this one result is not gated on enumeration settings. - Existing records for someone who later becomes a guest are left untouched in the database — they simply stop being listed.
All tables are prefixed with the Nextcloud table prefix and namespaced absence_.
Use OCP\Migration\IMigrationStep / ISchemaWrapper migrations under
lib/Migration/. Entities use OCP\AppFramework\Db\Entity + QBMapper.
The central table: one row per leave request.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK, autoincrement | |
employee_uid |
string(64), indexed | Requesting user. |
manager_uid |
string(64), nullable, indexed | Denormalized at submission (§2.1). |
type_id |
int, FK → absence_leave_types.id, indexed |
Leave type. |
start_date |
date | Inclusive. |
end_date |
date | Inclusive. |
working_days |
decimal(5,1) | Entered manually by the employee and verified by the manager (§7). Positive; stored as entered. |
status |
string(20), indexed | Enum, see §4. |
reason |
text, nullable | Optional employee note. |
replacement_uid |
string(64), nullable | The colleague nominated to cover (mandatory for types with requires_replacement, §5.1). |
attachment_note |
text, nullable | e.g. reference to a doctor's note (free text; file attachment optional, see §3.7). |
decided_by |
string(64), nullable | Manager or HR user id who made the last decision. |
decided_at |
datetime, nullable | |
decision_comment |
text, nullable | Rejection reason / approval note. |
escalated |
boolean, default false | True once auto-escalated to HR (§5.4). |
supersedes_id |
bigint, nullable | For the edit workflow: points to the request this one replaces (§5.3). |
calendar_event_uri |
string(255), nullable | Reference(s) to the CalDAV event(s) created on approval, for cleanup (§10). |
created_at |
datetime | |
updated_at |
datetime |
Indexes: (employee_uid, status), (manager_uid, status), (start_date, end_date), (type_id).
Configurable leave types. Seeded with defaults on install; HR/admin can add/edit.
| Column | Type | Notes |
|---|---|---|
id |
int, PK, autoincrement | |
key |
string(32), unique | Machine key, e.g. annual, sick, unpaid, special. |
label |
string(128) | Display name (translatable via l10n key where seeded). |
color |
string(7) | Hex color for chips, calendar events and ring segments. |
icon |
string(16) | Emoji shown alongside the label (🌴 annual, 🤒 sick, …), used across chips/calendar/widget (§15.4). |
counts_against_balance |
boolean | Annual/paid = true; sick/unpaid/special configurable. |
requires_approval |
boolean | When false → auto-approved & recorded on submit (§4.1). |
requires_note |
boolean | e.g. sick leave beyond N days requires a note. |
requires_replacement |
boolean, default false | When true, the employee must nominate a replacement colleague (§5.1). Annual/unpaid/special = true. |
employee_requestable |
boolean, default true | When false, employees cannot self-request this type; only HR records it, on an employee's behalf (§5.6). Sick leave = false. |
enabled |
boolean, default true | Soft-disable instead of delete. |
sort_order |
int |
Seeded defaults:
| key | label | counts_against_balance | requires_approval | requires_note | requires_replacement | employee_requestable |
|---|---|---|---|---|---|---|
annual |
Annual leave | true | true | false | true | true |
sick |
Sick leave | false | false | false | false | false (HR-recorded) |
unpaid |
Unpaid leave | false | true | false | true | true |
special |
Special leave | false | true | false | true | true |
Full days only. No half-days or hours. working_days is therefore an integer
in practice but stored as decimal(5,1) to leave room for a future half-day
feature without a migration.
Yearly leave entitlement (quota) per employee. Balance tracking is full: entitlement + used + remaining + carry-over.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
employee_uid |
string(64), indexed | |
year |
int, indexed | Calendar year. |
type_id |
int, FK | Entitlement is per leave-type-that-counts (primarily annual). |
base_days |
decimal(5,1) | Annual allotment for that year. |
carry_over_days |
decimal(5,1), default 0 | Carried from previous year (§6.2). |
manual_adjustment |
decimal(5,1), default 0 | HR correction (+/−), with adjustment_note. |
adjustment_note |
text, nullable | |
created_at / updated_at |
datetime |
Unique constraint: (employee_uid, year, type_id).
Balance formula (computed, not stored):
entitlement = base_days + carry_over_days + manual_adjustment
used = Σ working_days of requests in that year+type with status ∈ {APPROVED}
pending = Σ working_days of requests in that year+type with status ∈ {PENDING, ESCALATED}
remaining = entitlement − used
available = entitlement − used − pending (what the employee can still safely book)
A request that spans a year boundary is split for accounting: working days are attributed to the year in which each day falls.
Public-holiday tracking was removed (§7): keeping an up-to-date holiday calendar per
region is impractical, so working days are entered manually instead. There is no
holidays feature, no region concept, and no WorkingDayCalculator. (The table may still
exist as an unused orphan on instances installed before the change; it is never read.)
Threaded comments on a request (employee ↔ manager ↔ HR discussion).
| Column | Type |
|---|---|
id |
bigint PK |
request_id |
bigint FK, indexed |
author_uid |
string(64) |
body |
text |
created_at |
datetime |
An immutable, append-only audit trail per request, surfaced in the request's History tab (§15.1) so the employee, line manager and HR can all see exactly what happened and when. One row is written for every meaningful transition.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
request_id |
bigint FK, indexed | The request this event belongs to. |
actor_uid |
string(64) | Who performed the action; the literal system for automated events (e.g. escalation). |
event_type |
string(32) | Machine key: request_created, request_updated, request_edited_superseding, request_hr_edited, withdrawal_requested, request_cancelled, withdrawal_approved, request_approved, request_rejected, withdrawal_rejected, request_escalated, comment_added. |
detail |
text, nullable | Human-readable extra. For an edit this is the difference, not the result: Working days 3 → 5 (+2); Reason “Wedding” → “Wedding (extended)”. Recording only the resulting state cannot answer what anybody opens the history to ask — what changed and by how much — and a day count means nothing without the number it replaced. On creation it carries the type, dates, day count and the employee's reason, since the request itself only ever shows its current state. |
created_at |
datetime |
Events are written by the same audit() path that emits the server-log entry (§11),
so history, server log and activity stay in sync from a single call site. History
writes are best-effort — a failure never blocks the workflow.
The same idea for entitlements, which had no timeline at all: §3.7 is keyed on
request_id, and an entitlement belongs to no request, so an adjustment left only a
server-log line and an activity entry reading "Leave balance of X was adjusted" —
with neither the amount nor the reason. Worse, the note HR is required to give
when adjusting was stored on the entitlement row, displayed nowhere, and overwritten
by the next adjustment.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
entitlement_id |
bigint, indexed | The entitlement this change belongs to. |
employee_uid |
string(64), indexed | Denormalised from the entitlement so the GDPR purge (§17) and per-person views need no join to a row that is about to be deleted. |
actor_uid |
string(64) | Who made the change. |
field |
string(32) | base_days, carry_over_days or manual_adjustment. |
old_value / new_value |
float | The figure before and after; the delta is derived. |
note |
text, nullable | The reason given, attached to every figure that save touched. |
created_at |
datetime |
One row per changed figure, not per save, so "+2 days for the wedding" reads on its own. A save that moves nothing writes nothing. Surfaced in the entitlement editor in HR → Balances, and carried into the activity entry so it says what changed rather than only that something did. Best-effort, like §3.7: an unwritable history must not cost HR the adjustment they just made.
For doctor's notes: allow attaching a file reference stored in the user's Files.
Model as a nullable attachment_file_id on absence_requests pointing at a
Nextcloud file id. Phase 2 — for phase 1 a free-text attachment_note suffices.
Leave is stored as dates, not instants: start_date and end_date are the days
the employee typed on a calendar. Comparing them against "now" therefore needs a day
boundary, and a day boundary only exists relative to a timezone.
Nextcloud pins PHP's default timezone to UTC for the whole request, so a bare
date('Y-m-d') answers in UTC wherever anybody is. For a timestamp that is correct.
For a day boundary it is a bug: at 09:00 on 2 January in Auckland it is still
1 January in UTC, so an employee booking leave for today would be told it "is
entirely in the past" for a day that has not finished where they live. Berlin has the
same fault in the other direction for the last hour of every day.
Which boundary is right depends on who is asking, so ClockService exposes the
two separately rather than one ambiguous today(), and every caller has to choose:
| Used for | Examples | |
|---|---|---|
userToday() / userYear() |
anything an employee sees or is judged against, in their timezone | the "not entirely in the past" validation (§5.1), the dashboard widget's upcoming leave, the default year for balances, reports and exports |
serverToday() / serverYear() |
company-wide policy and background jobs, where there is no user to ask | short-notice measurement (§8), carry-over expiry and the year rollover (§6.2, §9) |
now() |
stored timestamp columns (created_at, decided_at, …) |
deliberately UTC — a timestamp records when something happened, which is the same moment for everyone |
Company-wide policy uses the server's clock on purpose: one request must get one answer whether the manager, HR or the job that mails them is asking, and a warning that changed depending on the reader's timezone would be worse than no warning.
The instant itself comes from ITimeFactory so tests can pin it — new \DateTime()
cannot be frozen.
┌─────────────────────────────────────────────┐
│ │
(create) ──▶ PENDING ──approve──▶ APPROVED ──withdraw req──▶ WITHDRAWAL_PENDING
│ │ │ │
employee │ │ auto-escalate │ manager/HR approves │
cancels │ │ (timeout §5.4) │ withdrawal ▼
▼ ▼ │ CANCELLED
CANCELLED ESCALATED ──────┘ (balance restored)
▲ │
│ ├─approve──▶ APPROVED
manager/HR │ └─reject───▶ REJECTED
rejects ────┴──────────────────────▶ REJECTED
Status enum values: PENDING, ESCALATED, APPROVED, REJECTED,
CANCELLED, WITHDRAWAL_PENDING.
If the leave type has requires_approval = false, a created request goes straight to
APPROVED (recorded, not routed). It still fires activity and appears in calendars and
stats, and can be cancelled/edited/adjusted afterwards. Note that sick leave combines
this with employee_requestable = false, so in practice it is only ever created by
HR on an employee's behalf (§5.6) — employees don't self-record it.
PENDING/ESCALATED/WITHDRAWAL_PENDING: counts toward pending (reducesavailable, notremaining).APPROVED: counts toward used (reducesremaining).REJECTED/CANCELLED: no balance effect; if it was previously approved, the used days are released back.
-
Employee opens New request, picks a leave type, start & end date, optional reason.
-
Employee enters the number of working days manually (§7) — a required field. The dialog shows the impact on their balance (
availablebefore/after) from the entered number. Warn — do not block — ifavailablewould go negative (HR may still allow it; unpaid/special don't count). -
Replacement (mandatory for
requires_replacementtypes). For annual, unpaid and special leave the employee must nominate a replacement — a colleague who covers for them. The UI is a user picker over the whole organisation (NcSelect+ core autocomplete), excluding the employee themselves. Submit is blocked until one is chosen. Sick leave (HR-recorded) needs none.The requirement is on the employee, not on the record. It exists because somebody arranging their own leave knows who can cover and is asked to sort it out before going. When HR records or corrects an absence for somebody else (§5.5, §5.6) they are stating a fact, often after the event, and cannot nominate cover on that person's behalf — so there the field is offered but never demanded, and reads "Who is the replacement?" rather than "Who covers for you?". HR recording their own absence gets the self-service wording and requirement, since it is their leave.
Who may be named is not conditional: a replacement must be an employee (not a guest, §2.2) and not the person being covered for, whether or not the type demands one.
-
On submit, backend:
- Validates dates (
start ≤ end, not entirely in the past — in the employee's timezone, §3.9 — unless HR, not overlapping an existing non-terminal request for the same user — reject overlap with a clear error). - Enforces
requires_note,requires_replacement(thereplacement_uidmust be present, a valid user, and not the employee), and thatworking_daysis a positive number — all 422 otherwise.working_daysis stored as entered, not recomputed. - Resolves
manager_uid(§2.1). - Creates the request as
PENDING(orAPPROVEDif auto-approve, §4.1; orESCALATEDif no manager, §2.1). - Fires notification + email + activity to the manager (or HR if escalated).
- Validates dates (
Replacement notifications (§8). When the request becomes APPROVED, the replacement is informed with a push notification ("You are covering for {employee} …"). When an approved request is later cancelled/withdrawn, the replacement is notified they no longer need to cover. Editing a request re-validates the replacement; HR can change it via the HR edit path.
- Manager sees a queue of pending requests from direct reports.
- When opening a request, the manager sees a coverage panel (§8): who else on the team is off during the requested dates, and a conflict warning if the overlap exceeds the configured threshold.
- Manager approves (optional note) or rejects (comment required).
- Decision fires notification + email + activity to the employee. Balance updates per §4.2.
- Pending request: employee may edit fields or cancel outright. Editing a pending request updates it in place and re-notifies the manager.
- Approved request — edit: employee submits an edit; this creates a new
request (
supersedes_id→ original) inPENDING, and the original staysAPPROVEDuntil the new one is approved. On approval of the superseding request, the original transitions toCANCELLEDand balance is recomputed. On rejection, the superseding request becomesREJECTEDand the original remainsAPPROVED. Only one edit may be in flight per approved request: while a superseding request is non-terminal, further edit attempts on the original are rejected (409) — otherwise two edits could both be approved and overlap. - Approved request — withdraw: employee requests withdrawal → status
WITHDRAWAL_PENDING; manager/HR must approve the withdrawal. On approval →CANCELLED(balance restored); on rejection → back toAPPROVED. Not while an edit is in flight (409, same rule as above and for the same reason): the edit excludes the original from its overlap check as part of the supersedes chain, so an original sitting inWITHDRAWAL_PENDINGwhen the edit is approved would leave both in force — the same leave counted twice, and a declined withdrawal would put two overlappingAPPROVEDrequests on the same dates. Cancel the edit first, then withdraw. Correspondingly, retiring a superseded request treatsWITHDRAWAL_PENDINGas still in force, so rows written before this rule still retire cleanly. - Cancellation of any non-terminal request restores pending/used balance.
- Background job (§9) scans
PENDINGrequests older than the configured escalation window (admin setting, default 3 working days since creation with no decision). - Such requests are marked
ESCALATED(escalated = true), and HR is notified (notification + email + activity). HR can then approve/reject on the manager's behalf. - Requests with no manager (§2.1) start life effectively escalated and are surfaced in the HR queue immediately.
- HR can approve/reject/cancel any request regardless of state, edit dates,
adjust
working_days, and edit any balance/entitlement (with an audit note). - All HR overrides are recorded in activity and
decision_comment/adjustment_note.
Some leave types have no approval workflow and are not self-requested by
employees — sick leave is the canonical example (employee_requestable = false).
- Employees never request it. They notify HR through their normal channel (out of band); the type does not appear in the employee's "New request" type picker.
- HR records it on the employee's behalf. A
createcall may include anemployeeUid; only HR may set it. ThePermissionServicerejects (403) any attempt by a non-HR user to record on behalf of someone else, or to create a non-employee_requestabletype. - Booked directly to
APPROVEDwith no manager routing — non-requestable types, auto-approve types (requires_approval = false), and any HR-on-behalf creation are recorded straight to approved (decided_by = the HR user,decided_at = now). - Fully visible & on the calendar. Because it is approved, it appears for the
employee (My leave), their line manager (Team) and HR (overview), counts in stats, and
is written to the shared/personal CalDAV calendar exactly like any approved leave
(§10). Sick leave does not count against the balance (
counts_against_balance = false). - Recorded via a dedicated "Record absence" HR action in the UI (§14.2) with an employee search + date range + type; history/audit note the entry as "Recorded by HR".
- Only HR can edit or cancel it. For an HR-recorded type,
PermissionService::canModifyreturns false for the employee (owner) — so the employee sees no Edit/Cancel controls and the API rejects such attempts; only HR may change it (§17). - Correcting a record. HR reaches individual records through the Absences
view (§15.2) — or by selecting one in Who's off / the Sick leave drilldown — and
edits or cancels it from the standard detail sidebar. There is no delete: a wrong
entry is cancelled (
CANCELLED), so the row and its history stay intact for the audit trail (§17). HR's cancel skips the withdrawal step and applies immediately (§5.5). - No "Approved" label shown. Since approval isn't a concept for HR-recorded leave,
the UI hides the status chip and the approval progress stepper when such a request
is
APPROVED(a cancelled one still shows its status). The leave-type chip (e.g. "🤒 Sick leave") still identifies it.
- HR sets
base_daysper employee per year per counting type via the HR area. - Bulk actions: set a default entitlement for a whole group/all employees for a given year (admin default in settings, §11, used to seed).
- Manual adjustments (+/−) require an
adjustment_note.
- A background job (or HR-triggered action) at year start computes carry-over into the new year's entitlement row.
- Carry-over policy is configurable (admin setting):
none,unlimited, orcappedat N days, with an optional expiry date (e.g. carried days expire end of Q1). Expired carry-over is zeroed by the rollover/expiry job. - The rollover creates the next year's
absence_entitlementsrows using the defaultbase_days+ computedcarry_over_days.
There is no automatic working-day calculation. Keeping an accurate, always-current public-holiday calendar for every region is impractical, so the app does not attempt it.
- The employee enters the number of working days the absence covers (excluding
weekends and public holidays — their judgement), as a required numeric field on the
request (
working_days). - The line manager reviews and verifies this number when approving. HR may correct it via the HR edit path (§5.5).
- The server validates only that it is a positive, sane number (
> 0,≤ 366); it never recomputes it.working_daysis stored as entered and is authoritative. - Accounting simplification: because there is no day-by-day breakdown, a request's working days are attributed wholly to the year (for balances, §3.4) and month (for trends, §13) in which it starts. Year-boundary requests are rare; split them into two requests if precise per-year accounting is needed.
Consequences of removing holidays: there is no
WorkingDayCalculator, no public holidays feature (absence_holidays), and the admin "default region" option is gone. The entered value is always authoritative; the server never recomputes it.
To save typing, the request dialog prefills the working-days field with a
client-side estimate: days in the picked range that fall on the user's working
weekdays (detected from their Availability settings, overridable) minus public
holidays (from the bundled date-holidays data, for a country/region the user may
set; detected from locale/phone as a suggestion). The estimate is served by
PersonalDefaultsService + /api/personal/config, with a small personal-settings
section appended to the built-in Availability page (lib/Settings/Personal.php).
The prefill stops as soon as the user edits the field, is absent when editing an
existing request, and is never used server-side — the manually confirmed number
is what counts (§7).
- Team calendar / who's-off: managers see their direct reports; HR sees the
whole company; every employee sees their own team (peers who share the same
manager_uid). A month/timeline view rendered from approved + pending requests.- Whose leave type is shown. The type travels with an event when the admin set
the shared-calendar visibility to
reveal, or when the viewer is somebody who could open that request and read the type off it anyway — its owner, their line manager, or HR (§2,canView). Everyone else getstypeId: nulland the client labels the absence generically. With no viewer the type is withheld from all (fail-closed).- The policy protects a colleague's privacy — a peer must not learn that somebody is on sick leave. It is not a restriction on HR, who record sick leave, nor on the line manager who approved the absence, and withholding it from them only degraded their own view: with no type to label the absence, the client fell back to a generic marker, so an HR timeline of sick colleagues read as a row of holidays.
- The generic marker must be neutral about the reason. Withholding why somebody is away and then implying a cheerful reason is worse than either revealing it or saying nothing.
- Whose leave type is shown. The type travels with an event when the admin set
the shared-calendar visibility to
- Conflict warning: when a manager reviews a request, compute the maximum number of concurrently-absent team members on any day in the requested range. If it meets or exceeds a configurable threshold (admin setting max concurrent absences per team, default e.g. 2, or a percentage), show a prominent warning in the review panel. It is a warning, not a hard block.
- Short-notice warning: a request whose leave starts sooner than the admin's
expected notice period (calendar days, default 14;
0switches the check off) is flagged to the line manager and to HR. Like the conflict warning it informs a decision and blocks nothing, and it appears wherever the decision is made: on the request's Details tab, in the notification and in the subject line of the email that asks for a decision (§11) — including the escalation to HR and the pending reminder, by which point the notice given has shrunk further.- Calendar days, not working days: "two weeks' notice" is a fortnight on the wall calendar. Measured against the server's today (§3.9), so one request gets one answer for the manager, for HR and for the job that mails them.
- Only while a decision is outstanding (
PENDING/ESCALATED), and so never for leave with no approval workflow — sick leave is recorded after the fact and auto-approved types are booked straight through (§4.1), so nobody is weighing the notice, and nobody can give notice of falling ill.
- Before the ask, not only after it: the request dialog runs the same team-scope
overlap query as the dates are picked and names the colleagues already off then,
as an
infonote — or awarningwhen booking would take the team to the concurrency threshold. It is advisory in the strongest sense: it never disables submit, a failed lookup simply omits the hint, and the employee stays free to book a clash they have already agreed with their team. The point is that the person choosing the dates learns what the manager will see at the moment they can still cheaply choose differently.- Shown only for one's own leave. The endpoint answers for the caller's team, so HR recording an absence for somebody else would otherwise be shown the wrong team's names, and no hint beats a misleading one.
- Leave types are neutralised by the same shared-calendar visibility policy that governs the timeline — a colleague's sick leave does not become visible because somebody opened the booking form.
- Provide an API endpoint to query overlaps for a date range + scope (team/company).
Registered in appinfo/info.xml <background-jobs> and/or via IJobList, using
TimedJob:
- EscalationJob (hourly): finds overdue
PENDINGrequests and escalates (§5.4). - ReminderJob (daily): sends reminder notifications to managers with pending requests approaching the escalation window.
- YearRolloverJob (daily, acts once per year / idempotent): computes carry-over and seeds next-year entitlements (§6.2); zeroes expired carry-over.
- CalendarSyncJob (optional, if async sync chosen): reconciles approved leave with the CalDAV target calendar (§10).
All jobs must be idempotent and safe to run repeatedly.
Approved leave is written into Nextcloud Calendar via CalDAV.
- Use the
dav/calendarserver APIs —OCA\DAV\CalDAV\CalDavBackend/OCP\Calendar\ICalendarProvider&OCP\Calendar\IManagerfor reading, and the CalDAV backend for writing events. Prefer the publicOCP\Calendarinterfaces where write support is available; otherwise writeVEVENTobjects to the target calendar via the DAV backend. - Two targets (both configurable, either can be disabled in admin settings):
- Personal: an all-day
VEVENTon the employee's own calendar (auto-created "Absence" calendar or a configured one) marked as busy/out-of-office (X-NC-... busy/TRANSP: OPAQUE). - Shared team "Absences" calendar: a company/team calendar showing everyone's approved leave (all-day events titled with employee + leave type, colored by type). Auto-provisioned and shared with the relevant groups.
- Personal: an all-day
- Lifecycle: create the event on approval; delete/update it on
cancellation/withdrawal/edit. Store the created event's URI/UID on the request
(add nullable
calendar_event_uricolumn toabsence_requests) so it can be cleaned up. - Sick-leave / private types: allow admin to configure whether the event title on the shared calendar reveals the type or shows a neutral "Absent".
- Sync may be synchronous (on decision) or via
CalendarSyncJob; synchronous is acceptable for phase 1, with the job as a reconciler.
All four channels are required.
- Nextcloud notifications: implement
OCP\Notification\INotifier. Events: new request (→ manager), decision made (→ employee), escalation (→ HR), reminder (→ manager), withdrawal request (→ manager/HR), comment added (→ the employee and their manager, plus HR once the request has been escalated; never back to the comment's own author), replacement assigned (→ replacement, on approval) and replacement cancelled (→ replacement, when approved leave is cancelled) — §5.1. These are pushed (a standard NC notification is delivered to push automatically). - Actionable where the recipient owes an answer. The four notifications that ask
for a decision — new request, escalation, reminder, withdrawal — carry three
buttons: Approve, Decline and Review.
- Approve is a
POSTstraight torequest#approve, the same endpoint the app uses, so the common answer costs one click and no page load. The notification dismisses itself on success; a request whose state moved on in the meantime fails the same way it would in the app. - Decline is deliberately not a one-click verdict. A decline requires a
reason (§5.2), and a manager able to reject somebody's holiday from a toast
without saying why would be a worse app, not a faster one. It is a
WEBlink to#/requests/{id}?decide=decline, which opens the request with the reason box already unfolded — a step better than Review for someone who has decided to say no, and still a decision they have to confirm. - A withdrawal asks the opposite question, so its buttons read Approve withdrawal / Keep leave, matching the sidebar's wording (§15.2).
- Notifications that merely report an outcome (approved, declined, comment, replacement) carry no decision buttons: nothing is owed on them.
- Approve is a
- Email: via
OCP\Mail\IMailerwith templated messages (OCP\Mail\IEMailTemplate) for each of the above events. Respect the user's configured email + language. - What people wrote travels with the message. Free text on a request — the
applicant's
reason, thedecision_comment, the body of a comment (§3.6) — is carried by the notification and the email that announce the event, attributed to its author. A recipient must never have to open the app to find out what was actually said. The email quotes the text in full; the notification, which renders on one line, carries a whitespace-collapsed opening of it and where a note is present it takes the place of boilerplate like "Review it in Absence.". One deliberate exception: the replacement (§5.1) is told the dates only, never the reason — cover duty does not come with a right to read it. - Activity: implement
OCP\Activity\IProvider/ setting so all state changes appear in the Activity app feed, filterable to an "Absence" activity type. Include activity for HR overrides and balance adjustments. - Server-log audit trail (always-on): every important action is written to
nextcloud.logas a structured entry tagged["app" => "absence"]with a machine-readableactionand full context (actor, request id, employee, type, dates, working days, status) plus, where the action carried free text, that text itself —detailfor a comment body or decision comment,reasonfor the note the applicant wrote on creation. Covered actions: the full request lifecycle (create, edit, superseding edit, HR edit, approve, reject, cancel, withdrawal request/approve/reject, escalate, comment), entitlement changes, bulk-set, carry-over rollover/expiry, leave-type and holiday changes, admin-config changes, and GDPR user-data purge.- These entries must appear regardless of the instance log level. Achieve this
with Nextcloud's
log.condition.appsmechanism: on install/update a repair step mergesabsenceintolog.condition.apps(never clobbering existing config), which makes Nextcloud force DEBUG capture for the app's tagged messages; a matching uninstall step removes it. Only messages taggedapp=absence(the audit calls) are forced always-on — incidental diagnostic logs still follow the normal level. - The same call site also writes the per-request history event (§3.7), keeping the log, history and activity in sync.
- These entries must appear regardless of the instance log level. Achieve this
with Nextcloud's
App admin settings page (OCP\Settings\ISettings, type: 'admin', section under a
new "Absence" settings section or "Personal info"/"Administration"):
| Setting | Default | Notes |
|---|---|---|
| HR group id | hr |
Which NC group is HR (§2). |
| Default annual entitlement (days) | 28 | Seed for new entitlement rows. |
| Escalation window | 3 working days | For EscalationJob (§5.4). |
| Reminder lead time | 1 day before escalation | For ReminderJob. |
| Carry-over policy | capped |
none / unlimited / capped. |
| Carry-over cap (days) | 5 | Used when capped. |
| Carry-over expiry | none / date (e.g. Mar 31) | §6.2. |
| Max concurrent team absences | 2 | Conflict threshold (§8). |
| Expected notice period | 14 calendar days | Short-notice threshold (§8); 0 disables. |
| CalDAV: write personal events | true | §10. |
| CalDAV: write shared team calendar | true | §10. |
| Shared calendar type-visibility | neutral | Reveal type vs "Absent" on shared cal. |
| Leave types | seeded (§3.2) | Add/edit/enable/disable, colors, flags. |
The only personal settings are the working-day prefill preferences (§7.1) — a small section appended to the built-in Availability page, not a separate Absence settings page. Notification preferences defer to the global Nextcloud notification settings.
Note on out-of-office: the app does not touch the built-in
user_statusout-of-office / auto-responder feature. Calendar busy state (§10) is the only presence signal it sets.
The HR area (visible only to HR-group members) provides:
- Per-employee balances table: entitlement / used / pending / remaining / carry-over, per year and type. Filterable by group/department, searchable, sortable. Drill-down to an employee's request history.
- Company-wide trends (charts): absence days over time (by month), by leave
type, by department/group; headcount-on-leave heatmap. Use a lightweight charting
approach consistent with Nextcloud (e.g.
vue-chartjs/Chart.js already used elsewhere in the ecosystem — confirm a bundled option; otherwise a small SVG chart component). Follow thedatavizdesign guidance for palette/accessibility. - Who's-off calendar (org-wide): all absences, filterable by team/type, for planning.
- Export: CSV and Excel (
.xlsx) export of raw requests and of the balances report, with date-range and group filters, for payroll/external HR. CSV via native PHP; XLSX via a bundled library (e.g. PhpSpreadsheet) or a documented CSV fallback if a dependency is undesirable.
Managers get a scoped version (their reports only) of the who's-off calendar — the
Team timeline. The scoped balances table is specified but not yet built: every
balance view is HR-only today, and report#balances asserts HR. balance#forEmployee
already authorises a manager to read one report's balance (canViewBalanceOf), so the
gap is a UI one; no frontend calls it yet.
RESTful controllers under lib/Controller/, routes in appinfo/routes.php, all
guarded by the appropriate middleware/attribute-based access checks
(#[NoAdminRequired] for employee endpoints; explicit HR/manager checks in a shared
PermissionService). Use OCS or app routes consistently (app routes recommended for
the SPA). All list endpoints paginate and accept filters.
Requests
GET /api/requests— list (scoped by role: own / reports / all-for-HR; filters: status, type, date range, employee, group).POST /api/requests— create (§5.1).GET /api/requests/{id}— detail (with comments, coverage summary, and the employee's balance for that leave type in the year the leave starts — gated oncanViewBalanceOf, so a colleague who may read the request still cannot read the allowance. Null for a type that counts against nothing. It is there because "took three days" says nothing about whether any are left, and finding out otherwise means abandoning the view for the Balances report).PUT /api/requests/{id}— edit (§5.3; behavior depends on current status).POST /api/requests/{id}/cancel— cancel / request withdrawal.POST /api/requests/{id}/approve— manager/HR approve (optional comment).POST /api/requests/{id}/reject— manager/HR reject (comment required).POST /api/requests/{id}/comments— add comment.
Balances & entitlements
GET /api/balance— current user's balance (all years/types or filtered).GET /api/employees/{uid}/balance— manager (reports) / HR only.GET /api/entitlements/PUT /api/entitlements/{id}— HR manage.POST /api/entitlements/bulk— HR bulk set.PUT /api/entitlements/{id}— HR manage.adjustmentDeltaadds to the stored manual adjustment;manualAdjustmentsets it outright. Corrections are made as deltas ("+2 for the wedding", later "−2, booked in error") and must cancel to nothing — treating the second as an absolute set is what made 25 → +2 → 27 → −2 land on 23 instead of back on 25. Sending both is refused rather than guessed at.GET /api/entitlements/{id}/history— HR only: who changed which figure on an entitlement, from what to what, and the note they gave. One row per figure per save, so a single adjustment reads on its own rather than having to be diffed out of a blob.
Coverage & calendar
GET /api/coverage?from&to&scope=team|company— overlaps + conflict count (§8).GET /api/calendar?from&to&scope— events for the in-app calendar/timeline.
Reference data
GET /api/leave-types.- HR/admin CRUD for leave types. (No holidays endpoints — the holidays/region feature was removed, §7.)
HR reporting
GET /api/reports/balances— balances report (filters).GET /api/reports/trends— aggregated stats for charts.GET /api/reports/sick-leave— sick-leave overview, every employee ranked by days lost. Counts the leave type keyedsickby default;typeIdcounts another type instead, which the Sick leave view exposes as a type picker. The response carries the types it aggregated, so the page can name what it is counting rather than implying "sickness" is a fixed concept — and can say so plainly when an instance has no matching type instead of showing a table of zeroes.GET /api/export/requests.csv|.xlsx,GET /api/export/balances.csv|.xlsx.
All write endpoints require CSRF protection (default AppFramework) and validate role
server-side. Never trust client-computed working_days or manager_uid.
Single-page app mounted from the app's main navigation entry, plus HR/admin settings
pages. Build with Vite (the tree already uses Vite — see build/frontend). Use
@nextcloud/vue, @nextcloud/axios, @nextcloud/router, @nextcloud/l10n,
@nextcloud/dialogs, and @mdi/svg / vue-material-design-icons for icons.
The app must feel native to Nextcloud, pretty, elegant, and playful — not a generic form-over-table CRUD tool.
- Native first, always the design system. Never hand-roll a control that
@nextcloud/vuealready provides. All layout, spacing, radius, elevation, and color come from Nextcloud CSS variables (--color-*,--border-radius*,--default-grid-baselinespacing scale,--animation-*). No hard-coded hex colors, no custom pixel spacing that ignores the grid baseline. - Elegant. Generous whitespace, a calm neutral canvas (
--color-main-background), content grouped on subtly elevated cards (--color-background-hover), one clear primary action per view. Density and typography follow Nextcloud defaults — no dense spreadsheets except where HR genuinely needs a data table. - Playful, tastefully. Personality lives in small moments, never at the expense of
clarity or accessibility:
- Leave types carry a color + emoji/MDI icon (🌴 annual, 🤒 sick, 🕊️ special, …) used consistently across chips, calendar events, and cards.
- Friendly empty states via
NcEmptyContentwith an illustrative icon and warm one-liner ("No requests yet — time to plan a break? 🌴"). - Micro-interactions: gentle transitions on status changes — suppressed under
prefers-reduced-motion. (Decisions themselves stay quiet: approving shows no toast or confetti; the updated status chip is the feedback.) - Balance shown as delightful progress rings/bars, not just numbers — a donut ring of used vs. remaining per type, animated on load.
- Warm, human microcopy throughout (§15.5).
- Accessible & adaptive (non-negotiable). WCAG AA contrast in light and dark
themes, full keyboard nav, visible focus rings, ARIA labels, respects
prefers-reduced-motionand high-contrast themes. Playfulness decorates; it never carries meaning alone. - Responsive. Works from mobile width up; navigation collapses per Nextcloud behavior; tables become card lists on narrow screens.
Use the canonical Nextcloud app scaffold so the sidebar, content, and detail sidebar behave exactly like every other Nextcloud app:
NcContent(app-name="absence")
├── NcAppNavigation ← LEFT sidebar (primary navigation)
│ ├── NcAppNavigationNew ("+ New request") ← prominent primary CTA at top
│ ├── NcAppNavigationItem My leave (icon: mdiBeach)
│ ├── NcAppNavigationItem Approvals [badge] (managers only; badge = pending count)
│ ├── NcAppNavigationItem Team (icon: mdiAccountGroup)
│ ├── NcAppNavigationCaption "HR" (HR only)
│ ├── NcAppNavigationItem Balances
│ ├── NcAppNavigationItem Statistics
│ ├── NcAppNavigationItem Who's off
│ └── NcAppNavigationItem Exports (no settings entry — §12: no personal settings)
├── NcAppContent ← CENTER (the active routed view)
│ └── router-view
└── NcAppSidebar ← RIGHT detail sidebar (request detail)
└── NcAppSidebarTab(s): Details · Coverage · Comments · History
- Left
NcAppNavigationis the required primary sidebar. Items are gated by role (§2): managers see Approvals with a live pending-countNcCounterBubble; HR sees the HR section (grouped under anNcAppNavigationCaption). Every item uses an@mdiicon. TheNcAppNavigationNew"New request" button sits at the top as the single prominent primary action. - Center
NcAppContenthosts the routed view. - Right
NcAppSidebaropens when a request is selected, with tabbed detail (Details / Coverage / Comments / History) — the standard Nextcloud master-detail pattern, used instead of a modal for viewing a request. - Route with
vue-router; deep-link each view (#/my,#/approvals,#/team,#/hr/balances, …).
- My leave (
#/my) — a "next break" hero (gradient card with a countdown to, or "enjoy your leave!" during, the soonest upcoming approved leave). The countdown is in whole days until the leave is under 48 hours away, at which point it ticks in seconds (H:MM:SS, tabular figures) under the eyebrow Almost there — "1 day to go" is a poor description of an afternoon. The ticking element carriesrole="timer", which is silent by default: anaria-liveregion here would read the clock aloud every second. The page's clock also keeps the day count honest across midnight for a tab left open, and only commits a new value when the rendered text can have changed, so a hero reading "12 days to go" does not re-render the request list once a second all year. Then one compact balance card per counting leave type: an animated balance ring (the remaining number counts up on load) beside a breakdown ledger (base allowance- carry-over ± adjustment = entitlement, minus used and pending → available).
Below, two monthly
BarCharts for the current year — approved leave taken per month and sick days per month (always visible, empty months at zero) — then the list of my requests. Each row carries a leave-type accent stripe and a status chip, hover-lifts, and animates in/out via<TransitionGroup>. While loading, a skeleton placeholder shows instead of a spinner. The empty state uses an animated palm illustration + warm copy + CTA. Selecting a request opens the rightNcAppSidebar.
- carry-over ± adjustment = entitlement, minus used and pending → available).
Below, two monthly
- New/Edit request — an
NcModalwith: leave-type picker (NcSelectshowing color+icon; employees only see self-requestable types — sick leave is excluded, §5.6), From / To date fields using the standard native picker (NcDateTimePickerNative, locale-formatted), and a required Working days number field (NcTextField type="number") the employee fills in manually (§7). A live preview shows the balance impact as before → after plus a fill bar from the entered number. For types withrequires_replacement, a mandatory replacement picker (NcSelect+ org-wide user autocomplete, self excluded) appears. Optional reason (NcTextArea, labelled "(optional)" unless the type requires a note) and conditional note. Submit disabled until valid; negativeavailableshows an inlineNcNoteCard type="warning"(warn, don't block). A second inline note names the colleagues already off during the picked dates (§8) — debounced as the dates move, one entry per person, "and N more" past three. - Record absence (HR only) — the same dialog opened in HR mode from the HR
nav: adds an employee search (
NcSelectwith user autocomplete) and offers all enabled types (including sick); the balance preview is hidden (it's another person's leave). The primary button reads "Record" (not "Submit request" — there is no request/approval flow). Submitting postsemployeeUidand books the leave directly as approved (§5.6). - Approvals (managers) — skeleton-then-list queues (team + escalated), rows with the
same accent-stripe/transition treatment. Opening one reveals the request progress
stepper and coverage panel (§8) in the sidebar with a conflict warning when the
threshold is met. Approve / Reject as
NcButton(primary / error); reject requires a comment; a successful approve updates the list quietly (no toast or confetti). - Team / Who's off — a Gantt-style month timeline (
TeamTimeline, §15.7): a sticky avatar rail with continuous rounded leave pills (colored by type, hatched while pending), weekend shading, a "today" line, month navigation + a "Today" jump, and a legend.scope="team"for managers,scope="company"for HR. Withselectablethe pills become buttons that open the request in the sidebar; only Who's off sets it, because HR may read every request the org-wide calendar shows while a team timeline can include leave the viewer is not allowed to open. - Absences (
#/hr/absences, HR only) — the counterpart to Record absence: the full list of recorded absences with filters for employee (user autocomplete), leave type, status and year, paged with a "Load more" button. Rows are the sameRequestListItemas elsewhere and open the detail sidebar, whose Edit and Cancel controls are what let HR correct a wrong vacation or sick day (§5.6). People are named, never printed as user ids: requests are serialized with anemployeeName(display name, falling back to the uid for a deleted account), and the sidebar names the employee under its title whenever the leave is not the viewer's own. Accepts?employee=&employeeName=&type=&status=&year=so other views can deep-link into it — the Sick leave overview does, from each employee row. - HR (HR group only): Balances (searchable/sortable data table →
entitlement/used/pending/remaining/carry-over, inline entitlement editor, skeleton on
load), Statistics (stat tiles + a
LineChartarea for monthly trend and aDonutChartwith legend for by-type, following thedatavizpalette & a11y rules, themed to Nextcloud vars), Who's off (org-wide Gantt timeline), Exports (filter form + CSV buttons), plus entitlement / leave-type / holiday management. - Admin settings — §12 (no personal settings page), via
NcSettingsSection,NcCheckboxRadioSwitch,NcTextField,NcSelect.
All list/loading transitions and the illustration animations are suppressed
under prefers-reduced-motion.
Build exclusively from these (extend only for a genuine gap):
NcContent, NcAppNavigation, NcAppNavigationNew, NcAppNavigationItem,
NcAppNavigationCaption, NcAppContent, NcAppSidebar,
NcAppSidebarTab, NcButton, NcModal/NcDialog, NcSelect,
NcDateTimePickerNative, NcTextField, NcTextArea, NcCheckboxRadioSwitch,
NcNoteCard, NcEmptyContent, NcLoadingIcon, NcAvatar, NcUserBubble,
NcCounterBubble (nav badges), NcActions/NcActionButton (row menus),
NcChip/status pills, NcListItem, NcSettingsSection,
NcDateTimePickerNative (From/To in the dialog + HR filters), NcTextField (manual
working days). Icons from @mdi/svg. (The Dashboard widget is an API widget, §15.6, not
NcDashboardWidget.)
App-specific components built on top of the above (see §15.7): BalanceRing,
BalanceCard, StatusChip, LeaveTypeChip, RequestListItem, RequestDialog,
RequestSidebar, RequestStepper, CoveragePanel, TeamTimeline, SkeletonList,
PalmIllustration, DonutChart, LineChart, BarChart.
- Status colors map to Nextcloud semantic vars — but chips render the label using
the contrast-optimised
--color-*-textvariants (--color-warning-text,--color-success-text,--color-error-text; muted--color-text-maxcontrastfor cancelled) on a solid tint (color-mix(... 18%, --color-main-background)) with a subtle border — so labels stay legible in both themes.PENDING/ESCALATED→ warning,APPROVED→ success,REJECTED→ error,CANCELLED→ muted,WITHDRAWAL_PENDING→ warning. - Leave-type color comes from
absence_leave_types.color(§3.2) and is the single source for chips, the row accent stripe, calendar/timeline pills, chart segments, and ring segments — consistent everywhere. Leave-type chips pull their text 50% toward--color-main-textfor contrast (colors are arbitrary/HR-defined). - Elevation & shape: cards use
--border-radius-largeand hover--color-background-hover; never custom shadows outside Nextcloud tokens.
Warm, concise, human, translatable. Examples (final strings via l10n):
empty my-leave → "Nothing booked yet — your next adventure starts here 🌴";
request sent → "On its way
A Dashboard tile that adapts to the viewer's role:
- Every employee sees a balance summary line (remaining annual leave, used, pending) followed by their own upcoming/pending leave (type + date range + status).
- Line managers additionally see their team's requests awaiting a decision.
- HR additionally sees the escalated queue across the whole company.
Every item deep-links into the app (#/requests/{id} or #/my); the list is capped
at the dashboard's requested limit with a friendly empty state.
Implemented as an API widget (OCP\Dashboard\IAPIWidgetV2 + IAPIWidget for
back-compat + IIconWidget for the palm-tree icon), registered via
registerDashboardWidget(). The core Dashboard renders the item list, so no separate
frontend bundle is required. (A richer custom-rendered variant with a balance ring and
grouped sections — via OCA.Dashboard.register() and its own JS entry — is a possible
future enhancement.)
Signature components built on top of @nextcloud/vue:
BalanceRing— animated SVG donut of used vs. remaining for one leave type; the arc grows and the centre number counts up on load.BalanceCard— a My-leave card pairing a compactBalanceRingwith the breakdown ledger (base + carry-over ± adjustment = entitlement, − used − pending → available, right-aligned beside the ring).TeamTimeline— Gantt-style month view: sticky avatar rail, continuous rounded leave pills (hatched while pending), weekend shading, "today" line, month nav.RequestStepper— horizontal progress stepper (Requested → Review/With HR → Approved/Declined/Cancelled/Withdrawing) shown atop the sidebar Details tab.RequestSidebar— master-detail sidebar with Details (stepper + facts + actions), Coverage, Comments, and History (§3.7) tabs.SkeletonList— shimmer placeholder shown while lists load (instead of a spinner).PalmIllustration— animated empty-state SVG (swaying palm, bobbing sun) that follows the calendar: blossom and a passing bird in spring, full sun in summer, fronds turning and shedding in autumn, a snow-capped island and snowfall in winter. Meteorological seasons, flipped for southern-hemisphere users off the country already chosen for public holidays — no new setting, and no snow in a Sydney January. Aseasonprop forces one for screenshots and tests.DonutChart/LineChart— dependency-free, theme-aware SVG charts for HR stats (by-type donut with legend; monthly-trend area line).BarChart(same family) powers the monthly leave-taken and sick-days charts on My leave.StatusChip/LeaveTypeChip— contrast-optimised pills (§15.4).
Motion policy: every animation (ring count-up, list <TransitionGroup>, timeline
pills, skeleton shimmer, illustration, stepper pulse, chart draw) is disabled
under prefers-reduced-motion: reduce.
- Target the latest stable Nextcloud (34) with full multi-language support.
- All user-facing PHP and JS strings wrapped in translation functions
(
$l->t(...)/t('absence', ...)). Providel10n/structure and.potextraction via the standard Nextcloud transifex/translationtoolsetup. - Dates/numbers localized via Nextcloud locale APIs; week-start respects locale.
- Seeded leave-type labels use translatable defaults.
- Strict server-side authorization on every endpoint via a central
PermissionService(is-owner / is-manager-of / is-HR / is-admin). - An employee may only read their own data; a manager only their reports; HR all.
- Sick-leave reasons/notes are sensitive: never expose reason/note text to peers; restrict shared-calendar titles per the type-visibility setting (§10/§12).
- Full audit trail on three levels: per-request history (§3.7), the Activity feed,
and an always-on
nextcloud.logaudit entry per action (§11) for all decisions, overrides, and balance edits. - Input validation and rate-limiting on create/edit endpoints
(
#[UserRateLimit]where appropriate). - GDPR: provide data via Nextcloud's user data export/deletion hooks — implement
OCP\User\Events\BeforeUserDeletedEventhandling to anonymize/remove a deleted user's requests per policy, and register with the privacy/personal-data-export mechanism.
- PHPUnit unit tests for services (BalanceService, PermissionService, carry-over, the replacement/state-machine logic) and mappers, plus integration tests for controllers. Aim for high coverage on the balance/state-machine logic.
- Frontend unit tests (Vitest) for balance preview and date logic; component tests for the request form.
- State-machine tests covering every transition in §4, including edit/withdrawal and escalation edge cases and year-boundary accounting.
- Lint/format per repo standards (
composer cs:check,psalm,eslint,stylelint).
As built:
apps/absence/
├── appinfo/
│ ├── info.xml # id=absence, ns=Absence, NC 34–36, nav entry, jobs, notifier, activity, repair steps, settings
│ └── routes.php # app routes for the SPA + JSON API (§14)
├── composer.json # OCA\Absence\ autoload, dev deps (phpunit, psalm, cs-fixer, nextcloud/ocp)
├── package.json # frontend deps (@nextcloud/vue 9, vue 3, @nextcloud/vite-config)
├── vite.config.js # inlineCSS injection → js/absence-*.mjs
├── psalm.xml
├── img/ # app.svg (white), app-dark.svg (black) — account-clock glyph
├── lib/
│ ├── AppInfo/Application.php # registers Notifier, Dashboard widget, UserDeleted listener
│ ├── Controller/ # Page, Request, Balance, Entitlement, Coverage, Calendar,
│ │ # LeaveType, Report, Export, Config (+ ApiControllerTrait)
│ ├── Db/ # LeaveRequest, LeaveType, Entitlement, RequestComment,
│ │ # RequestEvent + matching QBMappers
│ ├── Service/ # ConfigService, ManagerResolver, PermissionService,
│ │ # BalanceService, RequestService, CoverageService, CalendarService,
│ │ # NotificationService, ActivityPublisher, ReportService, ExportService,
│ │ # EntitlementService, SessionService
│ ├── BackgroundJob/ # EscalationJob, ReminderJob, YearRolloverJob
│ ├── Dashboard/AbsenceWidget.php # role-aware API widget (§15.6)
│ ├── Notification/Notifier.php
│ ├── Activity/ # Provider + Setting
│ ├── Exception/ # AbsenceException + Validation/Forbidden/NotFound/Conflict
│ ├── Migration/ # Version1000Date… schema; Version1001/1002/1003 (columns);
│ │ # SeedLeaveTypes; EnableAuditLogging / DisableAuditLogging (§11)
│ ├── Settings/ # AdminDeclarativeSettings (server-rendered form) + AdminSection + Personal
│ └── Listener/UserDeletedListener.php # GDPR purge (§17)
├── src/ # Vue 3 SPA: App.vue, router, store, api,
│ │ # utils/ (dates),
│ │ # views/ (MyLeave, Approvals, Team, hr/*, settings/PersonalSettings),
│ │ # components/ (BalanceRing, BalanceCard, StatusChip, LeaveTypeChip,
│ │ # RequestListItem, RequestDialog, RequestSidebar, RequestStepper,
│ │ # CoveragePanel, TeamTimeline, SkeletonList,
│ │ # PalmIllustration, DonutChart, LineChart, BarChart)
│ └── {main,personal-settings}.js
├── templates/ # main.php, personal-settings.php (mount points)
├── tests/ # phpunit.xml
├── README.md
└── SPECIFICATION.md # this file
Note: CalendarSyncJob from the original plan was not needed — calendar writes are
synchronous on decision (§10), which is sufficient for phase 1.
Definition of done for phase 1 — all met:
- ✅ Employee can apply; manager can approve/reject; HR sees overview, stats, export.
- ✅ Full balance tracking with entitlements + carry-over.
- ✅ Full-day requests, configurable leave types, manually entered + manager-verified working-day counts (§7).
- ✅ Notifications (bell), email, activity, and always-on server-log audit for all state changes.
- ✅ Escalation to HR on manager non-response.
- ✅ Edit/cancel/withdraw workflow with balance restoration.
- ✅ Approved leave written to Nextcloud Calendar (personal + shared team), removed on cancellation.
- ✅ Coverage conflict warnings for managers.
- ✅ Per-request history timeline visible to employee/manager/HR (§3.7, §15.1).
- ✅ Role-aware Dashboard widget (§15.6).
- ✅ Multi-language, NC 34, lint-clean; core logic covered by executable tests.
Phase 2 (nice-to-have): file attachments for doctor's notes (§3.8), ICS import for holidays, percentage-based coverage thresholds, department analytics dashboards, half-day support (schema already allows it), a richer custom-rendered Dashboard widget, mobile-tuned views.
| Topic | Decision |
|---|---|
| Approval flow | Manager approves; HR escalation & override; HR handles no-manager cases |
| Manager source | IUser::getManagerUids() — the NC 34 manager account field (denormalized per request); no IAccountManager::PROPERTY_MANAGER exists |
| Leave types | Annual/paid, sick, unpaid, special (configurable; color + emoji icon; employee_requestable flag) |
| Sick leave | HR-recorded (no approval, not self-requested); visible to employee/manager/HR and on the shared calendar (§5.6) |
| Replacement | Mandatory for annual/unpaid/special (org-wide picker); replacement gets a push notification on approval and on cancellation (§5.1) |
| Balances | Full tracking: entitlement, used, pending, remaining, available, carry-over |
| Calendar | Nextcloud Calendar via CalDAV (personal + shared team) |
| Notifications | Nextcloud notifications + email + activity stream + always-on server log |
| Holidays | None — removed; working days are entered manually (§7) |
| Working days | Entered manually by the employee, verified by the manager (no auto-calc, no region) |
| Coverage | Team overlap view + conflict warnings for managers |
| Granularity | Full days only (schema allows future half-days) |
| HR reports | Per-employee balances, company trends/charts, CSV/Excel export, who's-off calendar |
| Out-of-office | Independent — does NOT touch built-in user_status |
| Lifecycle | Full edit workflow (edit/cancel/withdraw with re-approval + balance restore) |
| HR role | Configurable Nextcloud group (default hr) |
| Escalation | Auto-escalate to HR after a configurable pending window |
| History | Per-request timeline (§3.7) shown to employee/manager/HR |
| Dashboard | Role-aware API widget (§15.6) |
| Platform | Nextcloud 34 (compatible up to 36), standard PHP + Vue 3 app, full multi-language |
Recorded during the build so the spec matches the code:
- Manager resolution uses
OCP\IUser::getManagerUids()(§2.1). The originally proposedIAccountManager::PROPERTY_MANAGERdoes not exist in NC 34 — the manager relationship lives onIUser, not in the account-properties list. - Always-on audit logging (§11) was added: a structured
nextcloud.logentry per important action, forced regardless of log level via a mergedlog.condition.appsrepair step (EnableAuditLogging/DisableAuditLogging). - Per-request history (§3.7,
absence_request_events) was added and surfaced in the sidebar History tab, written from the sameaudit()call site. - Dashboard widget (§15.6) implemented as a role-aware
IAPIWidgetV2(no custom frontend bundle). - Leave types carry an
icon(emoji) column in addition tocolor(§3.2), used consistently across chips, calendar, timeline and the widget. UI chips use Nextcloud's contrast-optimised--color-*-textvariables on solid tints for readability in both themes (§15.4). - App icon is a palm tree:
img/app.svgis white (for the coloured top bar, which the server inverts on bright backgrounds);img/app-dark.svgis black (for light surfaces — settings, notifications, activity). - Default entitlement falls back to the configured default only for the
annualtype; other counting types start at zero until HR grants an entitlement, and carry-over rollover only processes employees/types that already had an entitlement (avoids fabricating balances). - Frontend build: standalone per-app
@nextcloud/vite-configwithinlineCSS: { relativeCSSInjection: true }, so a singleUtil::addScriptstyles the whole app (no separate stylesheet to enqueue). Output:js/absence-*.mjs. - CalendarSyncJob was dropped — synchronous CalDAV writes on decision suffice for phase 1 (§10, §19).
- Platform range widened to NC 34–36 in
info.xml. - Sick leave is HR-recorded, not self-requested (§5.6): added an
employee_requestableflag to leave types (sick = false), a migration to add it on existing installs (Version1002…, app bumped to 1.0.2), HR create-on-behalf via anemployeeUidoncreate, and a "Record absence" HR action (employee search + all types) in the frontend. Employees no longer see sick leave in their request picker. The "Approved" chip and approval stepper are hidden for HR-recorded approved leave, and only HR may edit/cancel it (canModify). - Manual working days; no holidays/region (§7): removed the automatic working-day
calculation and the whole public-holiday/region concept. The employee now enters
working_days(validated> 0), the manager verifies it, and balances/trends attribute it to the request's start year/month. DeletedWorkingDayCalculator, theHoliday/HolidayMapper/HolidayController+ routes, the personal settings page (region), the admin "default region" option, the frontendRangeCalendar+ client-side working-day helpers; the request dialog uses the standard native date pickers plus a manual "Working days" field. App bumped to 1.0.4. (Theabsence_holidaystable is left as an unused orphan on already-installed instances.) - Working-day prefill (§7.1, added after the holidays feature was removed): the
request dialog prefills
working_daysfrom the user's Availability weekdays and a lazily-loadeddate-holidaysdataset, configurable in a personal-settings section on the Availability page. Purely client-side convenience — the server still stores the number as entered. - Escalation & reminder windows count working days (Mon–Fri approximation, §5.4): a request filed on Friday does not burn its manager's window over the weekend.
- One in-flight edit per approved request (§5.3): a second superseding edit is rejected with 409 while one is pending.
- Mandatory replacement (§5.1): a
requires_replacementleave-type flag (annual/ unpaid/special = true) + areplacement_uidon requests (Version1003…, app bumped to 1.0.3). The request dialog shows a mandatory org-wide user picker; the backend validates it; the replacement gets a push notification on approval and on cancellation of approved leave (newreplacement_assigned/replacement_cancellednotification + email subjects). Shown in the request sidebar. - UI polish pass (§15.2, §15.7): skeleton loaders, leave-type accent stripes +
list transitions, count-up balance rings, a "next break" hero, an animated palm
empty-state, approval confetti (since removed, see below), a Gantt-style team
timeline, a request progress
stepper, a visual range-calendar picker with presets + a live balance bar, and
upgraded HR charts (donut + area line + stat tiles). All motion respects
prefers-reduced-motion. - My-leave overview & quieter decisions (§15.2, §15.7): each balance ring gained a
breakdown ledger beside it (
BalanceCard: base + carry-over ± adjustment = entitlement, − used − pending → available) and the view gained two monthlyBarCharts (approved leave taken, and sick days, for the current year — always visible, empty months at zero; multi-month requests are split across months pro rata by calendar days). The approval confetti and "Approved — enjoy! 🎉" toast were removed (decisions are quiet; the status chip is the feedback), the sidebar's dangling Settings link was dropped (no personal settings, §12), the reason field is labelled "(optional)" when the type doesn't require a note, and the HR record dialog's primary button reads "Record" instead of "Submit request". Fixed:workingDayswas not accepted by the create endpoint (every new request failed validation), and page titles now clear the floating navigation-toggle button.