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