From 185a9914eef15e2fa73d9ca32b5c2fc3b3ae2a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:37:46 +0000 Subject: [PATCH] fix(seating): Vegetarian and Vegan together show both, not nobody Seating's guest filter chips were all ANDed. A guest has one diet and one side, so ticking Vegetarian and Vegan always showed an empty list, and ticking both partners' chips showed only the guests on both sides. Chips in one category (diet, side) now widen the list, and chips in different categories still narrow it: Vegan + Alex's is Alex's vegans. Closes #65 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VhFp1PtVBwgmxST9ZrRTEs --- .../apps/tableaux/utils/guestFilters.test.js | 28 +++++++++++++++++ suite/apps/tableaux/utils/guestFilters.ts | 31 ++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/suite/apps/tableaux/utils/guestFilters.test.js b/suite/apps/tableaux/utils/guestFilters.test.js index e3ea9da8..574b8be4 100644 --- a/suite/apps/tableaux/utils/guestFilters.test.js +++ b/suite/apps/tableaux/utils/guestFilters.test.js @@ -9,3 +9,31 @@ describe('the Unassigned chip', () => { expect(matchesFilters(declined, ['unassigned'])).toBe(false) }) }) + +// Chips in one category widen the list; chips in different categories narrow +// it. A guest has one diet and one side, so ANDing two chips from the same +// category could only ever show nobody, or only the guests on both sides. +describe('combining chips', () => { + const veggie = { dietary: 'vegetarian', side: 'a', assignedTableId: null, rsvpStatus: 'confirmed' } + const vegan = { dietary: 'vegan', side: 'b', assignedTableId: 't1', rsvpStatus: 'confirmed' } + const neither = { dietary: '', side: 'both', assignedTableId: null, rsvpStatus: 'confirmed' } + + it('shows vegetarians and vegans when both diet chips are on', () => { + expect(matchesFilters(veggie, ['vegetarian', 'vegan'])).toBe(true) + expect(matchesFilters(vegan, ['vegetarian', 'vegan'])).toBe(true) + expect(matchesFilters(neither, ['vegetarian', 'vegan'])).toBe(false) + }) + + it("shows everyone from either side when both partners' chips are on", () => { + expect(matchesFilters(veggie, ['a', 'b'])).toBe(true) + expect(matchesFilters(vegan, ['a', 'b'])).toBe(true) + expect(matchesFilters(neither, ['a', 'b'])).toBe(true) + }) + + it('still narrows across categories', () => { + expect(matchesFilters(veggie, ['vegan', 'a'])).toBe(false) + expect(matchesFilters(vegan, ['vegan', 'b'])).toBe(true) + expect(matchesFilters(vegan, ['vegetarian', 'vegan', 'unassigned'])).toBe(false) + expect(matchesFilters(veggie, ['vegetarian', 'vegan', 'unassigned'])).toBe(true) + }) +}) diff --git a/suite/apps/tableaux/utils/guestFilters.ts b/suite/apps/tableaux/utils/guestFilters.ts index fc67d4dd..4d3f8f74 100644 --- a/suite/apps/tableaux/utils/guestFilters.ts +++ b/suite/apps/tableaux/utils/guestFilters.ts @@ -37,15 +37,32 @@ const PREDICATES: Record boolean> = { notes: (g) => !!(g.notes && g.notes.trim()), } -// TODO(ux-audit): matchesFilters ANDs every active filter (below), which -// produces silent, misleading results for two real combos: Vegetarian+Vegan -// ticked together is a guaranteed-empty result (dietary is a single value, -// can never match both); and both side chips together only matches -// side==='both', not the union a user would expect from ticking two side -// chips. See tmp/ux-audit.md #G7. +/** + * Chips in one category widen the list, and chips in different categories + * narrow it: Vegetarian + Vegan is everyone on either diet, and Vegan + Alex's + * is Alex's vegans. A guest has one diet and one side, so ANDing two chips of + * the same category could only ever show nobody, or just the guests on both + * sides. + */ +const CATEGORY: Record = { + unassigned: 'seat', + a: 'side', + b: 'side', + vegetarian: 'diet', + vegan: 'diet', + 'gluten-free': 'diet', + notes: 'notes', +} + export function matchesFilters(guest: Guest, filters: readonly string[] | null | undefined): boolean { if (!filters || filters.length === 0) return true - return filters.every((f) => (f in PREDICATES ? PREDICATES[f as FilterKey](guest) : true)) + const byCategory = new Map() + for (const f of filters) { + if (!(f in PREDICATES)) continue + const key = f as FilterKey + byCategory.set(CATEGORY[key], [...(byCategory.get(CATEGORY[key]) ?? []), key]) + } + return [...byCategory.values()].every((keys) => keys.some((key) => PREDICATES[key](guest))) } export function matchesSearch(guest: Guest, group: Pick | null | undefined, query: string): boolean {