Skip to content

Taskboard: remember each project's filters - #11

Merged
MateoCerquetella merged 6 commits into
MateoCerquetella:mainfrom
RIP21:taskboard-sticky-filters
Aug 27, 2026
Merged

Taskboard: remember each project's filters#11
MateoCerquetella merged 6 commits into
MateoCerquetella:mainfrom
RIP21:taskboard-sticky-filters

Conversation

@RIP21

@RIP21 RIP21 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Taskboard's filter chips live in an in-memory React ref (app.tsx, browsePreferencesRef), so they survive switching projects inside one session and nothing else. Reload the panel and every chip is empty again. If your daily view is "assigned to me", you re-apply it by hand several times a day.

This persists filter state per project in the plugin's own database, alongside the existing board settings.

Design notes

Keyed per project, not per surface. Three surfaces render TrackerList with different in-memory scope keys (<projectId>, across-projects, right-panel:<projectId>). filterStateScopeId collapses them onto one storage key, so the board and a thread's Taskboard panel show the same filters for the same project. Seeing different filters for one project depending on which pane you were looking at seemed like a bug rather than a feature.

A separate table, not extra columns on project_board_settings. That row holds deliberate config edited in Manage; filter state is rewritten on every keystroke in the search box. Sharing a row would mean a routine filter change rewrites statusOrder on every save.

Stored server-side rather than in localStorage, even though the plugin already uses localStorage for sidebar width and last project. A remote bb connect browser session has a different localStorage than the desktop app, so filters would silently not follow the user between them.

work-schemas.ts is new and small. filter-state.ts needs workSourceSchema and workStateCategorySchema, and contract.ts re-exports filter-state.js. Since both build schemas at module top level, importing them from contract.ts risks a temporal-dead-zone crash at plugin load. Moving the enums to a leaf module keeps filter-state.ts free of any import from contract.ts. trackerViewSchema moved there too, so filter-state.ts needs exactly one local import. contract.ts and board-settings.ts re-export everything, so no existing importer changes.

Things worth knowing during review

Saves are serialized, not just debounced. rpc.call is an independent HTTP POST with no ordering guarantee, invokeRpcHandler has no per-key mutex, and the handler awaits a variable-latency bb.sdk.projects.list() before its synchronous SQLite write. Two saves fired 500ms apart can therefore commit out of order, leaving the older payload on disk. A response-dropping revision guard does not fix this, since you cannot un-write a committed row; chaining the requests does.

A save cannot fire before the first load resolves. Without that gate, the mount-time fingerprint mismatch schedules a write at t+500ms that overwrites the saved row with defaults whenever the RPCs take longer than the debounce. The gate opens only inside .then(), never .finally(), so a failed load leaves persistence inert rather than destroying a row it simply failed to read.

One .ts import specifier, in filter-state.ts. Every pre-existing tested module imports local files with import type, which the type stripper erases, so this never came up before. filter-state.ts is the first test-loaded module with a runtime local import, and node --test --experimental-strip-types will not resolve ./x.js to x.ts. It is confined to that one new file; no existing file changed import style.

Known limitations, deliberately not addressed

  • Each instance writes its whole snapshot and saveBoardFilterState publishes no realtime signal, so a stale instance in a second client can clobber a newer write. The same staleness already applies to board settings.
  • rpc.call takes no abort signal, so a request that never settles stalls the serialization chain until the component remounts. Racing it against a timeout would reintroduce out-of-order commits, so it is left alone and documented in-code.

Verification

npm run check passes from the workspace root. New unit tests cover the schema, scope-key collapsing, normalization, and fingerprinting. Store and RPC layers are verified by typecheck and build, matching this repo's existing approach where every test is a pure-function test.

Manually verified in a running bb against a Linear-backed project: filters survive a reload, the board and a thread's Taskboard panel share them, "Clear filters" persists, and typing produces one save rather than one per keystroke.

Filter presets build on filter-state.ts and will follow in a separate PR.

🤖 Generated with Claude Code

RIP21 added 5 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.
@RIP21

RIP21 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

I apologize in advance if that PR is annoying in any way :) But resetting filters every time I go the the thread and return back, was super annoying, figured others will benefit from that too :)

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>
@RIP21

RIP21 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a fix for a bug found while dogfooding this branch.

Returning to the board from a thread cleared that project's filters, while other projects kept theirs. The saved row was never corrupted — the state was fetched correctly and then discarded.

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, caching 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 — visible in the plugin log as a duplicate getBoardFilterState about 30ms after the first.

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

Two changes, both at the source:

  • initialPreferences is captured in a ref at mount. It seeds useState; it has no business re-running the load.
  • The recording effect refuses to cache a snapshot until the load has resolved, so an empty placeholder can never masquerade as real in-session state.

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

@MateoCerquetella

Copy link
Copy Markdown
Owner

Thanks I will merge after conflicts!

@MateoCerquetella
MateoCerquetella merged commit bbdb3b8 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