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.
This commit is contained in:
bwilliams committed 2026-10-19 10:11:19 +01:00
1 parent 746fa0a9c1
commit 0819fb442b
5 files changed
+112 -5

No files matched your search

+2 -2
View File
@@ -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
+7 -1
View File
@@ -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<OpportunityFilters>(emptyFilters());
const [filters, setFilters] = createSignal<OpportunityFilters>(
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));
+43
View File
@@ -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]),
});
});
});
+59 -2
View File
@@ -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<string, unknown> =>
typeof value === 'object' && value !== null;
/** The items of value that pass isItem, or undefined if it isn't an array. */
function arrayOf<T>(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.
+1
View File
@@ -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;