Supersedes the Phase 7b two-section split (m: 'tasks section should collect
from mBrian AS WELL AS CalDAV and display together'). The detail page now
renders a single merged task list per project:
- buildUnifiedTasks merges mBrian-native tasks (TasksForItem) + CalDAV VTODOs
(detailTodos → taskFromTodo) into one open/done split via the uniform
store.Task shape. Each row carries a subtle source label (calendar name for
CalDAV, 'projax' for mBrian). Sorted: dated-before-undated, earlier due
first, then created, then title.
- Row actions dispatch by Source: CalDAV rows POST /caldav/todo/{action}
(calendar_url+uid, ETag writeback); mBrian rows POST /task/{action}
(node_id). One template, branch on .Source.
- ONE add-form, backend by §3.1 selector (unifiedTasks.AddTarget): CalDAV on a
bound project (hidden calendar_url for a single list, a <select> when
several), mBrian-native on an unbound project. New-task default per m's spec.
- Both handlers (handleCalDAVTodoAction + handleTaskAction) now re-render the
SAME merged fragment via renderUnifiedTasks, so a write from either backend
refreshes the unified list in place.
- Retired the two-section split: deleted mbrian_tasks_section.tmpl + its
registration/render-case, rewrote tasks_section.tmpl as the unified list,
removed renderTasksSection. CalDAV link/create-list affordances preserved.
Unit-tested: sortTaskRows (merge order), AddTarget (backend selection across
bound/unbound × single/multi-calendar). Updated TestDetailLinkExistingCalendar
to the unified UI (no per-calendar block; bound project → add-form targets the
linked calendar, create-new hidden). caldav/gitea/mcp/internal green; the 8
remaining web failures are the pre-existing TestProjectFilter*/TestTimeline*
route-drift (fail on 6436b52).
121 lines
3.7 KiB
Go
121 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|