feat(phase 4f): per-item timeline_exclude flag (hide noise from /timeline)
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)
This commit is contained in:
28
mcp/tools.go
28
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 {
|
||||
|
||||
Reference in New Issue
Block a user