feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 7s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board
- Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api
- Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme
- PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven
- Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
Hermes
2026-07-14 10:13:51 -03:00
commit 041c3fab4b
44 changed files with 5931 additions and 0 deletions

540
PLAN.md Normal file
View File

@@ -0,0 +1,540 @@
# Havenllo implementation plan
## 1. Overview and goals
Havenllo will be a single-user kanban application for the Haven homelab. It will
provide multiple boards, ordered lists, and ordered task cards without accounts,
authentication, or external services. One Go HTTP server will expose a
same-origin JSON API and serve the compiled Preact SPA from one container. Its
only durable state will be a SQLite database stored on a 1Gi PVC.
The implementation will favour a polished day-to-day board: fast board loading,
native card drag-and-drop, useful empty/loading states, keyboard-friendly inline
editing, and a responsive horizontally scrolling kanban layout. It will not add
an ORM, WebSockets, a separate frontend server, or a separate database service.
## 2. Proposed repository tree
```text
.
├── .dockerignore # Excludes Git data, node_modules, local DBs, and build artefacts from Docker context.
├── .gitattributes # Enforces LF for Docker/YAML/workflow files edited from Windows.
├── .gitea/
│ └── workflows/
│ └── main.yaml # Gitea Actions: verify, multi-arch build/push, apply, and restart.
├── .gitignore # Ignores database, binaries, coverage, node_modules, and web/dist output.
├── Dockerfile # Three-stage Node + Go + distroless build for the one production image.
├── README.md # Local development, test, build, and Kubernetes deployment instructions.
├── go.mod # Module definition; direct dependency only on modernc.org/sqlite.
├── go.sum # Checksums generated by go mod tidy and committed.
├── cmd/
│ └── havenllo/
│ └── main.go # Configuration, database setup, and HTTP server lifecycle.
├── internal/
│ ├── api/
│ │ ├── handlers.go # Go 1.22 ServeMux routes and endpoint handlers.
│ │ ├── http.go # JSON, errors, ID parsing, and validation helpers.
│ │ ├── types.go # API DTOs and domain-to-response conversion.
│ │ └── handlers_test.go # HTTP contract tests against temporary SQLite.
│ ├── app/
│ │ ├── service.go # Transactional validation, ordering, moves, and board seeding.
│ │ └── service_test.go # Ordering, move, delete, and default-board tests.
│ ├── database/
│ │ ├── database.go # SQLite options, migrations, and initial seed data.
│ │ ├── migrations.go # Versioned schema migrations.
│ │ └── database_test.go # Migration/idempotency and foreign-key tests.
│ ├── domain/
│ │ └── models.go # Board, List, Card, snapshot, and domain errors.
│ └── store/
│ ├── sqlite.go # Parameterized SQL repository methods; no ORM.
│ └── sqlite_test.go # Persistence and ordering-query tests.
├── web/
│ ├── embed.go # Embeds dist/ into the Go binary and exposes an fs.FS.
│ ├── index.html # Vite HTML shell, title, theme-color, and root element.
│ ├── package.json # Preact runtime; Vite/TS/Preact preset development tools.
│ ├── package-lock.json # Generated locked npm dependency graph, committed.
│ ├── tsconfig.json # Strict TypeScript configuration.
│ ├── vite.config.ts # Preact plugin and deterministic web/dist output.
│ ├── dist/
│ │ └── .gitkeep # Placeholder for pre-build Go embedding; replaced in image builds.
│ └── src/
│ ├── main.tsx # Preact bootstrap and global error boundary.
│ ├── App.tsx # Board selection, fetch, optimistic state hook, and shell.
│ ├── api.ts # Typed fetch client, endpoint methods, and API error parsing.
│ ├── types.ts # TypeScript representations of resources and errors.
│ ├── board-state.ts # Reducer/selectors, pending mutations, rollback, and toast state.
│ ├── drag.ts # Native drag payload, drop-index, and position helpers.
│ ├── styles.css # Design tokens and responsive component styling.
│ └── components/
│ ├── Header.tsx # Wordmark, board picker, create board, and status indicator.
│ ├── BoardCanvas.tsx # List rail, empty state, and add-list affordance.
│ ├── ListColumn.tsx # List editing, count, drop zones, and inline card creation.
│ ├── CardItem.tsx # Draggable card, inline title edit, done control, actions.
│ ├── CardEditor.tsx # Description editor and destructive card action.
│ ├── ConfirmDialog.tsx # Accessible confirmation dialog for deletes.
│ └── Toast.tsx # Non-blocking mutation feedback.
└── deploy/
└── havenllo.yaml # PVC, Deployment, Service, and Ingress multi-document manifest.
```
No application source files will be created during this planning step. The
`web/dist` directory is ignored except for its tracked placeholder; the Docker
build replaces it with Vite output before Go evaluates the embed directive.
## 3. Data model and SQLite schema
IDs are SQLite `INTEGER PRIMARY KEY` values. `position` is a floating-point
sort key; records are ordered by `(position, id)`. A new sibling receives the
current maximum plus `1024`; a drag chooses a value between its new neighbours.
The service renumbers a sibling group to `1024` increments inside the same
transaction whenever positions become too close or invalid.
Timestamps are UTC RFC 3339 strings written by Go. SQLite foreign keys are
enabled for every connection, and migrations run transactionally before the
server accepts traffic.
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
created_at TEXT NOT NULL,
position REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY,
board_id INTEGER NOT NULL,
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
position REAL NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY,
list_id INTEGER NOT NULL,
title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 240),
description TEXT NOT NULL DEFAULT '',
position REAL NOT NULL,
done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_boards_position ON boards(position, id);
CREATE INDEX IF NOT EXISTS idx_lists_board_position ON lists(board_id, position, id);
CREATE INDEX IF NOT EXISTS idx_cards_list_position ON cards(list_id, position, id);
```
Migration 1 atomically seeds a `Haven` board with `To do`, `In progress`, and
`Done` lists. Creating later boards seeds the same three lists. Deleting a board
cascades its lists and cards, but the API rejects deletion of the final board
with `409 Conflict` so the app remains usable.
The database comes from `HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`).
Connection setup uses WAL, a 5-second busy timeout, `foreign_keys=ON`, and one
open connection. This single-replica deployment intentionally keeps SQLite a
safe, simple single-writer database on the NFS-backed PVC.
## 4. REST API contract
All endpoints are same-origin under `/api`, accept and return JSON, and use
integer resource IDs. Mutation requests require `Content-Type: application/json`.
Unknown fields are rejected; names and titles are trimmed; descriptions allow up
to 10,000 characters. PATCH DTOs use pointer fields so `done: false` is distinct
from an omitted field.
Every error has this shape:
```json
{
"error": {
"code": "validation_error",
"message": "title is required"
}
}
```
Success statuses are `200`, `201`, and `204`. The API returns `400` for malformed
JSON/IDs, `404` for absent records, `409` for final-board deletion, `415` for an
incorrect media type, and `422` for business validation. Unexpected database
errors are logged server-side and produce a non-sensitive `500` response.
### Resource shapes
A board is `{ "id": 1, "name": "Haven", "position": 1024,
"created_at": "2026-07-14T12:00:00Z" }`.
A list is `{ "id": 10, "board_id": 1, "name": "To do", "position": 1024,
"card_count": 2 }`. `card_count` is included only in list collection responses.
A card is:
```json
{
"id": 101,
"list_id": 10,
"title": "Back up the router config",
"description": "Export it after the firmware update.",
"position": 1024,
"done": false,
"created_at": "2026-07-14T12:00:00Z",
"updated_at": "2026-07-14T12:00:00Z"
}
```
The primary board-load response is a snapshot:
```json
{
"board": { "id": 1, "name": "Haven", "position": 1024, "created_at": "2026-07-14T12:00:00Z" },
"lists": [
{ "id": 10, "board_id": 1, "name": "To do", "position": 1024, "cards": [] }
]
}
```
### Health
| Method | Path | Behaviour and response |
| --- | --- | --- |
| `GET` | `/api/health` | Pings SQLite and returns `200 {"status":"ok"}`; used by Kubernetes probes. |
### Boards
| Method | Path | Request body | Success response |
| --- | --- | --- | --- |
| `GET` | `/api/boards` | — | `200 {"boards":[Board...]}`, ordered by position. |
| `POST` | `/api/boards` | `{"name":"Homelab"}` | `201` BoardSnapshot with three starter lists. |
| `GET` | `/api/boards/{boardID}` | — | `200` BoardSnapshot with lists and cards in render order. |
| `PATCH` | `/api/boards/{boardID}` | `{"name":"…","position":2048}`; either field optional | `200 {"board":Board}`. |
| `DELETE` | `/api/boards/{boardID}` | — | `204`, unless it is the final board (`409`). |
### Lists
| Method | Path | Request body | Success response |
| --- | --- | --- | --- |
| `GET` | `/api/boards/{boardID}/lists` | — | `200 {"lists":[List...]}`, with card counts but not card bodies. |
| `POST` | `/api/boards/{boardID}/lists` | `{"name":"Waiting","position":4096}`; position optional | `201 {"list":List}`. |
| `GET` | `/api/lists/{listID}` | — | `200 {"list":List,"cards":[Card...]}`. |
| `PATCH` | `/api/lists/{listID}` | `{"name":"…","board_id":2,"position":1024}`; all optional | `200 {"list":List}`. |
| `DELETE` | `/api/lists/{listID}` | — | `204`; its cards are cascade-deleted after UI confirmation. |
### Cards
| Method | Path | Request body | Success response |
| --- | --- | --- | --- |
| `GET` | `/api/lists/{listID}/cards` | — | `200 {"cards":[Card...]}`, ordered by position. |
| `POST` | `/api/lists/{listID}/cards` | `{"title":"Replace UPS battery","description":"…","position":4096}`; description/position optional | `201 {"card":Card}`. |
| `GET` | `/api/cards/{cardID}` | — | `200 {"card":Card}`. |
| `PATCH` | `/api/cards/{cardID}` | `{"title":"…","description":"…","done":true,"list_id":11,"position":1536}`; all optional | `200 {"card":Card}`. |
| `DELETE` | `/api/cards/{cardID}` | — | `204`. |
The browser calculates target position from adjacent cards at the drop location.
The service independently validates it, normalizes sibling positions when
needed, and returns the authoritative resource. A card list move and position
update happen in one transaction.
## 5. Frontend architecture and interaction design
The frontend uses Preact, TypeScript, and Vite with only Preact and the Preact
Vite preset as dependencies. It uses no component, state, icon, or drag library.
`web/embed.go` exposes the completed Vite output as an embedded `fs.FS`, so the
SPA and API are always same-origin and need no CORS configuration.
`App` loads boards, restores the selected board from `localStorage` when it is
still valid (otherwise picks the first), and fetches one BoardSnapshot. Its
`useReducer`-based board state owns the snapshot, selection, pending mutation
IDs, rollback snapshots, and toast/error state. Every create/edit/move action
first performs an immutable optimistic update, calls `api.ts`, reconciles with
the canonical response, and restores state with an error toast if it fails.
Controls disable only for their pending resource, rather than freezing the board.
`Header` contains the Havenllo wordmark, board picker, create-board action, and
small connection/status feedback. `BoardCanvas` maps lists to columns and ends
with an inline Add list affordance. `ListColumn` provides list-name inline edit,
card count, card drop zones, and inline add-card input. `CardItem` provides
click-to-edit title, a done control, and a small actions control; `CardEditor`
edits descriptions and hosts destructive actions. `ConfirmDialog` is required
before deleting a card, list, or board, and `Toast` gives non-blocking feedback.
Cards use native HTML5 drag-and-drop. The drag payload holds card and source-list
IDs. Drop zones before every card plus one at the end provide an unambiguous
target index. Drag-over uses an accent outline and local preview; drop calculates
the new fractional position, updates optimistically, and sends one PATCH. A
failed call restores the pre-drag snapshot. The usual add/edit/done controls stay
fully usable on phones, while the board uses a horizontally scrolling rail,
280px minimum columns, and 44px touch targets; this remains useful on mobile
browsers with limited native DnD support.
Plain CSS in `styles.css` will use variables and deliberately named component
classes. The visual system is deep slate/navy with restrained teal/indigo accents,
1216px rounded surfaces, subtle borders, layered soft shadows, system font
stacks, clear focus rings, and reduced-motion support. Done cards, skeletons,
empty states, inline save/cancel affordances, and error states will feel designed
rather than like bare CRUD.
## 6. Backend architecture
`cmd/havenllo/main.go` reads `HAVENLLO_LISTEN_ADDR` (default `:8080`) and
`HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`), configures standard
library logging, opens/migrates SQLite, constructs the service and HTTP handler,
and gracefully shuts down on SIGINT/SIGTERM.
`database` owns parent-directory creation, SQLite connection options, and ordered
migrations. `store` contains parameterized SQL and record scanning, with no ORM.
`app.Service` owns cross-record validation, default list creation, ordering and
renumbering, and final-board rules. This keeps every state-changing operation
transactional and independently testable.
`api` uses Go 1.22 `http.ServeMux` method/path patterns and `PathValue`. It
decodes bounded JSON bodies with `DisallowUnknownFields`, maps typed domain errors
to the documented JSON shape, and never exposes SQL errors. It registers `/api/`
before static files: hashed Vite assets are served directly, and embedded
`index.html` is a fallback only for non-API SPA routes. An unknown API route
always produces JSON 404, never the SPA HTML. CORS headers are intentionally not
set because all browser traffic is same-origin.
Tests cover migration/seed behaviour, cascade effects, position normalization,
invalid moves, final-board protection, JSON validation, and essential status/
response shapes. Temporary SQLite files and an injectable static filesystem keep
tests independent of the PVC and production frontend bundle.
## 7. Dockerfile strategy
The Dockerfile has three stages:
1. A `node:22-alpine` frontend stage copies package manifests first, runs
`npm ci`, then builds `web/` with `npm run build` into `web/dist`.
2. A `golang:1.24-alpine` stage copies `go.mod`/`go.sum`, runs `go mod download`,
copies source and the frontend stage's `web/dist`, then runs
`CGO_ENABLED=0 go build`. Buildx arguments `TARGETOS` and `TARGETARCH` are
passed to the build so the pure-Go `modernc.org/sqlite` binary targets each
requested architecture without CGO.
3. `gcr.io/distroless/static-debian12:nonroot` receives only the statically
linked `havenllo` binary, exposes 8080, and runs as its non-root user.
The Dockerfile declares `BUILDPLATFORM`, `TARGETOS`, and `TARGETARCH` for clean
`linux/amd64,linux/arm64` remote Buildx builds. `.dockerignore` excludes local
`web/node_modules`, existing `web/dist`, Git metadata, test output, and `*.db`.
The stage-to-stage copy supplies the actual bundle, so ignoring any local dist
cannot break embedding. The final image contains no Node runtime, Go toolchain,
shell, npm cache, source tree, or database.
## 8. Exact Kubernetes manifest
`deploy/havenllo.yaml` will contain the following four documents. Each resource
explicitly targets the existing `default` namespace. One replica and a `Recreate`
strategy prevent concurrent SQLite writers or a conflicting RWO attachment during
an update. `fsGroup` matches the non-root distroless user so the provisioned NFS
volume remains writable.
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: havenllo-data
namespace: default
annotations:
nfs.io/storage-path: havenllo-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: nfs-client
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: havenllo
namespace: default
labels:
app.kubernetes.io/name: havenllo
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: havenllo
template:
metadata:
labels:
app.kubernetes.io/name: havenllo
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
containers:
- name: havenllo
image: git.ivanch.me/ivanch/havenllo:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: HAVENLLO_DATABASE_PATH
value: /data/havenllo.db
- name: HAVENLLO_LISTEN_ADDR
value: :8080
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumes:
- name: data
persistentVolumeClaim:
claimName: havenllo-data
---
apiVersion: v1
kind: Service
metadata:
name: havenllo
namespace: default
labels:
app.kubernetes.io/name: havenllo
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: havenllo
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: havenllo
namespace: default
annotations:
cert-manager.io/cluster-issuer: internal-ca
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- havenllo.haven
secretName: havenllo-tls
rules:
- host: havenllo.haven
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: havenllo
port:
number: 8080
```
There is no ConfigMap or Secret: the only environment values are non-sensitive,
and all durable state is the mounted SQLite file. The image needs no pull secret
when the cluster is already configured for `git.ivanch.me`; if that is not true,
the implementation will reference an existing pull-secret by name rather than
commit registry credentials.
## 9. Build and deploy flow
`.gitea/workflows/main.yaml` will run on pushes to `main` and manual dispatch.
It will keep YAML files LF-only via `.gitattributes` and use descriptive `name:`
values for every Gitea job.
1. A `verify` job on `runner-slim-amd64` checks out the repository, runs Go
tests, runs `npm ci` and the TypeScript/Vite production build, and performs a
client-side `kubectl apply --dry-run=client` validation of the manifest when
kubectl is available.
2. A `build` job uses
`https://git.ivanch.me/ivanch/pipeline-actions/build-and-push@main` with
`image: git.ivanch.me/ivanch/havenllo`, `image_tag: latest`, registry-password
and remote-builder SSH-key secrets. It retains the action's default
`linux/amd64,linux/arm64` platforms, builds on `docker-build.haven`, and pushes
`git.ivanch.me/ivanch/havenllo:latest`.
3. A dependent `deploy` job uses
`https://git.ivanch.me/ivanch/pipeline-actions/kubectl-apply@main` with
`manifest: deploy/havenllo.yaml` and `${{ secrets.KUBE_CONFIG }}`. It then uses
`https://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@main` with
`deployment_name: havenllo`, `namespace: default`, and the same kubeconfig.
Applying before restart reconciles PVC/Service/Ingress changes before the
`latest` image is restarted.
4. Deployment verification is `kubectl -n default rollout status deployment/havenllo`,
followed by `kubectl -n default get certificate,ingress havenllo` and opening
`https://havenllo.haven`. cert-manager creates `havenllo-tls` through the
existing `internal-ca` ClusterIssuer.
The Gitea repository requires `REGISTRY_PASSWORD`, `SSH_KEY_DOCKERBUILD`, and
`KUBE_CONFIG` secrets. No secret values are committed.
## 10. Ordered implementation checklist
1. Add `.gitignore`, `.dockerignore`, `.gitattributes`, and README. Confirm no
local database or generated frontend build is tracked and workflow YAML will
remain LF-only on Windows.
2. Add `go.mod` with only `modernc.org/sqlite`, run `go mod tidy`, and define the
domain models and errors.
3. Implement SQLite connection options, migration tracking, the schema above,
and one-time `Haven`/starter-list seeding. Add migration and foreign-key tests.
4. Implement parameterized SQLite store methods and the transactional service:
validation, float ordering/normalization, list/card moves, board starter lists,
cascade-aware deletion, and final-board protection. Add store/service tests.
5. Implement the standard-library API router, JSON/error helpers, health check,
all documented handlers, and graceful process entry point. Add HTTP contract
tests for normal, invalid, missing, and conflict cases.
6. Scaffold Vite/Preact, the typed client, reducer-based optimistic board state,
board selector persistence, and loading/error states.
7. Implement the visual components and CSS system: header, list rail, inline
creation/editing, count badges, done state, descriptions, confirmations,
toasts, empty states, and responsive interaction styling.
8. Add native card drag/drop previews and drop zones for same-list and cross-list
moves. Manually test error rollback, keyboard controls, and mobile-sized views.
9. Add `web/embed.go` and Go static-file/SPA fallback routing. Verify a production
frontend build is embedded and API misses return JSON rather than HTML.
10. Add the multi-stage Dockerfile; build locally for the host platform and verify
the container creates/uses `/data/havenllo.db`, serves `/`, and passes
`/api/health` with a read-only root filesystem.
11. Add the exact deployment manifest and Gitea workflow using the documented
pipeline-actions URLs. Validate YAML, a client-side apply, and LF-only files.
12. Push `main`; allow the multi-architecture image to build and deploy; verify
rollout, certificate, and ingress; then smoke-test create, edit, complete,
drag, refresh persistence, and deletion confirmation at `havenllo.haven`.