# 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 `
`, 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(, 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` 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, wires optimistic mutations with rollback + toasts, and renders `Header` + `BoardCanvas` + `Toasts`. - `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, and reload button. - `web/src/components/BoardCanvas.tsx`: horizontally-scrolling list rail, inline "add list" affordance, and the empty/loading states. Maps lists to `ListColumn`. - `web/src/components/ListColumn.tsx`: one column — inline list-name edit, card count, drop zones (one before each card + one at the end), inline add-card input, and per-list delete confirmation. - `web/src/components/CardItem.tsx`: one draggable card — click-to-edit title, done toggle, expand to `CardEditor`, and delete confirmation. - `web/src/components/CardEditor.tsx`: description editor and destructive (delete) action for a card. - `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. Dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), CSS custom properties, rounded surfaces, soft shadows, focus rings, and `prefers-reduced-motion` support. 44px touch targets for mobile. ### 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. - Inline add/edit for lists and cards, done toggle, descriptions, delete-with-confirm. - 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.