- TypeScript 59%
- Vue 36.9%
- CSS 2.8%
- JavaScript 0.8%
- HTML 0.3%
- Other 0.2%
| .forgejo/workflows | ||
| .superdesign | ||
| client | ||
| docker | ||
| server | ||
| .dockerignore | ||
| .env.prod.example | ||
| .gitattributes | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| future-impl.md | ||
| LICENSE | ||
| README.md | ||
HoliCal
HoliCal is an absence-management application for a single company. It is built as a Vue 3 single-page app talking to an Express/Postgres JSON API, with session authentication, OIDC single sign-on (compatible with Authentik), a leave engine that enforces the company's booking rules, and a light/dark theme.
This is a ground-up v2. The original server-rendered Handlebars/Sequelize app has been removed; the codebase is now just
server/(API) andclient/(SPA).
Features
- Auth: local password (bcrypt), OIDC/Authentik SSO, and password reset, with shared login/reset throttling, strong passphrases, and session rotation.
- Leave: booking with working-day costing, half-day parts (morning or afternoon), public-holiday and weekend rules, day-part-aware overlap protection, and per-type frequency limits.
- Half-day pairing: two complementary halves can share a day - e.g. a morning Annual Leave and an afternoon Free half-day. Overlap detection works per half-day, so only the same half collides.
- Approval workflow: auto-approve (per user or per leave type) or route to the department manager and supervisors. Approve, reject, cancel, revoke, and comment. The Requests nav item carries a count of what is waiting on you.
- Dashboard statistics: a per-user balance breakdown - nominal allowance, carried-over, individual adjustment, part-year employment proration, and used - alongside per-type usage against each type's annual cap, plus supervisor/department details.
- Allowance model: per-user/department/company allowance, manual adjustments, year-to-year carry-over, and part-year proration from each user's start/end date. Cross-year leave is split and charged to each calendar year's allowance.
- Departments & team scoping: CRUD, manager, secondary supervisors, per-department allowance, public-holiday policy, and a minimum-present coverage rule. A read-only manager Overview rolls up each managed team. Supervisor emails are opt-out per department, for teams that use the supervisor list purely as group membership: clearing Email supervisors about new requests stops their mail while leaving their approval rights and their in-app pending count intact. The department manager is emailed either way.
- Users: admin CRUD, roles, activation, allowance overrides, and onboarding emails.
- Work schedules: a company default plus per-user schedules (whole, morning, afternoon, or off per weekday) that feed the day-count engine.
- Bank holidays: CRUD plus country presets (GB, US, PT).
- Leave types: CRUD with colour, allowance and auto-approve flags, and monthly/yearly limits.
- Calendars: a personal month calendar and a team wallchart (respecting the team-view-hidden and share-all-absences settings). Both render half-days split in place - morning on the left, afternoon on the right.
- iCal feeds: private personal and team subscription links.
- Reports: allowance-by-time and leaves, exportable as JSON or CSV.
- Audit: an entity change log and an outgoing-email log. Leave status transitions are audited, and removed bookings retain their history.
- Email: notifications for requests, decisions, onboarding, and resets, plus a daily absence digest sent at 09:00 in the company timezone. Sends go over SMTP; delivery metadata is recorded without retaining message content.
- Integration API: bearer-token JSON endpoints under
/integration/v1.
The integration suite in server/test runs 86 tests across 13 files. It
seeds its own demo fixtures (demo@holical.dev admin, alice@holical.dev
employee); the production seed ships no demo users.
Architecture
client/ Vue 3 + Vite + TypeScript SPA (light/dark theme)
server/ Express + TypeScript JSON API, Postgres (pg), express-session
compose.yml One-command stack: builds the app image + Postgres 16
Dockerfile Multi-stage build (client + server → slim runtime)
docker/entrypoint.sh Applies migrations/seed and creates the initial admin when absent
- Auth: local email/password (bcrypt) or OIDC SSO. The session is stored in
Postgres via
connect-pg-simple, in a cookie namedholical.sid(httpOnly, sameSite). Authentication rotates the session id; password resets invalidate existing sessions. Production refuses to start with the fallback or a shortSESSION_SECRET. /api/mereturns401when unauthenticated and the user when signed in.- The SPA is served at
/. Any non-/apiGET falls back toindex.htmlso client-side routes (/calendar, deep links, hard refresh) resolve. - The leave engine (
server/src/leave/engine.ts) counts working days (Mon–Fri minus public holidays, adjusted by the user's schedule) and enforces the booking rules below; the balance logic lives inserver/src/leave/service.ts. - A small in-process scheduler (
server/src/scheduler.ts) fires the daily absence digest at 09:00 company time. A database claim ensures only one app replica sends the digest for a company date.
Database migrations
Database changes live as immutable, ordered files in
server/src/db/migrations. schema_migrations records each successful version
and checksum. Migrations run inside individual transactions under a Postgres
advisory lock, so concurrent app replicas cannot apply the same version twice.
Migration 001 is the frozen, idempotent adoption baseline for installations
created by the former schema.sql mechanism. It runs once on both legacy and
fresh databases; subsequent migrations are incremental and are never replayed.
To add a change, create the next zero-padded SQL file and register it in
server/src/db/migrate.ts. Never edit a version that may already have run.
Import from TimeOff.Management
An existing TimeOff.Management SQLite database can be imported into a clean
HoliCal PostgreSQL database. Stop the application while importing and back up
both databases first. The host running the command needs the sqlite3 command
line tool; set SQLITE3_BIN if it is installed under a different name.
For a new installation, the simplest route is Administration → Company settings → Import legacy data. Choose the SQLite file, review the dry-run counts, and confirm the import. This browser flow is available only while the signed-in bootstrap administrator is HoliCal's sole user and no operational data has been added. Their email must identify a legacy administrator; HoliCal preserves that account's current password or OIDC login and signs out all sessions when the import completes. Uploaded previews expire after 15 minutes.
cd server
# Validate and show the import counts without touching PostgreSQL.
npm run import:timeoff -- --source ../db.development.sqlite
# Import into DATABASE_URL after reviewing the dry-run report.
npm run import:timeoff -- --source ../db.development.sqlite --apply
The target may contain HoliCal's baseline company, schedule, and leave types, but no operational data. The importer refuses a non-empty target and writes everything in one transaction. It preserves users, departments, supervisors, leave types, holidays, leave history, allowance adjustments, comments, audit metadata, email-audit metadata, schedules, and usable calendar feeds.
TimeOff.Management passwords cannot be migrated safely: they are legacy MD5 hashes tied to the old application's secret, while HoliCal uses bcrypt. Users must reset their password or sign in through OIDC. To give one existing legacy administrator a new password during import, set both variables:
IMPORT_ADMIN_EMAIL=admin@example.com \
IMPORT_ADMIN_PASSWORD='a unique passphrase of at least 15 characters' \
npm run import:timeoff -- --source ../db.development.sqlite --apply
Duplicate case-insensitive email addresses are retained as separate users; all
but the newest record receive a +legacy-ID address and are identified by user
ID in the dry-run report. Duplicate holiday dates are collapsed. Orphaned
allowance rows and ownerless feeds are skipped, legacy company feeds become
team feeds, email bodies are deliberately not retained, and password-related
audit rows are omitted. A user is imported as inactive when the legacy account
is not activated or its employment end date has passed. Historical leave on
non-working days is retained with zero working days and does not create an
allowance allocation.
Quick start
Local development runs the API and SPA with npm run dev, backed by a Postgres
container.
# 1. Postgres (just the db service from compose.yml).
# The password must match server/.env.example's DATABASE_URL (holical).
POSTGRES_PASSWORD=holical docker compose up -d db
# 2. API
cd server
cp .env.example .env # tweak as needed
npm install
# Optionally set ADMIN_EMAIL + ADMIN_PASSWORD in .env to bootstrap an admin.
npm run seed # applies pending migrations, then the baseline seed data
npm run dev # http://localhost:3000
# 3. SPA (separate terminal)
cd client
npm install
npm run dev # http://localhost:5173 (proxies /api -> :3000)
A fresh seed creates the company, the leave types, and the default work
schedule, but no users. Bootstrap the first admin by setting ADMIN_EMAIL and
ADMIN_PASSWORD before seeding, or configure OIDC and sign in via SSO. The
bootstrap is create-only: it never changes an existing account's password,
name, or admin role.
npm run seedapplies pending migrations first, then seeds baseline data.npm run migrateonly applies pending database migrations.
To see a real notification without waiting for 09:00, send the daily absence digest on demand:
cd server
npm run digest # today, in the company timezone
npm run digest -- 2026-08-03 # a specific date
It sends the same message the scheduler sends, but writes no claim to
daily_digest_runs, so it can be re-run for the same date. It reports why
nothing was sent when the digest is off, has no recipients, lands on a weekend,
or nobody is away. Without SMTP_URL the message is only recorded in the audit
log - point it at a local catcher such as Mailpit to actually receive it. The
other notifications need no command: book leave for someone whose requests are
not auto-approved, then approve or reject it.
Deployment (Docker)
compose.yml builds the app image and runs it alongside Postgres, serving the
API and the built SPA on a single origin:
cp .env.prod.example .env # set SESSION_SECRET, POSTGRES_PASSWORD, ADMIN_*, …
docker compose up -d --build
# HoliCal on http://localhost:3000 (override with APP_PORT)
The container's entrypoint applies pending migrations and the idempotent seed on
start, then creates the ADMIN_EMAIL/ADMIN_PASSWORD admin only when that email
does not already exist. Remove ADMIN_PASSWORD from the deployment environment
after the first successful startup. Put the app behind a
TLS-terminating reverse proxy and set BASE_URL=https://… (which enables Secure
cookies); for plain HTTP set SECURE_COOKIES=false.
Leave rules
| Behaviour | Rule |
|---|---|
| Working-day cost | A 14-calendar-day request 2026-01-05 → 2026-01-18 costs exactly 10 working days (weekends excluded). Allowance 22 becomes 12. |
| Half-day parts | A booking may take only the morning or only the afternoon, costing ½ day. |
| Half-day pairing | Two leaves may share one day if they take opposite halves (morning + afternoon); overlap is rejected only when the same half is already taken. |
| Weekend start | A booking may not start on a Saturday or Sunday; it is rejected. |
| Public holiday | A booking may not start on a public holiday; it is rejected and named (e.g. "…public holiday: May Day."). Holidays inside a range simply don't count. |
| Overlap | A request that overlaps an existing booking (same half-day) is rejected. |
| Free half-day | Costs ½ day, at most one per calendar month. Doesn't reduce the annual allowance. |
| Birthday | Costs 1 day, at most one per year. Doesn't reduce the annual allowance. |
| Employment proration | A part-year employee's nominal allowance is scaled by the fraction of the year they're employed (from their start/end date), rounded to the nearest half-day. |
| Balance | remaining = allowance − Σ working_days of allowance-deducting leaves in the year, where allowance = prorated nominal + adjustment + carry-over. |
Leave types, holidays, and the demo fixtures are defined in
server/src/db/seed.ts; the rule engine lives in
server/src/leave/engine.ts and the balance maths
in server/src/leave/service.ts.
OIDC / Authentik SSO
OIDC is enabled automatically when OIDC_ISSUER, OIDC_CLIENT_ID, and
OIDC_CLIENT_SECRET are set. The login screen then shows Sign in with SSO.
The implementation (server/src/auth/oidc.ts) uses openid-client with OIDC
discovery, PKCE, and nonce/state, so it works with any compliant provider.
Configure Authentik
-
In Authentik, create an OAuth2/OpenID Provider:
- Client type: Confidential
- Redirect URI:
http://localhost:3000/api/auth/oidc/callback - Scopes:
openid,profile,email
-
Create an Application bound to that provider and note its slug.
-
Set the server env:
OIDC_ISSUER=https://authentik.example.com/application/o/<app-slug>/ OIDC_CLIENT_ID=<client id> OIDC_CLIENT_SECRET=<client secret> OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback OIDC_SCOPES=openid profile email OIDC_AUTO_PROVISION=true # create local users on first SSO login
On callback the user is matched by oidc_sub, then by email, then
auto-provisioned (if enabled) with the default 22-day allowance.
Flow: GET /api/auth/oidc/login → provider → GET /api/auth/oidc/callback
→ session established → redirect to /.
Theme & branding
HoliCal ships a light and a dark theme on a neutral slate surface, with a calm,
enterprise-leaning visual language (crisp panels, hairline borders, restrained
type). The choice is applied before paint (an inline script in index.html) to
avoid flashes, respects prefers-color-scheme, and is persisted to
localStorage via the header toggle. See client/src/composables/useTheme.ts
and client/src/styles/main.css.
The brand and accent colours are runtime-configurable per deployment, so
the same generic codebase can wear any company's identity. An admin sets a single
hex for each in Admin → Settings; client/src/lib/palette.ts derives a full
50→950 ramp from each anchor and writes them to :root as CSS custom properties,
recolouring the entire UI with no rebuild. The shipped defaults are a
brand-neutral professional blue (#2552a0) and a calm cyan (#0e7490) - both
chosen to meet WCAG AA contrast - and mirror the company table defaults so the
first paint is already correct. GET /api/branding exposes the company name and
colours (non-sensitive) for the login screen and pre-auth paint.
API reference
The complete, browsable reference is Swagger UI at /api/docs (e.g.
http://localhost:3000/api/docs once the server is running). It is generated from
a hand-written OpenAPI 3.0 spec at server/openapi.yaml,
also served raw at /api/openapi.yaml. The docs page is open to everyone: it
describes the API but exposes no data. Every endpoint, every status code, and
every request/response shape is documented there, grouped by area (auth, leave,
approvals, calendar, reference data, the admin areas, and the integration API).
The table below is just a representative slice. All app routes are mounted under
/api; the integration API is separate, under /integration/v1, and the iCal
feeds under /feed.
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/health |
– | liveness probe |
POST |
/api/auth/login |
– | { email, password } sets the session cookie |
POST |
/api/auth/logout |
– | clears the session |
GET |
/api/me |
✓ | current user; 401 when unauthenticated |
GET |
/api/auth/config |
– | { oidcEnabled } |
GET |
/api/auth/oidc/login |
– | begin SSO |
GET |
/api/auth/oidc/callback |
– | SSO callback |
GET |
/api/leave-types |
✓ | |
GET |
/api/holidays |
✓ | |
GET |
/api/leaves |
✓ | current user's bookings |
GET |
/api/balance?year=YYYY |
✓ | balance breakdown, per-type usage, supervisors, department |
POST |
/api/leaves |
✓ | { leaveTypeId, start, end, dayPartStart?, dayPartEnd?, note? }, 422 on rule violation |
DELETE |
/api/leaves/:id |
✓ | soft-remove a booking; retained as cancelled audit history |
GET |
/api/team?start=…&end=… |
✓ | team wallchart data |
GET |
/feed/... |
token | personal/team iCal subscriptions |
Tests
The rules and flows are covered by an integration suite (Vitest + Supertest
against a real Postgres holical_test database) - 86 tests across 13 files
in server/test.
cd server
# Create the test database once (against your dev Postgres):
docker compose exec db psql -U holical -c "CREATE DATABASE holical_test"
npm test
vitest.config.ts points at holical_test (override with TEST_DATABASE_URL)
and globalSetup drops, migrates, and seeds it before the run. Coverage spans:
auth (rate limits, password policy, session rotation/invalidation, /api/me),
the working-day/balance rules, cross-year charging, weekend/holiday/overlap
rejection, half-day pairing, the free half-day and birthday limits, employment
proration, departments and concurrent team coverage, reports, feeds,
single-delivery email/digest claims, audited approval/removal workflows, and SPA
serving with the client-route fallback. Migration coverage includes legacy
schema adoption, sensitive-data cleanup, data backfill, and no-op reruns.