diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 06456f5..d80047a 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -177,7 +177,7 @@ func (h *Handler) updateCard(w http.ResponseWriter, r *http.Request) { return } card, err := h.service.UpdateCard(r.Context(), id, app.CardPatch{ - Title: request.Title, Description: request.Description, Done: request.Done, ListID: request.ListID, Position: request.Position, + Title: request.Title, Description: request.Description, ListID: request.ListID, Position: request.Position, }) if err != nil { writeDomainError(w, err) diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 580f91a..29c824a 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -84,3 +84,42 @@ func TestJSONValidationAndLastListConflict(t *testing.T) { t.Fatalf("last list status = %d", got.Code) } } + +func TestCardDoneFieldIsNotPartOfTheAPI(t *testing.T) { + t.Parallel() + handler := newHandler(t) + var board struct { + Lists []struct { + ID int64 `json:"id"` + } `json:"lists"` + } + if err := json.Unmarshal(request(t, handler, http.MethodGet, "/api/board", "", false).Body.Bytes(), &board); err != nil { + t.Fatal(err) + } + if len(board.Lists) == 0 { + t.Fatal("board has no lists") + } + + created := request(t, handler, http.MethodPost, "/api/lists/"+strconv.FormatInt(board.Lists[0].ID, 10)+"/cards", `{"title":"status comes from the list"}`, true) + if created.Code != http.StatusCreated { + t.Fatalf("create card status = %d, body %s", created.Code, created.Body) + } + var payload struct { + Card map[string]json.RawMessage `json:"card"` + } + if err := json.Unmarshal(created.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload.Card["done"]; exists { + t.Fatalf("card response unexpectedly contains done: %s", created.Body) + } + + cardID := payload.Card["id"] + var id int64 + if err := json.Unmarshal(cardID, &id); err != nil { + t.Fatal(err) + } + if got := request(t, handler, http.MethodPatch, "/api/cards/"+strconv.FormatInt(id, 10), `{"done":true}`, true); got.Code != http.StatusBadRequest { + t.Fatalf("done patch status = %d, body %s", got.Code, got.Body) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 807db5d..ee63d68 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -21,7 +21,6 @@ type createCardRequest struct { type updateCardRequest struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` - Done *bool `json:"done,omitempty"` ListID *int64 `json:"list_id,omitempty"` Position *float64 `json:"position,omitempty"` } diff --git a/internal/app/service.go b/internal/app/service.go index fc85cd1..5deb987 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -135,8 +135,8 @@ func (s *Service) CreateCard(ctx context.Context, listID int64, title, descripti return domain.Card{}, err } now := timestamp() - result, err := tx.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at) - VALUES (?, ?, ?, ?, 0, ?, ?)`, listID, title, description, pos, now, now) + result, err := tx.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, listID, title, description, pos, now, now) if err != nil { return domain.Card{}, err } @@ -154,7 +154,6 @@ func (s *Service) CreateCard(ctx context.Context, listID int64, title, descripti type CardPatch struct { Title *string Description *string - Done *bool ListID *int64 Position *float64 } @@ -189,9 +188,6 @@ func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (do if patch.Description != nil { card.Description = *patch.Description } - if patch.Done != nil { - card.Done = *patch.Done - } if patch.ListID != nil { if _, err := store.List(ctx, tx, *patch.ListID); err != nil { return domain.Card{}, err @@ -207,12 +203,8 @@ func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (do } card.Position = pos + positionStep } - done := 0 - if card.Done { - done = 1 - } - if _, err := tx.ExecContext(ctx, `UPDATE cards SET list_id = ?, title = ?, description = ?, position = ?, done = ?, updated_at = ? WHERE id = ?`, - card.ListID, card.Title, card.Description, card.Position, done, timestamp(), id); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE cards SET list_id = ?, title = ?, description = ?, position = ?, updated_at = ? WHERE id = ?`, + card.ListID, card.Title, card.Description, card.Position, timestamp(), id); err != nil { return domain.Card{}, err } if err := normalizeCards(ctx, tx, sourceListID); err != nil { diff --git a/internal/domain/models.go b/internal/domain/models.go index 08585fa..aac7c77 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -25,7 +25,6 @@ type Card struct { Title string `json:"title"` Description string `json:"description"` Position float64 `json:"position"` - Done bool `json:"done"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 04a0660..58a2df7 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -53,7 +53,7 @@ func List(ctx context.Context, q DBTX, id int64) (domain.List, error) { } func Cards(ctx context.Context, q DBTX, listID int64) ([]domain.Card, error) { - rows, err := q.QueryContext(ctx, `SELECT id, list_id, title, description, position, done, created_at, updated_at + rows, err := q.QueryContext(ctx, `SELECT id, list_id, title, description, position, created_at, updated_at FROM cards WHERE list_id = ? ORDER BY position, id`, listID) if err != nil { return nil, err @@ -71,7 +71,7 @@ func Cards(ctx context.Context, q DBTX, listID int64) ([]domain.Card, error) { } func Card(ctx context.Context, q DBTX, id int64) (domain.Card, error) { - row := q.QueryRowContext(ctx, `SELECT id, list_id, title, description, position, done, created_at, updated_at + row := q.QueryRowContext(ctx, `SELECT id, list_id, title, description, position, created_at, updated_at FROM cards WHERE id = ?`, id) card, err := scanCard(row) if err == sql.ErrNoRows { @@ -84,10 +84,7 @@ type scanner interface{ Scan(...any) error } func scanCard(row scanner) (domain.Card, error) { var card domain.Card - var done int - err := row.Scan(&card.ID, &card.ListID, &card.Title, &card.Description, &card.Position, &done, &card.CreatedAt, &card.UpdatedAt) - card.Done = done != 0 - return card, err + return card, row.Scan(&card.ID, &card.ListID, &card.Title, &card.Description, &card.Position, &card.CreatedAt, &card.UpdatedAt) } func Snapshot(ctx context.Context, q DBTX) (domain.Snapshot, error) { diff --git a/project-context.md b/project-context.md index a98e2ec..2b2b470 100644 --- a/project-context.md +++ b/project-context.md @@ -1,130 +1,126 @@ # 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. +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 (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. +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 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` +- Go module `havenllo`, Go 1.24. +- Backend: `modernc.org/sqlite`, pure Go and CGO-free. +- Frontend runtime: `preact`, `commonmark`, and `dompurify`; build tooling: `@preact/preset-vite`, `@types/commonmark`, `typescript`, and `vite`. +- Card movement: custom Pointer Events interaction; no DnD library. +- Static SPA assets are embedded with Go `embed` from `web/dist`. +- Runtime port: `8080`; `HAVENLLO_LISTEN_ADDR` defaults to `:8080` and `HAVENLLO_DATABASE_PATH` defaults to `/data/havenllo.db`. +- Docker: `node:22-alpine` frontend build, `golang:1.24-alpine` CGO-free build, then `gcr.io/distroless/static-debian12`. +- Deployment: one `Recreate` Deployment replica, `nfs-client` PVC, plain nginx ingress `havenllo.haven` in `default`, no TLS. -## Repository Organization (source code) +## Repository Organization -### Backend (Go) +### Backend -- `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. +- `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`: domain `List`, `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 seeds `To do`, `In progress`, and `Done`. Its historical `cards.done` column remains physically present but is dormant. +- `internal/store/sqlite.go`: parameterized SQL queries and scans only. Card queries intentionally omit `cards.done`. +- `internal/app/service.go`: transactional validation, ordering, normalization, last-list protection, list/card CRUD, and atomic move/reorder operations. It neither reads nor writes `done`. +- `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 (Preact + Vite + TypeScript) +### Frontend -- `web/index.html`: SPA shell — title, `theme-color`, viewport, root `
`, 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(, 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` 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 and selected-card/list-management UI state, wires optimistic mutations with rollback + toasts, and renders `Header`, `BoardCanvas`, the selected card's `CardDetailModal`, and `Toasts`. -- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management. -- `web/src/components/BoardCanvas.tsx`: responsive horizontally-scrolling list rail, management-mode-only add-list affordance, and board empty state. Maps lists to `ListColumn`. -- `web/src/components/ListColumn.tsx`: one column — card count, native drop zones (one before each card + one at the end), inline add-card input, empty-column state, and management-mode-only list rename/delete controls with delete confirmation. -- `web/src/components/CardItem.tsx`: one draggable card tile. A genuine click opens the board-level detail dialog; drag events are suppressed from opening it. It retains the done toggle, title/description preview, and a visible drag grip. -- `web/src/components/CardDetailModal.tsx`: accessible Jira-style card dialog with inline title and description editing, list/status properties, timestamps, delete confirmation, outside/Escape/X close controls, and modal focus restoration. -- `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. Elevated dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), responsive horizontal board rail, polished card/list/button states, edit-mode treatment, detail-dialog layout, empty states, focus rings, 44px mobile touch targets, and `prefers-reduced-motion` support. +- `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. `Card` and `CardPatch` contain no `done` member. +- `web/src/api.ts`: typed same-origin `/api` fetch 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 using `commonmark` plus 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`: brand, board label, refresh, and list-management toggle. +- `web/src/components/BoardCanvas.tsx`: board intro, centered-or-scrollable rail, list-management add control, and board-level Pointer Events drag lifecycle. It detects whole-column targets, insertion indices, pointer cancellation, and Escape cancellation. +- `web/src/components/ListColumn.tsx`: 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, and plain-text Markdown excerpt. 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 Haven theme, workflow-specific teal/indigo/gold accents, 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`: 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. +- `web/embed.go`: embeds `web/dist` and exports it as `web.Files` after stripping the `dist/` prefix. `web/dist` is generated and ignored; Docker rebuilds it. ## Backend Architecture -Layered, dependency-light: - -``` -main.go → api.Handler → app.Service → store (SQL) → database (sqlite.DB) - ↑ ↓ - domain (types/errors) domain (types/errors) +```text +main.go -> api.Handler -> app.Service -> store -> database (SQLite) + ^ | + domain <---------------+ ``` -- `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. +- `database` owns connections and migrations. +- `store` owns SQL text and scanning only. +- `app.Service` owns validation, transactions, ordering, and cross-resource behavior. +- `api` owns routing, JSON, and HTTP error shapes. +- `domain` contains shared dependency-free vocabulary. ## Data Model -Two tables (no `boards` table — there is exactly one fixed board): +There is no `boards` table: one fixed board has two tables. -- `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)`. +- `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. `done` is 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 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`. +`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`, JSON in/out, integer IDs. Mutation requests require `Content-Type: application/json`; unknown fields are rejected. +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` | 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` | +| 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`. | -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). +`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 -- 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. -- Clicking a card opens a centered, accessible Jira-style detail dialog. Its title and description patch independently, while list, done status, created/updated timestamps, and delete-with-confirm are visible in the dialog. It closes via X, Escape, or outside click, focuses on open, and restores the prior trigger focus when closed. -- The Header's Edit toggle enters list-management mode. Only in that mode are list rename/delete controls and the end-of-rail add-list control visible; card add, done, detail editing, delete, and drag-and-drop remain available in either mode. -- Inline add for cards, done toggle, descriptions, and delete-with-confirm. There is no inline card expansion/editor; card editing is centralized in the detail dialog. -- 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. +- The SPA loads `/api/board` on 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. `Done` has no special backend meaning. +- Descriptions remain raw strings in the API/database. The dialog writes CommonMark source and previews safe, sanitized CommonMark; 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 & Deploy +## Build and 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). +- Local verification: `go test ./...`, `CGO_ENABLED=0 go build ./...`, then `cd web && npm ci && npm run build`. +- `web/dist` and `web/node_modules` are git-ignored. Docker's frontend stage always rebuilds `dist`. +- The image is `git.ivanch.me/ivanch/havenllo:latest`, built multi-arch on `docker-build.haven`. +- `.gitea/workflows/main.yaml` verifies 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 at `http://havenllo.haven`. -## Constraints & Conventions +## Constraints and 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. +- Single fixed board; lists and cards only. +- SQLite has one writer on an NFS PVC: one replica, `Recreate` strategy, 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. `commonmark` and `dompurify` are the intentional runtime exception for safe, full Markdown descriptions. diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/web/dist/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/package-lock.json b/web/package-lock.json index 8561e10..83a1dd5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,10 +8,13 @@ "name": "havenllo-web", "version": "0.1.0", "dependencies": { + "commonmark": "^0.31.2", + "dompurify": "^3.4.12", "preact": "^10.26.4" }, "devDependencies": { "@preact/preset-vite": "^2.10.1", + "@types/commonmark": "^0.27.10", "typescript": "^5.8.3", "vite": "^6.3.5" } @@ -1327,6 +1330,13 @@ "win32" ] }, + "node_modules/@types/commonmark": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.10.tgz", + "integrity": "sha512-iEZobUnvlM+UX5fXWCmC4eQXwCs01Z8Xa1W0VjiWUF/XsNy4BHtskqJ9MyLZVMHbA0ezhyonCDqz3hMvsCm6Hg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1334,6 +1344,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/babel-plugin-transform-hook-names": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", @@ -1419,6 +1436,35 @@ ], "license": "CC-BY-4.0" }, + "node_modules/commonmark": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.31.2.tgz", + "integrity": "sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg==", + "license": "BSD-2-Clause", + "dependencies": { + "entities": "~3.0.1", + "mdurl": "~1.0.1", + "minimist": "~1.2.8" + }, + "bin": { + "commonmark": "bin/commonmark" + }, + "engines": { + "node": "*" + } + }, + "node_modules/commonmark/node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1518,6 +1564,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -1725,6 +1780,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index dec4e89..8e63695 100644 --- a/web/package.json +++ b/web/package.json @@ -9,10 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "commonmark": "^0.31.2", + "dompurify": "^3.4.12", "preact": "^10.26.4" }, "devDependencies": { "@preact/preset-vite": "^2.10.1", + "@types/commonmark": "^0.27.10", "typescript": "^5.8.3", "vite": "^6.3.5" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index a3d568a..1f3a8f4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -141,11 +141,11 @@ export function App() { await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted"); }; - const moveCard = async (cardID: number, sourceListID: number, targetListID: number, dropIndex: number) => { + const moveCard = async (cardID: number, targetListID: number, dropIndex: number) => { const current = boardRef.current; const card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID); if (!current || !card) return; - const position = movePosition(current.lists, cardID, sourceListID, targetListID, dropIndex); + const position = movePosition(current.lists, cardID, targetListID, dropIndex); await editCard(cardID, { list_id: targetListID, position }); }; @@ -167,12 +167,11 @@ export function App() { ) : null} {state.board ? ( setSelectedCardID(null)} onEdit={(patch) => editCard(selectedCard.id, patch)} onDelete={() => deleteCard(selectedCard.id)} diff --git a/web/src/components/BoardCanvas.tsx b/web/src/components/BoardCanvas.tsx index 8b82052..641dad9 100644 --- a/web/src/components/BoardCanvas.tsx +++ b/web/src/components/BoardCanvas.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "preact/hooks"; -import type { ListWithCards } from "../types"; +import { useEffect, useRef, useState } from "preact/hooks"; +import type { Card, ListWithCards } from "../types"; import { ListColumn } from "./ListColumn"; interface BoardCanvasProps { @@ -8,16 +8,43 @@ interface BoardCanvasProps { onEditList: (id: number, patch: { name?: string; position?: number }) => Promise; onDeleteList: (id: number) => Promise; onCreateCard: (listID: number, title: string) => Promise; - onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise; - onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise; + onMoveCard: (cardID: number, targetListID: number, index: number) => Promise; onOpenCard: (id: number) => void; managingLists: boolean; } +interface ActiveCardDrag { + cardID: number; + sourceListID: number; + targetListID: number | null; + targetIndex: number | null; + pointerID: number; +} + +function dropTargetAtPoint(cardID: number, clientX: number, clientY: number): Pick | null { + const element = document.elementFromPoint(clientX, clientY); + const column = element?.closest("[data-list-id]"); + const targetListID = Number(column?.dataset.listId); + if (!column || !Number.isInteger(targetListID)) return null; + + const cards = Array.from(column.querySelectorAll("[data-card-id]")) + .filter((card) => Number(card.dataset.cardId) !== cardID); + const index = cards.findIndex((card) => { + const bounds = card.getBoundingClientRect(); + return clientY < bounds.top + bounds.height / 2; + }); + return { targetListID, targetIndex: index === -1 ? cards.length : index }; +} + export function BoardCanvas(props: BoardCanvasProps) { const [adding, setAdding] = useState(false); const [name, setName] = useState(""); const [saving, setSaving] = useState(false); + const [drag, setDrag] = useState(null); + const dragRef = useRef(null); + const cleanupDragRef = useRef<() => void>(() => undefined); + + useEffect(() => () => cleanupDragRef.current(), []); useEffect(() => { if (!props.managingLists) { @@ -40,6 +67,69 @@ export function BoardCanvas(props: BoardCanvasProps) { } }; + const startCardDrag = (card: Card, event: PointerEvent) => { + if (dragRef.current) return; + const initial: ActiveCardDrag = { + cardID: card.id, + sourceListID: card.list_id, + targetListID: null, + targetIndex: null, + pointerID: event.pointerId + }; + dragRef.current = initial; + setDrag(initial); + document.body.classList.add("is-card-dragging"); + + const updateTarget = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== initial.pointerID || !dragRef.current) return; + const target = dropTargetAtPoint(initial.cardID, moveEvent.clientX, moveEvent.clientY); + const current = dragRef.current; + if (current.targetListID === target?.targetListID && current.targetIndex === target?.targetIndex) return; + const next = { ...current, targetListID: target?.targetListID ?? null, targetIndex: target?.targetIndex ?? null }; + dragRef.current = next; + setDrag(next); + }; + + const finish = (commit: boolean) => { + const finalDrag = dragRef.current; + cleanupDragRef.current(); + cleanupDragRef.current = () => undefined; + dragRef.current = null; + setDrag(null); + document.body.classList.remove("is-card-dragging"); + if (!commit || !finalDrag || finalDrag.targetListID === null || finalDrag.targetIndex === null) return; + + const source = props.lists.find((list) => list.id === finalDrag.sourceListID); + const sourceIndex = source?.cards.findIndex((item) => item.id === finalDrag.cardID); + if (finalDrag.targetListID === finalDrag.sourceListID && finalDrag.targetIndex === sourceIndex) return; + void props.onMoveCard(finalDrag.cardID, finalDrag.targetListID, finalDrag.targetIndex); + }; + + const onPointerMove = (moveEvent: PointerEvent) => updateTarget(moveEvent); + const onPointerUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId === initial.pointerID) finish(true); + }; + const onPointerCancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId === initial.pointerID) finish(false); + }; + const onKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key === "Escape") finish(false); + }; + const cleanup = () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + window.removeEventListener("keydown", onKeyDown); + document.body.classList.remove("is-card-dragging"); + }; + cleanupDragRef.current = cleanup; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + window.addEventListener("keydown", onKeyDown); + updateTarget(event); + }; + return (
@@ -53,54 +143,59 @@ export function BoardCanvas(props: BoardCanvasProps) {
{props.lists.length ? (
- {props.lists.map((list) => ( - props.onEditList(list.id, patch)} - onDeleteList={() => props.onDeleteList(list.id)} - onCreateCard={(title) => props.onCreateCard(list.id, title)} - onEditCard={props.onEditCard} - onMoveCard={props.onMoveCard} - onOpenCard={props.onOpenCard} - /> - ))} - {props.managingLists ?
- {adding ? ( -
{ - event.preventDefault(); - void addList(); - }} - > - - setName(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - setAdding(false); - setName(""); - } - }} +
+ {props.lists.map((list, index) => ( + props.onEditList(list.id, patch)} + onDeleteList={() => props.onDeleteList(list.id)} + onCreateCard={(title) => props.onCreateCard(list.id, title)} + onStartCardDrag={startCardDrag} + onOpenCard={props.onOpenCard} /> -
- - -
- - ) : ( - - )} -
: null} + ))} + {props.managingLists ?
+ {adding ? ( +
{ + event.preventDefault(); + void addList(); + }} + > + + setName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setAdding(false); + setName(""); + } + }} + /> +
+ + +
+
+ ) : ( + + )} +
: null} +
) : (
diff --git a/web/src/components/CardDetailModal.tsx b/web/src/components/CardDetailModal.tsx index b49b38e..2ea172d 100644 --- a/web/src/components/CardDetailModal.tsx +++ b/web/src/components/CardDetailModal.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from "preact/hooks"; -import type { Card } from "../types"; +import { renderMarkdown } from "../markdown"; +import type { Card, CardPatch, List } from "../types"; import { ConfirmDialog } from "./ConfirmDialog"; interface CardDetailModalProps { card: Card; listName: string; + lists: List[]; onClose: () => void; - onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise; + onEdit: (patch: CardPatch) => Promise; onDelete: () => Promise; } @@ -19,12 +21,13 @@ function formatTimestamp(value: string): string { }).format(timestamp); } -export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: CardDetailModalProps) { +export function CardDetailModal({ card, listName, lists, onClose, onEdit, onDelete }: CardDetailModalProps) { const [title, setTitle] = useState(card.title); const [description, setDescription] = useState(card.description); + const [descriptionMode, setDescriptionMode] = useState<"write" | "preview">("write"); const [savingTitle, setSavingTitle] = useState(false); const [savingDescription, setSavingDescription] = useState(false); - const [savingStatus, setSavingStatus] = useState(false); + const [savingList, setSavingList] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const dialogRef = useRef(null); const returnFocusRef = useRef(null); @@ -38,6 +41,7 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C useEffect(() => { setTitle(card.title); setDescription(card.description); + setDescriptionMode("write"); }, [card.id]); useEffect(() => { @@ -86,6 +90,18 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C } }; + const moveToList = async (listID: number) => { + if (listID === card.list_id) return; + setSavingList(true); + try { + await onEdit({ list_id: listID }); + } catch { + // The app restores the card and shows the request error as a toast. + } finally { + setSavingList(false); + } + }; + const close = () => { const nextTitle = title.trim(); if (!discardTitleSaveRef.current && nextTitle && nextTitle !== card.title) void saveTitle(); @@ -93,17 +109,6 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C }; closeRef.current = close; - const toggleDone = async () => { - setSavingStatus(true); - try { - await onEdit({ done: !card.done }); - } catch { - // The app restores the card and shows the request error as a toast. - } finally { - setSavingStatus(false); - } - }; - const deleteCard = async () => { setConfirmingDelete(false); try { @@ -173,18 +178,21 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
-
List{listName}
Status - + {listName}
+
@@ -195,18 +203,48 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C {descriptionChanged ? Unsaved changes : null} -