Compare commits
2 Commits
main
...
mai/kahn/p
| Author | SHA1 | Date | |
|---|---|---|---|
| 1020d60c75 | |||
| 6e4fabfab9 |
175
docs/plans/phase-7-entity-model.md
Normal file
175
docs/plans/phase-7-entity-model.md
Normal 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
5
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
25
mcp/tools.go
25
mcp/tools.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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">`,
|
||||
|
||||
110
web/server.go
110
web/server.go
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
1
web/static/htmx.min.js
vendored
1
web/static/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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); }
|
||||
|
||||
@@ -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',
|
||||
|
||||
410
web/task.go
410
web/task.go
@@ -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
|
||||
}
|
||||
120
web/task_test.go
120
web/task_test.go
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user