Contributing
Thanks for your interest in improving hyper-calendar. This is a small, self-hosted calendar PWA for personal and family use — contributions are welcome, and this page is the whole guide.
The repository keeps a short CONTRIBUTING.md because GitLab links it from the merge request and issue forms. It points here.
Licensing of contributions
hyper-calendar is licensed under the GNU AGPL-3.0-only (LICENSE). By submitting a contribution you agree it is licensed under the same terms.
Getting started
Requirements: Node.js ≥ 22 and npm. Nothing else — SQLite is embedded.
npm install # all four workspaces
npm run dev # server on :3001, Vite on :5173 (proxies /api)Set AUTH_DEV_AUTOLOGIN=1 to skip the login screen in development (it uses a seeded dev user). Never set it in production — it bypasses authentication entirely.
Getting started covers the dev environment in full, and Building is the complete reference for the two builds, all five test layers, the environment variables and the migrations.
The stack — don't deviate without asking
These are settled choices, not defaults that happen to be in place. If a change needs something outside this list, raise it before writing the code.
| Area | Choice |
|---|---|
| Frontend | React 18 + TypeScript (strict) + Vite + Tailwind CSS v4 |
| Backend | Hono + Drizzle ORM + SQLite (better-sqlite3 in Node, WASM on-device) |
| Dates | date-fns + @date-fns/tz |
| Drag/resize | Hand-rolled Pointer Events — no drag-and-drop library |
| Validation | zod schemas in shared/, used by both sides of every endpoint |
| Package manager | npm workspaces |
Architecture rules to respect
Architecture explains the design; these are the rules a change is most likely to break.
- Events are stored once as UTC instants plus an IANA timezone. Per-day "segments" are derived on the frontend at local midnight and are never stored.
- Auth is app-owned (
users+sessions, scrypt hashes, HttpOnly cookie), and every query is scoped to the authenticated user's id. There is no reverse-proxy IdP — the proxy only terminates TLS. - Dates go through date-fns /
@date-fns/tz. Never rawDatemath for day or week boundaries. - Gestures are hand-rolled Pointer Events, and every one must work with mouse and touch.
core/stays portable. Anything needing the disk goes through theHostcapability incore/src/host.ts, which is what lets the same backend run in a Web Worker on Android.- API is REST under
/api, JSON, validated against the shared zod schemas.
How work lands
Everyday work goes straight to dev — no merge request needed. Releases are a dev → main merge request, and that is where everything is checked. Merge and release workflow has the branch table, what gates a merge, how to cut and sign a release, hotfixes, and the security gate.
Quality bar
Before opening a merge request, all of these must pass:
npm run typecheck # TypeScript strict, all four workspaces
npm run lint # ESLint
npm run format # Prettier check — `format:write` fixes
npm test # Vitest unit testsFormatting is enforced: the format job runs on every merge request and every push to dev and main. Run npm run format:write before pushing, and still keep unrelated reformatting out of feature commits.
Beyond those there are five Playwright layers, each proving something the others cannot. Building explains what each covers and when it is worth running. The two you will reach for most:
| Command | Run it when |
|---|---|
npm run test:gui | You touched gestures or the week view (mocked /api/*, real pointer input) |
npm run test:e2e | You touched anything the server persists (real server, throwaway DB) |
Run npm run test:local:dist before cutting an APK — it is the only layer that exercises the built standalone bundle.
Guidelines:
- TypeScript strict; no
anywithout a comment justifying it. - Every gesture feature must work with mouse AND touch.
- Date and segment logic must be covered by Vitest tests. The
unit-testsjob reports coverage ofcore/src/lib/**andshared/src/**on every merge request, both as a percentage and as line-by-line annotations on the diff. No threshold blocks a merge — a floor on a codebase this size buys tests written to clear it — so read the annotations in review instead. The same report is available locally withnpm run test:coverage. - Keep changes scoped, and write a clear merge request description of what and why.
Reporting bugs
Open an issue and pick the Bug template from the description dropdown; for a request, pick Feature. Both ask which of the three deployment modes you are on — Docker/self-hosted, Android standalone, or Android connected to a server — because that decides which backend host the code actually ran on, and they are genuinely different backends running the same code.
Users without a GitLab account can report by email instead; the address is in the README. This matters more than usual for self-hosted software: the people running the container often are not GitLab users, and requiring a signup loses most of those reports.
For security vulnerabilities, follow the Security policy instead of filing a public issue.
Triaging the Service Desk
Maintainer-facing. Mail to the support address becomes an issue, replies to it become comments, and comments posted on the issue are emailed back to the sender. Description edits are not — always answer in a comment, or the reporter never hears it.
Tickets are confidential by default, which is what keeps the sender's email address invisible to non-members on a public project. Don't flip one to public to "make it a proper bug report": open a separate issue and link it, or the reporter's address becomes world-readable.
So they don't sit unseen:
- Every Service Desk issue arrives labelled
~support— that comes from the quick action in.gitlab/issue_templates/Service Desk.md, which GitLab appends to the description. A filter on~supportis the queue. - Relabel once it's understood:
~bugif confirmed,~enhancementif it is really a request, leave~supportif it is a usage question. - The auto-reply already asks for deployment mode and version, so check the comments before asking again.
- Expect spam on a published inbox. Close it and move on; don't reply.