Files
projax/web/templates/tasks_section.tmpl
mAi 311cf943bc feat(caldav): link-existing picker + projax-tagged VTODOs for shared lists
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.
2026-05-27 14:16:04 +02:00

123 lines
5.6 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{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}}