131 lines
13 KiB
Markdown
131 lines
13 KiB
Markdown
# Project Context
|
|
|
|
This file is the canonical project context for AI agents working on Havenllo. Read it before making changes, and keep it updated whenever project purpose, architecture, APIs, file roles, workflows, constraints, or implementation patterns change.
|
|
|
|
## Purpose
|
|
|
|
Havenllo (portmanteau of "Haven" + "Trello") is a lightweight, single-user, no-authentication kanban/to-do web app for tracking tasks inside the Haven homelab. It is a minimal Trello: one fixed board, ordered lists (columns), and ordered cards (tasks). No login, no multi-user, no accounts — open the URL and use it.
|
|
|
|
The product is intentionally small: one Go binary serves both the JSON API and the embedded Preact SPA. The only durable state is a SQLite database file on a Kubernetes PVC.
|
|
|
|
## Technology Stack
|
|
|
|
- Go module: `havenllo`
|
|
- Go version: `1.24`
|
|
- Backend dependencies: `modernc.org/sqlite` (pure-Go, CGO-free — keeps `CGO_ENABLED=0`)
|
|
- Frontend dependencies: `preact` only (runtime); `@preact/preset-vite`, `typescript`, `vite` (build)
|
|
- Frontend DnD: native HTML5 drag-and-drop (no DnD library)
|
|
- Static assets: embedded with Go `embed` (`web/embed.go`)
|
|
- Runtime port: `8080`
|
|
- Environment: `HAVENLLO_LISTEN_ADDR` (default `:8080`), `HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`)
|
|
- Docker build: `node:22-alpine` (frontend) → `golang:1.24-alpine` (build, `CGO_ENABLED=0`) → `gcr.io/distroless/static-debian12`
|
|
- Deployment: single Deployment (1 replica, `Recreate` strategy), PVC on `nfs-client`, nginx ingress `havenllo.haven` (default namespace, no TLS), image `git.ivanch.me/ivanch/havenllo:latest`
|
|
|
|
## Repository Organization (source code)
|
|
|
|
### Backend (Go)
|
|
|
|
- `cmd/havenllo/main.go`: entrypoint. Reads env (`HAVENLLO_LISTEN_ADDR`, `HAVENLLO_DATABASE_PATH`), opens/migrates the SQLite DB, builds the service + HTTP handler wiring in the embedded SPA, and runs the server with graceful SIGINT/SIGTERM shutdown.
|
|
- `internal/domain/models.go`: core domain types — `List`, `Card`, `Snapshot` — plus sentinel errors `ErrNotFound`, `ErrLastList`, and `ValidationError`. No dependencies beyond `errors`.
|
|
- `internal/database/database.go`: opens the SQLite connection (parent-dir creation, WAL, busy timeout, `foreign_keys=ON`), then runs migrations. Returns the `*sql.DB` used by the whole app. Imports `_ "modernc.org/sqlite"`.
|
|
- `internal/database/migrations.go`: versioned, transactional migrations. Migration 1 creates the `lists` and `cards` tables and seeds the three starter lists (`To do`, `In progress`, `Done`) with positions `1024, 2048, 3072`.
|
|
- `internal/store/sqlite.go`: small, parameterized SQL query/scan helpers (no ORM). Defines the `DBTX` interface (so tests can pass a `*sql.Tx` or `*sql.DB`), list/card queries, max-position helpers, `CountLists`, and error-wrapping helpers (`Wrap`, `MustRows`).
|
|
- `internal/app/service.go`: business rules and the single "board" aggregate. Owns transactional ordering (float `position` keys, `positionStep = 1024`, renormalization when gaps get too small), last-list protection (`ErrLastList` on deleting the final list), default-list seeding, and validation. Exposes `Health`, `Board`, list/card CRUD, and move/reorder operations — every mutation runs in one transaction.
|
|
- `internal/api/types.go`: request DTOs. PATCH bodies use pointer fields (`*string`, `*float64`, `*bool`, `*int64`) so an explicit `false`/`0` is distinguishable from an omitted field.
|
|
- `internal/api/http.go`: JSON helpers (`writeJSON`, `writeError` with the `{ "error": { "code", "message" } }` shape), bounded-body decoding with `DisallowUnknownFields`, and typed error mapping (validation → 422, not-found → 404, last-list → 409, etc.).
|
|
- `internal/api/handlers.go`: Go 1.22 `http.ServeMux` routes (`GET /api/health`, `GET /api/board`, lists CRUD, cards CRUD). Registers `/api/` before the embedded static filesystem; unknown API routes return JSON 404, never SPA HTML. All browser traffic is same-origin, so no CORS.
|
|
|
|
### Frontend (Preact + Vite + TypeScript)
|
|
|
|
- `web/index.html`: SPA shell — title, `theme-color`, viewport, root `<div id="app">`, and the Vite module script.
|
|
- `web/vite.config.ts`: Preact preset; deterministic `web/dist` output.
|
|
- `web/package.json`: `havenllo-web`, scripts `dev` / `build` (`tsc --noEmit && vite build`) / `preview`; `preact` dependency only.
|
|
- `web/src/main.tsx`: Preact `render(<App/>, document.getElementById("app"))` bootstrap.
|
|
- `web/src/types.ts`: TypeScript mirrors of the API resources (`Card`, `List`, `ListWithCards`, `Board`, `CardPatch`, `ListPatch`).
|
|
- `web/src/api.ts`: typed fetch client (`APIError` class, `request<T>` helper). All calls are same-origin under `/api`.
|
|
- `web/src/board-state.ts`: `useReducer` board state — owns the `Board` snapshot, `loading`, `error`, and `toasts`. Pure action creators/reducers for add/update/remove list & card and optimistic transforms, plus rollback support.
|
|
- `web/src/drag.ts`: native HTML5 drag-and-drop helpers — `cardDragType` MIME, `beginCardDrag`/`readCardDrag` payloads, and `movePosition` (computes the new fractional position between drop neighbours).
|
|
- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer and selected-card/list-management UI state, wires optimistic mutations with rollback + toasts, and renders `Header`, `BoardCanvas`, the selected card's `CardDetailModal`, and `Toasts`.
|
|
- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management.
|
|
- `web/src/components/BoardCanvas.tsx`: responsive horizontally-scrolling list rail, management-mode-only add-list affordance, and board empty state. Maps lists to `ListColumn`.
|
|
- `web/src/components/ListColumn.tsx`: one column — card count, native drop zones (one before each card + one at the end), inline add-card input, empty-column state, and management-mode-only list rename/delete controls with delete confirmation.
|
|
- `web/src/components/CardItem.tsx`: one draggable card tile. A genuine click opens the board-level detail dialog; drag events are suppressed from opening it. It retains the done toggle, title/description preview, and a visible drag grip.
|
|
- `web/src/components/CardDetailModal.tsx`: accessible Jira-style card dialog with inline title and description editing, list/status properties, timestamps, delete confirmation, outside/Escape/X close controls, and modal focus restoration.
|
|
- `web/src/components/ConfirmDialog.tsx`: accessible confirmation dialog used before any delete.
|
|
- `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s).
|
|
- `web/src/styles.css`: all styling. Elevated dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), responsive horizontal board rail, polished card/list/button states, edit-mode treatment, detail-dialog layout, empty states, focus rings, 44px mobile touch targets, and `prefers-reduced-motion` support.
|
|
|
|
### Embedding bridge
|
|
|
|
- `web/embed.go`: embeds `web/dist` (`//go:embed all:dist`) and re-exports it as `web.Files` (via `fs.Sub`, stripping the `dist/` prefix) for the API handler to serve. `web/dist` is git-ignored; the Docker `frontend` stage rebuilds it before the Go build copies it in.
|
|
|
|
## Backend Architecture
|
|
|
|
Layered, dependency-light:
|
|
|
|
```
|
|
main.go → api.Handler → app.Service → store (SQL) → database (sqlite.DB)
|
|
↑ ↓
|
|
domain (types/errors) domain (types/errors)
|
|
```
|
|
|
|
- `database` owns connection + migrations (incl. first-run seed).
|
|
- `store` owns SQL text and row scanning only.
|
|
- `app.Service` owns ordering, validation, last-list protection, and wraps every state change in a transaction.
|
|
- `api` owns HTTP routing, JSON (de)serialization, and error shaping. It never exposes SQL errors.
|
|
- `domain` is the shared vocabulary (types + sentinel errors) with no internal deps.
|
|
|
|
## Data Model
|
|
|
|
Two tables (no `boards` table — there is exactly one fixed board):
|
|
|
|
- `lists(id INTEGER PK, name TEXT, position REAL)` — columns. Seeded with `To do`, `In progress`, `Done`.
|
|
- `cards(id INTEGER PK, list_id INTEGER FK→lists ON DELETE CASCADE, title TEXT, description TEXT DEFAULT '', position REAL, done INTEGER 0/1, created_at TEXT, updated_at TEXT)`.
|
|
|
|
`position` is a float sort key ordered by `(position, id)`. New siblings get `max+1024`; drags pick a value between neighbours; the service renormalizes a sibling group to `1024` increments when gaps shrink below threshold. Foreign keys are enabled on every connection; deleting a list cascades its cards; deleting the last list is rejected with `409`.
|
|
|
|
## API Contract
|
|
|
|
All endpoints are same-origin under `/api`, JSON in/out, integer IDs. Mutation requests require `Content-Type: application/json`; unknown fields are rejected.
|
|
|
|
| Method | Path | Purpose |
|
|
| --- | --- | --- |
|
|
| GET | `/api/health` | Liveness; pings SQLite, returns `200 {"status":"ok"}` (K8s probe target) |
|
|
| GET | `/api/board` | Full board: `{ "lists": [ {id,name,position,card_count?,cards:[Card] } ] }` |
|
|
| GET | `/api/lists` | List collections with `card_count`, no card bodies |
|
|
| POST | `/api/lists` | Create list `{"name","position"?}` → `201 {list}` |
|
|
| GET | `/api/lists/{id}` | One list with its cards |
|
|
| PATCH | `/api/lists/{id}` | `{"name"?,"position"?}` → `200 {list}` |
|
|
| DELETE | `/api/lists/{id}` | `204`; cascades cards; `409` if it is the last list |
|
|
| GET | `/api/lists/{id}/cards` | Cards in a list, ordered |
|
|
| POST | `/api/lists/{id}/cards` | Create card `{"title","description"?,"position"?}` → `201 {card}` |
|
|
| GET | `/api/cards/{id}` | One card |
|
|
| PATCH | `/api/cards/{id}` | `{"title"?,"description"?,"done"?,"list_id"?,"position"?}` → `200 {card}` (move + reorder in one tx) |
|
|
| DELETE | `/api/cards/{id}` | `204` |
|
|
|
|
Error shape: `{ "error": { "code": "validation_error"|"not_found"|..., "message": "..." } }`. Statuses: `200/201/204` success, `400` malformed, `404` missing, `409` last-list, `415` bad media type, `422` validation, `500` unexpected (non-sensitive).
|
|
|
|
## Frontend Behavior
|
|
|
|
- Loads `/api/board` on mount; no board picker (single fixed board).
|
|
- Native HTML5 drag-and-drop reorders cards within and across lists; drop computes a fractional position, updates optimistically, and reconciles/rolls back on failure.
|
|
- Clicking a card opens a centered, accessible Jira-style detail dialog. Its title and description patch independently, while list, done status, created/updated timestamps, and delete-with-confirm are visible in the dialog. It closes via X, Escape, or outside click, focuses on open, and restores the prior trigger focus when closed.
|
|
- The Header's Edit toggle enters list-management mode. Only in that mode are list rename/delete controls and the end-of-rail add-list control visible; card add, done, detail editing, delete, and drag-and-drop remain available in either mode.
|
|
- Inline add for cards, done toggle, descriptions, and delete-with-confirm. There is no inline card expansion/editor; card editing is centralized in the detail dialog.
|
|
- Optimistic UI: every mutation updates state immediately, then reconciles with the server response; failures restore the prior snapshot and show an error toast. Only the pending resource's controls disable.
|
|
- Responsive horizontally-scrolling board; usable on phones.
|
|
|
|
## Build & Deploy
|
|
|
|
- **Local verify**: `go test ./...` (backend unit tests) and `cd web && npm ci && npm run build` (frontend type-check + bundle). `web/dist` is produced by the build, not committed.
|
|
- **Image**: multi-stage Dockerfile → `git.ivanch.me/ivanch/havenllo:latest`, multi-arch `linux/amd64,linux/arm64`, built on `docker-build.haven` (remote Buildx). Final image is distroless, runs as `USER 0`, serves on `:8080`, mounts the SQLite PVC at `/data`.
|
|
- **CI**: `.gitea/workflows/main.yaml` on `main` push / manual — `verify` (install Go+Node on the Alpine slim runner, `go test`, `npm run build`, offline YAML doc check) → `build` (`pipeline-actions/build-and-push@main`) → `deploy` (`pipeline-actions/kubectl-apply@main` + `pipeline-actions/deploy-restart@main`) using user-level secrets `REGISTRY_PASSWORD`, `SSH_KEY_DOCKERBUILD`, `KUBE_CONFIG`.
|
|
- **Manual deploy**: `kubectl apply -f deploy/havenllo.yaml -n default` then rollout restart; ingress is `http://havenllo.haven` (no TLS).
|
|
|
|
## Constraints & Conventions
|
|
|
|
- Single replica + `Recreate` strategy — SQLite is a single-writer file on the NFS PVC; never run two writable pods against it.
|
|
- Container runs as root (`USER 0`) because the NFS `all_squash` export maps to `anonuid=0`; the root filesystem is read-only and only `/data` is writable.
|
|
- No auth, no external services beyond the SQLite file.
|
|
- Keep dependencies minimal and the image small; no CGO, no ORM, no frontend framework beyond Preact, no DnD/state/icon libraries.
|