m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.
## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
toggle himself via /admin/bulk or the detail-page form.
## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
before fanning out — items with the kind in their exclude array are
dropped entirely (no CalDAV call wasted on excluded sources). Doc and
creation rows check the per-item flag inline. `?include_excluded=1`
(URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
("todo") or plural ("todos") to bridge the kind-constant / persisted-
value naming choice — see comment for the why.
## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
checkboxes (todos / events / docs / creation) and helper text. Open by
default when any kind is excluded, so m can see at a glance what's
hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
and "Re-include on timeline" — the other three kinds stay editable
per-item only per the task brief (most common use case is just todos).
## MCP
- update_item accepts timeline_exclude as a partial-update field with an
enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
can render the toggle state without a second round-trip.
## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances
## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.
## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
via /admin/bulk after the deploy — schema change stays behaviour-neutral)
213 lines
7.4 KiB
Go
213 lines
7.4 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
// TestTimelineExcludeMigrationLanded asserts the new column + GIN index
|
|
// are queryable. Each task in the chain adds a column; if a future
|
|
// migration drops the chain, this test fires loudly.
|
|
func TestTimelineExcludeMigrationLanded(t *testing.T) {
|
|
_, pool := mustServer(t)
|
|
defer pool.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var col string
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema='projax' AND table_name='items' AND column_name='timeline_exclude'`,
|
|
).Scan(&col); err != nil {
|
|
t.Fatalf("timeline_exclude column missing: %v", err)
|
|
}
|
|
if col != "timeline_exclude" {
|
|
t.Errorf("got %q, want timeline_exclude", col)
|
|
}
|
|
var idxDef string
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT indexdef FROM pg_indexes WHERE schemaname='projax' AND indexname='items_timeline_exclude_idx'`,
|
|
).Scan(&idxDef); err != nil {
|
|
t.Fatalf("items_timeline_exclude_idx missing: %v", err)
|
|
}
|
|
if !strings.Contains(idxDef, "gin") {
|
|
t.Errorf("expected GIN index, got: %s", idxDef)
|
|
}
|
|
}
|
|
|
|
// TestTimelineExcludeSkipsTodosForFlaggedItem seeds a projax item with
|
|
// timeline_exclude=['todos'] and a calendar holding one open VTODO; the
|
|
// /timeline response should NOT include that VTODO, but should still
|
|
// include any docs/creation rows for the same item.
|
|
func TestTimelineExcludeSkipsTodosForFlaggedItem(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
// Fake CalDAV that always returns one VTODO due today.
|
|
icsTodo := `BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:tle-1@fake
|
|
SUMMARY:Shopping list item
|
|
STATUS:NEEDS-ACTION
|
|
DUE;VALUE=DATE:` + time.Now().UTC().Format("20060102") + `
|
|
END:VTODO
|
|
END:VCALENDAR`
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/dav/calendars/m/Home/", func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
w.WriteHeader(207)
|
|
if strings.Contains(string(body), "VTODO") {
|
|
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
|
<d:response><d:href>/dav/calendars/m/Home/t1.ics</d:href><d:propstat><d:prop>
|
|
<d:getetag>"t1"</d:getetag>
|
|
<cal:calendar-data>`+icsTodo+`</cal:calendar-data>
|
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
</d:multistatus>`)
|
|
return
|
|
}
|
|
// VEVENT branch — empty
|
|
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"></d:multistatus>`)
|
|
})
|
|
fake := httptest.NewServer(mux)
|
|
defer fake.Close()
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/dav/calendars/m/", "u", "p")}
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tle-" + stamp
|
|
calURL := fake.URL + "/dav/calendars/m/Home/"
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var dev, id string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids, timeline_exclude)
|
|
values (array['project']::text[], 'TLE', $1, ARRAY[$2]::uuid[], ARRAY['todos'])
|
|
returning id`,
|
|
slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
|
values ($1, 'caldav-list', $2, 'tracks')`,
|
|
id, calURL,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/timeline")
|
|
if strings.Contains(body, "Shopping list item") {
|
|
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
|
|
}
|
|
|
|
// Override: ?include_excluded=1 brings it back.
|
|
_, peekBody := get(t, h, "/timeline?include_excluded=1")
|
|
if !strings.Contains(peekBody, "Shopping list item") {
|
|
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
|
|
}
|
|
}
|
|
|
|
// TestTimelineExcludeBulkAction flips the array via /admin/bulk and
|
|
// verifies the change persists.
|
|
func TestTimelineExcludeBulkAction(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tle-bk-" + stamp
|
|
var dev, id string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids)
|
|
values (array['project']::text[], 'TLE Bulk', $1, ARRAY[$2]::uuid[])
|
|
returning id`,
|
|
slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
|
|
// Exclude todos.
|
|
form := url.Values{}
|
|
form.Add("ids", id)
|
|
form.Set("timeline_todos", "exclude")
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
var arr []string
|
|
if err := pool.QueryRow(ctx, `select timeline_exclude from projax.items where id=$1`, id).Scan(&arr); err != nil {
|
|
t.Fatalf("re-read: %v", err)
|
|
}
|
|
if len(arr) != 1 || arr[0] != "todos" {
|
|
t.Errorf("exclude bulk action should have set ['todos'], got %v", arr)
|
|
}
|
|
|
|
// Idempotent: applying again leaves it unchanged (no duplicate).
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
|
|
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
h.ServeHTTP(w2, req2)
|
|
if err := pool.QueryRow(ctx, `select timeline_exclude from projax.items where id=$1`, id).Scan(&arr); err != nil {
|
|
t.Fatalf("re-read 2: %v", err)
|
|
}
|
|
if len(arr) != 1 {
|
|
t.Errorf("second exclude should be idempotent, got %v", arr)
|
|
}
|
|
|
|
// Re-include.
|
|
form2 := url.Values{}
|
|
form2.Add("ids", id)
|
|
form2.Set("timeline_todos", "include")
|
|
req3 := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form2.Encode()))
|
|
req3.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w3 := httptest.NewRecorder()
|
|
h.ServeHTTP(w3, req3)
|
|
if err := pool.QueryRow(ctx, `select timeline_exclude from projax.items where id=$1`, id).Scan(&arr); err != nil {
|
|
t.Fatalf("re-read 3: %v", err)
|
|
}
|
|
if len(arr) != 0 {
|
|
t.Errorf("re-include should empty the array, got %v", arr)
|
|
}
|
|
}
|
|
|
|
// TestTimelineExcludeMCPUpdateItemRoundTrip — call update_item with
|
|
// timeline_exclude:['todos','events'], verify both the returned view and
|
|
// the DB hold the value.
|
|
func TestTimelineExcludeDetailFormShowsCheckboxes(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/i/dev")
|
|
for _, want := range []string{
|
|
`name="timeline_exclude" value="todos"`,
|
|
`name="timeline_exclude" value="events"`,
|
|
`name="timeline_exclude" value="docs"`,
|
|
`name="timeline_exclude" value="creation"`,
|
|
`data-section="timeline-behaviour"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("detail form missing timeline-exclude affordance %q", want)
|
|
}
|
|
}
|
|
}
|