Files
havenllo/project-context.md
Hermes a082764e8a
All checks were successful
Build and deploy Havenllo / Verify Go and frontend (push) Successful in 32s
Build and deploy Havenllo / Build and push image (push) Successful in 25s
Build and deploy Havenllo / Apply and restart Havenllo (push) Successful in 6s
docs: add project-context.md and AGENTS.md for AI agents
2026-07-14 10:23:56 -03:00

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 — 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, 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.