From 6fbf7e56b2c12fe91b8efc88e3d92cd6659f10c1 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Mon, 19 Oct 2026 10:19:34 +0100 Subject: [PATCH] The classification filter now offers only the loaded opportunities' classifications It lists the classifications the opportunities in the days window have, not every one stored, plus any already chosen, so a selection stays visible and can be removed when nothing in the window has it. The other filters don't narrow it, so choosing a status doesn't make classifications vanish. --- web/README.md | 5 +++-- web/src/App.tsx | 13 ++++++++++++- web/src/lib/classifications.test.ts | 14 +++++++++++++- web/src/lib/classifications.ts | 19 ++++++++++++++++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/web/README.md b/web/README.md index 40cfe2a..92cb04c 100644 --- a/web/README.md +++ b/web/README.md @@ -69,8 +69,9 @@ again. The list is fetched a page (100) at a time until there are no more. The marks are `interested`, `skipped`, `bid_submitted`, `lost`, `won` and `hidden`; -Won and Lost exclude each other (409). The classifications are the ones the -filter offers: only those some opportunity has. The client fetches the CSRF +Won and Lost exclude each other (409). The classifications are every one some +stored opportunity has; the filter offers only those the loaded opportunities +(the days window) have, and any already chosen. The client fetches the CSRF token when it first needs it, and a new one if a request is refused for it. ## Where things are diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c5f82c..34fe463 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { OpportunityTable } from '@/components/OpportunityTable'; import { buttonPrimary } from '@/components/styles'; import { ThemeToggle } from '@/components/ThemeToggle'; import { ToastList } from '@/components/ToastList'; +import { classificationsInUse } from '@/lib/classifications'; import type { OpportunityFilters } from '@/lib/filterOpportunities'; import { emptyFilters, @@ -44,6 +45,16 @@ export function App(): JSX.Element { const theme = createTheme(); const statuses = createMemo(() => statusOptions(opportunities.items())); + // Only the classifications of the opportunities loaded (the days window), + // not those the other filters leave: choosing a status shouldn't make + // classifications vanish from the picker. + const pickable = createMemo(() => + classificationsInUse( + classifications.items(), + opportunities.items(), + filters().classificationIds, + ), + ); // annotations.get reads from a store, so hiding an opportunity re-runs this. const rows = createMemo(() => filterOpportunities(opportunities.items(), filters(), annotations.get), @@ -89,7 +100,7 @@ export function App(): JSX.Element { onChange={setFilters} onClear={clear} statuses={statuses()} - classifications={classifications.items()} + classifications={pickable()} isLoadingClassifications={classifications.isLoading()} classificationsError={classifications.error()} onRetryClassifications={classifications.reload} diff --git a/web/src/lib/classifications.test.ts b/web/src/lib/classifications.test.ts index 5f07e2e..ff79816 100644 --- a/web/src/lib/classifications.test.ts +++ b/web/src/lib/classifications.test.ts @@ -1,7 +1,8 @@ import { expect, test } from 'vitest'; import type { ClassificationCode } from '@/types/Opportunities'; -import { applyClassificationChanges } from './classifications'; +import { applyClassificationChanges, classificationsInUse } from './classifications'; +import { testOpportunity } from './testOpportunity'; const code = (id: number, value: string): ClassificationCode => ({ id, @@ -27,3 +28,14 @@ test('an added code replaces one with the same id', () => { }); expect(after).toEqual([code(1, '03000001')]); }); + +test('classificationsInUse keeps those the opportunities have, and the selected ones', () => { + const all = [code(1, '03000000'), code(2, '45000000'), code(3, '72000000'), code(4, '90000000')]; + const opportunities = [ + testOpportunity('a', { classifications: [code(3, '72000000')] }), + testOpportunity('b', { classifications: [code(1, '03000000'), code(3, '72000000')] }), + testOpportunity('c', { classifications: null }), + ]; + const inUse = classificationsInUse(all, opportunities, new Set([4])); + expect(inUse.map((c) => c.id)).toEqual([1, 3, 4]); +}); diff --git a/web/src/lib/classifications.ts b/web/src/lib/classifications.ts index edab7b6..84afa18 100644 --- a/web/src/lib/classifications.ts +++ b/web/src/lib/classifications.ts @@ -1,5 +1,5 @@ import type { ClassificationsUpdatedPayload } from '@/types/Events'; -import type { ClassificationCode } from '@/types/Opportunities'; +import type { ClassificationCode, Opportunity } from '@/types/Opportunities'; /** * The classifications after change: without the removed ids, with the added @@ -15,3 +15,20 @@ export function applyClassificationChanges( (first, second) => first.code.localeCompare(second.code) || first.id - second.id, ); } + +/** + * The classifications some of the opportunities have, and those whose ids + * are selected, so a selection stays visible (and can be removed) when no + * opportunity loaded has it. + */ +export function classificationsInUse( + classifications: readonly ClassificationCode[], + opportunities: readonly Opportunity[], + selected: ReadonlySet, +): ClassificationCode[] { + const used = new Set(selected); + for (const opportunity of opportunities) { + for (const code of opportunity.classifications ?? []) used.add(code.id); + } + return classifications.filter((code) => used.has(code.id)); +}