From 0819fb442b1543e5a3a2520bb4439102c84ed1f7 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Mon, 19 Oct 2026 10:11:19 +0100 Subject: [PATCH] The filters are now remembered in localStorage Every filter, the days and title included, is stored under job_search.filters when it changes and read back when the page loads. A stored field that isn't valid (missing, from an older version or edited by hand) falls back to its default, keeping the rest. Clear filters still goes back to the defaults. --- web/CODE_CONVENTIONS.md | 4 +- web/src/App.tsx | 8 +++- web/src/lib/filterOpportunities.test.ts | 43 +++++++++++++++++ web/src/lib/filterOpportunities.ts | 61 ++++++++++++++++++++++++- web/src/lib/storage.ts | 1 + 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/web/CODE_CONVENTIONS.md b/web/CODE_CONVENTIONS.md index e2d1c31..8ee99a8 100644 --- a/web/CODE_CONVENTIONS.md +++ b/web/CODE_CONVENTIONS.md @@ -357,8 +357,8 @@ external keep its spelling (`Authorization`, CSS `color`). (`readStored`, `writeStored`), which never throws: storage can be disabled or full, and a preference then just isn't kept. - Keys are listed in `STORAGE_KEYS`, prefixed with the app's name. -- Only preferences are stored (theme, panel state); data comes from the - API every time. +- Only preferences are stored (theme, panel state, filters); data comes + from the API every time. ## Dependencies diff --git a/web/src/App.tsx b/web/src/App.tsx index 2003bf9..1c5f82c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,10 +10,13 @@ import type { OpportunityFilters } from '@/lib/filterOpportunities'; import { emptyFilters, filterOpportunities, + parseFilters, releasedFrom, + serialiseFilters, statusOptions, } from '@/lib/filterOpportunities'; import { formatCount } from '@/lib/format'; +import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage'; import { createLiveUpdates } from '@/primitives/createLiveUpdates'; import { createClassifications, createOpportunities } from '@/primitives/createOpportunities'; import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations'; @@ -27,7 +30,10 @@ const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text /** The page: the filters, the table of opportunities, and toasts for errors. */ export function App(): JSX.Element { const toasts = createToasts(); - const [filters, setFilters] = createSignal(emptyFilters()); + const [filters, setFilters] = createSignal( + parseFilters(readStored(STORAGE_KEYS.filters)), + ); + createEffect(() => writeStored(STORAGE_KEYS.filters, serialiseFilters(filters()))); // A memo of a string, so other filters' changes, which give the same // time, don't load the opportunities again. const releasedSince = createMemo(() => releasedFrom(filters().releasedWithinDays)); diff --git a/web/src/lib/filterOpportunities.test.ts b/web/src/lib/filterOpportunities.test.ts index ebcfa1e..d12b1a5 100644 --- a/web/src/lib/filterOpportunities.test.ts +++ b/web/src/lib/filterOpportunities.test.ts @@ -7,7 +7,9 @@ import { emptyFilters, filterOpportunities, NOT_MARKED, + parseFilters, releasedFrom, + serialiseFilters, statusLabel, statusOptions, } from './filterOpportunities'; @@ -96,3 +98,44 @@ test('statusOptions lists the known statuses, then others sorted', () => { expect(options[0]).toBe('planning'); expect(statusLabel('active')).toBe('Active'); }); + +describe('stored filters', () => { + test('read back as they were stored', () => { + const filters: OpportunityFilters = { + releasedWithinDays: 30, + title: 'cloud', + statuses: new Set(['active', 'withdrawn']), + marks: new Set(['interested', NOT_MARKED]), + classificationIds: new Set([1, 42]), + smeSuitable: true, + showHidden: true, + }; + expect(parseFilters(serialiseFilters(filters))).toEqual(filters); + }); + + test.each([ + { name: 'nothing stored', text: null }, + { name: 'text that isn’t JSON', text: '{not json' }, + { name: 'JSON that isn’t an object', text: '[1, 2]' }, + ])('$name reads as the starting filters', ({ text }) => { + expect(parseFilters(text)).toEqual(emptyFilters()); + }); + + test('fields that aren’t valid keep their starting values', () => { + const filters = parseFilters( + JSON.stringify({ + releasedWithinDays: 0, + title: 5, + statuses: 'active', + marks: ['won', 'not-a-mark'], + classificationIds: [3, '4', 1.5], + smeSuitable: 'yes', + }), + ); + expect(filters).toEqual({ + ...emptyFilters(), + marks: new Set(['won']), + classificationIds: new Set([3]), + }); + }); +}); diff --git a/web/src/lib/filterOpportunities.ts b/web/src/lib/filterOpportunities.ts index c6bf07d..a7fb504 100644 --- a/web/src/lib/filterOpportunities.ts +++ b/web/src/lib/filterOpportunities.ts @@ -1,4 +1,5 @@ import type { Annotation, OpportunityMark } from '@/types/Annotations'; +import { isOpportunityMark } from '@/types/Annotations'; import type { Opportunity, OpportunityStatus } from '@/types/Opportunities'; import { OPPORTUNITY_STATUSES } from '@/types/Opportunities'; import { parseDate } from './format'; @@ -31,8 +32,9 @@ export interface OpportunityFilters { } /** - * The filters to start with: the last DEFAULT_RELEASED_WITHIN_DAYS days' - * opportunities, apart from hidden ones. + * The filters with nothing chosen, which Clear filters goes back to: the + * last DEFAULT_RELEASED_WITHIN_DAYS days' opportunities, apart from hidden + * ones. */ export const emptyFilters = (): OpportunityFilters => ({ releasedWithinDays: DEFAULT_RELEASED_WITHIN_DAYS, @@ -44,6 +46,61 @@ export const emptyFilters = (): OpportunityFilters => ({ showHidden: false, }); +/** The filters as text to store, with the sets as arrays. */ +export function serialiseFilters(filters: OpportunityFilters): string { + return JSON.stringify({ + ...filters, + statuses: [...filters.statuses], + marks: [...filters.marks], + classificationIds: [...filters.classificationIds], + }); +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +/** The items of value that pass isItem, or undefined if it isn't an array. */ +function arrayOf(value: unknown, isItem: (item: unknown) => item is T): T[] | undefined { + return Array.isArray(value) ? value.filter(isItem) : undefined; +} + +const isString = (value: unknown): value is string => typeof value === 'string'; +const isMarkFilter = (value: unknown): value is MarkFilter => + value === NOT_MARKED || isOpportunityMark(value); +const isId = (value: unknown): value is number => Number.isInteger(value); +const isDays = (value: unknown): value is number => Number.isInteger(value) && Number(value) >= 1; + +/** + * Filters read back from serialiseFilters' text. It may be missing, from an + * older version of the app or edited by hand, so each field that isn't + * valid is left as emptyFilters has it. + */ +export function parseFilters(text: string | null): OpportunityFilters { + const filters = emptyFilters(); + let stored: unknown; + try { + stored = JSON.parse(text ?? 'null'); + } catch { + return filters; + } + if (!isRecord(stored)) return filters; + + const statuses = arrayOf(stored.statuses, isString); + const marks = arrayOf(stored.marks, isMarkFilter); + const classificationIds = arrayOf(stored.classificationIds, isId); + return { + releasedWithinDays: isDays(stored.releasedWithinDays) + ? stored.releasedWithinDays + : filters.releasedWithinDays, + title: isString(stored.title) ? stored.title : filters.title, + statuses: statuses ? new Set(statuses) : filters.statuses, + marks: marks ? new Set(marks) : filters.marks, + classificationIds: classificationIds ? new Set(classificationIds) : filters.classificationIds, + smeSuitable: typeof stored.smeSuitable === 'boolean' ? stored.smeSuitable : filters.smeSuitable, + showHidden: typeof stored.showHidden === 'boolean' ? stored.showHidden : filters.showHidden, + }; +} + /** * How many separate filters are changed from emptyFilters, for the collapsed * panel's badge. diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts index facbde1..ea91489 100644 --- a/web/src/lib/storage.ts +++ b/web/src/lib/storage.ts @@ -23,4 +23,5 @@ export function writeStored(key: string, value: string): void { export const STORAGE_KEYS = { theme: 'job_search.theme', filtersOpen: 'job_search.filtersOpen', + filters: 'job_search.filters', } as const;