diff --git a/db/migrations/0015_items_timeline_exclude.sql b/db/migrations/0015_items_timeline_exclude.sql new file mode 100644 index 0000000..7dca961 --- /dev/null +++ b/db/migrations/0015_items_timeline_exclude.sql @@ -0,0 +1,65 @@ +-- 0015_items_timeline_exclude.sql +-- +-- Phase 4f: per-item switch for excluding kinds of content from the +-- /timeline aggregation. m's stated use case: home VTODOs (shopping list) +-- shouldn't pollute the chronological spine by default, but they should +-- stay visible on the home detail page itself. +-- +-- Values: 'todos' | 'events' | 'docs' | 'creation' — one per timeline +-- source kind. Empty array = nothing excluded = current behaviour. The +-- column is behaviour-neutral on write; m flips the toggle himself via +-- /admin/bulk or the detail-page form after deploy. +-- +-- GIN index because every timeline aggregation walks every item and +-- checks the kind against the array; the index covers the same-kind +-- containment probes the aggregation does. + +ALTER TABLE projax.items + ADD COLUMN IF NOT EXISTS timeline_exclude text[] NOT NULL DEFAULT '{}'; + +CREATE INDEX IF NOT EXISTS items_timeline_exclude_idx + ON projax.items USING gin (timeline_exclude); + +-- items_unified must surface the new column or store reads will silently +-- drop it. Same DROP+CREATE dance as 0014 — Postgres can't append cols to +-- a view via CREATE OR REPLACE. + +DROP VIEW IF EXISTS projax.items_unified; +CREATE VIEW projax.items_unified AS +SELECT + i.id, + i.kind, + i.title, + i.slug, + i.paths, + i.parent_ids, + i.content_md, + i.aliases, + i.metadata, + i.status, + i.pinned, + i.archived, + i.start_time, + i.end_time, + 'projax'::text AS source, + (SELECT l.ref_id FROM projax.item_links l + WHERE l.item_id = i.id AND l.ref_type = 'mai-project' LIMIT 1) AS source_ref_id, + i.tags, + i.management, + i.public, + i.public_description, + i.public_live_url, + i.public_source_url, + i.public_screenshots, + i.timeline_exclude, + i.created_at, + i.updated_at +FROM projax.items i +WHERE i.deleted_at IS NULL; + +DO $own$ BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN + EXECUTE 'ALTER VIEW projax.items_unified OWNER TO projax_admin'; + EXECUTE 'GRANT SELECT ON projax.items_unified TO projax_admin'; + END IF; +END $own$; diff --git a/docs/design.md b/docs/design.md index 95f9be9..0e42ffe 100644 --- a/docs/design.md +++ b/docs/design.md @@ -526,6 +526,16 @@ Items without a date never appear here — the tree/graph/dashboard cover the re **Cache**: 90 s in-memory map keyed by `(filter, from, to, order, kinds)`. Looser than the dashboard's 60 s because timeline is browse-y, not action-y. The cache is invalidated wholesale on VTODO writeback (`/dashboard/task/*`, `/i/{path}/caldav/todo/*`) and on dated-link add/remove — any of those could move rows on or off the spine and the cost of a re-aggregation is cheap. +**Per-item exclusion (Phase 4f)**: + +Each item carries a `timeline_exclude text[]` whose values name kinds to hide from the spine: `'todos'`, `'events'`, `'docs'`, `'creation'` (empty array = default = nothing hidden). The aggregator drops the matching source for each flagged item *before* fanning out — no CalDAV call is made for an item whose VTODOs are excluded, no creation marker is emitted for an item whose `'creation'` kind is excluded, and so on. + +The detail page (`/i/{path}`) still surfaces everything regardless — exclusion is a render-time concern for the timeline view only, so m doesn't lose visibility into his data, he just stops seeing it braided into the chronological spine. + +URL override: `?include_excluded=1` (and the MCP `include_excluded: true` arg) ignore the per-item arrays and surface everything — useful for "show me what I'm hiding" peek. + +Bulk action: `/admin/bulk` offers an "Exclude todos from timeline" / "Re-include todos on timeline" pair (the most common use case — m's home shopping list). The other three kinds (events / docs / creation) are editable per-item only. + **Out of scope for 4a**: - Drag-to-create-on-date (would require write paths from a non-detail page). diff --git a/mcp/tools.go b/mcp/tools.go index 8a42f78..c2308b8 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -82,7 +82,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) { }) s.Register(Tool{ Name: "update_item", - Description: "Partial update of an existing item. Pass any subset of title/slug/content_md/status/tags/management/parent_paths/pinned/archived and the Phase-4d public-listing fields (public, public_description, public_live_url, public_source_url, public_screenshots). parent_paths replaces the full parent list.", + Description: "Partial update of an existing item. Pass any subset of title/slug/content_md/status/tags/management/parent_paths/pinned/archived, the Phase-4d public-listing fields (public, public_description, public_live_url, public_source_url, public_screenshots), or the Phase-4f timeline_exclude array. parent_paths replaces the full parent list.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { @@ -101,7 +101,8 @@ func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) { "public_description": {"type": "string"}, "public_live_url": {"type": "string"}, "public_source_url": {"type": "string"}, - "public_screenshots": {"type": "array", "items": {"type": "string"}} + "public_screenshots": {"type": "array", "items": {"type": "string"}}, + "timeline_exclude": {"type": "array", "items": {"type": "string", "enum": ["todos","events","docs","creation"]}, "description": "Phase 4f — kinds to hide from /timeline (per item)"} } }`), Handler: updateItemTool(st), @@ -390,6 +391,8 @@ type itemView struct { PublicLiveURL string `json:"public_live_url"` PublicSourceURL string `json:"public_source_url"` PublicScreenshots []string `json:"public_screenshots"` + // Phase 4f + TimelineExclude []string `json:"timeline_exclude"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Links []linkView `json:"links,omitempty"` @@ -429,6 +432,7 @@ func toItemView(it *store.Item) itemView { PublicLiveURL: it.PublicLiveURL, PublicSourceURL: it.PublicSourceURL, PublicScreenshots: sliceOr(it.PublicScreenshots, []string{}), + TimelineExclude: sliceOr(it.TimelineExclude, []string{}), CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), UpdatedAt: it.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), } @@ -668,6 +672,7 @@ func updateItemTool(st *store.Store) ToolHandler { PublicLiveURL *string `json:"public_live_url"` PublicSourceURL *string `json:"public_source_url"` PublicScreenshots *[]string `json:"public_screenshots"` + TimelineExclude *[]string `json:"timeline_exclude"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input @@ -693,6 +698,7 @@ func updateItemTool(st *store.Store) ToolHandler { PublicLiveURL: it.PublicLiveURL, PublicSourceURL: it.PublicSourceURL, PublicScreenshots: it.PublicScreenshots, + TimelineExclude: it.TimelineExclude, } if in.Title != nil { patch.Title = *in.Title @@ -733,6 +739,24 @@ func updateItemTool(st *store.Store) ToolHandler { if in.PublicScreenshots != nil { patch.PublicScreenshots = *in.PublicScreenshots } + if in.TimelineExclude != nil { + // Whitelist values so a stray entry doesn't poison the array. Same + // allowlist as parseTimelineExcludeList in web/. + allowed := map[string]struct{}{"todos": {}, "events": {}, "docs": {}, "creation": {}} + out := make([]string, 0, len(*in.TimelineExclude)) + seen := map[string]struct{}{} + for _, v := range *in.TimelineExclude { + if _, ok := allowed[v]; !ok { + continue + } + if _, dup := seen[v]; dup { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + patch.TimelineExclude = out + } if in.ParentPaths != nil { pids, err := resolveParentPaths(ctx, st, *in.ParentPaths) if err != nil { diff --git a/store/store.go b/store/store.go index 554afa4..845ea36 100644 --- a/store/store.go +++ b/store/store.go @@ -42,10 +42,38 @@ type Item struct { PublicLiveURL string PublicSourceURL string PublicScreenshots []string + // Phase 4f timeline-exclude. Per-item array of kinds to hide from + // /timeline aggregation. Values: 'todos' | 'events' | 'docs' | 'creation'. + // Empty array (default) = nothing excluded = current behaviour. + TimelineExclude []string CreatedAt time.Time UpdatedAt time.Time } +// 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 +// ("todos", "events", "docs", "creation") per the task brief and so the +// UI labels read naturally. This translates between the two so callers +// can pass either. +func (it *Item) ExcludesTimelineKind(kind string) bool { + plural := kind + switch kind { + case "todo": + plural = "todos" + case "event": + plural = "events" + case "doc": + plural = "docs" + } + for _, k := range it.TimelineExclude { + if k == kind || k == plural { + return true + } + } + return false +} + // IsRoot reports whether this item sits at the top of the DAG (no parents). func (it *Item) IsRoot() bool { return len(it.ParentIDs) == 0 } @@ -98,7 +126,7 @@ var ErrNotFound = errors.New("projax: item not found") 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, -public_screenshots, created_at, updated_at` +public_screenshots, timeline_exclude, created_at, updated_at` func scanItem(row pgx.Row) (*Item, error) { var it Item @@ -108,6 +136,7 @@ func scanItem(row pgx.Row) (*Item, error) { &it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID, &it.Tags, &it.Management, &it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, &it.PublicScreenshots, + &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt, ); err != nil { return nil, err @@ -126,6 +155,7 @@ func scanItems(rows pgx.Rows) ([]*Item, error) { &it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID, &it.Tags, &it.Management, &it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, &it.PublicScreenshots, + &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt, ); err != nil { return nil, err @@ -279,6 +309,9 @@ type UpdateInput struct { PublicLiveURL string PublicSourceURL string PublicScreenshots []string + // Phase 4f timeline-exclude. Full-replace; values 'todos' / 'events' / + // 'docs' / 'creation'. + TimelineExclude []string } func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) { @@ -294,19 +327,24 @@ func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, e if in.PublicScreenshots == nil { in.PublicScreenshots = []string{} } + if in.TimelineExclude == nil { + in.TimelineExclude = []string{} + } _, err := s.Pool.Exec(ctx, ` update projax.items set title=$2, slug=$3, parent_ids=$4, content_md=$5, status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10, tags=$11, management=$12, public=$13, public_description=$14, public_live_url=$15, - public_source_url=$16, public_screenshots=$17 + public_source_url=$16, public_screenshots=$17, + timeline_exclude=$18 where id=$1 and deleted_at is null`, id, in.Title, in.Slug, in.ParentIDs, in.ContentMD, in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime, in.Tags, in.Management, in.Public, in.PublicDescription, in.PublicLiveURL, in.PublicSourceURL, in.PublicScreenshots, + in.TimelineExclude, ) if err != nil { return nil, fmt.Errorf("update: %w", err) diff --git a/web/bulk.go b/web/bulk.go index e7e83cc..f199120 100644 --- a/web/bulk.go +++ b/web/bulk.go @@ -145,6 +145,7 @@ type bulkAction struct { SetMgmt string // "mai" / "self" / "external" / "clear" SetStatus string // "active" / "done" / "archived" SetPublic string // "" / "make_public" / "make_private" — Phase 4d + TimelineTodos string // "" / "exclude" / "include" — Phase 4f } func parseBulkAction(r *http.Request) bulkAction { @@ -155,6 +156,7 @@ func parseBulkAction(r *http.Request) bulkAction { SetMgmt: get("set_mgmt"), SetStatus: get("set_status"), SetPublic: get("set_public"), + TimelineTodos: get("timeline_todos"), } } @@ -172,6 +174,10 @@ func (a bulkAction) describe() string { return "make public" case a.SetPublic == "make_private": return "make private" + case a.TimelineTodos == "exclude": + return "exclude todos from timeline" + case a.TimelineTodos == "include": + return "re-include todos on timeline" } return "" } @@ -277,6 +283,22 @@ func (s *Server) applyBulk(ctx context.Context, ids []string, a bulkAction) erro update projax.items set public = false where id = any($1::uuid[]) and deleted_at is null`, ids) + case a.TimelineTodos == "exclude": + // Idempotent: append 'todos' only when not already in the array. + _, err = tx.Exec(ctx, ` + update projax.items + set timeline_exclude = case + when 'todos' = any(timeline_exclude) then timeline_exclude + else array_append(timeline_exclude, 'todos') + end + where id = any($1::uuid[]) and deleted_at is null`, + ids) + case a.TimelineTodos == "include": + _, err = tx.Exec(ctx, ` + update projax.items + set timeline_exclude = array_remove(timeline_exclude, 'todos') + where id = any($1::uuid[]) and deleted_at is null`, + ids) default: return errors.New("bulk: empty action") } diff --git a/web/server.go b/web/server.go index 05865a8..0bafaee 100644 --- a/web/server.go +++ b/web/server.go @@ -511,6 +511,10 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) { PublicLiveURL: strings.TrimSpace(r.FormValue("public_live_url")), PublicSourceURL: strings.TrimSpace(r.FormValue("public_source_url")), PublicScreenshots: parseScreenshotList(r.Form["public_screenshots"]), + // 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"]), } updated, err := s.Store.Update(r.Context(), it.ID, in) if err != nil { @@ -591,6 +595,33 @@ func parseScreenshotList(raw []string) []string { return out } +// parseTimelineExcludeList accepts the multi-value `timeline_exclude` form +// field and returns the deduplicated subset of recognised kinds. Any +// unknown value is dropped silently — the form is a fixed checkbox set, +// so unknown values only appear via a crafted POST. +func parseTimelineExcludeList(raw []string) []string { + allowed := map[string]struct{}{ + "todos": {}, + "events": {}, + "docs": {}, + "creation": {}, + } + seen := map[string]struct{}{} + out := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if _, ok := allowed[v]; !ok { + continue + } + if _, dup := seen[v]; dup { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + // parseCSV splits a comma/space-delimited chip input into a deduplicated, // trimmed lowercase string slice. Empty input → []string{} (nil avoided so // JSON/SQL writes get an explicit empty array). diff --git a/web/templates/bulk_section.tmpl b/web/templates/bulk_section.tmpl index 3ea09c4..a0a72be 100644 --- a/web/templates/bulk_section.tmpl +++ b/web/templates/bulk_section.tmpl @@ -84,6 +84,13 @@ + diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl index 4e1415f..7a79ddf 100644 --- a/web/templates/detail.tmpl +++ b/web/templates/detail.tmpl @@ -120,6 +120,19 @@ +
+ Timeline behaviour {{if .Item.TimelineExclude}}(hiding {{len .Item.TimelineExclude}}){{end}} +
+ Timeline behaviour +

