- JavaScript 81.5%
- CSS 10.8%
- HTML 6.1%
- Dockerfile 1.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| public | ||
| server | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.test.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| Taskfile.yml | ||
Lab Equipment Reservation Manager
A small web app for tracking lab hardware and reserving it for up to 7 days at a time. No login required.
Run it
With Task installed, task lists everything available; the common ones:
task up # build + start the app in the background
task logs # tail the app server's logs
task down # stop it
Without Task, the equivalent is:
docker compose up --build
Either way, then open http://localhost:3000. Data lives in Postgres, persisted in the pgdata Docker volume, so it survives restarts.
Demo data
To start with a pre-populated set of fake equipment and reservations (via @faker-js/faker):
task up:seed
# or: SEED_DEMO_DATA=true docker compose up --build
Seeding only happens once, the first time the equipment table is empty.
All tasks
| Task | What it does |
|---|---|
task up |
Build + start the app in the background |
task up:seed |
Same, pre-populated with demo data |
task down |
Stop the app |
task restart |
Rebuild + restart just the app server (after a code change) |
task logs |
Tail the app server's logs |
task build |
Build the production image |
task test |
Run the backend/unit suite, isolated, self-cleaning |
task test:e2e |
Run the Playwright E2E suite, isolated, self-cleaning |
task test:all |
Both of the above, one after the other |
task clean |
Stop everything and remove volumes (dev + test stacks) |
task install |
npm install in server/, for local non-Docker development |
How it's built
- Backend: Node.js + Express, talking to Postgres via
pg. Schema lives inserver/schema.sqland is applied automatically on startup.server/index.js— assembles the app and mounts the routers; no route logic of its ownserver/routes/— one file per resource:equipment.js,reservations.js,avatar.js,events.jsserver/lib/— small shared pieces used by more than one route:http.js(the async-route wrapper and date parsing),broadcast.js(the SSE client list),dicebear.js(avatar rendering)server/db.js/server/seed.js— the Postgres pool/migrations and the demo-data seeder
- Frontend: plain JS (
public/js/) loaded as native ES modules — no build step, no bundler.index.htmlloads a single entry point (js/main.js); everything else is pulled in viaimport.main.js— wires up view-tab switching and the one-time setup for each view, then does the initial loadrefresh.js— the only module that knows about all three views; re-fetches equipment and re-renders List/Manage/Timeline after any changelist-view.js,manage-view.js,timeline-view.js— one per view (List, Manage, Timeline/Gantt)reservation-modal.js,confirm-dialog.js— the two modals, shared across viewsapi.js— everyfetch()call to the backend, in one placedate-utils.js,format.js— pure helpers with no DOM dependency (unit-tested directly, see below)
- API:
GET /api/equipment— list equipment with availability status and current/upcoming reservationsPOST /api/equipment— add new hardwarePATCH /api/equipment/:id— update a device's descriptionDELETE /api/equipment/:id— remove hardwareGET /api/categories— distinct categories currently in use, for the "Add Equipment" dropdownPOST /api/reservations— reserve equipment (max 7 days, no overlapping bookings)DELETE /api/reservations/:id— cancel a reservation (soft delete — it moves to history)GET /api/equipment/:id/reservations/history— a device's past reservations (expired or cancelled)GET /api/reservations?from&to— reservations touching a time window, across all equipment (feeds the Timeline view)GET /api/avatar/:seed— renders a DiceBear "bottts" avatar SVG for the given seed, entirely offlinePOST /api/equipment/:id/regenerate-avatar— assigns a new random avatar seedGET /api/events— Server-Sent Events stream; the frontend refreshes automatically whenever any browser reserves, cancels, or adds/removes equipment
Reservations expire naturally: once end_at passes, they stop counting toward availability and drop out of the equipment list on their own — no cleanup job needed. They remain visible under each device's "History". A periodic server heartbeat (every 60s) also nudges connected browsers to refresh, so a reservation expiring purely because time passed still updates the UI without a page reload.
The Dockerfile
Multi-stage, with production (the default build target) kept lean:
deps— installs production npm dependencies only (npm ci --omit=dev), cached independently of the app source so code changes don't bust this layerproduction— the imagedocker-compose.ymlactually runs: copies indeps'node_modulesplus the app source, runs as the non-rootnodeuser, and exposes aHEALTHCHECKagainst/healthz. No test tooling of any kind ends up in here.e2e— used only bydocker-compose.test.yml(--target e2e), built from Microsoft'smcr.microsoft.com/playwrightimage (browsers preinstalled — Playwright doesn't run reliably on Alpine/musl) with the fullnpm ciincluding devDependencies
Running tests
There are two independent suites, both run fully containerized against a throwaway Postgres instance each.
Backend / unit tests (server/tests/), via Node's built-in test runner — no extra framework needed:
date-utils.test.js— pure date/time logic shared with the frontend (public/js/date-utils.js), including regression tests for two real bugs found during development (one rounding bug, caught by hand; one caught by the E2E suite below — seecomputeDefaultStart does not round "now" forward)equipment.test.js— equipment CRUD, description updates, categories, and avatar generation/regenerationreservations.test.js— reservation validation (7-day max, no past starts, no overlaps, back-to-back is allowed), cancellation, and historyevents.test.js— the live-update (SSE) broadcast
task test
# or: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test test
Frontend / E2E tests (server/e2e/), via Playwright, driving a real Chromium browser against a real running instance of the app:
equipment.spec.js— adding hardware, editing/cancelling a description edit, avatar regeneration, the remove-confirmation dialog, adding a new categoryreservation.spec.js— reserving from the List view, the overlap-conflict error, cancellingtimeline.spec.js— reserving by clicking a day on the Gantt chart, Earlier/Later navigationlive-updates.spec.js— two separate browser contexts, confirming the SSE live-update actually reaches a second, idle browser
task test:e2e
# or: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from e2e e2e
Run both suites with task test:all, or the two docker compose commands separately (not combined into one up) — combining them under --abort-on-container-exit would tear the whole run down as soon as whichever suite finishes first, rather than letting both run to completion. Either way, task test and task test:e2e clean up their own containers/volumes automatically, on both success and failure (task clean also does this by hand, plus wipes the dev stack).
Or locally, against a Postgres instance you already have running:
task install # or: cd server && npm install
DATABASE_URL=postgres://reservemgr:reservemgr@localhost:5432/reservemgr_test npm test # backend, from server/
DATABASE_URL=postgres://reservemgr:reservemgr@localhost:5432/reservemgr_test npm start & # in one terminal
E2E_BASE_URL=http://localhost:3000 npx playwright test # in another
Point DATABASE_URL at a database you don't mind being wiped — the backend suite truncates all tables between tests, and the E2E suite gives every piece of equipment it creates a unique, timestamped name rather than relying on a clean slate.
Local development (without Docker)
You'll need a Postgres instance reachable via DATABASE_URL.
cd server
npm install
DATABASE_URL=postgres://reservemgr:reservemgr@localhost:5432/reservemgr npm start
The server serves the frontend from ../public, so visiting http://localhost:3000 works the same way.