2 Commits

Author SHA1 Message Date
mAi
1020d60c75 docs: Phase 7 — record m's decisions (§0); Q4 override (show both task sources)
m approved the design + answered all 6 questions (via head). Five matched
the inventor pick; Q4 overridden to (b): a CalDAV-bound project with
mBrian-native tasks renders BOTH as sub-sections rather than hiding the
mBrian ones. §0 captures the picks; §5 updated to the two-section render.
Implementation: t-projax-7b-tasks (gated on the type-field API ask).
2026-06-01 17:28:29 +02:00
mAi
6e4fabfab9 docs: Phase 7 entity-model design — projects/tasks/tasklists + hybrid CalDAV/mBrian task backend
DESIGN PASS (inventor, no code). docs/plans/phase-7-entity-model.md.

Formalizes the entity model on mBrian now that it's the canonical backend:
- ONE new node type (task=['task']); zero new edge rels. Areas/projects
  unchanged. Tasklist/checklist = a container + metadata.projax.render
  hint, NOT a new type (keeps the type vocabulary at two — m's keep-it-
  simple watchword). Sub-tasks/checklist items = tasks nested via child_of.
- Task done-state reuses the existing status lifecycle (done); due/order
  via metadata.projax.* — no new fields, no new API beyond one.
- Hybrid task backend per m's decision: caldav-list link presence selects
  per-project (CalDAV VTODOs vs mBrian-native task nodes); uniform Task
  shape over both (slice-B adapter pattern). CalDAV side already full
  read/write — no change.
- Verified against live system: mBrian has NO task type today (0 nodes);
  POST /api/projax/nodes forces type=['project'] → the ONE cross-repo ask
  is accepting type in {project,task}. Everything else reuses Phase-6
  structures (child_of, status, PATCH-projax partial, projax_origin
  scoping). Supersedes PRD §2.1 'tasks live outside projax' for non-CalDAV
  projects; reconciled in §7.

6 open questions batched for head→m; 1 cross-repo API ask for mBrian/head.
Parked at the gate per inventor flow.
2026-06-01 17:16:48 +02:00
33 changed files with 735 additions and 1941 deletions

View File

@@ -0,0 +1,175 @@
# Phase 7 — entity model on mBrian: projects / tasks / tasklists + hybrid CalDAV-vs-mBrian task backend
**Status**: Phase A design (inventor — no code). Branch `mai/kahn/phase-7a-entity-model`.
**Author**: kahn (inventor), 2026-06-01.
**Predecessor**: Phase 6 — mBrian is now projax's canonical backend (reads direct-DB, writes via mBrian's scoped `/api/projax` HTTP surface; `PROJAX_BACKEND=mbrian` live on 7c84c96). See `docs/plans/mbrian-backend-migration.md` + `docs/plans/slice-c-writepath-contract.md`.
**m's vision (verbatim)**: *"Nestable project components: Projects (completely nestable into sub-projects), tasks, tasklists (as checklists, too), CalDAV connections for tasks."*
**Watchword**: keep the DB simple (m's hard constraint from Phase 6).
---
## §0 — m's decisions (2026-06-01, relayed via head)
m approved the design and answered all six §9 questions. Five matched the inventor pick; Q4 was overridden.
- **Q1 (Tasklist)**: (a) — one container + `metadata.projax.render='checklist'` hint, no new type. *(pick)*
- **Q2 (Done-state)**: (a) — reuse `status='done'`. *(pick)*
- **Q3 (Hybrid selector)**: (a) — `caldav-list` link presence ALONE selects the backend; `management` is orthogonal, NOT a gate. *(pick)*
- **Q4 (CalDAV + mBrian tasks on one project)**: **(b) — OVERRIDE.** Show **both** as separate sub-sections, don't hide the mBrian tasks. *(pick was (a) hide; m wants both visible — so a CalDAV-bound project can still carry mBrian-native tasks alongside its VTODOs, both rendered.)*
- **Q5 (Ordering)**: (a) — `created_at` order only; defer drag-reorder. *(pick)*
- **Q6 (Task edge)**: (a) — `child_of` + render-filter tasks out of `/tree`+`/graph`. *(pick)*
**Cross-repo ask** (POST `/api/projax/nodes` accept `type ∈ {project, task}`): head opened it with mBrian/head (msg 2719); the slug-control fix (2711) is also in flight. mBrian-native task CREATE is gated on the `type` field landing; everything else builds now.
Implementation tracked under `t-projax-7b-tasks-implement` (branch `mai/kahn/phase-7b-tasks`).
---
## §1 — Diagnosis: what exists, what's missing
**Verified against the live system (not docs):**
- mBrian node types today (msupabase, `mbrian.nodes`): `project` (76), `mai-managed` (43 co-type), `event` (242), plus contact/book/idea/note/concept/question/… — **no `task` or `todo` type exists.** projax tasks are not mBrian nodes today.
- projax projects/areas: `type=['project']`; areas additionally carry `metadata.projax.kind='area'`; nesting is arbitrary-depth via `child_of` edges (Phase 6). Confirmed holds.
- **Tasks today live OUTSIDE projax.** PRD §2.1: *"Task — atomic work item. Lives outside projax (CalDAV todos, Gitea issues, mai.tasks). projax references and aggregates them; it does not own them."* The only task-shaped surface projax owns end-to-end is the **CalDAV VTODO read/write** path (PRD §5): a `caldav-list` link (self-edge `rel='projax-caldav-list'`, `metadata.url`) binds a project to a calendar; the detail page + dashboard + timeline aggregate its VTODOs and write them back (complete/reopen/edit/delete/add) with ETag optimistic concurrency.
- `management text[]` = `self` / `mai` / `external` (empty = unmanaged), already per-project.
**The gap Phase 7 closes:** m wants first-class tasks + tasklists/checklists for projects that AREN'T CalDAV-bound, while keeping CalDAV as the task backend where he's already wired a calendar. This **supersedes PRD §2.1's "tasks live outside projax"** — but only for non-CalDAV projects; CalDAV-bound projects keep CalDAV as their task store. §7 reconciles the PRD.
---
## §2 — Entity types + mBrian mapping
The design adds **exactly one new node type** (`task`) and **zero new edge rels** — everything else reuses Phase-6 structures. This is the DB-simple path.
| entity | mBrian shape | new? |
|---|---|---|
| **Area** | `type=['project']` + `metadata.projax.kind='area'`, root-level (no parent `child_of`). | unchanged |
| **Project** | `type=['project']`, nestable via `child_of` edges, arbitrary depth. | unchanged |
| **Task** | `type=['task']`, attached to its parent (project or task) via a `child_of` edge. Done-state reuses the existing `status` lifecycle (`active``done``archived`); `metadata.projax.{due, order, priority}` optional. | **NEW type** |
| **Tasklist / checklist** | **NOT a new type.** Any container (project OR task) with `metadata.projax.render='checklist'` renders its child tasks in compact checklist mode. A "tasklist" is a project (or task) whose children are tasks; "checklist" is a render hint, not a type. | render flag only |
**Why task reuses `status` not a new `done` bool**: the lifecycle `active→done→archived` already exists on every node (`metadata.projax.status`), the PATCH-projax partial already writes it, and the reader already unpacks it. A task is "done" when `status='done'`. Zero new field, zero new API surface. (Open Q2 confirms.)
**Why one container concept, not three types** (m's "tasklists as checklists too" + the parked micro-project idea): projects, tasklists, and checklists are the same structural thing — a container of tasks — differing only in render density. Modelling them as one container + a `render` hint keeps the type vocabulary at two (`project`, `task`) instead of four. A checklist sub-item is just a task nested under a task. (Open Q1 confirms.)
**Task attachment — `child_of` vs a dedicated edge** (inventor pick: `child_of`): tasks attach via the same `child_of` edge projects use. Pros: reuses the reader's graph walk, path derivation, and the write API's existing `child_of` support — no new edge rel (the `/api/projax/edges` allowlist is `child_of | projax-*`). Con: tasks become structural children, so they'd surface in `/tree` and `/graph` unless those renderers filter by type. That filter is cheap — `Item.Kind` already carries `['task']`, so the project surfaces render `type=['project']` only and the Tasks section renders `type=['task']` children. The alternative (a dedicated task edge for hard separation from the project DAG) is cleaner-on-paper but adds a rel + a reader change for no functional gain at m's scale. (Open Q6.)
---
## §3 — The hybrid CalDAV-vs-mBrian task backend (m's decision)
m already decided task storage is **hybrid, segmented per-project** — not uniformly mBrian. His words: *"If we decide to use caldav, we use that - and mBrian for others. We already have the managed classification so we can separate."*
### §3.1 — Per-project backend-selection rule
**The selector is the `caldav-list` link.** A project's task backend is decided per-project, the same way m already decides it — by binding (or not binding) a CalDAV list:
```
project has a caldav-list link → CalDAV-backed (tasks = that calendar's VTODOs)
project has NO caldav-list link → mBrian-native (tasks = type=['task'] child nodes)
```
`management` (`self`/`mai`/`external`) is the **existing classification that makes this clean** (m's "we already have the managed classification so we can separate") — it is orthogonal *who-runs-it* metadata, not the switch itself. The caldav-list link is the switch. (Open Q3 checks whether m wants `management` to ALSO gate — e.g. force mBrian-native for `self`, or suppress task-authoring on `external`.)
### §3.2 — CalDAV sync model (CalDAV-bound projects)
**No change — it already works.** PRD §5's VTODO surface is already full read/write (complete/reopen/edit/delete/add, ETag optimistic concurrency, ICS round-trip). A CalDAV-bound project's tasks ARE its VTODOs; Phase 7 just formalizes "this project is CalDAV-backed" as a first-class concept rather than an aggregation side-effect. No new CalDAV writeback is needed. (The §5 read-only stance on VEVENTs is unchanged — events aren't tasks.)
### §3.3 — Uniform task shape (one shape, two sources)
Both backends present through one `Task` view-shape, exactly the slice-B adapter pattern (one `Item` shape, two backends):
```
Task { ID, Title, Done bool, Due *date, Source "caldav"|"mbrian",
Order int, ParentItemID, Priority? , raw-source-handle }
```
- CalDAV source: materialised from a VTODO (`SUMMARY`→Title, `STATUS`→Done, `DUE`→Due, UID+calendar_url as the handle for writeback).
- mBrian source: materialised from a `type=['task']` node (`title`→Title, `status='done'`→Done, `metadata.projax.due`→Due, node id as the handle).
The detail-page Tasks section, dashboard task rollups, and timeline render the uniform shape — they don't care which backend produced it. Writes dispatch on `Source`: CalDAV → existing VTODO PUT path; mBrian → `/api/projax` node PATCH/POST.
---
## §4 — Nesting + ordering
- **Project nesting**: unchanged — arbitrary depth via `child_of`.
- **Task nesting**: tasks nest under a project (`task child_of project`) and under a task (`subtask child_of task`) — arbitrary depth, reusing `child_of`. Sub-tasks ARE checklist items; a task with a `render='checklist'` hint shows its sub-tasks as a compact checklist. This folds in the parked micro-project/checklist-sub-item idea for free.
- **Ordering within a list**: `metadata.projax.order` (int) for manual reorder; default order is `created_at` when `order` is unset. Drag-to-reorder writes `order` via PATCH-projax. (Open Q5: ship manual reorder in v1, or `created_at`-only and defer drag?)
- **CalDAV task ordering**: VTODOs have no portable order field; CalDAV-backed lists keep their existing sort (due-date / priority / summary). Manual reorder is mBrian-native-only.
---
## §5 — UI / render implications
- **Detail page Tasks section** (`/i/{path}`): today CalDAV-only. Extend to render the uniform Task shape. For a CalDAV-bound project → VTODOs (as now). For an mBrian-native project → its `type=['task']` child nodes, with an "add task" affordance, a done checkbox (→ `status` flip), inline title/due edit, and delete — mirroring the CalDAV affordances so the two sources feel identical. **Per m's Q4=(b): a CalDAV-bound project that ALSO has mBrian-native task nodes renders BOTH — a CalDAV sub-section (VTODOs) and an mBrian sub-section (task nodes) — rather than hiding the mBrian tasks. The backend selector (§3.1) decides where a *newly created* task lands (CalDAV-bound → VTODO), but both sources always render when present.**
- **Checklist render mode**: a container with `metadata.projax.render='checklist'` renders tasks as a dense checkbox list (no per-row chrome) vs the default roomier task rows.
- **Tree / graph / tiles**: filter to `type=['project']` so tasks don't clutter the project DAG. `Item.Kind` already distinguishes — a one-line predicate in each renderer. (If Q6 picks the dedicated-edge alternative, this filtering is unnecessary.)
- **Dashboard / timeline**: task rollups + the chronological spine consume the uniform shape, so mBrian-native tasks appear alongside CalDAV ones with no per-surface special-casing.
- **Task creation**: on a project detail page, "add task" creates a `type=['task']` node `child_of` the project (mBrian-native projects) or a VTODO (CalDAV-bound) — the backend selector (§3.1) decides which, transparently.
---
## §6 — Write-API needs (cross-repo with mBrian/head)
mBrian owns the `/api/projax` write surface + `db.ts`. Phase 7's mBrian-native tasks need **one** real API addition; everything else is already supported.
| need | status |
|---|---|
| **Create a `type=['task']` node** | **NEW ASK.** Today `POST /api/projax/nodes` *forces* `type=['project']` (slice-C contract §2). Ask: accept an optional `type` field allowlisted to `{project, task}` (default `project`; reject anything else to preserve the projax-scoping model). Areas keep `type=['project']`+`metadata.projax.kind='area'`, so no `area` type needed. |
| Task done-toggle | **No ask**`PATCH /api/projax/nodes/{id}` already shallow-merges `projax:{status:'done'}`. |
| Task due / order / priority | **No ask** — same PATCH-projax partial (`metadata.projax.{due,order,priority}`). |
| Task→project / subtask→task edges | **No ask**`POST /api/projax/edges` already allows `rel='child_of'`. |
| Task slug control | **Confirmed-incoming** — head has already opened the explicit-slug fix with mBrian/head (slug on `POST`+`PATCH`); tasks need user-meaningful slugs, so this design assumes slug-control is restored (do not design around the generate-from-title bug). |
| Reader picks up tasks | **No ask** — task nodes carry `metadata.projax_origin` (created via the API), so `MBrianReader`'s `projax_origin` scoping already includes them; `Item.Kind=['task']` flows through. |
**Net cross-repo ask: one field** (`type` on `POST /api/projax/nodes`). Batched to head with the open questions.
---
## §7 — Migration of existing CalDAV-task usage
- **CalDAV-bound projects**: zero migration. Their tasks are VTODOs and keep working through the existing surface; Phase 7 only re-labels them "CalDAV-backed."
- **Non-CalDAV projects**: gain the ability to hold mBrian-native tasks. No data to migrate — there are no mBrian task nodes today (verified: 0 `task`-type nodes).
- **PRD reconciliation**: PRD §2.1's "tasks live outside projax" is amended — tasks are now first-class for non-CalDAV projects, external (CalDAV/Gitea) for the rest. Phase 7 extends, doesn't contradict: the aggregation surfaces (dashboard/timeline) still consume external tasks; they additionally consume mBrian-native ones. A short PRD addendum captures this.
- **No `projax.items` involvement**: tasks are mBrian-only from day one (no legacy projax.items.task rows ever existed). Slice E's table drop is unaffected.
---
## §8 — Implementation slicing
Depends on the §6 cross-repo `type`-field ask landing first (gates A).
- **A — task read adapter + uniform shape**: define the `Task` shape; the reader materialises `type=['task']` nodes; the detail Tasks section renders CalDAV + mBrian tasks uniformly per the §3.1 selector. (Read-only mBrian tasks first — mirrors the slice-B-before-C discipline.)
- **B — mBrian task write path**: create / done-toggle / edit / delete mBrian-native tasks via `/api/projax` (node POST with `type=task`, PATCH `status`/`due`, edge POST for `child_of`, node DELETE). Reuses the slice-C `MBrianWriter`.
- **C — checklist render mode + sub-tasks**: `metadata.projax.render='checklist'`, task-under-task nesting, compact render.
- **D — ordering / drag-reorder**: `metadata.projax.order` + drag affordance (only if Q5 wants it in v1).
- **E — tree/graph/tiles type-filter**: exclude `type=['task']` from project surfaces (skip if Q6 picks the dedicated-edge model).
Slice A is the cheapest win and de-risks the rest. B is the bulk. C/D/E are additive polish.
---
## §9 — Open questions
Batched to **projax/head** (not chip-asked to m — projax routes all forks through head). Inventor pick listed first.
1. **Tasklist model** — one container concept (project/task) + `metadata.projax.render='checklist'` render hint **[pick]**, or a dedicated `type=['tasklist']`? Pick keeps the type vocabulary at two and satisfies "checklists too" via render mode.
2. **Task done-state** — reuse the existing `status` lifecycle (`done`) **[pick]**, or a separate `metadata.projax.done` bool? Pick adds zero fields/API.
3. **Hybrid selector**`caldav-list` link presence alone decides the backend **[pick]**, or does `management` ALSO gate (e.g. force mBrian-native for `self`, suppress authoring on `external`)?
4. **CalDAV-bound project that also has mBrian task nodes** — CalDAV wins for the Tasks section, mBrian tasks hidden there **[pick]**; or show both (two sub-sections); or forbid creating mBrian tasks under a CalDAV-bound project?
5. **Manual task ordering in v1** — ship `metadata.projax.order` + drag-reorder now, or **`created_at` order only and defer drag [pick]** (simpler v1; reorder is additive)?
6. **Task attachment edge**`child_of` + render-filter tasks out of project surfaces **[pick]**, or a dedicated task edge for hard separation from the project DAG? Pick reuses everything; the alternative is cleaner separation at the cost of a new rel + reader change.
**Cross-repo ask (to mBrian/head via head, not m):** extend `POST /api/projax/nodes` to accept `type ∈ {project, task}` (§6) — the one new API field Phase 7 needs.
---
## §10 — Out of scope (Phase 7)
- Recurring tasks (RRULE) — CalDAV app territory; mBrian-native recurrence deferred.
- Task dependencies / blocked-by graph — possible later via a `projax-blocks` edge; not now.
- Gitea issues as first-class tasks — issues stay aggregated (read/writeback) as today, not converted to task nodes.
- Bidirectional CalDAV↔mBrian task mirroring — a project is one backend or the other, never synced both ways.
- Reminders / notifications — Otto-PWA's domain.

5
go.mod
View File

@@ -2,10 +2,7 @@ module github.com/m/projax
go 1.25.5
require (
github.com/jackc/pgx/v5 v5.9.2
github.com/yuin/goldmark v1.7.16
)
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect

2
go.sum
View File

@@ -16,8 +16,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=

View File

@@ -31,27 +31,6 @@ func ValidationToolError(ve *itemwrite.ValidationError) *ToolError {
}
}
// slugAwareToolError promotes the adapter's slug sentinels into typed
// validation tool errors (so MCP clients get {kind, detail} on a slug
// collision / invalid slug from the mBrian backend, just like the
// pre-flight validator), and falls back to InternalError otherwise.
func slugAwareToolError(err error) *ToolError {
switch {
case errors.Is(err, store.ErrSlugTaken):
return ValidationToolError(&itemwrite.ValidationError{
Kind: itemwrite.KindSlugCollision,
Detail: "slug already taken (possibly by a deleted item)",
})
case errors.Is(err, store.ErrInvalidSlug):
return ValidationToolError(&itemwrite.ValidationError{
Kind: itemwrite.KindInvalidSlugFormat,
Detail: "invalid slug — lower-case, no dots or whitespace",
})
default:
return InternalError(err)
}
}
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
type TimelineArgs struct {
@@ -895,7 +874,7 @@ func createItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
Metadata: in.Metadata,
})
if err != nil {
return nil, slugAwareToolError(err)
return nil, InternalError(err)
}
return toItemView(it), nil
}
@@ -1047,7 +1026,7 @@ func updateItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
}
updated, err := wr.Update(ctx, it.ID, patch)
if err != nil {
return nil, slugAwareToolError(err)
return nil, InternalError(err)
}
return toItemView(updated), nil
}

