Skip to content

Taskboard: named filter presets - #12

Merged
MateoCerquetella merged 13 commits into
MateoCerquetella:mainfrom
RIP21:taskboard-filter-presets
Aug 27, 2026
Merged

Taskboard: named filter presets#12
MateoCerquetella merged 13 commits into
MateoCerquetella:mainfrom
RIP21:taskboard-filter-presets

Conversation

@RIP21

@RIP21 RIP21 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Save the current filters under a name and reapply them in one click, from the board or the CLI. Builds on #11.

Stacked on #11. This branch is cut from that one, so its diff against main currently includes #11's commits. GitHub will not let me base a cross-fork PR on a branch that lives only in my fork. Once #11 merges, this diff reduces to the preset work alone — the seven commits from Add Taskboard filter preset module onward. Review those; everything before them is #11.

What it does

A Presets chip at the head of the filter bar lists the project's presets and applies one on click. "Save current filters as..." names the current chips. Rename, reorder, and delete live in Manage → Board preferences.

The same thing from the CLI:

bb taskboard presets list [--project <proj_id>] [--json]
bb taskboard presets save <name> --from-state <json> [--project <proj_id>] [--json]
bb taskboard presets rename <name> <new-name> [--project <proj_id>] [--json]
bb taskboard presets delete <name> [--project <proj_id>] [--json]
bb taskboard list --preset <name> [--project <proj_id>] [--json]

list --preset is the one that earns its keep. A preset spans both filtering layers, so source, stateCategories, and query go into store.list, and the rest is applied with filterWorkItemsByAttributes from browse.ts — the exact function the UI filters with, so the two cannot drift. An explicit --source or --query beats the preset's value. On a real project this narrows 200 cached items across 10 assignees down to the 37 belonging to one.

presets save --from-state is the weak verb and I want to be upfront about it: there are no chips to capture from a terminal, so the JSON is hand-written and nobody will use it in practice. It exists because the README promises human and agent parity and a half-CRUD surface is worse than a complete one. Happy to drop it if you would rather the CLI only read presets.

Design notes

No preset is ever applied automatically, and there is deliberately no default preset. #11 already makes filters sticky per project, so it decides what the board opens with. A default preset would compete with it for the same job, and two mechanisms racing to answer "what do I see on open" is worse than one.

Applying a preset is an ordinary filter change. It drives the same setters the user's own clicks do, so #11's debounced save persists it with no separate write path and no special case. Clicking "My work" today therefore means the board still shows it tomorrow.

A preset carries the List/Kanban view, and applying one replaces every field including clearing ones the preset leaves empty. A partial apply would make the result depend on what happened to be set already, which is not a preset.

Nothing tracks an "active" preset. No dirty state, no "update preset" prompt, no current selection on the chip. That would mean reconciling filter state against every preset on each change, and it is not needed to make one-click apply useful.

Presets store literal filter values. Taskboard has no concept of the current user — no source fetches viewer identity — so "assigned to me" is whatever assignee string the chip held at save time. Resolving a real @me token would need a per-source identity lookup and cache; a reasonable follow-up, out of scope here.

Implementation notes for review

Ordering and naming rules live in a pure module, filter-presets.ts, so they are unit-testable without a database. This repo has no DB-backed test and every existing test is a pure-function test, so the store stays thin deliberately rather than accidentally.

normalizePresetName uses locale-independent toLowerCase(), unlike board-settings.ts which uses toLocaleLowerCase() for status names. That one is an in-memory check; this value is persisted as name_normalized and backs a UNIQUE constraint, so it must not vary by host locale. Under tr-TR the locale-aware form folds U+0130 to a bare i, which would make two names collide on one machine and not another.

A preset row that fails to parse hides one preset rather than breaking the list. reorderFilterPresets therefore validates against the parseable subset, not the raw rows — otherwise a single corrupt row would reject every reorder for that project forever, since the client can only send ids it was shown.

deleteFilterPreset on an unknown id is a deliberate idempotent no-op, while save and reorder throw. Commented at the definition so it does not read as an oversight. Callers resync from the returned list rather than trusting a local delta.

