Production-ready timesheet system for a Malaysian company (Kuala Lumpur, Asia/Kuala_Lumpur):
daily attendance, leave (MC/EL/AL), overtime & night-maintenance tracking, automatic Comp-Off
entitlements, and a multi-manager monthly approval workflow.
Ships as a single Docker image — the container serves the React SPA, the REST API, and
auto-runs database migrations on startup. With docker compose up you get a fully working
stack (app + PostgreSQL) in about a minute.
docker compose up -d # app on http://localhost:8080, Postgres on :5433
Log in with [email protected] / Password123! (see Seeded accounts).
┌─────────────────────────────────────────────────────────────────────┐
│ Docker image: garfieldwtf/timesheet │
│ │
│ ┌─────────────────────┐ ┌────────────────────────────────┐ │
│ │ React + MUI SPA │ /api │ Express + TypeScript (Node 22)│ │
│ │ (Vite build, │ ──────► │ (port 4000) │ │
│ │ served statically)│ │ │ │
│ │ │ │ Routes: /auth /me /manager │ │
│ │ - Login (JWT) │ │ /admin (+ /api/*) │ │
│ │ - Timesheet grid │ │ Services: │ │
│ │ - Manager dashboard│ │ - compoff.ts (OT→comp-off) │ │
│ │ - Edit history │ │ - workingday.ts (next work │ │
│ └─────────────────────┘ │ day) │ │
│ │ - timesheet.ts (grid, submit)│ │
│ /uploads (volume) │ - holidays.ts (Google Cal) │ │
│ └─ MC attachments │ - email.ts (SMTP + log) │ │
│ │ - audit.ts (edit history) │ │
│ │ Scheduler (node-cron, KL tz): │ │
│ │ holiday sync / expiry / │ │
│ │ reminders │ │
│ └───────────────┬────────────────┘ │
└──────────────────────────────────────────────────┼──────────────────┘
│ pg (node-postgres)
┌──────────────▼───────────────┐
│ PostgreSQL 16 │
│ (compose: port 5433) │
└──────────────────────────────┘
Stack: Node.js 22 + Express + TypeScript · PostgreSQL (node-pg-migrate migrations) · React 18 + MUI + Vite · JWT auth with refresh tokens · bcrypt · Zod validation · node-cron · Nodemailer (SMTP) · Docker (multi-stage build, Alpine).
backend/
src/
config.ts # typed env settings
index.ts # Express app entry; serves SPA in production
scheduler.ts # cron jobs (holiday sync, expiry, reminders)
db/pool.ts # pg pool + transaction helper
middleware/auth.ts # JWT verify, RBAC, manager-of-user checks
migrations/ # node-pg-migrate migrations (plain JS)
routes/ # auth.ts, me.ts, manager.ts, admin.ts
services/ # compoff.ts, workingday.ts, timesheet.ts,
# holidays.ts, email.ts, audit.ts
tests/ # Jest unit tests (47 tests)
seed.ts # seed: admin, 2 managers, 3 employees, 2 groups, holidays
frontend/
src/
api/index.ts # axios client + token refresh interceptor
pages/ # Login, TimesheetPage, ManagerDashboard, EditHistoryPage
types/index.ts # shared DTO types
openapi.yaml # API documentation
Dockerfile # multi-stage build → single production image
docker-compose.yml # app + PostgreSQL 16 stack
docker-entrypoint.sh # wait-for-db → migrations → optional seed → start
.env.example # all configurable settings
Prerequisites: Docker (with the Compose plugin) — Docker Desktop, Rancher Desktop, or a plain Docker Engine + compose plugin on Linux.
Start the stack (from the repository root):
docker compose up -d --build
The first build takes a few minutes (npm ci + TypeScript/Vite builds). Subsequent builds are much faster thanks to layer caching.
Open the app: http://localhost:8080
Log in with a seeded account (password Password123!) — e.g.
[email protected]. The demo seed is enabled by default via SEED_ON_START=true
(idempotent upserts — safe on every restart).
The image entrypoint (docker-entrypoint.sh) runs as PID 1 via
tini (proper signal handling, fast graceful shutdown) and:
DATABASE_URL (works with any Postgres the container can reach, not just compose).node-pg-migrate up applies every pending migration in
backend/src/migrations (7 migrations: schema, tasks, holidays, amendments, etc.).
Migrations are idempotent; already-applied ones are skipped.SEED_ON_START=true, runs the compiled seed script
(demo users, groups, 2026 Malaysian public holidays). All inserts are ON CONFLICT
upserts, so restarting never duplicates data. Disable it (SEED_ON_START=false)
for real deployments after you have created your own admin account.node backend/dist/index.js (Express on port 4000):
/api/* → REST API (mirrors the dev-time Vite proxy rewrite)/uploads/* → uploaded MC attachments/health and /api/health → health probe/login, /timesheet, …)Asia/Kuala_Lumpur time)Password for all: Password123!
| Role | Groups | |
|---|---|---|
| [email protected] | admin | — |
| [email protected] | manager | Engineering Team, IT Department |
| [email protected] | manager | IT Department |
| [email protected] | employee | Engineering Team, IT Department |
| [email protected] | employee | Engineering Team |
| [email protected] | employee | Engineering Team |
⚠️ Change these passwords immediately if you expose the app beyond localhost. Log in as each user (or use the admin user management endpoints) and rotate them. All seeded passwords are bcrypt hashes of
Password123!.
docker compose down # stop app + db (data preserved in volumes)
docker compose down -v # stop and DELETE all data (volumes included)
docker compose logs -f app # tail application logs
docker compose logs -f db # tail Postgres logs
docker compose restart app # restart just the app (re-runs migrations, harmless)
Everything is configured via environment variables (see .env.example for
the full annotated list). In Docker, docker compose reads a .env file placed next to
docker-compose.yml and substitutes values; every variable also has a working default so
docker compose up works out of the box.
| Variable | Default (compose) | Description |
|---|---|---|
DATABASE_URL | postgres://timesheet:timesheet@db:5432/timesheet | Postgres connection string. Set automatically by compose; override to use your own database. |
POSTGRES_USER | timesheet | Postgres superuser name (compose db service). |
POSTGRES_PASSWORD | timesheet | Postgres password (compose db service). |
POSTGRES_DB | timesheet | Database name (compose db service). |
POSTGRES_PORT | 5433 | Host port mapping for the db service. Set to an empty value to unpublish. |
| Variable | Default | Description |
|---|---|---|
JWT_ACCESS_SECRET | change-me-access | HMAC secret for access tokens. Must be changed in production. |
JWT_REFRESH_SECRET | change-me-refresh | HMAC secret for refresh tokens. Must be changed in production. |
JWT_ACCESS_TTL | 15m | Access token lifetime. |
JWT_REFRESH_TTL | 7d | Refresh token lifetime. |
BCRYPT_ROUNDS | 10 | bcrypt cost factor for password hashing. |
PORT | 4000 | Express listen port (inside the container). |
💡 Generate strong secrets with
openssl rand -hex 32.
| Variable | Default | Description |
|---|---|---|
CORS_ORIGIN | http://localhost:8080 | Allowed browser origin for CORS. Since the SPA is served from the same origin in the Docker image, this rarely needs changing unless you host the frontend separately. |
APP_PORT | 8080 | Host port the app is published on (compose only). |
TZ | Asia/Kuala_Lumpur | Container timezone (cron jobs always run in KL time regardless). |
| Variable | Default | Description |
|---|---|---|
SMTP_HOST | (empty) | SMTP server hostname. When empty/localhost, sending fails gracefully and is recorded in email_log. |
SMTP_PORT | 1025 | SMTP port (587 for STARTTLS, 465 with SMTP_SECURE=true). |
SMTP_SECURE | false | Use TLS on connect (port 465). |
SMTP_USER / SMTP_PASS | (empty) | SMTP credentials. |
MAIL_FROM | Timesheet App <[email protected]> | From address. |
| Variable | Default | Description |
|---|---|---|
GOOGLE_CALENDAR_API_KEY | (empty) | API key for the Google Calendar API v3. Without it, the monthly sync is a no-op and admins can maintain holidays manually. |
GOOGLE_CALENDAR_ID | en.malaysia#[email protected] | Malaysian public holidays calendar. |
HOLIDAY_SYNC_CRON | 0 6 1 * * | Sync schedule (monthly, 06:00 on day 1, KL time). |
| Variable | Default | Description |
|---|---|---|
COMP_OFF_HALF_DAY_MINUTES | 0 | OT minutes above this earns a half day (0 = disabled). |
COMP_OFF_FULL_DAY_MINUTES | 240 | OT minutes ≥ this earns a full day (4h). |
COMP_OFF_CAP_MINUTES | 480 | Cap: 1 full day per OT entry (excess earns nothing). |
COMP_OFF_EXPIRY_DAYS | 90 | Comp-offs expire after this many days (daily cron). |
COMP_OFF_ELIGIBLE_WINDOWS | workday_after_hours,weekend,public_holiday | When OT qualifies for comp-off. |
| Variable | Default | Description |
|---|---|---|
ALLOW_SUBMIT_WITH_PENDING | false | Allow submitting the monthly timesheet while items are pending approval. |
SUBMIT_FROM_LAST_DAY_OF_MONTH | true | Submission window opens on the last day of the month. |
| Variable | Default | Description |
|---|---|---|
UPLOAD_DIR | /app/uploads (image) / ./uploads (dev) | Where MC attachments are stored. Compose mounts a named volume here for persistence. |
MAX_UPLOAD_MB | 5 | Max upload size in MB (PDF/PNG/JPG only). |
| Variable | Default | Description |
|---|---|---|
SEED_ON_START | true (compose) | Run the idempotent demo seed at container start. Set to false in production. |
NODE_ENV | production (image) | Set by the image; do not override. |
Prerequisites: Node.js ≥ 20 and PostgreSQL ≥ 14.
Install dependencies (repo root):
npm install
Configure environment:
cp .env.example .env
# edit DATABASE_URL, JWT secrets, SMTP, Google Calendar API key
Create the database:
createdb timesheet # or: psql -c "CREATE DATABASE timesheet;"
Run migrations:
npm run db:migrate
Seed (admin, 2 managers, 3 employees, 2 groups, 2026 Malaysian holidays):
npm run db:seed
Run:
npm run dev:backend # http://localhost:4000
npm run dev:frontend # http://localhost:5173 (proxies /api → :4000)
In dev the SPA runs on the Vite dev server (port 5173) which proxies /api and
/uploads to the backend. In production (Docker image) the built SPA is served by
Express itself — no Vite, no separate frontend container.
The Dockerfile is a multi-stage build producing a small Alpine-based runtime:
| Stage | Base | Purpose |
|---|---|---|
deps | node:22-alpine | npm ci with the workspace manifests only (optimal layer caching). |
build | node:22-alpine | tsc for the backend, vite build for the SPA. |
prod-deps | node:22-alpine | npm ci --omit=dev — production node_modules for the backend only. |
runtime | node:22-alpine | Final image: compiled backend, migrations, built SPA, prod deps, tini, entrypoint. |
Notes:
tini is PID 1 → docker stop cleanly interrupts Express and the cron scheduler./app; the app listens on 4000 (publish it however you like)..dockerignore keeps node_modules, local data/, .tools/, and .env out of the
build context (secrets never enter the image).The image is database-agnostic. Point DATABASE_URL at any reachable Postgres ≥ 14:
docker run -d --name timesheet \
-p 8080:4000 \
-e DATABASE_URL="postgres://user:pass@your-postgres-host:5432/timesheet" \
-e JWT_ACCESS_SECRET="$(openssl rand -hex 32)" \
-e JWT_REFRESH_SECRET="$(openssl rand -hex 32)" \
-e SEED_ON_START=false \
-v timesheet-uploads:/app/uploads \
garfieldwtf/timesheet:latest
The entrypoint waits for that database, migrates it, and starts the server.
docker build -t garfieldwtf/timesheet:latest .
docker push garfieldwtf/timesheet:latest
# versioned tags are recommended:
docker build -t garfieldwtf/timesheet:1.0.0 -t garfieldwtf/timesheet:latest .
docker push garfieldwtf/timesheet:1.0.0
docker network create timesheet-net
docker run -d --name timesheet-db --network timesheet-net \
-e POSTGRES_USER=timesheet -e POSTGRES_PASSWORD=timesheet -e POSTGRES_DB=timesheet \
-v timesheet-pgdata:/var/lib/postgresql/data postgres:16-alpine
docker run -d --name timesheet-app --network timesheet-net \
-p 8080:4000 \
-e DATABASE_URL="postgres://timesheet:timesheet@timesheet-db:5432/timesheet" \
-e JWT_ACCESS_SECRET="change-me" -e JWT_REFRESH_SECRET="change-me" \
-v timesheet-uploads:/app/uploads \
garfieldwtf/timesheet:latest
Liveness: GET /health (or /api/health) → {"ok":true} — no auth required.
Database backups (compose stack):
# backup
docker compose exec db pg_dump -U timesheet timesheet > backup_$(date +%F).sql
# restore
cat backup_2026-01-15.sql | docker compose exec -T db psql -U timesheet timesheet
Uploaded attachments live in the uploads volume (/app/uploads in the app
container). Back it up along with the database:
docker run --rm -v timesheet2_uploads:/data -v ${PWD}:/backup alpine \
tar czf /backup/uploads_$(date +%F).tar.gz -C /data .
Only time outside 09:00–18:00 counts as overtime (configurable per user via
standard_start/standard_end). Cross-midnight windows are supported
(e.g. 22:00→02:00 = 4h).
| OT minutes (outside standard hours) | Comp-Off earned |
|---|---|
| 0 | NONE |
| > 0 and < 240 (4h) | HALF_DAY |
| ≥ 240 and ≤ 480 (8h) | FULL_DAY |
| > 480 | FULL_DAY (capped; excess recorded but earns nothing) |
POST /me/comp-off/:id/request-date);
requires manager approval; rejection falls back to the default assignment.COMP_OFF_EXPIRY_DAYS (default 90); a daily cron marks
them expired.COMP_OFF_ELIGIBLE_WINDOWS
(workday_after_hours, weekend, public_holiday).COMP_OFF code; if two
comp-offs resolve to the same date, one must be moved via a date-change request.draft → (submit, from last day of month) → submitted → (approve|reject) →
approved (read-only, terminal) | rejected (editable, resubmit allowed).
{ ok: true, alreadyApproved: true }).npm test — 47 unit tests covering: comp-off calculation (all hour boundaries,
cross-midnight, eligibility windows), next-working-day resolver (weekends, PHs,
month/year rollover), approval state machine, edit-history diffing./health, /api/health, /api/auth/login,
SPA root and client-side routes all verified against a live PostgreSQL.npm run build (frontend) type-checks under strict TS and builds.DATE columns.email_log (sent/failed) — without SMTP the send fails
gracefully and is recorded.GOOGLE_CALENDAR_API_KEY);
admin overrides are never deleted by sync.Content type
Image
Digest
sha256:942cad7cd…
Size
75.8 MB
Last updated
1 day ago
docker pull garfieldwtf/timesheet