Skip to content

feat(#193 follow-up): filter the capabilities registry by Lifebook#256

Merged
jayzalowitz merged 3 commits into
mainfrom
jayzalowitz/issue-193-capabilities-domain-filter
May 12, 2026
Merged

feat(#193 follow-up): filter the capabilities registry by Lifebook#256
jayzalowitz merged 3 commits into
mainfrom
jayzalowitz/issue-193-capabilities-domain-filter

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

Summary

Closes the "capability filter by domain on Capabilities page" item that PR #242 (#193 Child 1) explicitly deferred. The Capabilities page now carries a Lifebook dropdown alongside the existing category filter; selecting a Lifebook intersects the registry results with that Lifebook's suggestedCapabilities: registryId[] set.

What changed

One file: apps/web/public/js/pages/capabilities.js

  • Fetch /api/lifebooks/:userId alongside the existing capabilities + recipes + opt-ins data on render. Best-effort — failure degrades to "no Lifebook filter offered" rather than blocking the page.
  • New renderLifebookFilterDropdown(lifebooks) renders the <select> only when at least one visible Lifebook exists; hidden Lifebooks (hiddenAt set) are filtered out, matching the existing "hidden surfaces stay queryable in memory; surface visibility is the user's call" contract Lifebooks already follow.
  • New applyLifebookFilter(entries, lifebookDomain) is the load-bearing piece. Pure function — intersects the registry results with the selected Lifebook's suggestedCapabilities set. Three short-circuit cases documented in the function docstring: no filter selected, Lifebook not in cached list, Lifebook has empty suggestedCapabilities (extractor proposed nothing yet — show everything rather than collapse to zero).
  • Empty-state copy surfaces the active Lifebook name (No results found for the "Health" Lifebook.) so the user can immediately tell why nothing's showing.

Internal cleanup

The three renderRegistryResults(userId, q, category) call sites collapsed to a single renderRegistryResults(userId, readRegistryFilterState()) form via a new readRegistryFilterState() helper. Adding the Lifebook filter without this would have meant editing five separate call sites; the next filter to land only has to touch two places now (the dropdown markup and the state reader).

What this PR deliberately does NOT do

  • No DB migration. The intersection set already lives on lifebooks.suggested_capabilities, populated by the domain-extraction worker that shipped in PR feat(#193 Child 1): Emergent Lifebooks — domain extractor + dashboard surface #242. Adding a domain_name column on mcp_servers would have been the heavier approach; this one stays purely on the read side.
  • No server-side filter. The filter runs in JS after the API round-trip. The registry response is small (it's already pre-filtered by category) and lifebooks rarely have more than ~10 suggested capabilities each, so the client-side intersect is cheap.
  • No filter on the Installed section (yet). Today the filter only narrows the Browse Registry results — that's where users discover new capabilities and most want to narrow by Lifebook. Filtering Installed is a follow-up if usage suggests it.

Test plan

  • Smoke-tested in Chrome at localhost:3202/#/capabilities with mocked Lifebook + registry data:
    • All Lifebooks (initial): all 4 mocked entries shown.
    • Health Lifebook: filter narrows to cap-fitbit only (other Health-suggested entries aren't in the registry mock; the intersect handled it correctly).
    • Money Lifebook: filter narrows to cap-mint only.
    • Back to All Lifebooks: all 4 entries restored.
    • Hidden Lifebook: confirmed it doesn't appear in the dropdown options.
  • node --check clean on capabilities.js.

Smoke-test results captured live:

{
  "dropdownPresent": true,
  "dropdownOptions": ["", "Health", "Money"],  // "Hidden" correctly absent
  "allEntriesCount": 4,
  "healthEntries": ["cap-fitbit"],
  "moneyEntries": ["cap-mint"],
  "allBackEntries": ["cap-fitbit", "cap-mint", "cap-github", "cap-spotify"]
}

Notes for reviewers

  • applyLifebookFilter() is intentionally pure (no DOM reads, no module-level state mutation) so it's portable to a vitest harness if apps/web ever gets one. The three short-circuit cases (no filter selected / Lifebook missing / empty suggested list) are documented inline.
  • The "empty suggestedCapabilities means show everything, not collapse" call is the only judgment one here. The alternative ("extractor hasn't proposed anything yet, so this Lifebook filter should show zero") would have made the empty state harder to interpret — users would think the filter was broken. Worth flagging if you read it differently.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 11, 2026 15:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Lifebook-based filter to the Capabilities “Browse registry” section by fetching the user’s Lifebooks and intersecting registry results with the selected Lifebook’s suggestedCapabilities list. This completes the deferred “filter capabilities by domain/Lifebook” follow-up from #242 / #193.

Changes:

  • Fetch Lifebooks on Capabilities page render and conditionally render a Lifebook <select> filter.
  • Introduce a unified readRegistryFilterState() helper and update registry re-render call sites to use a single state object.
  • Apply client-side Lifebook intersection filtering and enhance empty-state messaging to reflect the active Lifebook filter.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
CHANGELOG.md Adds an unreleased changelog entry describing the Lifebook registry filter feature and UX behavior.
apps/web/public/js/pages/capabilities.js Fetches Lifebooks, renders a Lifebook filter dropdown, centralizes filter state reading, and intersects registry results with Lifebook suggestions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

_cachedDormant = capData.dormant ?? [];
_cachedRecipes = recipesData.recipes ?? [];
_cachedPendingOptIns = optInsData.optIns ?? [];
_cachedLifebooks = (lifebooksData?.lifebooks ?? []).filter((lb) => !lb.hiddenAt);
Comment on lines +257 to +261
document.getElementById('registry-category')?.addEventListener('change', () => {
renderRegistryResults(userId, readRegistryFilterState());
});
document.getElementById('registry-lifebook')?.addEventListener('change', () => {
renderRegistryResults(userId, readRegistryFilterState());
Comment on lines +581 to +582
* `suggestedCapabilities` set. Pure function so it's trivial to unit-test
* separately if a vitest harness for `apps/web` lands later.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment on lines +579 to +596
/**
* Intersect a registry-entries list with the selected Lifebook's
* `suggestedCapabilities` set. Pure function so it's trivial to unit-test
* separately if a vitest harness for `apps/web` lands later.
*
* Returns the input unchanged when:
* - No Lifebook is selected (empty string passed)
* - The Lifebook isn't in our cached list (defensive — should never happen)
* - The Lifebook has no suggested capabilities (empty registryId[] means
* "the domain extractor didn't propose anything yet" — better to show
* all than show nothing)
*/
function applyLifebookFilter(entries, lifebookDomain) {
if (!lifebookDomain) return entries;
const lb = _cachedLifebooks.find((x) => x.domainName === lifebookDomain);
if (!lb) return entries;
const allowed = lb.suggestedCapabilities ?? [];
if (allowed.length === 0) return entries;
Comment on lines +251 to 262
// Wire category + lifebook <select> change events. The singleton-delegator
// pattern in `handleCapabilitiesAction` handles clicks via `data-action`,
// but native `change` events on form controls don't bubble through the
// same path on every browser — wire them explicitly. Using
// `readRegistryFilterState()` keeps both selectors agreeing on the state
// they hand to renderRegistryResults.
document.getElementById('registry-category')?.addEventListener('change', () => {
renderRegistryResults(userId, readRegistryFilterState());
});
document.getElementById('registry-lifebook')?.addEventListener('change', () => {
renderRegistryResults(userId, readRegistryFilterState());
});
Comment thread CHANGELOG.md
Comment on lines +13 to +19
**No DB migration required.** The filter is purely client-side: the
intersection set already lives on `lifebooks.suggested_capabilities`
(populated by the domain-extraction worker that landed in #242). The
capabilities page now fetches `/api/lifebooks/:userId` alongside its
existing data and runs the intersect in `applyLifebookFilter()` — a
pure helper that's trivial to lift to a vitest harness if/when one
lands in `apps/web`.
jayzalowitz added a commit that referenced this pull request May 11, 2026
…t handlers, make filter truly pure

Three Copilot findings on PR #256 addressed:

1. Lifebook visibility filter now reads `hidden: boolean` (the actual
   API field) instead of the nonexistent `hiddenAt`. The previous
   `!lb.hiddenAt` was a no-op AND would have fail-opened if the
   endpoint ever returned hidden rows. The server-side listVisible()
   still does the real filtering; the client-side guard is defense
   in depth.

2. Removed data-action from the category + lifebook <select>
   elements. Each had an explicit change listener too, so the
   global click delegator double-fired renderRegistryResults on
   every dropdown-open. Change listener is now the sole entry
   point. Dead switch cases deleted.

3. applyLifebookFilter() is actually pure now — lifebooks is the
   third argument with a default that pulls from cache only at the
   call site. Future vitest harness can drive it without
   monkeypatching module state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jayzalowitz added a commit that referenced this pull request May 12, 2026
Copilot round-2 flagged that the CHANGELOG entry described
readRegistryFilterState() as "pure" — but it reads the current
dropdown / input values from the DOM, so it's stateful by definition.
Rewrote the bullet to describe it as "isolated" (one place to change
when filters are added) instead, which is the actual property the
helper provides.

The other two round-2 comments on this PR (applyLifebookFilter
"pure" docstring + data-action duplicate listeners) were both
already addressed in the round-1 fix-up (2c7eef2) — they were
re-flagged because Copilot was reading stale state.
@jayzalowitz

Copy link
Copy Markdown
Owner Author

Round-2 review reply:

Already fixed in round 1 (2c7eef2):

  • applyLifebookFilter now truly pure — lifebooks is the third argument with a cache-default at the call site.
  • data-action removed from both <select> elements; the explicit change listeners are the sole entry point. Dead switch cases (registry-filter-change, registry-category-change) deleted.

Round-2 finding fixed in eac5b7b:

  • CHANGELOG no longer describes readRegistryFilterState() as "pure" — it reads the DOM, so the description now calls it "isolated" instead.

jayzalowitz and others added 3 commits May 12, 2026 00:34
Closes the "capability filter by domain on Capabilities page" item PR
carries a Lifebook dropdown next to the existing category filter;
selecting a Lifebook intersects the registry results with that
Lifebook's suggestedCapabilities: registryId[] set.

No DB migration required — the intersection set already lives on
lifebooks.suggested_capabilities (populated by the domain-extraction
worker shipped in #242). The capabilities page fetches /api/lifebooks
alongside its existing data and applies applyLifebookFilter() — a
pure helper trivial to lift into a vitest harness if/when one lands
for apps/web.

UX details:
- Dropdown shows visible (non-hidden) Lifebooks only.
- When the user has zero Lifebooks, the dropdown is omitted entirely.
- Empty-result state surfaces the active Lifebook name in the copy.
- A Lifebook with empty suggestedCapabilities means "extractor
  proposed nothing yet" — shows everything rather than collapsing,
  distinguishing "haven't decided" from "decided nothing matches."

Internal cleanup: three renderRegistryResults() call sites collapsed
to (userId, readRegistryFilterState()) via a new readRegistryFilterState
helper. Adding the Lifebook filter without this would have meant
editing five separate call sites.

Test plan: smoke-tested in Chrome across three scenarios
(all-Lifebooks, Health-only, Money-only, switch back to all-Lifebooks).
Filter toggles correctly; hidden Lifebook stays out of the dropdown.
node --check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t handlers, make filter truly pure

Three Copilot findings on PR #256 addressed:

1. Lifebook visibility filter now reads `hidden: boolean` (the actual
   API field) instead of the nonexistent `hiddenAt`. The previous
   `!lb.hiddenAt` was a no-op AND would have fail-opened if the
   endpoint ever returned hidden rows. The server-side listVisible()
   still does the real filtering; the client-side guard is defense
   in depth.

2. Removed data-action from the category + lifebook <select>
   elements. Each had an explicit change listener too, so the
   global click delegator double-fired renderRegistryResults on
   every dropdown-open. Change listener is now the sole entry
   point. Dead switch cases deleted.

3. applyLifebookFilter() is actually pure now — lifebooks is the
   third argument with a default that pulls from cache only at the
   call site. Future vitest harness can drive it without
   monkeypatching module state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot round-2 flagged that the CHANGELOG entry described
readRegistryFilterState() as "pure" — but it reads the current
dropdown / input values from the DOM, so it's stateful by definition.
Rewrote the bullet to describe it as "isolated" (one place to change
when filters are added) instead, which is the actual property the
helper provides.

The other two round-2 comments on this PR (applyLifebookFilter
"pure" docstring + data-action duplicate listeners) were both
already addressed in the round-1 fix-up (2c7eef2) — they were
re-flagged because Copilot was reading stale state.
@jayzalowitz jayzalowitz force-pushed the jayzalowitz/issue-193-capabilities-domain-filter branch from eac5b7b to 23d3256 Compare May 12, 2026 04:35
@jayzalowitz jayzalowitz merged commit eab1339 into main May 12, 2026
7 checks passed
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