View File

@@ -157,10 +157,6 @@ func itemFromNode(r *nodeRow) *Item {
if v, ok := projaxMeta["timeline_exclude"]; ok {
it.TimelineExclude = anyToStringSlice(v)
}
// Phase 7 checklist render hint (Q1). Empty/absent → roomy default.
if v, ok := projaxMeta["render"].(string); ok {
it.Render = v
}
if t := parseTimeAny(projaxMeta["start_time"]); t != nil {
it.StartTime = t
}
@@ -374,17 +370,7 @@ func (r *MBrianReader) ListAll(ctx context.Context) ([]*Item, error) {
return nil, err
}
out := make([]*Item, 0, len(gc.nodeByID))
for id, n := range gc.nodeByID {
// Phase 7 (Q6): task nodes are NOT project items. Exclude them from
// every list-producing surface at this one reader chokepoint —
// ListAll feeds tree/graph/dashboard/calendar/timeline + MCP
// list_items/tree, and ListByFilters/ItemsCreatedInRange delegate
// here — so tasks can't leak into any project surface. GetByID/
// GetByPath still resolve a task node (the writer read-back + any
// future task access need that); only the *lists* drop them.
if nodeIsTask(n) {
continue
}
for id := range gc.nodeByID {
out = append(out, gc.buildItem(id))
}
sort.Slice(out, func(i, j int) bool {
@@ -475,10 +461,7 @@ func (r *MBrianReader) Roots(ctx context.Context) ([]*Item, error) {
return nil, err
}
out := []*Item{}
for id, n := range gc.nodeByID {
if nodeIsTask(n) {
continue
}
for id := range gc.nodeByID {
if len(gc.parentsOf[id]) == 0 {
out = append(out, gc.buildItem(id))
}
@@ -496,9 +479,6 @@ func (r *MBrianReader) MaiOrphans(ctx context.Context) ([]*Item, error) {
}
out := []*Item{}
for id, n := range gc.nodeByID {
if nodeIsTask(n) {
continue
}
if len(gc.parentsOf[id]) > 0 {
continue
}
@@ -509,6 +489,7 @@ func (r *MBrianReader) MaiOrphans(ctx context.Context) ([]*Item, error) {
if !it.HasManagement("mai") {
continue
}
_ = n
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
@@ -648,10 +629,7 @@ func (r *MBrianReader) Search(ctx context.Context, q string, limit int) ([]*Item
// across title / slug / aliases / content_md / paths.
ql := strings.ToLower(q)
out := []*Item{}
for id, n := range gc.nodeByID {
if nodeIsTask(n) {
continue
}
for id := range gc.nodeByID {
it := gc.buildItem(id)
if it == nil {
continue
@@ -731,9 +709,6 @@ func (r *MBrianReader) AllTags(ctx context.Context) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, n := range gc.nodeByID {
if nodeIsTask(n) {
continue
}
if pm, ok := n.Metadata["projax"].(map[string]any); ok {
for _, t := range anyToStringSlice(pm["tags"]) {
if t == "" || seen[t] {

View File

@@ -1,76 +0,0 @@
package store
import (
"context"
"sort"
)
// Phase 7 — mBrian-native task READ path. Task nodes are mbrian.nodes with
// type containing 'task', carrying metadata.projax_origin (so the shared
// reader scoping already includes them) plus metadata.projax.{status,due}.
// They attach to their parent project via a child_of edge, exactly like a
// sub-project — the only structural difference is the type, which is why the
// list-producing reader methods filter them out (nodeIsTask) and this file
// surfaces them through the dedicated Task shape instead.
// Compile-time witness: MBrianReader satisfies TaskReader.
var _ TaskReader = (*MBrianReader)(nil)
// nodeIsTask reports whether a node's type marks it a projax task. Tasks are
// excluded from every project-list surface (ListAll/Roots/MaiOrphans/Search/
// AllTags) so they never clutter the project DAG; they surface only through
// TasksForItem (and remain individually resolvable via GetByID/GetByPath).
func nodeIsTask(r *nodeRow) bool { return containsString(r.Type, "task") }
// TasksForItem returns the mBrian-native tasks attached to itemID via a
// child_of edge, in created-at order (Q5 — created order, no manual reorder).
// One graph build; cheap at m's scale. Each task materialises into the
// uniform Task shape with Source=mbrian.
func (r *MBrianReader) TasksForItem(ctx context.Context, itemID string) ([]*Task, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
out := []*Task{}
for _, childID := range gc.childrenOf[itemID] {
n, ok := gc.nodeByID[childID]
if !ok || !nodeIsTask(n) {
continue
}
out = append(out, taskFromNode(n, itemID))
}
// Created-at order; stable tiebreak on slug so equal stamps don't churn.
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].Slug < out[j].Slug
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}
// taskFromNode hoists a task node row into the uniform Task shape, unpacking
// metadata.projax.{status,due}. parentID is the item the caller resolved this
// task under (the child_of target driving TasksForItem).
func taskFromNode(n *nodeRow, parentID string) *Task {
t := &Task{
ID: n.ID,
Title: n.Title,
Source: TaskSourceMBrian,
Status: "active",
ParentItemID: parentID,
CreatedAt: n.CreatedAt,
NodeID: n.ID,
Slug: n.Slug,
}
if pm, ok := n.Metadata["projax"].(map[string]any); ok {
if v, ok := pm["status"].(string); ok && v != "" {
t.Status = v
}
if due := parseTimeAny(pm["due"]); due != nil {
t.Due = due
}
}
t.Done = t.Status == "done"
return t
}

View File

@@ -1,104 +0,0 @@
package store
import (
"testing"
"time"
)
func TestNodeIsTask(t *testing.T) {
cases := []struct {
name string
typ []string
want bool
}{
{"plain task", []string{"task"}, true},
{"task co-typed mai-managed", []string{"task", "mai-managed"}, true},
{"project", []string{"project"}, false},
{"project co-typed", []string{"project", "mai-managed"}, false},
{"empty", []string{}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := nodeIsTask(&nodeRow{Type: c.typ}); got != c.want {
t.Fatalf("nodeIsTask(%v) = %v, want %v", c.typ, got, c.want)
}
})
}
}
func TestTaskFromNode(t *testing.T) {
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
n := &nodeRow{
ID: "task-uuid",
Type: []string{"task"},
Title: "Buy cement",
Slug: "buy-cement",
CreatedAt: created,
Metadata: map[string]any{
"projax": map[string]any{
"status": "done",
"due": "2026-06-15",
},
},
}
got := taskFromNode(n, "parent-uuid")
if got.ID != "task-uuid" || got.NodeID != "task-uuid" {
t.Fatalf("id/nodeID = %q/%q", got.ID, got.NodeID)
}
if got.Title != "Buy cement" || got.Slug != "buy-cement" {
t.Fatalf("title/slug = %q/%q", got.Title, got.Slug)
}
if got.Source != TaskSourceMBrian {
t.Fatalf("source = %q, want %q", got.Source, TaskSourceMBrian)
}
if got.Status != "done" || !got.Done {
t.Fatalf("status/done = %q/%v, want done/true", got.Status, got.Done)
}
if got.ParentItemID != "parent-uuid" {
t.Fatalf("parent = %q", got.ParentItemID)
}
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-06-15" {
t.Fatalf("due = %v, want 2026-06-15", got.Due)
}
if !got.CreatedAt.Equal(created) {
t.Fatalf("createdAt = %v, want %v", got.CreatedAt, created)
}
}
func TestTaskFromNodeDefaults(t *testing.T) {
// No projax metadata → active, not done, no due.
n := &nodeRow{ID: "t", Type: []string{"task"}, Title: "x", Slug: "x"}
got := taskFromNode(n, "p")
if got.Status != "active" || got.Done {
t.Fatalf("default status/done = %q/%v, want active/false", got.Status, got.Done)
}
if got.Due != nil {
t.Fatalf("default due = %v, want nil", got.Due)
}
}
func TestDueToJSON(t *testing.T) {
if got := dueToJSON(nil); got != "" {
t.Fatalf("nil due = %q, want empty", got)
}
dateOnly := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := dueToJSON(&dateOnly); got != "2026-06-15" {
t.Fatalf("date-only = %q, want 2026-06-15", got)
}
withClock := time.Date(2026, 6, 15, 14, 30, 0, 0, time.UTC)
if got := dueToJSON(&withClock); got != "2026-06-15T14:30:00Z" {
t.Fatalf("with-clock = %q, want RFC3339", got)
}
}
func TestItemRendersChecklist(t *testing.T) {
if (&Item{Render: "checklist"}).RendersChecklist() != true {
t.Fatal("checklist render not detected")
}
if (&Item{Render: ""}).RendersChecklist() != false {
t.Fatal("empty render should be false")
}
if (&Item{Render: "card"}).RendersChecklist() != false {
t.Fatal("non-checklist render should be false")
}
}

View File

@@ -192,25 +192,6 @@ func extractAPIMessage(raw []byte) string {
return strings.TrimSpace(string(raw))
}
// mapSlugWriteErr promotes the slug-relevant statuses on a node
// create/rename into typed sentinels (ErrSlugTaken / ErrInvalidSlug) that
// web handlers + MCP tools branch on to render a clean message, while
// keeping the server's *APIError (with its message) in the chain. Only
// node create/PATCH carry a slug, so 409/400 here are slug outcomes;
// edge ops never route through this.
func mapSlugWriteErr(err error) error {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Status {
case http.StatusConflict: // 409
return fmt.Errorf("%w: %w", ErrSlugTaken, apiErr)
case http.StatusBadRequest: // 400
return fmt.Errorf("%w: %w", ErrInvalidSlug, apiErr)
}
}
return err
}
// nodeWriteResponse is the {id, slug} shape POST/PATCH /nodes return.
type nodeWriteResponse struct {
ID string `json:"id"`
@@ -246,15 +227,9 @@ func (w *MBrianWriter) Create(ctx context.Context, in CreateInput) (*Item, error
"projax_origin": origin,
"projax": projaxBundleForCreate(in),
}
// Honor an explicit slug (the create form / MCP slug arg). Absent →
// mBrian title-derives + auto-suffixes. Slug is required on the create
// paths, so this is normally always set.
if slug := strings.TrimSpace(in.Slug); slug != "" {
body["slug"] = slug
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
return nil, mapSlugWriteErr(err)
return nil, err
}
// Parent links are child_of edges. Idempotent POST per parent.
for _, pid := range dedupe(in.ParentIDs) {
@@ -277,20 +252,9 @@ func (w *MBrianWriter) Update(ctx context.Context, id string, in UpdateInput) (*
"content_md": in.ContentMD,
"projax": projaxBundleForUpdate(in),
}
// Send slug only on a GENUINE rename. The detail-edit form and the bulk
// path both carry the current slug in UpdateInput; sending an unchanged
// slug would trip mBrian's rename-cascade (wikilink rewrite + alias
// append) for no reason on every edit. Read the current slug and include
// it only when it actually changed. A read failure → skip the rename
// (safe: no spurious slug write).
if newSlug := strings.TrimSpace(in.Slug); newSlug != "" {
if cur, err := w.reader().GetByID(ctx, id); err == nil && cur.Slug != newSlug {
body["slug"] = newSlug
}
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, &resp); err != nil {
return nil, mapSlugWriteErr(err)
return nil, err
}
if err := w.syncParents(ctx, id, in.ParentIDs); err != nil {
return nil, fmt.Errorf("update %s: sync parents: %w", id, err)
@@ -624,9 +588,6 @@ func projaxBundleForUpdate(in UpdateInput) map[string]any {
"end_time": timePtrToJSON(in.EndTime),
"pinned": in.Pinned,
"archived": in.Archived,
// Phase 7 checklist render hint (Q1). Shallow-merged into
// metadata.projax; "" turns the compact render off.
"render": in.Render,
}
}

View File

@@ -1,118 +0,0 @@
package store
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// Phase 7 — mBrian-native task WRITE path. Tasks funnel through the same
// scoped /api/projax HTTP surface the slice-C MBrianWriter already uses, so a
// projax-created task node is byte-identical to a UI/MCP/migration node. The
// only new server-side capability this relies on is the `type` field on
// POST /api/projax/nodes (allowlist {project,task}); everything else
// (PATCH-projax partial, POST /edges child_of, DELETE node) already exists.
// Compile-time witness: MBrianWriter satisfies TaskWriter.
var _ TaskWriter = (*MBrianWriter)(nil)
// CreateTask POSTs a type=['task'] node (slug honored, metadata.projax
// carrying status + optional due), attaches it to ParentItemID via a child_of
// edge, and returns the materialised Task. The returned Task is built from the
// known create inputs + the server-assigned id — no read-back round-trip
// needed (the next page render re-reads via TasksForItem).
func (w *MBrianWriter) CreateTask(ctx context.Context, in TaskCreateInput) (*Task, error) {
if strings.TrimSpace(in.Title) == "" {
return nil, errors.New("task title required")
}
if strings.TrimSpace(in.ParentItemID) == "" {
return nil, errors.New("task parent required")
}
origin, err := newUUIDv4()
if err != nil {
return nil, fmt.Errorf("mint projax_origin: %w", err)
}
projax := map[string]any{"status": "active"}
if in.Due != nil {
projax["due"] = dueToJSON(in.Due)
}
body := map[string]any{
"title": in.Title,
"type": "task",
"content_md": "",
"mai_managed": false,
"projax_origin": origin,
"projax": projax,
}
if slug := strings.TrimSpace(in.Slug); slug != "" {
body["slug"] = slug
}
var resp nodeWriteResponse
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
return nil, mapSlugWriteErr(err)
}
if err := w.postEdge(ctx, resp.ID, in.ParentItemID, "child_of", nil); err != nil {
return nil, fmt.Errorf("create task %s: attach to %s: %w", resp.ID, in.ParentItemID, err)
}
slug := strings.TrimSpace(in.Slug)
if resp.Slug != "" {
slug = resp.Slug // server-resolved (auto-suffix etc.) wins
}
return &Task{
ID: resp.ID,
Title: in.Title,
Done: false,
Due: in.Due,
Source: TaskSourceMBrian,
Status: "active",
ParentItemID: in.ParentItemID,
NodeID: resp.ID,
Slug: slug,
}, nil
}
// SetTaskStatus PATCHes metadata.projax.status. Task done-state reuses the
// existing lifecycle (Q2): done = status "done", reopen = status "active".
func (w *MBrianWriter) SetTaskStatus(ctx context.Context, nodeID, status string) error {
body := map[string]any{"projax": map[string]any{"status": status}}
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
}
// SetTaskDue PATCHes metadata.projax.due. A nil due writes "" — the reader's
// parseTimeAny treats an empty string as no-due, so this clears it
// deterministically without depending on null-key-delete semantics in the
// shallow-merge PATCH.
func (w *MBrianWriter) SetTaskDue(ctx context.Context, nodeID string, due *time.Time) error {
val := ""
if due != nil {
val = dueToJSON(due)
}
body := map[string]any{"projax": map[string]any{"due": val}}
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
}
// EditTaskTitle PATCHes the node title.
func (w *MBrianWriter) EditTaskTitle(ctx context.Context, nodeID, title string) error {
body := map[string]any{"title": title}
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
}
// DeleteTask soft-deletes the task node (reuses the node DELETE endpoint).
func (w *MBrianWriter) DeleteTask(ctx context.Context, nodeID string) error {
return w.SoftDelete(ctx, nodeID)
}
// dueToJSON renders a due date for metadata.projax.due. Date-only (clock at
// midnight) → "2006-01-02"; otherwise RFC3339. parseTimeAny reads both back.
func dueToJSON(t *time.Time) string {
if t == nil {
return ""
}
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
return t.Format("2006-01-02")
}
return t.Format(time.RFC3339)
}

View File

@@ -1,151 +0,0 @@
package store
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestMBrianTaskRoundTrip is the Phase 7b end-to-end verification: it drives a
// full mBrian-native task lifecycle (create → read → done → due → delete)
// against the LIVE mBrian write API + read DB, then asserts the task never
// leaks into the project list surface. It is the deploy-verification gate for
// "tasks live end-to-end" and is skipped unless all three live endpoints are
// configured:
//
// PROJAX_MBRIAN_API_URL e.g. https://mbrian.x.msbls.de
// PROJAX_MBRIAN_API_TOKEN the shared bearer (mBrian-side PROJAX_WRITE_TOKEN)
// SUPABASE_DATABASE_URL the msupabase pool the reader uses
//
// Run with all three set (head/CI):
//
// go test ./store/ -run TestMBrianTaskRoundTrip -v
//
// It is fully self-cleaning: it creates a throwaway parent project, attaches a
// task, and soft-deletes both at the end.
func TestMBrianTaskRoundTrip(t *testing.T) {
apiURL := os.Getenv("PROJAX_MBRIAN_API_URL")
apiToken := os.Getenv("PROJAX_MBRIAN_API_TOKEN")
dbURL := os.Getenv("SUPABASE_DATABASE_URL")
if apiURL == "" || apiToken == "" || dbURL == "" {
t.Skip("set PROJAX_MBRIAN_API_URL + PROJAX_MBRIAN_API_TOKEN + SUPABASE_DATABASE_URL to run the live task round-trip")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
w := NewMBrianWriter(apiURL, apiToken, pool)
rd := NewMBrianReader(pool)
// Unique-ish suffix without Math.rand/time.Now in the slug logic; the
// nanosecond stamp keeps reruns from colliding on the throwaway slugs.
suffix := time.Now().UTC().Format("20060102t150405.000000000")
// 1. Throwaway parent project.
parent, err := w.Create(ctx, CreateInput{
Kind: []string{"project"},
Title: "phase7b-itest-parent " + suffix,
Slug: "phase7b-itest-parent-" + sanitizeSlugStamp(suffix),
})
if err != nil {
t.Fatalf("create parent: %v", err)
}
t.Cleanup(func() { _ = w.SoftDelete(context.Background(), parent.ID) })
// 2. Create a task under it (slug auto-derived from title).
due := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
task, err := w.CreateTask(ctx, TaskCreateInput{
Title: "phase7b-itest-task " + suffix,
ParentItemID: parent.ID,
Due: &due,
})
if err != nil {
t.Fatalf("create task: %v", err)
}
t.Cleanup(func() { _ = w.DeleteTask(context.Background(), task.ID) })
if task.Source != TaskSourceMBrian || task.NodeID == "" {
t.Fatalf("created task shape wrong: %+v", task)
}
// 3. Read it back via TasksForItem — present, open, due preserved.
tasks, err := rd.TasksForItem(ctx, parent.ID)
if err != nil {
t.Fatalf("TasksForItem: %v", err)
}
got := findTask(tasks, task.ID)
if got == nil {
t.Fatalf("created task %s not returned by TasksForItem", task.ID)
}
if got.Done {
t.Fatal("fresh task should be open")
}
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-06-30" {
t.Fatalf("due not preserved: %v", got.Due)
}
// 4. The task must NOT leak into the project list surface (Q6 exclusion).
all, err := rd.ListAll(ctx)
if err != nil {
t.Fatalf("ListAll: %v", err)
}
for _, it := range all {
if it.ID == task.ID {
t.Fatalf("task %s leaked into ListAll (project surface)", task.ID)
}
}
// 5. Mark done → reads back done.
if err := w.SetTaskStatus(ctx, task.ID, "done"); err != nil {
t.Fatalf("set done: %v", err)
}
tasks, _ = rd.TasksForItem(ctx, parent.ID)
if got = findTask(tasks, task.ID); got == nil || !got.Done {
t.Fatalf("task not done after SetTaskStatus: %+v", got)
}
// 6. Clear the due date → reads back nil.
if err := w.SetTaskDue(ctx, task.ID, nil); err != nil {
t.Fatalf("clear due: %v", err)
}
tasks, _ = rd.TasksForItem(ctx, parent.ID)
if got = findTask(tasks, task.ID); got == nil || got.Due != nil {
t.Fatalf("due not cleared: %+v", got)
}
// 7. Delete → gone from TasksForItem.
if err := w.DeleteTask(ctx, task.ID); err != nil {
t.Fatalf("delete task: %v", err)
}
tasks, _ = rd.TasksForItem(ctx, parent.ID)
if findTask(tasks, task.ID) != nil {
t.Fatalf("task %s still present after delete", task.ID)
}
}
func findTask(tasks []*Task, id string) *Task {
for _, t := range tasks {
if t.ID == id {
return t
}
}
return nil
}
// sanitizeSlugStamp strips the dot from the nanosecond stamp so the throwaway
// slug stays dot-free (the projax slug invariant).
func sanitizeSlugStamp(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
if r == '.' {
continue
}
out = append(out, r)
}
return string(out)
}

View File

@@ -248,61 +248,6 @@ func TestProjaxBundleForUpdateNestsPublic(t *testing.T) {
}
}
func TestMBrianWriterCreateSendsSlugAndMaps409(t *testing.T) {
// The server asserts the POST body carries the explicit slug, then
// answers 409 — which returns before Create's pool-backed read-back, so
// no DB is needed. Proves both "slug is sent" and "409 → ErrSlugTaken".
var gotSlug any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotSlug = body["slug"]
w.WriteHeader(http.StatusConflict)
io.WriteString(w, `{"error":"slug 'paliad' already exists"}`)
}))
defer srv.Close()
w := newTestWriter(srv.URL, "tok")
_, err := w.Create(context.Background(), CreateInput{Kind: []string{"project"}, Title: "Paliad", Slug: "paliad"})
if gotSlug != "paliad" {
t.Errorf("create body slug = %v, want paliad (explicit slug must be sent, not title-derived)", gotSlug)
}
if !errors.Is(err, ErrSlugTaken) {
t.Errorf("409 should map to ErrSlugTaken, got %v", err)
}
}
func TestMapSlugWriteErr(t *testing.T) {
cases := []struct {
status int
want error // sentinel expected via errors.Is, nil = passthrough (no slug sentinel)
}{
{http.StatusConflict, ErrSlugTaken},
{http.StatusBadRequest, ErrInvalidSlug},
{http.StatusForbidden, nil},
{http.StatusInternalServerError, nil},
}
for _, c := range cases {
in := &APIError{Status: c.status, Message: "x", Op: "POST /api/projax/nodes"}
got := mapSlugWriteErr(in)
if c.want != nil && !errors.Is(got, c.want) {
t.Errorf("status %d: expected %v, got %v", c.status, c.want, got)
}
if c.want == nil && (errors.Is(got, ErrSlugTaken) || errors.Is(got, ErrInvalidSlug)) {
t.Errorf("status %d: should not map to a slug sentinel, got %v", c.status, got)
}
// The original *APIError must stay in the chain either way.
var apiErr *APIError
if !errors.As(got, &apiErr) || apiErr.Status != c.status {
t.Errorf("status %d: lost the *APIError in the chain: %v", c.status, got)
}
}
// Non-APIError passes through untouched.
plain := errors.New("network down")
if got := mapSlugWriteErr(plain); got != plain {
t.Errorf("non-APIError should pass through unchanged, got %v", got)
}
}
var uuidV4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
func TestNewUUIDv4Format(t *testing.T) {

View File

@@ -46,20 +46,10 @@ type Item struct {
// /timeline aggregation. Values: 'todos' | 'events' | 'docs' | 'creation'.
// Empty array (default) = nothing excluded = current behaviour.
TimelineExclude []string
// Phase 7 render hint. When "checklist", this container's child tasks
// render in compact checklist mode (design Q1 — a "tasklist"/"checklist"
// is any container carrying this hint, not a new type). Empty = the
// default roomy task rows. mBrian-backed only (metadata.projax.render);
// the legacy *Store leaves it "".
Render string
CreatedAt time.Time
UpdatedAt time.Time
}
// RendersChecklist reports whether this container should render its child
// tasks as a compact checklist (Phase 7, Q1).
func (it *Item) RendersChecklist() bool { return it.Render == "checklist" }
// ExcludesTimelineKind reports whether this item's timeline_exclude array
// names the given kind. The aggregator uses the singular form ("todo",
// "event", "doc", "creation"); the persisted values use the plural form
@@ -133,17 +123,6 @@ func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
var ErrNotFound = errors.New("projax: item not found")
// ErrSlugTaken is returned when a create or rename hits a slug collision —
// mBrian's write API answers 409. Covers both a live node and a
// soft-deleted tombstone squatting on the slug (the latter the projax-side
// validator can't see, since it scopes to non-deleted nodes). Handlers +
// MCP branch on it via errors.Is to surface a clean "slug taken" message.
var ErrSlugTaken = errors.New("projax: slug already taken")
// ErrInvalidSlug is returned when the write API rejects a slug as
// malformed or empty (400).
var ErrInvalidSlug = errors.New("projax: invalid slug")
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
tags, management, public, public_description, public_live_url, public_source_url,
@@ -333,10 +312,6 @@ type UpdateInput struct {
// Phase 4f timeline-exclude. Full-replace; values 'todos' / 'events' /
// 'docs' / 'creation'.
TimelineExclude []string
// Phase 7 checklist render hint. "checklist" → child tasks render
// compact; "" → default. mBrian-backed only (the legacy *Store.Update
// ignores it — there is no projax.items column for it).
Render string
}
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {

View File

@@ -1,91 +0,0 @@
package store
import (
"context"
"time"
)
// Phase 7 — Task is the uniform view-shape for a unit of work attached to a
// projax item, materialised from EITHER a CalDAV VTODO or an mBrian
// type=['task'] node. One shape, two sources; writes dispatch on Source
// (design §3.3, the slice-B "one Item shape, two backends" pattern applied
// to tasks). Consumers (the detail Tasks section, future dashboard/timeline
// rollups) render the uniform shape and don't care which backend produced it.
type Task struct {
// ID is a stable per-source identifier: the mBrian node uuid for an
// mBrian-native task, the VTODO UID for a CalDAV task. Unique within a
// source; templates key rows on it.
ID string
Title string
Done bool
Due *time.Time
// Source is "mbrian" or "caldav" — write handlers dispatch on it.
Source string
// Status is the raw lifecycle status for mBrian tasks (active|done|
// archived). For CalDAV tasks it carries the VTODO STATUS verbatim
// (NEEDS-ACTION|IN-PROCESS|COMPLETED|CANCELLED) for callers that want it;
// Done is the normalised boolean either way.
Status string
// ParentItemID is the projax item this task hangs under.
ParentItemID string
CreatedAt time.Time
// --- mBrian-source handle (Source == TaskSourceMBrian) ---
// NodeID is the mBrian node uuid (== ID); the write API targets it.
NodeID string
Slug string
// --- CalDAV-source handle (Source == TaskSourceCalDAV) ---
// CalendarURL + UID address the VTODO for ETag-guarded writeback via the
// existing caldav write path.
CalendarURL string
UID string
}
// Task source discriminators.
const (
TaskSourceMBrian = "mbrian"
TaskSourceCalDAV = "caldav"
)
// TaskCreateInput captures the editable surface of a new mBrian-native task.
// CalDAV tasks are created through the existing CalDAV write path (VTODO PUT),
// not this shape — only the mBrian backend creates task nodes.
type TaskCreateInput struct {
Title string
Slug string
ParentItemID string // the project (or task) this task attaches to via child_of
Due *time.Time // optional
}
// TaskReader is the read-path contract for mBrian-native task nodes. It is a
// capability SEPARATE from ItemReader: only the mBrian backend has task nodes,
// so the legacy *Store does not implement it. Web handlers obtain it via a
// type-assertion on the active Items backend (see Server.taskBackend).
type TaskReader interface {
// TasksForItem returns the mBrian-native tasks (type=['task'] nodes)
// attached to itemID via a child_of edge, in created-at order (Q5 —
// created order only; no manual reorder in v1).
TasksForItem(ctx context.Context, itemID string) ([]*Task, error)
}
// TaskWriter is the write-path contract for mBrian-native task nodes. Twin of
// TaskReader; implemented only by the mBrian backend (*MBrianWriter). Writes
// funnel through mBrian's scoped /api/projax HTTP surface so projax-created
// task nodes are byte-identical to UI/MCP/migration nodes (the slice-C
// discipline). Delete reuses the node soft-delete the API already exposes.
type TaskWriter interface {
// CreateTask POSTs a type=['task'] node (slug honored, metadata.projax
// carrying status/due) then attaches it to ParentItemID via a child_of
// edge, and returns the materialised Task.
CreateTask(ctx context.Context, in TaskCreateInput) (*Task, error)
// SetTaskStatus PATCHes metadata.projax.status (done|active|archived) —
// task done-state reuses the existing lifecycle (Q2), no separate field.
SetTaskStatus(ctx context.Context, nodeID, status string) error
// SetTaskDue PATCHes metadata.projax.due; a nil due clears it.
SetTaskDue(ctx context.Context, nodeID string, due *time.Time) error
// EditTaskTitle PATCHes the node title.
EditTaskTitle(ctx context.Context, nodeID, title string) error
// DeleteTask soft-deletes the task node.
DeleteTask(ctx context.Context, nodeID string) error
}

View File

@@ -505,12 +505,10 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
if s.timeline != nil {
s.timeline.InvalidateAll()
}
// Always re-render the unified tasks section so HTMX (or a plain redirect
// for non-HTMX clients) sees the post-write state. Phase 7c: CalDAV +
// mBrian tasks share ONE section, so a CalDAV write refreshes the merged
// list via the same renderer the mBrian path uses.
// Always re-render the tasks section so HTMX (or a plain redirect for
// non-HTMX clients) sees the post-write state.
if r.Header.Get("HX-Request") == "true" {
s.renderUnifiedTasks(w, r, it, banner)
s.renderTasksSection(w, r, it, banner)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
@@ -529,9 +527,39 @@ func caldavBanner(action string, err error) string {
return "Could not " + action + " task: " + err.Error()
}
// (renderTasksSection retired in Phase 7c — the CalDAV and mBrian task
// handlers now both re-render the merged list via Server.renderUnifiedTasks in
// task.go.)
// renderTasksSection re-runs detailTodos for the item and renders the
// tasks-section template fragment with an optional banner. Used by HTMX
// responses so swap operations stay in-place.
func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
tasks, err := s.detailTodos(r.Context(), it)
if err != nil {
s.fail(w, r, err)
return
}
// HTMX swaps re-render the section in place; the picker needs the same
// AvailableCalendars data the full /i/{path} render computes. Errors
// here are non-fatal — degrade to an empty picker.
var available []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
}
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
if aerr != nil {
s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr)
}
available = acs
}
data := map[string]any{
"Item": it,
"Tasks": tasks,
"AvailableCalendars": available,
"CalDAVOn": s.CalDAV != nil,
"Banner": banner,
}
s.render(w, r, "tasks_section", data)
}
// parseDueInput accepts an HTML5 date-input value (`YYYY-MM-DD`) or a
// datetime-local value (`YYYY-MM-DDTHH:MM`), returning the corresponding UTC

View File

@@ -24,11 +24,11 @@ import (
// on what projax wrote. Mirrors the pattern in dashboard_events_test.go
// but tailored to the Phase 5j flows.
type fakeCalDAVServer struct {
mu sync.Mutex
srv *httptest.Server
calendars []caldav.Calendar
todos map[string][]string // calendarURL → list of VTODO ICS docs
puts map[string]string // url → body of the latest PUT to that url
mu sync.Mutex
srv *httptest.Server
calendars []caldav.Calendar
todos map[string][]string // calendarURL → list of VTODO ICS docs
puts map[string]string // url → body of the latest PUT to that url
}
func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer {
@@ -172,13 +172,13 @@ func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id,
}
// TestDetailLinkExistingCalendar walks the original ask end-to-end:
// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs.
// 2. Seed an unlinked projax item under dev.
// 3. GET /i/{path} — assert the "link existing" <select> renders with
// all 3 calendars.
// 4. POST /i/{path}/caldav/link-existing with one URL.
// 5. GET /i/{path} again — assert the linked URL is gone from the
// picker (already linked) but appears in the tasks section.
// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs.
// 2. Seed an unlinked projax item under dev.
// 3. GET /i/{path} — assert the "link existing" <select> renders with
// all 3 calendars.
// 4. POST /i/{path}/caldav/link-existing with one URL.
// 5. GET /i/{path} again — assert the linked URL is gone from the
// picker (already linked) but appears in the tasks section.
func TestDetailLinkExistingCalendar(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
@@ -205,7 +205,7 @@ func TestDetailLinkExistingCalendar(t *testing.T) {
`>Family<`,
`>Travel<`,
`>Vacations 2026<`,
`+ Create new CalDAV list`,
`+ Create new list`,
} {
if !strings.Contains(body, want) {
t.Errorf("unlinked detail page missing %q", want)
@@ -221,23 +221,17 @@ func TestDetailLinkExistingCalendar(t *testing.T) {
}
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL)
// Step 5: picker no longer offers Vacations 2026 (already linked). Phase 7c
// unified the task UI: there's no per-calendar block anymore, so an empty
// linked calendar shows no header — instead the project becomes
// CalDAV-bound, so the single add-form now targets the linked calendar and
// the "create new" affordance disappears.
// Step 5: picker no longer offers Vacations 2026 (already linked);
// the tasks section now shows the linked calendar's block.
_, body = get(t, h, "/i/"+primary)
if strings.Contains(body, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL")
}
if strings.Contains(body, `+ Create new CalDAV list`) {
t.Errorf("create-new affordance should be gone once a calendar is linked (project is CalDAV-bound)")
if !strings.Contains(body, "Vacations 2026") {
t.Errorf("tasks section should display the linked Vacations 2026 list")
}
if !strings.Contains(body, `hx-post="/i/`+primary+`/caldav/todo/todo-create"`) {
t.Errorf("unified add-form should POST to the CalDAV create route on a bound project")
}
if !strings.Contains(body, `name="calendar_url" value="`+pickedURL+`"`) {
t.Errorf("unified add-form should target the linked calendar %q", pickedURL)
if !strings.Contains(body, `data-cal="`+pickedURL+`"`) {
t.Errorf("tasks section missing cal-block for the linked URL")
}
}

View File

@@ -5,36 +5,11 @@ import (
"testing"
)
// TestDetailNoDoubleHeader is the Phase 8 Slice 0 guard for m's literal
// complaint: each aux section showed TWO headers. Under Direction A the work
// sections are always-visible cards whose <h2 class="card-title"> is the single
// VISIBLE header; the partial's own <h2> is visually-hidden (it survives only
// as the a11y label for the standalone HTMX swap fragment). Documents always
// renders, so we assert on it.
func TestDetailNoDoubleHeader(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
// The visible card header.
if !strings.Contains(body, `class="card-title"`) {
t.Errorf("expected a visible card-title header on the read view")
}
// The in-partial h2 must be hidden (a11y label for the swap fragment).
if !strings.Contains(body, `<h2 class="visually-hidden">Documents</h2>`) {
t.Errorf("expected the Documents partial h2 to be visually-hidden")
}
// A bare, visible <h2>Documents</h2> would be the double-header regression.
if strings.Contains(body, `<h2>Documents</h2>`) {
t.Errorf("found a visible <h2>Documents</h2> — the double header has returned")
}
}
// TestDetailReadModeIsNotCollapsibles proves Direction A retired the top-level
// <details> collapse model + its localStorage persistence script: the read
// view renders work cards, not proj-section accordions, and the per-section
// toggle script is gone.
func TestDetailReadModeIsNotCollapsibles(t *testing.T) {
// TestDetailIncludesSectionToggleScript proves the inline JS that powers
// the per-item localStorage persistence ships on the detail page. Without
// it, the smart defaults would render but toggles wouldn't survive a
// reload — silent UX regression that's worth a guard.
func TestDetailIncludesSectionToggleScript(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
@@ -42,33 +17,77 @@ func TestDetailReadModeIsNotCollapsibles(t *testing.T) {
if code != 200 {
t.Fatalf("GET /i/dev → %d", code)
}
if !strings.Contains(body, `id="documents-card"`) {
t.Errorf("read view missing the Documents work card")
}
// The retired collapse-persistence script must be gone from the read view.
if strings.Contains(body, `projax.section.`) {
t.Errorf("the per-section localStorage collapse script should be retired under Direction A")
}
if strings.Contains(body, `proj-section-reset`) {
t.Errorf("the 'reset section state' link should be retired under Direction A")
}
}
// TestDetailEditModeKeepsSettingsDisclosures proves the rare settings
// (public-listing / timeline-behaviour) remain nested <details> disclosures
// INSIDE the edit form (B's accordion discipline within edit mode).
func TestDetailEditModeKeepsSettingsDisclosures(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev?edit=1")
for _, want := range []string{
`data-section="public"`,
`data-section="timeline-behaviour"`,
`class="proj-section-summary"`,
`details.proj-section[data-section][data-item-id]`,
`projax.section.`,
`proj-section-reset`,
`localStorage.setItem`,
} {
if !strings.Contains(body, want) {
t.Errorf("edit form missing settings disclosure %q", want)
t.Errorf("detail page missing collapsible-script fragment %q", want)
}
}
}
// TestDetailSectionsWrappedInDetails proves every section on /i/{path}
// gets a <details data-section="..."> wrapper, regardless of count. We
// can't easily set up dozens of issues for the count-driven default
// assertion in a unit test (Gitea is mocked), so this just verifies the
// wrapper exists and the data-section attribute is correct.
func TestDetailSectionsWrappedInDetails(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev → %d", code)
}
// Documents always renders (no integration deps); Public listing
// always renders inside the form.
for _, want := range []string{
`data-section="documents"`,
`data-section="public"`,
`class="proj-section-summary"`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page missing section wrapper %q", want)
}
}
}
// TestDetailDocumentsClosedDefaultsWhenManyItems is the threshold check
// for the Documents section (default open when ≤5). With a fresh item
// holding 0 dated links, the wrapper should be open. We can't easily
// seed >5 in a fast unit test against the live DB, so this just probes
// the open-state baseline; the count-driven branch is exercised by the
// template logic and visually verified during the deploy probe.
func TestDetailDocumentsClosedDefaultsWhenManyItems(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
// Find the documents section's opening tag and check for ` open` attr.
idx := strings.Index(body, `data-section="documents"`)
if idx < 0 {
t.Fatalf("documents section not found in body")
}
// Slice the surrounding 200 chars to look for the `open` attribute.
slice := body[max0(idx-100):min(len(body), idx+200)]
if !strings.Contains(slice, "open") {
t.Errorf("expected documents section to be open by default with 0 docs (≤5 threshold), got:\n%s", slice)
}
}
func max0(x int) int {
if x < 0 {
return 0
}
return x
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -5,19 +5,29 @@ import (
"testing"
)
// TestDetailEditFieldsRenderInOrder pins the top-to-bottom field flow of the
// EDIT form (Phase 8 Direction A: the form lives behind ?edit=1). Fields are
// grouped general → specific; the rare settings (public / timeline) are nested
// disclosures at the end; the work cards (Documents) follow the form. The test
// slices the rendered body into anchor strings and confirms each first-index is
// strictly greater than the previous.
func TestDetailEditFieldsRenderInOrder(t *testing.T) {
// TestDetailFieldsRenderInOrder pins m's requested top-to-bottom flow on
// the /i/{path} edit form: form-then-auxiliaries, with form fields
// grouped general → specific. The test slices the rendered body into the
// documented anchor strings (form labels, group headings, divider, aux
// heading, first auxiliary <details>) and confirms each anchor's first
// index is strictly greater than the previous one.
//
// Anchors deliberately picked to be robust:
// - Form field markup (name="title" / name="slug" / etc.) — won't drift
// unless the form is re-architected.
// - Group-heading IDs (hdr-general / hdr-classification / hdr-flags /
// hdr-content / hdr-aux) — emitted by the Phase-5i template.
// - The aux-divider <hr> — the explicit visual break m asked for.
//
// If a future refactor moves fields around inside a group, this test
// still passes as long as the cross-group order holds.
func TestDetailFieldsRenderInOrder(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/i/dev?edit=1")
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev?edit=1 → %d", code)
t.Fatalf("GET /i/dev → %d", code)
}
anchors := []struct {
@@ -37,9 +47,10 @@ func TestDetailEditFieldsRenderInOrder(t *testing.T) {
{"archived field", `name="archived"`},
{"Content heading", `id="hdr-content"`},
{"Content textarea", `name="content_md"`},
{"Public listing disclosure", `data-section="public"`},
{"Save button", `<button type="submit">Save</button>`},
{"Documents card", `id="documents-card"`},
{"Auxiliary divider", `class="aux-divider"`},
{"Auxiliary heading", `id="hdr-aux"`},
{"Documents section", `data-section="documents"`},
}
prevIdx := -1
@@ -59,53 +70,57 @@ func TestDetailEditFieldsRenderInOrder(t *testing.T) {
}
}
// TestDetailEditFormGroupHeadings proves the form group subheadings render
// (edit mode). "Related" was retired under Direction A — the work cards carry
// their own card titles instead.
func TestDetailEditFormGroupHeadings(t *testing.T) {
// TestDetailFormGroupHeadings proves the three group subheadings render
// with the expected human-readable copy. Hardcoded so a future "clean
// up the strings" pass doesn't silently strip the visual hierarchy m
// asked for.
func TestDetailFormGroupHeadings(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev?edit=1")
_, body := get(t, h, "/i/dev")
for _, want := range []string{
`>General</h2>`,
`>Classification</h2>`,
`>Flags</h2>`,
`>Content</h2>`,
`>Related</h2>`,
} {
if !strings.Contains(body, want) {
t.Errorf("edit form missing group heading %q", want)
t.Errorf("detail page missing group heading %q", want)
}
}
// Content heading carries a "(markdown)" hint now.
if !strings.Contains(body, `id="hdr-content"`) {
t.Errorf("edit form missing Content heading")
}
}
// TestDetailWorkCardsAfterForm proves the work cards (Documents) live BELOW
// the edit form, and the rare settings (public / timeline) stay INSIDE the
// form (saved by the main Save). Direction A keeps the work cards always
// visible, after the identity/content form in edit mode.
func TestDetailWorkCardsAfterForm(t *testing.T) {
// TestDetailAuxSectionsAfterForm proves the read-only auxiliary
// <details> sections (Tasks / Issues / Documents) live BELOW the
// form's </form> tag — that's the load-bearing visual contract from
// m's report. Public-listing + Timeline-behaviour stay INSIDE the form
// (form-bound, saved by the main Save) — this test asserts only the
// purely read-only sections moved.
func TestDetailAuxSectionsAfterForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev?edit=1")
_, body := get(t, h, "/i/dev")
// The layout has a <form action="/logout"> in the sidebar — its </form>
// would match first. Anchor on the detail form's start tag, then look
// for </form> AFTER that point so we're measuring the right boundary.
formStart := strings.Index(body, `<form method="post" action="/i/dev"`)
if formStart < 0 {
t.Fatalf("detail edit form start tag not found in body")
t.Fatalf("detail form start tag not found in body")
}
formEnd := strings.Index(body[formStart:], "</form>")
if formEnd < 0 {
t.Fatalf("</form> tag not found after detail form start")
}
formEnd += formStart
docs := strings.Index(body, `id="documents-card"`)
docs := strings.Index(body, `data-section="documents"`)
if docs < 0 {
t.Fatalf("documents card not found")
t.Fatalf("documents section not found")
}
if docs <= formEnd {
t.Errorf("Documents card (idx %d) must appear AFTER </form> (idx %d)", docs, formEnd)
t.Errorf("Documents section (idx %d) must appear AFTER </form> (idx %d)", docs, formEnd)
}
// Public listing stays inside the form — confirm the contract holds.
publicSection := strings.Index(body, `data-section="public"`)
@@ -113,31 +128,7 @@ func TestDetailWorkCardsAfterForm(t *testing.T) {
t.Fatalf("public section not found")
}
if publicSection >= formEnd {
t.Errorf("Public listing (idx %d) should remain INSIDE the form (before </form> at idx %d)",
t.Errorf("Public listing section (idx %d) should remain INSIDE the form (before </form> at idx %d) for save coherence",
publicSection, formEnd)
}
}
// TestDetailReadModeRendersMarkdown proves the default (read) view renders
// content_md as markdown and is NOT a form, and that the Edit toggle is
// present. The work cards are always visible.
func TestDetailReadModeRendersMarkdown(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev → %d", code)
}
// Read mode: no edit form, but an Edit toggle to ?edit=1.
if strings.Contains(body, `<textarea name="content_md"`) {
t.Errorf("read mode should NOT show the content_md textarea (that's edit mode)")
}
if !strings.Contains(body, `href="/i/dev?edit=1"`) {
t.Errorf("read mode missing Edit toggle to ?edit=1")
}
// Work cards always visible.
if !strings.Contains(body, `id="documents-card"`) {
t.Errorf("read mode missing Documents card")
}
}

View File

@@ -185,21 +185,6 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
}
}
// TestLayoutLoadsHTMX guards against the Phase 7c regression: the task / tree
// / dashboard / bulk / classify forms drive in-place swaps with hx-* attrs,
// which are inert unless htmx is actually loaded. It went unnoticed for many
// phases (every hx-post task form silently no-op'd to a GET-to-self). This
// test fails the moment the vendored htmx <script> drops out of the layout.
func TestLayoutLoadsHTMX(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `src="/static/htmx.min.js"`) {
t.Errorf("layout must load vendored htmx (hx-* forms are dead without it); body: %s", truncate(body, 400))
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s

View File

@@ -1,33 +0,0 @@
package web
import (
"bytes"
"html/template"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
// Phase 8 (Direction A) — content_md is rendered as markdown for the read
// view. goldmark is pure-Go (no cgo) and, crucially, SAFE BY DEFAULT: without
// goldmark's WithUnsafe option it omits raw HTML rather than passing it
// through, so rendering m's (trusted, single-user) content is still XSS-safe
// with no separate sanitizer. GFM adds tables / strikethrough / autolinks /
// task-lists — none of which re-enable raw HTML.
var mdRenderer = goldmark.New(goldmark.WithExtensions(extension.GFM))
// renderMarkdown converts content_md to safe HTML for the read view. Empty
// input → empty output (the template shows a muted placeholder). On the
// (practically impossible) render error it falls back to the HTML-escaped raw
// text so the page never blanks or leaks markup.
func renderMarkdown(md string) template.HTML {
if strings.TrimSpace(md) == "" {
return ""
}
var buf bytes.Buffer
if err := mdRenderer.Convert([]byte(md), &buf); err != nil {
return template.HTML(template.HTMLEscapeString(md))
}
return template.HTML(buf.String())
}

View File

@@ -1,45 +0,0 @@
package web
import (
"strings"
"testing"
)
func TestRenderMarkdown(t *testing.T) {
got := string(renderMarkdown("## Architecture\n\n1. Model first\n2. Interfaces second"))
if !strings.Contains(got, "<h2") || !strings.Contains(got, "Architecture</h2>") {
t.Errorf("expected ## to render as <h2>, got: %s", got)
}
if !strings.Contains(got, "<ol>") || !strings.Contains(got, "<li>Model first") {
t.Errorf("expected an ordered list, got: %s", got)
}
}
func TestRenderMarkdownEmpty(t *testing.T) {
if renderMarkdown("") != "" {
t.Errorf("empty input should render empty")
}
if renderMarkdown(" \n ") != "" {
t.Errorf("whitespace-only input should render empty")
}
}
// TestRenderMarkdownSafe proves goldmark's default omits raw HTML — a <script>
// in content_md must NOT survive into the rendered output (XSS guard).
func TestRenderMarkdownSafe(t *testing.T) {
got := string(renderMarkdown("ok\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>"))
if strings.Contains(got, "<script>") {
t.Errorf("raw <script> must not pass through goldmark safe render, got: %s", got)
}
if strings.Contains(got, "onerror=") {
t.Errorf("raw event-handler HTML must not pass through, got: %s", got)
}
}
// TestRenderMarkdownGFM confirms the GFM extension renders tables + autolinks.
func TestRenderMarkdownGFM(t *testing.T) {
got := string(renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |"))
if !strings.Contains(got, "<table>") {
t.Errorf("expected GFM table render, got: %s", got)
}
}

View File

@@ -188,10 +188,9 @@ func TestPublicListingDetailFormShipsAffordances(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Phase 8 Direction A: public-listing lives in the edit form (?edit=1).
code, body := get(t, h, "/i/dev?edit=1")
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev?edit=1 → %d", code)
t.Fatalf("GET /i/dev → %d", code)
}
for _, want := range []string{
`<fieldset class="public-listing">`,

View File

@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/internal/cache"
"github.com/m/projax/internal/itemwrite"
@@ -33,29 +34,6 @@ func (s *Server) itemWriteFailure(w http.ResponseWriter, r *http.Request, ve *it
s.Logger.Warn("itemwrite reject", "path", r.URL.Path, "kind", ve.Kind, "detail", ve.Detail)
}
// writeFailure renders a write error from the adapter. Slug outcomes from
// the mBrian backend (409 collision / 400 invalid) surface as the same
// friendly itemwrite banners the pre-flight validator uses, so a slug
// taken by a soft-deleted tombstone — which the validator can't see —
// still reads cleanly instead of dumping a raw API error. Everything else
// falls through to the generic failure page.
func (s *Server) writeFailure(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrSlugTaken):
s.itemWriteFailure(w, r, &itemwrite.ValidationError{
Kind: itemwrite.KindSlugCollision,
Detail: "That slug is already taken (possibly by a deleted item) — pick another.",
})
case errors.Is(err, store.ErrInvalidSlug):
s.itemWriteFailure(w, r, &itemwrite.ValidationError{
Kind: itemwrite.KindInvalidSlugFormat,
Detail: "Invalid slug — use lower-case, no dots or whitespace.",
})
default:
s.fail(w, r, err)
}
}
// itemWriteBannerCopy maps a ValidationError.Kind to the human-facing
// banner copy. Centralised so web/server.go + web/bulk.go share one
// authoritative phrasing.
@@ -103,7 +81,7 @@ type Server struct {
// concrete *Store satisfies the ItemReader interface (legacy path);
// after the mBrian backend rollout PROJAX_BACKEND=mbrian wires
// *store.MBrianReader here.
Items store.ItemReader
Items store.ItemReader
// Writes is the write-path adapter every UI write handler, the
// /admin/bulk apply path, and the MCP write tools depend on. Phase 6
// Slice C introduces it as the twin of Items: today the concrete
@@ -113,9 +91,9 @@ type Server struct {
Writes store.ItemWriter
pages map[string]*template.Template
Logger *slog.Logger
Auth *AuthConfig // nil → no auth (local dev / tests)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
Gitea *GiteaDeps // nil → Gitea integration disabled
Auth *AuthConfig // nil → no auth (local dev / tests)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
Gitea *GiteaDeps // nil → Gitea integration disabled
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
Version string // build-time -ldflags injection; surfaced on /admin
dashboard *cache.TTLCache[*dashboardPayload]
@@ -256,8 +234,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("parse detail: %w", err)
}
pages["detail"] = detailTmpl
// Standalone unified-tasks-section template for HTMX fragment responses
// (Phase 7c — CalDAV + mBrian task writes re-render the merged list).
// Standalone tasks-section template for HTMX fragment responses.
tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl")
if err != nil {
return nil, fmt.Errorf("parse tasks_section: %w", err)
@@ -627,6 +604,26 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
tasks, err := s.detailTodos(r.Context(), it)
if err != nil {
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
}
// Phase 5j: pre-load discoverable CalDAV calendars (minus the ones
// already linked) so the per-item Tasks section can offer a "Link
// existing list" picker alongside the create-new affordance. Errors
// are non-fatal — the section falls back to its pre-5j shape.
var availableCalendars []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
}
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
if aerr != nil {
s.Logger.Warn("detail available caldav", "path", it.PrimaryPath(), "err", aerr)
}
availableCalendars = acs
}
issues, err := s.detailIssues(r.Context(), it)
if err != nil {
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
@@ -640,34 +637,19 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
}
documents := computePERs(it.PrimaryPath(), docs)
// Phase 7c — ONE unified task list per project: mBrian-native tasks +
// CalDAV VTODOs merged into a single sorted list, each row tagged by
// source, actions dispatched by source. The section shows whenever a task
// backend is available (CalDAV configured or the mBrian task backend live).
unified := s.buildUnifiedTasks(r.Context(), it)
showTasks := unified.CalDAVOn || unified.MBrianOn
tasksOpen := len(unified.Open) > 0
tasksData := unifiedTasksData(it, unified, "")
// Phase 8 (Direction A): read-first detail page. Default is a rendered
// markdown read view; ?edit=1 swaps to the edit form (progressive-
// enhancement baseline — full-page toggle, no JS required). Save POSTs and
// redirects back to the read view.
edit := r.URL.Query().Get("edit") == "1"
s.render(w, r, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"Edit": edit,
"ContentHTML": renderMarkdown(it.ContentMD),
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"ShowTasks": showTasks,
"TasksOpen": tasksOpen,
"TasksData": tasksData,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
"Documents": documents,
"HighlightDate": highlight,
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"AvailableCalendars": availableCalendars,
"CalDAVOn": s.CalDAV != nil,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
"Documents": documents,
"HighlightDate": highlight,
})
}
@@ -691,13 +673,6 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
return
}
}
// Phase 7 — mBrian-native task actions.
for _, action := range []string{"create", "done", "reopen", "edit", "due", "delete"} {
if base, ok := strings.CutSuffix(path, "/task/"+action); ok {
s.handleTaskAction(w, r, base, action)
return
}
}
for _, action := range []string{"close", "reopen", "comment", "create"} {
if base, ok := strings.CutSuffix(path, "/issues/"+action); ok {
s.handleIssueAction(w, r, base, action)
@@ -766,14 +741,11 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
// Phase 4f: timeline-exclude form field is a multi-value checkbox set
// (`name="timeline_exclude" value="todos"`, …). parseTimelineExcludeList
// keeps only the known kinds so a stray value can't poison the array.
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
// Phase 7 checklist render hint (Q1): the flags checkbox maps to
// metadata.projax.render="checklist"; unchecked clears it ("").
Render: renderHint(r.FormValue("render_checklist") == "1"),
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
}
updated, err := s.Writes.Update(r.Context(), it.ID, in)
if err != nil {
s.writeFailure(w, r, err)
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
@@ -1006,7 +978,7 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
}
it, err := s.Writes.Create(r.Context(), in)
if err != nil {
s.writeFailure(w, r, err)
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)

View File

@@ -147,16 +147,15 @@ func TestHealthz(t *testing.T) {
}
}
// Phase 8 Direction A: the edit form lives behind ?edit=1 (read-first page).
func TestDetailRendersEditableForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/dev?edit=1")
code, body := get(t, srv.Routes(), "/i/dev")
if code != 200 {
t.Fatalf("status %d body=%s", code, body)
}
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
t.Errorf("edit form missing for /i/dev?edit=1")
t.Errorf("edit form missing for /i/dev")
}
if !strings.Contains(body, `name="tags"`) {
t.Errorf("tags input missing")

File diff suppressed because one or more lines are too long

View File

@@ -1416,64 +1416,22 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .collapse-icon {
.detail-form .form-group-content-label { gap: 0; }
.detail-form > details.proj-section { margin-top: 4px; }
/* --- Detail page: Direction A read-first document (Phase 8) --- */
/* Single reading column; description reads, work cards follow. */
.detail { max-width: 760px; }
.detail-head { margin-bottom: 20px; }
.detail-head .breadcrumb { margin: 0 0 2px; font-size: 0.82em; }
.detail-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.detail-title-row h1 { margin: 0; }
/* Link styled as a button (the Edit toggle). Mirrors the <button> element
style so read/edit affordances look consistent. */
.btn {
display: inline-block; font: inherit; padding: 6px 14px;
border: 1px solid var(--accent); background: var(--accent);
color: var(--accent-fg); border-radius: 4px; cursor: pointer;
text-decoration: none; white-space: nowrap;
/* Divider between the editable form and the read-only auxiliary
collapsibles. <hr> is semantically a thematic break — matches the
intent. The "Related" heading below it makes the change-of-mode
obvious without leaning on the line alone. */
.aux-divider {
border: 0;
border-top: 1px solid var(--border);
margin: 32px 0 16px;
}
.btn:hover { filter: brightness(1.08); }
.edit-toggle { flex: none; }
.content-empty { margin: 8px 0 24px; }
/* Rendered markdown (read view). Comfortable reading measure + spacing within
the existing palette — no foreign tokens. */
.markdown-body { margin: 0 0 8px; line-height: 1.6; }
.markdown-body > :first-child { margin-top: 0; }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
.markdown-body h1 { font-size: 1.5em; }
.markdown-body h2 { font-size: 1.25em; border-bottom: 1px solid var(--border); padding-bottom: 0.2em; }
.markdown-body h3 { font-size: 1.08em; }
.markdown-body p, .markdown-body ul, .markdown-body ol,
.markdown-body blockquote, .markdown-body table { margin: 0.6em 0; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5em; }
.markdown-body li { margin: 0.2em 0; }
.markdown-body a { color: var(--accent); }
.markdown-body code {
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.9em;
background: var(--bg-alt); border: 1px solid var(--border);
border-radius: 3px; padding: 0.1em 0.35em;
}
.markdown-body pre {
background: var(--bg-alt); border: 1px solid var(--border);
border-radius: 6px; padding: 12px 14px; overflow-x: auto;
}
.markdown-body pre code { background: none; border: 0; padding: 0; }
.markdown-body blockquote {
margin-left: 0; padding-left: 1em; border-left: 3px solid var(--border);
.aux-sections { display: flex; flex-direction: column; gap: 4px; max-width: 720px; }
.aux-heading {
margin: 0 0 8px;
font-size: 0.95em;
font-weight: 600;
color: var(--muted);
}
.markdown-body table { border-collapse: collapse; }
.markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
/* Work cards — Tasks / Issues / Documents, always visible (no top-level
collapse under Direction A). One visible header each. */
.detail-card {
margin-top: 20px; padding: 16px 18px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
}
.detail-card .card-title {
margin: 0 0 10px; font-size: 1.0em; font-weight: 600;
display: flex; align-items: baseline; gap: 8px;
}
.detail-card > section { margin: 0; }
.aux-reset { margin: 12px 0 0; font-size: 0.85em; }
.aux-reset .proj-section-reset { color: var(--muted); }
.aux-reset .proj-section-reset:hover { color: var(--bad); }

View File

@@ -12,12 +12,9 @@
// real PWA + keep static assets warm. Mutations (CalDAV / Gitea writeback)
// still require connectivity.
// v2: htmx.min.js joins the shell so the HTMX-driven forms work offline too
// (the cache name bump purges the v1 asset set on activate).
const CACHE_NAME = 'projax-shell-v2';
const CACHE_NAME = 'projax-shell-v1';
const SHELL_ASSETS = [
'/static/style.css',
'/static/htmx.min.js',
'/static/manifest.webmanifest',
'/static/icon-192.png',
'/static/icon-512.png',

View File

@@ -1,410 +0,0 @@
package web
import (
"context"
"net/http"
"sort"
"strings"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
// Phase 7c — UNIFIED task surface. The detail page renders ONE task list per
// project that merges mBrian-native tasks (type=['task'] child nodes) AND
// CalDAV tasks (VTODOs from linked calendars) into a single sorted list. Each
// row is subtly tagged by Source so m can tell where a task lives, but they
// read as one list. Actions dispatch to the right backend by Source: CalDAV
// rows POST to /caldav/todo/{action}; mBrian rows POST to /task/{action}.
//
// New tasks default by the §3.1 selector: a CalDAV-bound project (has a
// caldav-list link) creates VTODOs on its linked calendar; an unbound project
// creates mBrian-native task nodes. (Supersedes the Phase 7b two-section split
// per m's request to "collect from mBrian AS WELL AS CalDAV and display
// together".)
// taskBackend returns the task-capable reader + writer when the active backend
// supports mBrian-native tasks (PROJAX_BACKEND=mbrian). The legacy *Store
// backend has no task nodes, so both asserts fail and only CalDAV tasks show.
func (s *Server) taskBackend() (store.TaskReader, store.TaskWriter, bool) {
tr, rok := s.Items.(store.TaskReader)
tw, wok := s.Writes.(store.TaskWriter)
return tr, tw, rok && wok
}
// taskRow wraps a uniform store.Task with a render-only source label (the
// calendar display name for CalDAV tasks, "projax" for mBrian tasks). Kept out
// of store.Task so the store shape stays a pure data view.
type taskRow struct {
*store.Task
SourceLabel string
}
// unifiedTasks is the assembled per-project task surface the section template
// renders: open tasks up top, done below, plus the CalDAV management
// affordances (link/create) and the add-form routing inputs.
type unifiedTasks struct {
Open []taskRow
Done []taskRow
// CalDAVOn reports whether CalDAV integration is configured at all.
CalDAVOn bool
// CalDAVBound reports whether THIS project has ≥1 caldav-list link — the
// §3.1 selector that routes new tasks to CalDAV vs mBrian.
CalDAVBound bool
// LinkedCalendars are this project's bound calendars (url + display name),
// for the add-form target (a <select> when >1) and the row labels.
LinkedCalendars []caldav.Calendar
// AvailableCalendars feeds the "link existing list" picker (discoverable
// minus already-linked). Best-effort; empty on discovery error.
AvailableCalendars []caldav.Calendar
// MBrianOn reports whether the mBrian task backend is available (so the
// add-form can create native tasks on an unbound project).
MBrianOn bool
Banner string
}
// buildUnifiedTasks gathers mBrian-native + CalDAV tasks for the item, merges
// them into one sorted open/done split, and collects the CalDAV management
// data the section needs. Per-source failures degrade to a banner rather than
// blanking the whole list.
func (s *Server) buildUnifiedTasks(ctx context.Context, item *store.Item) unifiedTasks {
u := unifiedTasks{CalDAVOn: s.CalDAV != nil}
banners := []string{}
// --- CalDAV side ---
var linkedLinks []*store.ItemLink
if s.CalDAV != nil {
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
if err != nil {
s.Logger.Warn("unified tasks caldav links", "item", item.ID, "err", err)
}
linkedLinks = links
u.CalDAVBound = len(links) > 0
calName := map[string]string{}
for _, l := range links {
name := linkDisplay(l)
u.LinkedCalendars = append(u.LinkedCalendars, caldav.Calendar{URL: l.RefID, DisplayName: name})
calName[l.RefID] = name
}
sort.Slice(u.LinkedCalendars, func(i, j int) bool {
return u.LinkedCalendars[i].DisplayName < u.LinkedCalendars[j].DisplayName
})
cts, err := s.detailTodos(ctx, item)
if err != nil {
s.Logger.Warn("unified tasks detailTodos", "item", item.ID, "err", err)
banners = append(banners, "Could not load CalDAV tasks: "+err.Error())
}
for _, ct := range cts {
if ct.Error != "" {
banners = append(banners, ct.DisplayName+": "+ct.Error)
}
label := calName[ct.CalendarURL]
if label == "" {
label = "CalDAV"
}
for _, td := range ct.Open {
u.Open = append(u.Open, taskRow{Task: taskFromTodo(td, ct.CalendarURL, item.ID), SourceLabel: label})
}
for _, td := range ct.DoneRecent {
u.Done = append(u.Done, taskRow{Task: taskFromTodo(td, ct.CalendarURL, item.ID), SourceLabel: label})
}
}
if acs, aerr := s.availableCalendarsForItem(ctx, linkedLinks); aerr != nil {
s.Logger.Warn("unified tasks available caldav", "item", item.ID, "err", aerr)
} else {
u.AvailableCalendars = acs
}
}
// --- mBrian side ---
if tr, _, ok := s.taskBackend(); ok {
u.MBrianOn = true
tasks, err := tr.TasksForItem(ctx, item.ID)
if err != nil {
s.Logger.Warn("unified tasks mbrian", "item", item.ID, "err", err)
banners = append(banners, "Could not load projax tasks: "+err.Error())
}
for _, t := range tasks {
row := taskRow{Task: t, SourceLabel: "projax"}
switch {
case t.Done:
u.Done = append(u.Done, row)
case t.Status == "archived":
// hidden
default:
u.Open = append(u.Open, row)
}
}
}
sortTaskRows(u.Open)
sortTaskRows(u.Done)
u.Banner = strings.Join(banners, " · ")
return u
}
// sortTaskRows orders a task slice: earlier due first (undated last), then
// created-at, then title — a stable, sensible merge order across both sources.
func sortTaskRows(rows []taskRow) {
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
// Due: dated before undated; earlier due first.
switch {
case a.Due != nil && b.Due != nil:
if !a.Due.Equal(*b.Due) {
return a.Due.Before(*b.Due)
}
case a.Due != nil && b.Due == nil:
return true
case a.Due == nil && b.Due != nil:
return false
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.Before(b.CreatedAt)
}
return a.Title < b.Title
})
}
// addTarget describes where the single add-form creates a new task.
type addTarget struct {
// Mode is "caldav" or "mbrian".
Mode string
// CalendarURL is the default target calendar (CalDAV mode, single
// calendar). Empty when the form shows a calendar <select> (>1 calendar).
CalendarURL string
}
// addTargetFor decides the new-task backend per §3.1: CalDAV-bound → CalDAV
// (default to the sole calendar, or let the form pick when several); unbound →
// mBrian-native (when the backend supports it).
func (u unifiedTasks) AddTarget() addTarget {
if u.CalDAVBound {
t := addTarget{Mode: "caldav"}
if len(u.LinkedCalendars) == 1 {
t.CalendarURL = u.LinkedCalendars[0].URL
}
return t
}
if u.MBrianOn {
return addTarget{Mode: "mbrian"}
}
return addTarget{} // no add affordance
}
// unifiedTasksData builds the template payload for the unified section.
func unifiedTasksData(item *store.Item, u unifiedTasks, banner string) map[string]any {
if banner != "" && u.Banner != "" {
banner = banner + " · " + u.Banner
} else if banner == "" {
banner = u.Banner
}
return map[string]any{
"Item": item,
"Open": u.Open,
"Done": u.Done,
"CalDAVOn": u.CalDAVOn,
"CalDAVBound": u.CalDAVBound,
"LinkedCalendars": u.LinkedCalendars,
"AvailableCalendars": u.AvailableCalendars,
"MBrianOn": u.MBrianOn,
"Checklist": item.RendersChecklist(),
"AddTarget": u.AddTarget(),
"Banner": banner,
}
}
// itemIsCalDAVBound reports whether the item has ≥1 caldav-list link — the
// §3.1 backend selector. Errors degrade to false (treat as mBrian-native).
func (s *Server) itemIsCalDAVBound(ctx context.Context, itemID string) bool {
links, err := s.Items.LinksByType(ctx, itemID, refTypeCalDAV)
if err != nil {
s.Logger.Warn("caldav-bound check", "item", itemID, "err", err)
return false
}
return len(links) > 0
}
// renderUnifiedTasks re-renders the unified tasks fragment for HTMX swaps —
// shared by BOTH the CalDAV and mBrian task action handlers so a write from
// either backend refreshes the same merged list.
func (s *Server) renderUnifiedTasks(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
u := s.buildUnifiedTasks(r.Context(), it)
s.render(w, r, "tasks_section", unifiedTasksData(it, u, banner))
}
// handleTaskAction dispatches POST /i/{path}/task/{action} for mBrian-native
// tasks. action ∈ {create, done, reopen, edit, due, delete}. Every mutating
// action on an EXISTING task verifies the node belongs to this item (guards
// against a crafted form targeting an arbitrary node), then writes via the
// TaskWriter and re-renders the unified section.
func (s *Server) handleTaskAction(w http.ResponseWriter, r *http.Request, path, action string) {
tr, tw, ok := s.taskBackend()
if !ok {
http.Error(w, "tasks not supported on this backend", http.StatusServiceUnavailable)
return
}
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
banner := ""
switch action {
case "create":
// New tasks are created mBrian-native only when the project isn't
// CalDAV-bound (§3.1). A crafted create against a CalDAV-bound project
// is refused so the backend selector can't be bypassed.
if s.itemIsCalDAVBound(r.Context(), it.ID) {
http.Error(w, "project is CalDAV-bound — create tasks via the CalDAV list", http.StatusConflict)
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
banner = "Cannot create a task with an empty title."
break
}
in := store.TaskCreateInput{Title: title, ParentItemID: it.ID}
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
in.Due = &t
}
}
if _, err := tw.CreateTask(r.Context(), in); err != nil {
banner = taskBanner("create", err)
}
case "done", "reopen", "edit", "due", "delete":
nodeID := strings.TrimSpace(r.FormValue("node_id"))
if nodeID == "" {
http.Error(w, "node_id required", http.StatusBadRequest)
return
}
if !taskBelongsTo(r.Context(), tr, it.ID, nodeID) {
http.Error(w, "task not attached to this item", http.StatusForbidden)
return
}
switch action {
case "done":
if err := tw.SetTaskStatus(r.Context(), nodeID, "done"); err != nil {
banner = taskBanner("complete", err)
}
case "reopen":
if err := tw.SetTaskStatus(r.Context(), nodeID, "active"); err != nil {
banner = taskBanner("reopen", err)
}
case "edit":
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
banner = "Task title cannot be empty."
break
}
if err := tw.EditTaskTitle(r.Context(), nodeID, title); err != nil {
banner = taskBanner("edit", err)
break
}
// The edit form also carries the due field; apply it in the same
// submit so a single Save persists both.
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
if err := tw.SetTaskDue(r.Context(), nodeID, &t); err != nil {
banner = taskBanner("edit", err)
}
}
} else if _, present := r.Form["due"]; present {
// Field submitted but blank → user cleared it.
if err := tw.SetTaskDue(r.Context(), nodeID, nil); err != nil {
banner = taskBanner("edit", err)
}
}
case "due":
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
if t, ok := parseDueInput(dueStr); ok {
if err := tw.SetTaskDue(r.Context(), nodeID, &t); err != nil {
banner = taskBanner("set due on", err)
}
}
} else {
if err := tw.SetTaskDue(r.Context(), nodeID, nil); err != nil {
banner = taskBanner("clear due on", err)
}
}
case "delete":
if err := tw.DeleteTask(r.Context(), nodeID); err != nil {
banner = taskBanner("delete", err)
}
}
default:
http.Error(w, "unknown task action: "+action, http.StatusBadRequest)
return
}
// A task write can move it on/off the dashboard + timeline rollups, so
// bust those caches like the CalDAV path does.
if s.dashboard != nil {
s.dashboard.InvalidateAll()
}
if s.timeline != nil {
s.timeline.InvalidateAll()
}
if r.Header.Get("HX-Request") == "true" {
s.renderUnifiedTasks(w, r, it, banner)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
}
// renderHint maps the detail form's "render as checklist" checkbox to the
// metadata.projax.render value: "checklist" when on, "" when off.
func renderHint(checklist bool) string {
if checklist {
return "checklist"
}
return ""
}
// taskBelongsTo verifies nodeID is one of itemID's mBrian-native tasks — the
// authorisation guard for every existing-task mutation.
func taskBelongsTo(ctx context.Context, tr store.TaskReader, itemID, nodeID string) bool {
tasks, err := tr.TasksForItem(ctx, itemID)
if err != nil {
return false
}
for _, t := range tasks {
if t.NodeID == nodeID {
return true
}
}
return false
}
// taskBanner formats a user-facing banner for a task write error.
func taskBanner(action string, err error) string {
return "Could not " + action + " task: " + err.Error()
}
// taskFromTodo maps a CalDAV VTODO to the uniform store.Task shape (design
// §3.3 — one shape, two sources). Used by the unified list so CalDAV + mBrian
// tasks render through a single row template.
func taskFromTodo(td caldav.Todo, calURL, parentItemID string) *store.Task {
done := td.Status == "COMPLETED" || td.Status == "CANCELLED"
t := &store.Task{
ID: td.UID,
Title: td.Summary,
Done: done,
Due: td.Due,
Source: store.TaskSourceCalDAV,
Status: td.Status,
ParentItemID: parentItemID,
CalendarURL: calURL,
UID: td.UID,
}
if td.LastModified != nil {
t.CreatedAt = *td.LastModified
}
return t
}

View File

@@ -1,120 +0,0 @@
package web
import (
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
func TestTaskFromTodo(t *testing.T) {
due := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
mod := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
td := caldav.Todo{
UID: "vtodo-123",
Summary: "Pour foundation",
Status: "NEEDS-ACTION",
Due: &due,
LastModified: &mod,
}
got := taskFromTodo(td, "https://dav/cal/", "item-uuid")
if got.Source != store.TaskSourceCalDAV {
t.Fatalf("source = %q, want caldav", got.Source)
}
if got.ID != "vtodo-123" || got.UID != "vtodo-123" {
t.Fatalf("id/uid = %q/%q", got.ID, got.UID)
}
if got.Title != "Pour foundation" {
t.Fatalf("title = %q", got.Title)
}
if got.Done {
t.Fatal("NEEDS-ACTION should not be done")
}
if got.CalendarURL != "https://dav/cal/" {
t.Fatalf("calURL = %q", got.CalendarURL)
}
if got.ParentItemID != "item-uuid" {
t.Fatalf("parent = %q", got.ParentItemID)
}
if got.Due == nil || !got.Due.Equal(due) {
t.Fatalf("due = %v, want %v", got.Due, due)
}
if !got.CreatedAt.Equal(mod) {
t.Fatalf("createdAt = %v, want last-modified %v", got.CreatedAt, mod)
}
}
func TestTaskFromTodoDoneStates(t *testing.T) {
for _, st := range []string{"COMPLETED", "CANCELLED"} {
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
if !got.Done {
t.Fatalf("status %q should map to done", st)
}
}
for _, st := range []string{"NEEDS-ACTION", "IN-PROCESS", ""} {
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
if got.Done {
t.Fatalf("status %q should NOT map to done", st)
}
}
}
func TestRenderHint(t *testing.T) {
if renderHint(true) != "checklist" {
t.Fatal("true should map to checklist")
}
if renderHint(false) != "" {
t.Fatal("false should map to empty")
}
}
func TestSortTaskRows(t *testing.T) {
d := func(s string) *time.Time {
tm, _ := time.Parse("2006-01-02", s)
return &tm
}
mk := func(title string, due *time.Time, created string) taskRow {
c, _ := time.Parse("2006-01-02", created)
return taskRow{Task: &store.Task{Title: title, Due: due, CreatedAt: c}}
}
rows := []taskRow{
mk("undated-late", nil, "2026-06-03"),
mk("due-later", d("2026-06-20"), "2026-06-01"),
mk("undated-early", nil, "2026-06-02"),
mk("due-soon", d("2026-06-10"), "2026-06-05"),
}
sortTaskRows(rows)
got := []string{rows[0].Title, rows[1].Title, rows[2].Title, rows[3].Title}
want := []string{"due-soon", "due-later", "undated-early", "undated-late"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sort order = %v, want %v", got, want)
}
}
}
func TestAddTarget(t *testing.T) {
cal := func(url string) caldav.Calendar { return caldav.Calendar{URL: url, DisplayName: url} }
// CalDAV-bound, single calendar → caldav + that URL.
u := unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/")}}
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "https://c1/" {
t.Fatalf("single-cal bound = %+v, want caldav+https://c1/", at)
}
// CalDAV-bound, multiple calendars → caldav + empty URL (form shows select).
u = unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/"), cal("https://c2/")}}
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "" {
t.Fatalf("multi-cal bound = %+v, want caldav+empty", at)
}
// Unbound + mBrian backend → mbrian.
u = unifiedTasks{CalDAVBound: false, MBrianOn: true}
if at := u.AddTarget(); at.Mode != "mbrian" {
t.Fatalf("unbound+mbrian = %+v, want mbrian", at)
}
// Unbound + no mBrian backend → no add affordance.
u = unifiedTasks{CalDAVBound: false, MBrianOn: false}
if at := u.AddTarget(); at.Mode != "" {
t.Fatalf("unbound+no-backend = %+v, want empty mode", at)
}
}

View File

@@ -1,212 +1,244 @@
{{define "content"}}
<h1>{{.Item.Title}}</h1>
<p class="meta">
<span class="slug">{{.Item.PrimaryPath}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
{{if .Item.SourceRefDeref}}<span class="muted">mai id: {{.Item.SourceRefDeref}}</span>{{end}}
</p>
{{if .Item.OtherPaths}}
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
{{end}}
{{$itemID := .Item.ID}}
{{$path := .Item.PrimaryPath}}
<article class="detail">
{{if .Edit}}
{{/* ───────────────────────── EDIT MODE ─────────────────────────
Reached via ?edit=1. The identity + content fields live here only
(Direction A: they don't duplicate onto the read view — kills D7). Save
POSTs and redirects back to the read view; Cancel returns without saving.
Rare settings (Public listing / Timeline behaviour) stay nested
disclosures inside the form (B's accordion discipline). */}}
<header class="detail-head">
<p class="breadcrumb muted">{{$path}}</p>
<h1>Editing {{.Item.Title}}</h1>
</header>
{{/*
Phase 5i: reordered general → specific. Form first (Title → Slug →
Parents → Status → Classification → Flags → Content → form-bound
collapsibles) so the always-edit fields sit at the top, then a divider
before the auxiliary read-only collapsibles (Tasks / Issues /
Documents). Field grouping (General / Classification / Flags) reads as
three groups instead of nine flat labels per m's "pimped a bit" ask.
<form method="post" action="/i/{{$path}}" class="edit detail-form">
<section class="form-group" aria-labelledby="hdr-general">
<h2 id="hdr-general" class="form-group-heading">General</h2>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
</section>
Public listing + Timeline behaviour stay INSIDE the form so they save
with the main Save button — moving them out would require a separate
POST endpoint, which is out of scope for this pass.
<section class="form-group" aria-labelledby="hdr-classification">
<h2 id="hdr-classification" class="form-group-heading">Classification</h2>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
</section>
Phase 4e collapsibles smart-default + localStorage state preserved
exactly: same data-section keys, same proj-section CSS class, same
inline JS.
*/}}
<section class="form-group form-group-flags" aria-labelledby="hdr-flags">
<h2 id="hdr-flags" class="form-group-heading">Flags</h2>
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit detail-form">
<section class="form-group" aria-labelledby="hdr-general">
<h2 id="hdr-general" class="form-group-heading">General</h2>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
</section>
<section class="form-group" aria-labelledby="hdr-classification">
<h2 id="hdr-classification" class="form-group-heading">Classification</h2>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
</section>
<section class="form-group form-group-flags" aria-labelledby="hdr-flags">
<h2 id="hdr-flags" class="form-group-heading">Flags</h2>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
</section>
<section class="form-group" aria-labelledby="hdr-content">
<h2 id="hdr-content" class="form-group-heading">Content</h2>
<label class="form-group-content-label">
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
</section>
<details class="proj-section" data-section="public" data-item-id="{{$itemID}}">
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
<fieldset class="public-listing">
<legend class="visually-hidden">Public listing</legend>
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
consumer) can pull these fields via the projax MCP. The values are
preserved when public is off — toggling never destroys them.</p>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label class="checkbox">
<input type="checkbox" name="render_checklist" value="1" {{if .Item.RendersChecklist}}checked{{end}}> render tasks as checklist
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
</section>
<section class="form-group" aria-labelledby="hdr-content">
<h2 id="hdr-content" class="form-group-heading">Content <small class="muted">(markdown)</small></h2>
<label class="form-group-content-label">
<textarea name="content_md" rows="16">{{.Item.ContentMD}}</textarea>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
</section>
<details class="proj-section" data-section="public">
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
<fieldset class="public-listing">
<legend class="visually-hidden">Public listing</legend>
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
consumer) can pull these fields via the projax MCP. The values are
preserved when public is off — toggling never destroys them.</p>
<label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
</details>
</div>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
</details>
<details class="proj-section" data-section="timeline-behaviour"{{if .Item.TimelineExclude}} open{{end}}>
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
<fieldset class="timeline-exclude">
<legend class="visually-hidden">Timeline behaviour</legend>
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
{{$ex := .Item.TimelineExclude}}
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="docs" {{if contains $ex "docs"}}checked{{end}}> exclude docs (dated item_links)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="creation" {{if contains $ex "creation"}}checked{{end}}> exclude creation marker (this item's "added to projax" row)</label>
</fieldset>
</details>
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
<fieldset class="timeline-exclude">
<legend class="visually-hidden">Timeline behaviour</legend>
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
{{$ex := .Item.TimelineExclude}}
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="docs" {{if contains $ex "docs"}}checked{{end}}> exclude docs (dated item_links)</label>
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="creation" {{if contains $ex "creation"}}checked{{end}}> exclude creation marker (this item's "added to projax" row)</label>
</fieldset>
</details>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/i/{{$path}}">Cancel</a>
</div>
</form>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/views/tree">Cancel</a>
</div>
</form>
<script>
// Phase 4d screenshot list editor (edit mode only). Small inline JS — no
// framework. Rows are simple <input name="public_screenshots"> entries;
// the server's parseScreenshotList drops empties and preserves order.
(function() {
var box = document.getElementById("public-screenshots");
var add = document.getElementById("public-screenshot-add");
if (!box || !add) return;
function blankRow() {
var row = document.createElement("div");
row.className = "public-screenshot-row";
row.innerHTML = '<input name="public_screenshots" type="url" value="" placeholder="https://…"><button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>';
return row;
}
add.addEventListener("click", function() {
box.appendChild(blankRow());
var inp = box.lastElementChild && box.lastElementChild.querySelector("input");
if (inp) inp.focus();
});
box.addEventListener("click", function(e) {
var t = e.target;
if (!(t instanceof HTMLElement)) return;
if (!t.classList.contains("public-screenshot-remove")) return;
var row = t.closest(".public-screenshot-row");
if (!row) return;
row.remove();
if (!box.querySelector(".public-screenshot-row")) box.appendChild(blankRow());
});
})();
</script>
<hr class="aux-divider" aria-hidden="true">
{{else}}
{{/* ───────────────────────── READ MODE (default) ─────────────────────────
A projax item is a page you READ: rendered markdown description under a
compact chip header, with a single Edit toggle. Tasks/issues/docs are the
work, rendered below as always-visible cards (no top-level collapse). */}}
<header class="detail-head">
<p class="breadcrumb muted">{{$path}}</p>
<div class="detail-title-row">
<h1>{{.Item.Title}}</h1>
<a class="btn edit-toggle" href="/i/{{$path}}?edit=1">Edit</a>
</div>
<p class="meta">
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
{{if .Item.SourceRefDeref}}<span class="muted">mai id: {{.Item.SourceRefDeref}}</span>{{end}}
</p>
{{if .Item.OtherPaths}}
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
{{end}}
</header>
<section class="aux-sections" aria-labelledby="hdr-aux">
<h2 id="hdr-aux" class="aux-heading">Related</h2>
{{if .ContentHTML}}
<div class="content-rendered markdown-body">{{.ContentHTML}}</div>
{{else}}
<p class="muted content-empty">No description yet. <a href="/i/{{$path}}?edit=1">Add one</a>.</p>
{{if .CalDAVOn}}
{{/* Tasks section opens by default when any linked calendar has at least
one open VTODO. */}}
{{$tasksOpen := false}}
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
{{template "tasks-section" .}}
</details>
{{end}}
{{end}}
{{/* ───────── WORK CARDS — always visible in BOTH modes ─────────
Tasks/issues/docs were always HTMX-independent of the edit form, so they
stay live whether you're reading or editing. One visible card title each
(the in-partial h2 is visually-hidden — Slice 0); the #…-section ids +
outerHTML swap targets are preserved for HTMX writeback. */}}
{{if .ShowTasks}}
<section class="detail-card" id="tasks-card">
<h2 class="card-title">Tasks{{if .TasksOpen}} <small class="muted">(open)</small>{{end}}</h2>
{{template "tasks-section" .TasksData}}
</section>
{{end}}
{{if and .GiteaOn .Issues}}
{{$open := le .IssuesOpenTotal 10}}
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
{{template "issues-section" .}}
</details>
{{end}}
{{if and .GiteaOn .Issues}}
<section class="detail-card" id="issues-card">
<h2 class="card-title">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></h2>
{{template "issues-section" .}}
</section>
{{end}}
{{$docOpen := le (len .Documents) 5}}
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
{{template "documents-section" .}}
</details>
<section class="detail-card" id="documents-card">
<h2 class="card-title">Documents <small class="muted">({{len .Documents}})</small></h2>
{{template "documents-section" .}}
<p class="aux-reset">
<a class="proj-section-reset muted" href="#" data-item-id="{{$itemID}}">reset section state</a>
</p>
</section>
</article>
<script>
// Phase 4e collapsible-section persistence. Each <details data-section data-item-id>
// reads its open state from localStorage on boot (user choice wins over the
// server-rendered default), writes back on toggle. The reset link wipes
// every projax.section.{item}.* key so smart defaults take over again.
(function() {
var details = document.querySelectorAll("details.proj-section[data-section][data-item-id]");
function keyFor(d) {
return "projax.section." + d.getAttribute("data-item-id") + "." + d.getAttribute("data-section");
}
details.forEach(function(d) {
try {
var saved = localStorage.getItem(keyFor(d));
if (saved === "open") d.setAttribute("open", "");
else if (saved === "closed") d.removeAttribute("open");
} catch (e) { /* localStorage blocked — fall through to default */ }
d.addEventListener("toggle", function() {
try { localStorage.setItem(keyFor(d), d.open ? "open" : "closed"); } catch (e) {}
});
});
var reset = document.querySelector(".proj-section-reset");
if (reset) reset.addEventListener("click", function(e) {
e.preventDefault();
var itemID = reset.getAttribute("data-item-id");
if (!itemID) return;
try {
var prefix = "projax.section." + itemID + ".";
for (var i = localStorage.length - 1; i >= 0; i--) {
var k = localStorage.key(i);
if (k && k.indexOf(prefix) === 0) localStorage.removeItem(k);
}
} catch (e) {}
// Reload so the server's smart defaults take effect again.
location.reload();
});
})();
</script>
<script>
// Phase 4d screenshot list editor. Small inline JS — no framework. Rows
// are simple <input name="public_screenshots"> entries; the server's
// parseScreenshotList drops empties and preserves order. Removing the
// last row leaves one blank input so the user can always type in one.
(function() {
var box = document.getElementById("public-screenshots");
var add = document.getElementById("public-screenshot-add");
if (!box || !add) return;
function blankRow() {
var row = document.createElement("div");
row.className = "public-screenshot-row";
row.innerHTML = '<input name="public_screenshots" type="url" value="" placeholder="https://…"><button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>';
return row;
}
add.addEventListener("click", function() {
box.appendChild(blankRow());
var inp = box.lastElementChild && box.lastElementChild.querySelector("input");
if (inp) inp.focus();
});
box.addEventListener("click", function(e) {
var t = e.target;
if (!(t instanceof HTMLElement)) return;
if (!t.classList.contains("public-screenshot-remove")) return;
var row = t.closest(".public-screenshot-row");
if (!row) return;
row.remove();
// Ensure there's always at least one blank row to type into.
if (!box.querySelector(".public-screenshot-row")) box.appendChild(blankRow());
});
})();
</script>
{{end}}

View File

@@ -1,8 +1,6 @@
{{define "documents-section"}}
<section id="documents-section" class="documents">
{{/* Phase 8 Slice 0: visually-hidden — the detail <summary> is the visible
header; this remains the a11y label for the standalone swap fragment. */}}
<h2 class="visually-hidden">Documents</h2>
<h2>Documents</h2>
{{if .DocBanner}}<p class="banner warn" role="alert">{{.DocBanner}}</p>{{end}}
<form class="doc-add"

View File

@@ -1,8 +1,6 @@
{{define "issues-section"}}
<section class="issues" id="issues-section">
{{/* Phase 8 Slice 0: visually-hidden — the detail <summary> is the visible
header; this remains the a11y label for the standalone swap fragment. */}}
<h2 class="visually-hidden">Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}</h2>
<h2>Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}</h2>
{{if .Banner}}<p class="banner warn">{{.Banner}}</p>{{end}}
{{range .Issues}}
{{$repo := .Repo}}

View File

@@ -13,12 +13,6 @@
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
<link rel="stylesheet" href="/static/style.css">
<!-- HTMX powers the in-place fragment swaps on the task / tree / dashboard /
bulk / classify forms (hx-post/hx-get/hx-target/hx-swap). Vendored (not
CDN) — projax is Tailscale-only and ships its assets via go:embed. Loaded
deferred so it executes after parse but before DOMContentLoaded, where
htmx wires every hx-* element. Plain method=post forms are untouched. -->
<script src="/static/htmx.min.js" defer></script>
<script>
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
// main-content margin doesn't flash from 220px→56px on every navigation.

View File

@@ -1,131 +1,106 @@
{{define "tasks-section"}}
<section class="tasks unified{{if .Checklist}} checklist{{end}}" id="tasks-section">
{{/* Phase 8 Slice 0: the visible section header is the detail page's
<summary>; this h2 is the a11y label for the standalone HTMX swap
fragment only, so it's visually-hidden to kill the double header. */}}
<h2 class="visually-hidden">Tasks</h2>
<section class="tasks" id="tasks-section">
<h2>Tasks</h2>
{{if .Banner}}<p class="banner warn" role="alert">{{.Banner}}</p>{{end}}
{{if .Tasks}}
{{$root := .}}
{{range .Tasks}}
{{$calURL := .CalendarURL}}
<div class="cal-block" data-cal="{{$calURL}}">
<h3>{{.DisplayName}}</h3>
{{if .Error}}<p class="banner warn">{{.Error}}</p>{{end}}
{{$root := .}}
<form class="todo-create"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="text" name="summary" placeholder="Add a task…" required>
<input type="date" name="due" title="due date (optional)">
<button type="submit">Add</button>
</form>
{{/* Single add-form. Backend chosen by the §3.1 selector: a CalDAV-bound
project creates VTODOs on its linked calendar; an unbound project
creates mBrian-native task nodes. */}}
{{with .AddTarget}}
{{if eq .Mode "caldav"}}
<form class="todo-create"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
hx-target="#tasks-section"
hx-swap="outerHTML">
{{if .CalendarURL}}
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
{{else}}
<select name="calendar_url" required title="calendar">
{{range $root.LinkedCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
</select>
{{end}}
<input type="text" name="summary" placeholder="Add a task…" required>
<input type="date" name="due" title="due date (optional)">
<button type="submit">Add</button>
</form>
{{else if eq .Mode "mbrian"}}
<form class="todo-create"
hx-post="/i/{{$root.Item.PrimaryPath}}/task/create"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="text" name="title" placeholder="Add a task…" required>
<input type="date" name="due" title="due date (optional)">
<button type="submit">Add</button>
</form>
{{end}}
{{end}}
{{if .Open}}
<ul class="todo open">
{{range .Open}}
<li class="todo-row" data-uid="{{.UID}}">
<form class="todo-complete inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
</form>
{{if .Open}}
<ul class="todo open">
{{range .Open}}
<li class="todo-row" data-src="{{.Source}}">
{{/* complete */}}
{{if eq .Source "caldav"}}
<form class="todo-complete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
</form>
<form class="todo-edit inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<input type="text" name="summary" value="{{.Title}}" required>
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
<button type="submit" title="Save edits">Save</button>
</form>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{else}}
<form class="todo-complete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/done" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
</form>
<form class="todo-edit inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/edit" hx-target="#tasks-section" hx-swap="outerHTML">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<input type="text" name="title" value="{{.Title}}" required>
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
<button type="submit" title="Save edits">Save</button>
</form>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{end}}
<span class="task-src" title="{{if eq .Source "caldav"}}CalDAV — {{end}}{{.SourceLabel}}">{{.SourceLabel}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No open tasks.</p>
{{end}}
<form class="todo-edit inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit"
hx-target="#tasks-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<input type="text" name="summary" value="{{.Summary}}" required>
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
<button type="submit" title="Save edits">Save</button>
</form>
{{if .Done}}
<details>
<summary class="muted">{{len .Done}} done</summary>
<ul class="todo done">
{{range .Done}}
<li class="todo-row" data-src="{{.Source}}">
{{if eq .Source "caldav"}}
<form class="todo-reopen inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen" hx-target="#tasks-section" hx-swap="outerHTML" title="Reopen">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" aria-label="Reopen">☑</button>
</form>
<span class="summary">{{.Title}}</span>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
{{else}}
<form class="todo-reopen inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/reopen" hx-target="#tasks-section" hx-swap="outerHTML" title="Reopen">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="check" aria-label="Reopen">☑</button>
</form>
<span class="summary">{{.Title}}</span>
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="node_id" value="{{.NodeID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
<form class="todo-delete inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
hx-target="#tasks-section"
hx-swap="outerHTML"
hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
</li>
{{end}}
<span class="task-src" title="{{if eq .Source "caldav"}}CalDAV — {{end}}{{.SourceLabel}}">{{.SourceLabel}}</span>
</li>
</ul>
{{else}}
<p class="muted">No open tasks.</p>
{{end}}
</ul>
</details>
{{if .DoneRecent}}
<details>
<summary class="muted">{{len .DoneRecent}} completed in last 30 days</summary>
<ul class="todo done">
{{range .DoneRecent}}
<li class="todo-row" data-uid="{{.UID}}">
<form class="todo-reopen inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen"
hx-target="#tasks-section"
hx-swap="outerHTML"
title="Reopen">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="check" aria-label="Reopen">☑</button>
</form>
<span class="summary">{{.Summary}}</span>
<form class="todo-delete inline"
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
hx-target="#tasks-section"
hx-swap="outerHTML"
hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{$calURL}}">
<input type="hidden" name="uid" value="{{.UID}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
</li>
{{end}}
</ul>
</details>
{{end}}
</div>
{{end}}
{{else}}
<p class="muted">No CalDAV list linked.</p>
{{end}}
{{/* Project-level CalDAV management: link an existing list, or create a new
one (only when none is bound yet). Unchanged from the pre-7c flow. */}}
{{if .CalDAVOn}}
{{/* Phase 5j: per-item picker for sharing an existing list across
multiple projax items (e.g. one "Vacations 2026" list under
several admin.vacations sub-items). Renders in BOTH states:
unlinked items see it next to Create-new; already-linked items
see it as "+ link another" for the multi-list flow. */}}
<div class="caldav-actions">
{{if .AvailableCalendars}}
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
@@ -137,12 +112,11 @@
<button type="submit">Link</button>
</form>
{{end}}
{{if not .CalDAVBound}}
{{if not .Tasks}}
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
<button type="submit">+ Create new CalDAV list</button>
<button type="submit">+ Create new list</button>
</form>
{{end}}
</div>
{{end}}
</section>
{{end}}