Check a kind to hide it from /timeline. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use ?include_excluded=1 to peek at everything anyway.

+ {{$ex := .Item.TimelineExclude}} + + + + +
+
+
Cancel diff --git a/web/timeline.go b/web/timeline.go index 923eace..01ec196 100644 --- a/web/timeline.go +++ b/web/timeline.go @@ -142,6 +142,10 @@ type TimelineQuery struct { To time.Time Order string // "asc" | "desc" Kinds []string // sorted, lower-case; empty means "all four" + // Phase 4f: when true, per-item timeline_exclude arrays are ignored — + // every source surfaces regardless. Used for the "show me what I'm + // hiding" peek (URL: ?include_excluded=1, MCP arg: include_excluded). + IncludeExcluded bool } // activeKinds returns the effective kind set for filter math: returns the @@ -174,6 +178,9 @@ func (q TimelineQuery) cacheKey() string { if len(q.Kinds) > 0 { parts = append(parts, "kinds="+strings.Join(q.Kinds, ",")) } + if q.IncludeExcluded { + parts = append(parts, "include_excluded=1") + } return strings.Join(parts, "|") } @@ -207,6 +214,9 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery { if v := strings.TrimSpace(r.URL.Query().Get("order")); v == "asc" { q.Order = "asc" } + if r.URL.Query().Get("include_excluded") == "1" { + q.IncludeExcluded = true + } // Past-only / future-only narrowing. switch strings.TrimSpace(r.URL.Query().Get("when")) { case "past": @@ -239,15 +249,16 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery { // URL query string consumed by parseTimelineQuery. JSON-tagged so callers // can unmarshal a JSON object straight into it. type TimelineArgs struct { - From string `json:"from"` // YYYY-MM-DD, optional (default now-30d) - To string `json:"to"` // YYYY-MM-DD, optional (default now+90d) - Order string `json:"order"` // "asc" | "desc" (default desc) - Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all - Tags []string `json:"tags"` // tree-filter: ALL must be present - Mgmt []string `json:"mgmt"` // tree-filter: ANY match (incl. "unmanaged") - Has []string `json:"has"` // tree-filter: ALL ref-types present - Status []string `json:"status"` // tree-filter: ANY match (default ["active"]) - Q string `json:"q"` // tree-filter: substring match + From string `json:"from"` // YYYY-MM-DD, optional (default now-30d) + To string `json:"to"` // YYYY-MM-DD, optional (default now+90d) + Order string `json:"order"` // "asc" | "desc" (default desc) + Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all + Tags []string `json:"tags"` // tree-filter: ALL must be present + Mgmt []string `json:"mgmt"` // tree-filter: ANY match (incl. "unmanaged") + Has []string `json:"has"` // tree-filter: ALL ref-types present + Status []string `json:"status"` // tree-filter: ANY match (default ["active"]) + Q string `json:"q"` // tree-filter: substring match + IncludeExcluded bool `json:"include_excluded"`// Phase 4f: ignore per-item timeline_exclude arrays } // BuildTimelinePayloadFromArgs is the MCP entrypoint to the timeline @@ -293,6 +304,7 @@ func (s *Server) BuildTimelinePayloadFromArgs(ctx context.Context, args Timeline if args.Order == "asc" { q.Order = "asc" } + q.IncludeExcluded = args.IncludeExcluded seen := map[string]bool{} for _, k := range args.Kinds { k = strings.ToLower(strings.TrimSpace(k)) @@ -372,17 +384,35 @@ func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Ti matchedSet[it.ID] = struct{}{} } + // Phase 4f: per-item exclude filter. Pre-compute the subset of `matched` + // items that retain each source kind. Skipped here = the aggregator never + // fans out to CalDAV for that item; saves a network call for each + // excluded link too. + keepFor := func(kind string) []*store.Item { + if q.IncludeExcluded { + return matched + } + out := matched[:0:0] + for _, it := range matched { + if it.ExcludesTimelineKind(kind) { + continue + } + out = append(out, it) + } + return out + } + rows := []TimelineRow{} // --- VTODOs (DUE within window for open; LastModified within for done/cancelled). --- if q.wantKind(timelineKindTodo) && s.CalDAV != nil { - todos := s.collectTimelineTodos(ctx, matched, q.From, q.To) + todos := s.collectTimelineTodos(ctx, keepFor(timelineKindTodo), q.From, q.To) rows = append(rows, todos...) } // --- VEVENTs (DTSTART within window). --- if q.wantKind(timelineKindEvent) && s.CalDAV != nil { - events := s.collectTimelineEvents(ctx, matched, q.From, q.To) + events := s.collectTimelineEvents(ctx, keepFor(timelineKindEvent), q.From, q.To) rows = append(rows, events...) } @@ -400,6 +430,9 @@ func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Ti if _, in := matchedSet[it.ID]; q.Filter.Active() && !in { continue } + if !q.IncludeExcluded && it.ExcludesTimelineKind(timelineKindDoc) { + continue + } base := it.PrimaryPath() per := base + "." + formatPERDate(*d.Link.EventDate) rows = append(rows, TimelineRow{ @@ -423,6 +456,9 @@ func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Ti if _, in := matchedSet[it.ID]; q.Filter.Active() && !in { continue } + if !q.IncludeExcluded && it.ExcludesTimelineKind(timelineKindCreation) { + continue + } rows = append(rows, TimelineRow{ Date: startOfDay(it.CreatedAt), Kind: timelineKindCreation, diff --git a/web/timeline_exclude_test.go b/web/timeline_exclude_test.go new file mode 100644 index 0000000..5311d3c --- /dev/null +++ b/web/timeline_exclude_test.go @@ -0,0 +1,212 @@ +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, ` +/dav/calendars/m/Home/t1.ics +"t1" +`+icsTodo+` +HTTP/1.1 200 OK +`) + return + } + // VEVENT branch — empty + _, _ = io.WriteString(w, ``) + }) + 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) + } + } +}