11 KiB
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 — keepsCGO_ENABLED=0) - Frontend dependencies:
preactonly (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,
Recreatestrategy), PVC onnfs-client, nginx ingresshavenllo.haven(default namespace, no TLS), imagegit.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 errorsErrNotFound,ErrLastList, andValidationError. No dependencies beyonderrors.internal/database/database.go: opens the SQLite connection (parent-dir creation, WAL, busy timeout,foreign_keys=ON), then runs migrations. Returns the*sql.DBused by the whole app. Imports_ "modernc.org/sqlite".internal/database/migrations.go: versioned, transactional migrations. Migration 1 creates thelistsandcardstables and seeds the three starter lists (To do,In progress,Done) with positions1024, 2048, 3072.internal/store/sqlite.go: small, parameterized SQL query/scan helpers (no ORM). Defines theDBTXinterface (so tests can pass a*sql.Txor*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 (floatpositionkeys,positionStep = 1024, renormalization when gaps get too small), last-list protection (ErrLastListon deleting the final list), default-list seeding, and validation. ExposesHealth,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 explicitfalse/0is distinguishable from an omitted field.internal/api/http.go: JSON helpers (writeJSON,writeErrorwith the{ "error": { "code", "message" } }shape), bounded-body decoding withDisallowUnknownFields, and typed error mapping (validation → 422, not-found → 404, last-list → 409, etc.).internal/api/handlers.go: Go 1.22http.ServeMuxroutes (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; deterministicweb/distoutput.web/package.json:havenllo-web, scriptsdev/build(tsc --noEmit && vite build) /preview;preactdependency only.web/src/main.tsx: Preactrender(<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 (APIErrorclass,request<T>helper). All calls are same-origin under/api.web/src/board-state.ts:useReducerboard state — owns theBoardsnapshot,loading,error, andtoasts. 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 —cardDragTypeMIME,beginCardDrag/readCardDragpayloads, andmovePosition(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 rendersHeader+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 toListColumn.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 toCardEditor, 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, andprefers-reduced-motionsupport. 44px touch targets for mobile.
Embedding bridge
web/embed.go: embedsweb/dist(//go:embed all:dist) and re-exports it asweb.Files(viafs.Sub, stripping thedist/prefix) for the API handler to serve.web/distis git-ignored; the Dockerfrontendstage 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)
databaseowns connection + migrations (incl. first-run seed).storeowns SQL text and row scanning only.app.Serviceowns ordering, validation, last-list protection, and wraps every state change in a transaction.apiowns HTTP routing, JSON (de)serialization, and error shaping. It never exposes SQL errors.domainis 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 withTo 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/boardon 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) andcd web && npm ci && npm run build(frontend type-check + bundle).web/distis produced by the build, not committed. - Image: multi-stage Dockerfile →
git.ivanch.me/ivanch/havenllo:latest, multi-archlinux/amd64,linux/arm64, built ondocker-build.haven(remote Buildx). Final image is distroless, runs asUSER 0, serves on:8080, mounts the SQLite PVC at/data. - CI:
.gitea/workflows/main.yamlonmainpush / 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 secretsREGISTRY_PASSWORD,SSH_KEY_DOCKERBUILD,KUBE_CONFIG. - Manual deploy:
kubectl apply -f deploy/havenllo.yaml -n defaultthen rollout restart; ingress ishttp://havenllo.haven(no TLS).
Constraints & Conventions
- Single replica +
Recreatestrategy — 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 NFSall_squashexport maps toanonuid=0; the root filesystem is read-only and only/datais 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.