10 KiB
Project Context
This file is the canonical project context for AI agents working on Havenllo. Read it before making changes and keep it current whenever architecture, APIs, data, dependencies, runtime behavior, or product decisions change.
Purpose
Havenllo (Haven + Trello) is a lightweight, single-user, no-authentication kanban app for Haven homelab tasks. It has one fixed board with ordered lists and cards. One Go binary serves both its JSON API and embedded Preact SPA; SQLite on a Kubernetes PVC is the only durable state.
Technology Stack
- Go module
havenllo, Go 1.24. - Backend:
modernc.org/sqlite, pure Go and CGO-free. - Frontend runtime:
preact,commonmark, anddompurify; build tooling:@preact/preset-vite,@types/commonmark,@types/node,typescript, andvite. - Card movement: custom Pointer Events interaction; no DnD library.
- Static SPA assets are embedded with Go
embedfromweb/dist. - Runtime port:
8080;HAVENLLO_LISTEN_ADDRdefaults to:8080andHAVENLLO_DATABASE_PATHdefaults to/data/havenllo.db. - Docker:
node:22-alpinefrontend build,golang:1.24-alpineCGO-free build, thengcr.io/distroless/static-debian12. - Deployment: one
RecreateDeployment replica,nfs-clientPVC, plain nginx ingresshavenllo.havenindefault, no TLS.
Repository Organization
Backend
cmd/havenllo/main.go: reads environment, opens/migrates SQLite, wires service/API/embedded SPA, and shuts down gracefully on SIGINT/SIGTERM.internal/domain/models.go: domainList,Card,Snapshot, sentinel errors, and validation error type. Active cards have no completion boolean.internal/database/database.go: parent-directory setup, WAL/busy timeout/foreign-key connection settings, and migrations.internal/database/migrations.go: transactional migration 1 creates lists/cards and seedsTo do,In progress, andDone. Its historicalcards.donecolumn remains physically present but is dormant.internal/store/sqlite.go: parameterized SQL queries and scans only. Card queries intentionally omitcards.done.internal/app/service.go: transactional validation, ordering, normalization, last-list protection, list/card CRUD, and atomic move/reorder operations. It neither reads nor writesdone.internal/api/types.go: request DTOs with pointer fields for optional scalar values.internal/api/http.go: bounded JSON decoding with unknown-field rejection, JSON error responses, and typed status mapping.internal/api/handlers.go: Go 1.22 ServeMux routes for health, one board, lists, cards, and embedded SPA fallback./api/always returns JSON errors and needs no CORS.
Frontend
web/package.json: frontend scripts and the Preact/CommonMark/sanitization dependencies.web/src/main.tsx: Preact bootstrap.web/src/types.ts: API resource and patch types.CardandCardPatchcontain nodonemember.web/src/api.ts: typed same-origin/apifetch client and API error handling.web/src/board-state.ts: reducer, optimistic board transforms, rollback state, and toasts.web/src/drag.ts:movePosition, which calculates a fractional position from a target index after removing the dragged card.web/src/markdown.ts: safe CommonMark renderer usingcommonmarkplus DOMPurify, and plain-text extraction for card summaries.web/src/App.tsx: board loading, optimistic mutations, selected-card state, and component wiring.web/src/components/Header.tsx: Havenllo operations-board brand, local-ready state, refresh, and list-management toggle.web/src/components/BoardCanvas.tsx: centered-or-scrollable workflow rail, list-management add control, task count, and board-level Pointer Events drag lifecycle. It detects whole-column targets, insertion indices, pointer cancellation, and Escape cancellation.web/src/components/ListColumn.tsx: stage marker/kicker, column header/count, drop target and insertion marker, inline card creation, and management-only rename/delete controls.web/src/components/CardItem.tsx: focusable card tile with a dedicated drag grip, current-list status chip, plain-text Markdown excerpt, and open-card affordance. Clicking outside the grip opens details.web/src/components/CardDetailModal.tsx: accessible card dialog with title editing, current-list status, native move-to-list selector, CommonMark Write/Preview description editor, timestamps, and delete confirmation.web/src/components/ConfirmDialog.tsx: accessible destructive-action confirmation.web/src/components/Toast.tsx: auto-dismissing non-blocking feedback.web/src/styles.css: dark night-shift control-room theme with graphite surfaces, acid-lime/aurora accents, layered iridescent canvas wash, geometric grid/orbit decoration, stage-specific columns, centered short rails, horizontal mobile scrolling, pointer drop feedback, Markdown typography, focus states, 44px touch targets, and reduced-motion support.
Embedding bridge
web/embed.go: embedsweb/distand exports it asweb.Filesafter stripping thedist/prefix.web/distis generated and ignored except for its tracked.gitkeepplaceholder, which keeps Go embeds valid before Vite runs;web/vite.config.tsrecreates that placeholder after every build. Docker rebuilds the real bundle.
Backend Architecture
main.go -> api.Handler -> app.Service -> store -> database (SQLite)
^ |
domain <---------------+
databaseowns connections and migrations.storeowns SQL text and scanning only.app.Serviceowns validation, transactions, ordering, and cross-resource behavior.apiowns routing, JSON, and HTTP error shapes.domaincontains shared dependency-free vocabulary.
Data Model
There is no boards table: one fixed board has two tables.
lists(id INTEGER PK, name TEXT, position REAL): ordered columns. First-run seed: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): ordered cards.doneis retained only as unsupported historical storage; active code never reads or writes it. A card's sole active status is its current list name.
position is a float sort key, ordered by (position, id). New siblings use max + 1024; pointer drops select a value between adjacent cards. The service renormalizes a sibling group to 1024 increments when gaps become too small. Foreign keys are enabled on every connection; list deletion cascades cards; deletion of the final list returns 409.
API Contract
All endpoints are same-origin under /api, exchange JSON, use integer IDs, require Content-Type: application/json for mutations, and reject unknown fields.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
SQLite liveness response: 200 {"status":"ok"}. |
| GET | /api/board |
Full board: { "lists": [ListWithCards] }. |
| GET | /api/lists |
List summaries with card_count, without card bodies. |
| POST | /api/lists |
Create: {"name","position"?} -> 201 {list}. |
| GET | /api/lists/{id} |
One list and its cards. |
| PATCH | /api/lists/{id} |
Update: {"name"?,"position"?} -> {list}. |
| DELETE | /api/lists/{id} |
204; cascades cards, or 409 for the final list. |
| GET | /api/lists/{id}/cards |
Ordered cards in one list. |
| POST | /api/lists/{id}/cards |
Create: {"title","description"?,"position"?} -> 201 {card}. |
| GET | /api/cards/{id} |
One card. |
| PATCH | /api/cards/{id} |
Update or move: {"title"?,"description"?,"list_id"?,"position"?} -> {card} atomically. |
| DELETE | /api/cards/{id} |
204. |
Card JSON has id, list_id, title, description, position, created_at, and updated_at; it never has done. Sending done is an unknown field and returns 400.
Errors use { "error": { "code", "message" } }. Success uses 200, 201, or 204; malformed JSON is 400, missing resources 404, final-list deletion 409, invalid media type 415, validation errors 422, and unexpected failures 500.
Frontend Behavior
- The SPA loads
/api/boardon mount; there is no board picker. - Start a pointer drag from a card's grip. The entire destination column accepts it; the nearest card midpoint controls the insertion marker and fractional position. Mouse and touch both work. Pointer cancel or Escape aborts without a request. Drops update optimistically and roll back with a toast on failure.
- The card dialog also has a native Move to list select, which appends the card through the existing
PATCH /api/cards/{id}behavior and provides a keyboard-friendly alternative. - Status is derived solely from the card's current list. Tiles show the list-name chip; the dialog shows the same read-only status.
Donehas no special backend meaning. - Descriptions remain raw strings in the API/database. The dialog opens in a safe, sanitized CommonMark Preview tab and switches to Write mode for editing; card tiles show a compact plain-text rendering.
- The Edit toggle exposes list add/rename/delete controls. Card add, edit, delete, movement, and detail viewing remain available outside list-management mode.
- Short rails are horizontally centered on wide screens. Overflowing rails begin at the first column and use responsive horizontal scrolling and mobile scroll snapping.
Build and Deploy
- Local verification:
go test ./...,CGO_ENABLED=0 go build ./..., thencd web && npm ci && npm run build. web/distandweb/node_modulesare git-ignored. Docker's frontend stage always rebuildsdist.- The image is
git.ivanch.me/ivanch/havenllo:latest, built multi-arch ondocker-build.haven. .gitea/workflows/main.yamlverifies Go, frontend, and offline deployment YAML before build/deploy pipeline actions. The verify job must remain cluster-free.- Manual deployment:
kubectl apply -f deploy/havenllo.yaml -n default, then restart the Deployment. The app is served athttp://havenllo.haven.
Constraints and Conventions
- Single fixed board; lists and cards only.
- SQLite has one writer on an NFS PVC: one replica,
Recreatestrategy, root container user on the NFS mount. - Same-origin
/api, embedded SPA, no auth, no CORS, and no external services. - Keep the image and dependency surface small: no CGO, ORM, frontend framework beyond Preact, or DnD/state/icon libraries.
commonmarkanddompurifyare the intentional runtime exception for safe, full Markdown descriptions.