Files
havenllo/PLAN.md
Hermes 041c3fab4b
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
feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- 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
2026-07-14 10:13:51 -03:00

24 KiB
Raw Blame History

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

.
├── .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.

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:

{
  "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:

{
  "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:

{
  "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.

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.