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:
65
db/migrations/0015_items_timeline_exclude.sql
Normal file
65
db/migrations/0015_items_timeline_exclude.sql
Normal file
@@ -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$;
|
||||
@@ -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).
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
web/bulk.go
22
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")
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -84,6 +84,13 @@
|
||||
<option value="make_private">Make private</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>timeline todos
|
||||
<select name="timeline_todos">
|
||||
<option value="">—</option>
|
||||
<option value="exclude">Exclude from timeline</option>
|
||||
<option value="include">Re-include on timeline</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Apply</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -120,6 +120,19 @@
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
|
||||
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
|
||||
<fieldset class="timeline-exclude">
|
||||
<legend class="visually-hidden">Timeline behaviour</legend>
|
||||
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
|
||||
{{$ex := .Item.TimelineExclude}}
|
||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
|
||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
|
||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="docs" {{if contains $ex "docs"}}checked{{end}}> exclude docs (dated item_links)</label>
|
||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="creation" {{if contains $ex "creation"}}checked{{end}}> exclude creation marker (this item's "added to projax" row)</label>
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
|
||||
@@ -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,
|
||||
|
||||
212
web/timeline_exclude_test.go
Normal file
212
web/timeline_exclude_test.go
Normal file
@@ -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, `<?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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user