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)
171 lines
6.5 KiB
Cheetah
171 lines
6.5 KiB
Cheetah
{{define "bulk-section"}}
|
||
<section id="bulk-section" class="bulk-section">
|
||
|
||
<section class="tagbar" id="bulk-filterbar">
|
||
<form id="bulk-filter" class="search"
|
||
hx-get="/admin/bulk"
|
||
hx-target="#bulk-section"
|
||
hx-swap="outerHTML"
|
||
hx-trigger="keyup changed delay:200ms from:input[name=q], change from:select, change from:input[type=hidden], change from:input[name=show-archived]"
|
||
hx-push-url="true">
|
||
<input type="search" name="q" value="{{.Filter.Q}}" placeholder="search title, slug, content…" autocomplete="off">
|
||
|
||
<label>tag
|
||
<select name="tag" multiple size="3">
|
||
{{$selTags := .Filter.Tags}}
|
||
{{range .AllTags}}<option value="{{.}}" {{if contains $selTags .}}selected{{end}}>{{.}}</option>{{end}}
|
||
</select>
|
||
</label>
|
||
|
||
<label>mgmt
|
||
<select name="mgmt" multiple size="4">
|
||
{{$selM := .Filter.Management}}
|
||
<option value="mai" {{if contains $selM "mai"}}selected{{end}}>mai</option>
|
||
<option value="self" {{if contains $selM "self"}}selected{{end}}>self</option>
|
||
<option value="external" {{if contains $selM "external"}}selected{{end}}>external</option>
|
||
<option value="unmanaged" {{if contains $selM "unmanaged"}}selected{{end}}>unmanaged</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label>status
|
||
<select name="status" multiple size="3">
|
||
{{$selS := .Filter.Status}}
|
||
<option value="active" {{if contains $selS "active"}}selected{{end}}>active</option>
|
||
<option value="done" {{if contains $selS "done"}}selected{{end}}>done</option>
|
||
<option value="archived" {{if contains $selS "archived"}}selected{{end}}>archived</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="show-archived" value="1" {{if .Filter.ShowArchived}}checked{{end}}>
|
||
show archived
|
||
</label>
|
||
</form>
|
||
|
||
<p class="counts"><strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match</p>
|
||
</section>
|
||
|
||
{{with .Banner}}<p class="banner warn">{{.}}</p>{{end}}
|
||
|
||
<form id="bulk-actions"
|
||
method="post"
|
||
action="/admin/bulk/apply"
|
||
hx-post="/admin/bulk/apply"
|
||
hx-target="#bulk-section"
|
||
hx-swap="outerHTML"
|
||
hx-include="#bulk-filter">
|
||
|
||
<fieldset class="actions">
|
||
<legend>Bulk action <small>(applies to all checked rows)</small></legend>
|
||
<div class="action-row">
|
||
<label>+ tag <input name="add_tag" placeholder="tag-slug"></label>
|
||
<label>– tag <input name="remove_tag" placeholder="tag-slug"></label>
|
||
<label>set management
|
||
<select name="set_mgmt">
|
||
<option value="">—</option>
|
||
<option value="mai">mai</option>
|
||
<option value="self">self</option>
|
||
<option value="external">external</option>
|
||
<option value="clear">clear</option>
|
||
</select>
|
||
</label>
|
||
<label>set status
|
||
<select name="set_status">
|
||
<option value="">—</option>
|
||
<option value="active">active</option>
|
||
<option value="done">done</option>
|
||
<option value="archived">archived</option>
|
||
</select>
|
||
</label>
|
||
<label>public-listing
|
||
<select name="set_public">
|
||
<option value="">—</option>
|
||
<option value="make_public">Make public</option>
|
||
<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>
|
||
|
||
<table class="bulk">
|
||
<thead>
|
||
<tr>
|
||
<th><input type="checkbox" id="bulk-all" onclick="document.querySelectorAll('input[name=ids]').forEach(c=>c.checked=this.checked)"></th>
|
||
<th>slug</th>
|
||
<th>primary path</th>
|
||
<th>tags</th>
|
||
<th>mgmt</th>
|
||
<th>status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Rows}}
|
||
<tr id="bulk-row-{{.ID}}">
|
||
<td><input type="checkbox" name="ids" value="{{.ID}}"></td>
|
||
<td><a href="/i/{{.PrimaryPath}}">{{.Slug}}</a></td>
|
||
<td><small class="muted">{{.PrimaryPath}}</small></td>
|
||
<td class="cell-tags" id="cell-tags-{{.ID}}">
|
||
{{template "bulk-chip-tags" .}}
|
||
</td>
|
||
<td class="cell-mgmt" id="cell-mgmt-{{.ID}}">
|
||
{{template "bulk-chip-mgmt" .}}
|
||
</td>
|
||
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
|
||
</tr>
|
||
{{else}}
|
||
<tr><td colspan="6"><em>No items match. Loosen the filters.</em></td></tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</form>
|
||
</section>
|
||
{{end}}
|
||
|
||
{{define "bulk-chip-tags"}}
|
||
{{range .Tags}}
|
||
<span class="tag">{{.}}
|
||
<button class="chip-x"
|
||
hx-post="/admin/bulk/chip"
|
||
hx-vals='{"id":"{{$.ID}}","op":"remove","kind":"tag","value":"{{.}}"}'
|
||
hx-target="#cell-tags-{{$.ID}}"
|
||
hx-swap="innerHTML"
|
||
type="button"
|
||
title="remove tag">×</button>
|
||
</span>
|
||
{{end}}
|
||
{{/* No nested <form> — HTML forbids it. The input fires hx-post on
|
||
Enter; the +-button is a click trigger on the same endpoint. Both
|
||
carry id/op/kind via hx-vals; the input's own value field rides
|
||
along as name="value". */}}
|
||
<span class="chip-add">
|
||
<input name="value" placeholder="+tag" size="6"
|
||
id="chip-add-value-{{.ID}}"
|
||
hx-post="/admin/bulk/chip"
|
||
hx-target="#cell-tags-{{.ID}}"
|
||
hx-swap="innerHTML"
|
||
hx-trigger="keyup[key=='Enter']"
|
||
hx-vals='{"id":"{{.ID}}","op":"add","kind":"tag"}'>
|
||
<button type="button"
|
||
class="chip-add-btn"
|
||
hx-post="/admin/bulk/chip"
|
||
hx-target="#cell-tags-{{.ID}}"
|
||
hx-swap="innerHTML"
|
||
hx-include="#chip-add-value-{{.ID}}"
|
||
hx-vals='{"id":"{{.ID}}","op":"add","kind":"tag"}'
|
||
title="add tag">+</button>
|
||
</span>
|
||
{{end}}
|
||
|
||
{{define "bulk-chip-mgmt"}}
|
||
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||
{{if not .Management}}<span class="muted">unmanaged</span>{{end}}
|
||
{{end}}
|