- TypeScript 53.6%
- Vue 44%
- CSS 1.4%
- JavaScript 0.4%
- HTML 0.4%
- Other 0.2%
| .forgejo/workflows | ||
| client | ||
| docker | ||
| server | ||
| .dockerignore | ||
| .env.prod.example | ||
| .gitattributes | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| 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.
- 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.
- 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.
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.
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.