The Manage mutations are serialized behind a single in-flight flag. Rename, delete, and move each return a fresh authoritative list, so a slower earlier response landing after a faster later one would clobber it.

The CLI keys on names but every store method keys on id, so a single resolvePresetByName helper resolves case-insensitively via normalizePresetName and, when it misses, names the presets that do exist. rename resends the preset's existing state, since saveFilterPreset always requires it.

Verification

npm run check passes from the workspace root. Unit tests cover name normalization, the length bounds, and reorder permutation validation.

Exercised against a running bb, through the UI, directly over RPC, and through the CLI: create, case-insensitive duplicate rejection, case-only self-rename accepted, list ordering, reorder rejection on a non-permutation, delete, idempotent re-delete, list --preset narrowing, explicit-flag precedence, the unknown-preset error naming what is available, and the empty-project message.

🤖 Generated with Claude Code

RIP21 and others added 13 commits August 21, 2026 17:11
Extracts workSourceSchema, workStateCategorySchema, and trackerViewSchema
into a leaf work-schemas.ts module with no local imports of its own
(re-exported from contract.ts and board-settings.ts unchanged for existing
importers) and adds filter-state.ts, defining the persisted board filter
shape, per-project scope-key collapsing, and normalization/fingerprinting
so unordered array changes don't look like state changes.

work-schemas.ts exists because filter-state.ts is the first module in this
plugin that is both directly unit-tested and dependent on real
(non-type-only) values from a sibling module. Every other tested module
only crosses files via `import type`, which is erased before
node --experimental-strip-types ever tries to resolve it; that loader
never rewrites a .js specifier to a sibling .ts file, so a real value
import chain through board-settings.ts (which itself imports
credential-contract.js and browse.js) would not resolve under the raw
test runner. Concentrating the primitive enums in a dependency-free leaf
module lets filter-state.ts import real values by depending on that leaf
directly (via an explicit .ts specifier, since it's a new file loaded raw
by tests) without needing any other existing file to change its import
style. board-settings.ts keeps its original .js specifiers throughout and
only gains a re-export of trackerViewSchema/TrackerView from the new leaf.

Review fixes folded in:
- Added filter-state.ts and work-schemas.ts to package.json files, in
  alphabetical position; npm pack was previously missing work-schemas.ts
  even though contract.ts and board-settings.ts both depend on it.
- Extended the normalization test to assert every one of the seven array
  fields sorts and dedupes independently, with a distinct value per field,
  so a copy/paste cross-wiring in normalizeBoardFilterState fails loudly.
- filterStateScopeId no longer aliases an empty scope onto the
  across-projects row; it now falls through and is rejected downstream by
  bbProjectIdSchema like any other malformed scope, with a test and a
  comment noting the (unguarded) collision risk if a real bb project were
  ever literally named proj_across_projects.
- Removed the unused `type WorkStateCategory` from contract.ts's value
  import block (only the separate `export type` line needs it).
- Pinned the strictness test to safeParse + the `unrecognized_keys` issue
  code instead of an unpredicated assert.throws.
Returning to the board from a thread cleared that project's filters while
leaving other projects intact. The saved row was never corrupted: the state
was fetched correctly and then thrown away.

The parent re-reads initialPreferences from a mutable Map on every render and
passes it as a prop, and it sat in the load effect's dependency array. The
effect that records preferences ran on mount, before the load resolved, so it
cached an empty placeholder. That flipped initialPreferences from undefined to
a truthy empty object, re-ran the load, cancelled the in-flight fetch, and the
second pass then hit `if (initialPreferences) return` and refused to apply the
saved state. Two fetches, zero applications.

Switching projects was unaffected because it changes projectId, landing on a
scope whose cache entry was absent or already real. Only the thread round trip
re-rendered the parent without changing project.

- Capture initialPreferences in a ref at mount. It seeds useState; it has no
  business re-running the load.
- Refuse to cache a preferences snapshot until the load has resolved, so an
  empty placeholder can never masquerade as in-session state.

Confirmed against the running plugin: one getBoardFilterState per mount
instead of two, and the filters survive the round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MateoCerquetella
MateoCerquetella merged commit 8268ac8 into MateoCerquetella:main Aug 27, 2026
MateoCerquetella added a commit that referenced this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants