m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.
Three layers, one branch:
## 1. Link-existing picker (the original ask)
- New POST /i/{path}/caldav/link-existing handler validates the
submitted calendar_url is in the discoverable PROPFIND set (defence
against crafted forms pointing at arbitrary HTTP servers), then
inserts the item_link row with display_name + color metadata
preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
availableCalendarsForItem(ctx, links) — calendars from
s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
picker (<select> of available calendars) when AvailableCalendars
is non-empty AND the Create-new button (when the item has no
linked list yet). Same surface serves both the "first link" flow
and the "+ link another" flow per athena's brief.
## 2. Tag-on-create (CATEGORIES carries projax:<path>)
- caldav package gains Categories []string on Todo + the same on
VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
parseVTodos parses CATEGORIES comma-list into the slice with per-
entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
`Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
edit/complete/delete paths preserve existing CATEGORIES via the
unknown-property pass-through that's been tested since Phase 5
(TestApplyVTodoEditPreservesUnknown).
## 3. Per-item filter (managed-vs-legacy)
- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
whether the linked list is projax-managed (any projax: tag
anywhere) or legacy/unmanaged (zero projax: tags).
- Managed → filter to VTODOs whose CATEGORIES include this
item's projax:<path>. Multiple projax: tags are AND-of-OR — a
VTODO with two projax tags appears on both items per athena's
multi-tag contract.
- Legacy → show every VTODO untouched. Existing pre-5j users with
untagged lists keep seeing everything; the detail page doesn't
suddenly hide their tasks.
## Helpers (caldav package, exported)
- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal
## Tests
caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
TestParseVTodosMultiCategory.
web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
picker renders, POST link-existing creates the link, second GET
drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
Trip A and Trip B with three tagged VTODOs; A sees A+shared,
B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
tags renders ALL VTODOs (legacy fallback).
Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.
Net: +795 / -14.
123 lines
5.6 KiB
Cheetah
123 lines
5.6 KiB
Cheetah
{{define "tasks-section"}}
|
||
<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}}
|
||
|
||
<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>
|
||
|
||
{{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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
{{else}}
|
||
<p class="muted">No open tasks.</p>
|
||
{{end}}
|
||
|
||
{{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}}
|
||
|
||
{{/* 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">
|
||
<label class="visually-hidden" for="caldav-link-existing-select">Link existing CalDAV list</label>
|
||
<select id="caldav-link-existing-select" name="calendar_url" required>
|
||
<option value="">— link existing list —</option>
|
||
{{range .AvailableCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
|
||
</select>
|
||
<button type="submit">Link</button>
|
||
</form>
|
||
{{end}}
|
||
{{if not .Tasks}}
|
||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||
<button type="submit">+ Create new list</button>
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
</section>
|
||
{{end}}
|