Added a "Published in the last X days" filter, applied by the backend

GET /api/opportunities takes released_from, an RFC 3339 time, and lists
only the opportunities whose latest release is at or after it. It works
with paging and with changed_after, which keeps the same batch either
way; an invalid value is 400.

The frontend's filter defaults to 7 days, from midnight UK time, and
sends it with every load and catch-up, so a year of opportunities isn't
downloaded to show a week's. Changing the days loads the list again;
Clear filters goes back to 7.
This commit is contained in:
bwilliams committed 2026-10-19 10:05:45 +01:00
1 parent b8244da2e5
commit 746fa0a9c1
19 files changed
+333 -64

No files matched your search

+16 -4
View File
@@ -7,7 +7,12 @@ import { buttonPrimary } from '@/components/styles';
import { ThemeToggle } from '@/components/ThemeToggle';
import { ToastList } from '@/components/ToastList';
import type { OpportunityFilters } from '@/lib/filterOpportunities';
import { emptyFilters, filterOpportunities, statusOptions } from '@/lib/filterOpportunities';
import {
emptyFilters,
filterOpportunities,
releasedFrom,
statusOptions,
} from '@/lib/filterOpportunities';
import { formatCount } from '@/lib/format';
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
@@ -15,17 +20,22 @@ import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnno
import { createTheme } from '@/primitives/createTheme';
import { createToasts } from '@/primitives/createToasts';
const days = (count: number): string => (count === 1 ? 'day' : `${formatCount(count)} days`);
const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text-center';
/** The page: the filters, the table of opportunities, and toasts for errors. */
export function App(): JSX.Element {
const toasts = createToasts();
const opportunities = createOpportunities();
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
// 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));
const opportunities = createOpportunities(releasedSince);
const classifications = createClassifications();
const annotations = createOpportunityAnnotations(opportunities.items, toasts.show);
createLiveUpdates({ opportunities, classifications, annotations });
const theme = createTheme();
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
const statuses = createMemo(() => statusOptions(opportunities.items()));
// annotations.get reads from a store, so hiding an opportunity re-runs this.
@@ -107,7 +117,9 @@ export function App(): JSX.Element {
)}
</Match>
<Match when={opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>There are no opportunities yet.</p>
<p class={`${statePanel} text-muted`}>
No opportunities were published in the last {days(filters().releasedWithinDays)}.
</p>
</Match>
<Match when={rows().length === 0}>
<div class={`${statePanel} text-muted`}>
+28 -3
View File
@@ -62,7 +62,7 @@ describe('listOpportunities', () => {
});
};
const loaded = await api.listOpportunities();
const loaded = await api.listOpportunities('');
expect(sent.map((request) => request.url)).toEqual([
'/api/opportunities?offset=0&limit=100',
@@ -73,6 +73,18 @@ describe('listOpportunities', () => {
expect(loaded.batch).toBe('batch-0');
expect(sent[0]?.headers.get('Accept')).toBe('application/json');
});
test('asks only for those released since released_from', async () => {
const { api } = await loadClient();
respond = () =>
json({ opportunities: [], offset: 0, limit: 100, has_more: false, batch: 'b1' });
await api.listOpportunities('2026-10-12T00:00:00+01:00');
expect(sent[0]?.url).toBe(
'/api/opportunities?offset=0&limit=100&released_from=2026-10-12T00%3A00%3A00%2B01%3A00',
);
});
});
describe('listChangedOpportunities', () => {
@@ -87,19 +99,32 @@ describe('listChangedOpportunities', () => {
batch: 'newer',
});
const changes = await api.listChangedOpportunities('a b');
const changes = await api.listChangedOpportunities('a b', '');
expect(sent[0]?.url).toBe('/api/opportunities?changed_after=a%20b');
expect(changes.batch).toBe('newer');
expect(changes.opportunities.map((o) => o.uuid)).toEqual(['ocds-1']);
});
test('asks only for those released since released_from', async () => {
const { api } = await loadClient();
respond = () => json({ opportunities: [], offset: 0, limit: 0, has_more: false, batch: 'b2' });
await api.listChangedOpportunities('b1', '2026-10-11T23:00:00.000Z');
expect(sent[0]?.url).toBe(
'/api/opportunities?changed_after=b1&released_from=2026-10-11T23%3A00%3A00.000Z',
);
});
test('an unknown batch is an ApiError with status 410', async () => {
const client = await loadClient();
respond = () =>
json({ type: 'about:blank', status: 410, title: 'Gone', detail: 'unknown batch' }, 410);
const err: unknown = await client.api.listChangedOpportunities('old').catch((e: unknown) => e);
const err: unknown = await client.api
.listChangedOpportunities('old', '')
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(client.ApiError);
expect(err).toMatchObject({ status: 410, message: 'unknown batch' });
+18 -7
View File
@@ -13,6 +13,7 @@ import type {
*
* GET {base}/opportunities?offset=&limit= -> OpportunityPage
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
* both with &released_from={RFC 3339 time}: only those released since
* GET {base}/opportunities/{uuid} -> Opportunity (with its releases)
* GET {base}/classifications -> ClassificationCode[]
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
@@ -103,18 +104,23 @@ async function request<T>(path: string, init: RequestInit = {}, isRetry = false)
const opportunityPath = (uuid: string): string => `/opportunities/${encodeURIComponent(uuid)}`;
/** The released_from query parameter, after a '&', or nothing for no bound. */
const releasedFromParam = (releasedFrom: string): string =>
releasedFrom === '' ? '' : `&released_from=${encodeURIComponent(releasedFrom)}`;
/** The backend's operations the app uses. */
export const api = {
/**
* Every stored opportunity, newest release first, fetched a page at a
* Every stored opportunity released at or after releasedFrom (an RFC 3339
* time; "" for all of them), newest release first, fetched a page at a
* time, and the newest change batch before the first page was read.
*/
async listOpportunities(signal?: AbortSignal): Promise<OpportunityChanges> {
async listOpportunities(releasedFrom: string, signal?: AbortSignal): Promise<OpportunityChanges> {
const opportunities: Opportunity[] = [];
let batch = '';
for (let offset = 0; ; offset += PAGE_LIMIT) {
const page = await request<OpportunityPage>(
`/opportunities?offset=${offset}&limit=${PAGE_LIMIT}`,
`/opportunities?offset=${offset}&limit=${PAGE_LIMIT}${releasedFromParam(releasedFrom)}`,
{ signal },
);
// Later pages may name newer batches. Keeping the first page's means
@@ -127,12 +133,17 @@ export const api = {
},
/**
* The opportunities changed after a change batch, and the newest batch
* they include. An unknown batch is an ApiError with status 410.
* The opportunities released at or after releasedFrom ("" for any) that
* changed after a change batch, and the newest batch they include. An
* unknown batch is an ApiError with status 410.
*/
async listChangedOpportunities(batch: string, signal?: AbortSignal): Promise<OpportunityChanges> {
async listChangedOpportunities(
batch: string,
releasedFrom: string,
signal?: AbortSignal,
): Promise<OpportunityChanges> {
const page = await request<OpportunityPage>(
`/opportunities?changed_after=${encodeURIComponent(batch)}`,
`/opportunities?changed_after=${encodeURIComponent(batch)}${releasedFromParam(releasedFrom)}`,
{ signal },
);
return { opportunities: page.opportunities, batch: page.batch };
+28
View File
@@ -48,6 +48,7 @@ function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
*/
export function FilterPanel(props: Props): JSX.Element {
const titleId = createUniqueId();
const daysId = createUniqueId();
const bodyId = createUniqueId();
// Open by default; the last choice is remembered.
@@ -61,6 +62,15 @@ export function FilterPanel(props: Props): JSX.Element {
const update = (patch: Partial<OpportunityFilters>): void =>
props.onChange({ ...props.filters, ...patch });
// Read when the input is committed (Enter or leaving it), not on each
// key, as each change loads the opportunities again. A value that isn't
// a whole number of at least 1 is put back as it was.
const commitDays = (field: HTMLInputElement): void => {
const days = Number(field.value);
if (Number.isInteger(days) && days >= 1) update({ releasedWithinDays: days });
else field.value = String(props.filters.releasedWithinDays);
};
return (
<section class="mb-4 rounded-[10px] border border-line bg-surface" aria-label="Filters">
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 sm:px-4">
@@ -123,6 +133,24 @@ export function FilterPanel(props: Props): JSX.Element {
<div class="border-t border-line-soft">
<div class="grid grid-cols-1 items-start gap-x-8 gap-y-6 p-4 sm:p-5 md:grid-cols-2">
<div class="flex min-w-0 flex-col gap-5">
<div>
<label class={fieldLabel} for={daysId}>
Published in the last
</label>
<span class="inline-flex items-center gap-2">
<input
id={daysId}
type="number"
min="1"
step="1"
class={`${input} w-24 px-3 py-2.5 text-base`}
value={props.filters.releasedWithinDays}
onChange={(e) => commitDays(e.currentTarget)}
/>
days
</span>
</div>
<div>
<label class={fieldLabel} for={titleId}>
Title
+11
View File
@@ -7,6 +7,7 @@ import {
emptyFilters,
filterOpportunities,
NOT_MARKED,
releasedFrom,
statusLabel,
statusOptions,
} from './filterOpportunities';
@@ -73,6 +74,16 @@ test('activeFilterCount counts the filters in use', () => {
expect(
activeFilterCount({ ...emptyFilters(), title: ' ', smeSuitable: true, showHidden: true }),
).toBe(2);
expect(activeFilterCount({ ...emptyFilters(), releasedWithinDays: 30 })).toBe(1);
});
test('releasedFrom is the start of the day that many days ago, in UK time', () => {
// 19 October 2026 is in British Summer Time; 12 October's midnight is 23:00 UTC.
const now = new Date('2026-10-19T09:30:00Z');
expect(releasedFrom(7, now)).toBe('2026-10-11T23:00:00.000Z');
expect(releasedFrom(0, now)).toBe('2026-10-18T23:00:00.000Z');
// Across the end of BST (25 October), midnight is 00:00 UTC again.
expect(releasedFrom(7, new Date('2026-11-02T12:00:00Z'))).toBe('2026-10-26T00:00:00.000Z');
});
test('statusOptions lists the known statuses, then others sorted', () => {
+28 -2
View File
@@ -3,12 +3,21 @@ import type { Opportunity, OpportunityStatus } from '@/types/Opportunities';
import { OPPORTUNITY_STATUSES } from '@/types/Opportunities';
import { parseDate } from './format';
/** How many days back opportunities are loaded from, unless the user chooses otherwise. */
export const DEFAULT_RELEASED_WITHIN_DAYS = 7;
/** Marks filter option for opportunities with no marks at all. */
export const NOT_MARKED = 'none';
export type MarkFilter = OpportunityMark | typeof NOT_MARKED;
/** What the table is filtered by. */
export interface OpportunityFilters {
/**
* Only opportunities with a release in the last this many days, and
* today. The backend applies it (see releasedFrom); the rest are applied
* here.
*/
releasedWithinDays: number;
title: string;
/** Empty means every status. */
statuses: ReadonlySet<string>;
@@ -21,8 +30,12 @@ export interface OpportunityFilters {
showHidden: boolean;
}
/** Filters that let every opportunity through, apart from hidden ones. */
/**
* The filters to start with: the last DEFAULT_RELEASED_WITHIN_DAYS days'
* opportunities, apart from hidden ones.
*/
export const emptyFilters = (): OpportunityFilters => ({
releasedWithinDays: DEFAULT_RELEASED_WITHIN_DAYS,
title: '',
statuses: new Set(),
marks: new Set(),
@@ -31,9 +44,13 @@ export const emptyFilters = (): OpportunityFilters => ({
showHidden: false,
});
/** How many separate filters are in use, for the collapsed panel's badge. */
/**
* How many separate filters are changed from emptyFilters, for the collapsed
* panel's badge.
*/
export const activeFilterCount = (filters: OpportunityFilters): number =>
[
filters.releasedWithinDays !== DEFAULT_RELEASED_WITHIN_DAYS,
filters.title.trim() !== '',
filters.statuses.size > 0,
filters.marks.size > 0,
@@ -46,6 +63,15 @@ export const activeFilterCount = (filters: OpportunityFilters): number =>
export const hasActiveFilters = (filters: OpportunityFilters): boolean =>
activeFilterCount(filters) > 0;
/**
* The start of the day days before now, in local time, as an RFC 3339 time
* for the backend's released_from: 7 days on a Monday is from the Monday
* before, so a whole week and today.
*/
export function releasedFrom(days: number, now: Date = new Date()): string {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - days).toISOString();
}
/** The opportunities that pass filters, newest release first. */
export function filterOpportunities(
opportunities: readonly Opportunity[],
+26 -4
View File
@@ -1,4 +1,4 @@
import { createRoot } from 'solid-js';
import { createRoot, createSignal } from 'solid-js';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type * as clientModule from '@/api/client';
@@ -10,9 +10,12 @@ import { createOpportunities } from './createOpportunities';
// The two requests createOpportunities makes; each test answers them.
const requests = vi.hoisted(() => ({
listOpportunities: vi.fn<(signal?: AbortSignal) => Promise<OpportunityChanges>>(),
listOpportunities:
vi.fn<(releasedFrom: string, signal?: AbortSignal) => Promise<OpportunityChanges>>(),
listChangedOpportunities:
vi.fn<(batch: string, signal?: AbortSignal) => Promise<OpportunityChanges>>(),
vi.fn<
(batch: string, releasedFrom: string, signal?: AbortSignal) => Promise<OpportunityChanges>
>(),
}));
vi.mock('@/api/client', async (importOriginal) => ({
@@ -51,6 +54,7 @@ let loads: Deferred<OpportunityChanges>[];
let catchUps: { batch: string; result: Deferred<OpportunityChanges> }[];
let list: OpportunityList;
let dispose: () => void;
let setReleasedFrom: (from: string) => void;
const titles = (): string[] => list.items().map((opportunity) => opportunity.title);
@@ -69,7 +73,9 @@ beforeEach(() => {
});
createRoot((disposeRoot) => {
dispose = disposeRoot;
list = createOpportunities();
const [releasedFrom, setFrom] = createSignal('2026-10-12T00:00:00Z');
setReleasedFrom = setFrom;
list = createOpportunities(releasedFrom);
});
});
@@ -170,3 +176,19 @@ test('drops a catch-up that a reload overtook', async () => {
await settle();
expect(catchUps.at(-1)?.batch).toBe('b5');
});
test('loads only those released since releasedFrom, and loads again when it changes', async () => {
await loaded();
expect(requests.listOpportunities.mock.calls[0]?.[0]).toBe('2026-10-12T00:00:00Z');
list.catchUp();
await settle();
expect(requests.listChangedOpportunities.mock.calls[0]?.[1]).toBe('2026-10-12T00:00:00Z');
catchUps[0]?.result.resolve(changes('b2'));
await settle();
setReleasedFrom('2026-10-01T00:00:00Z');
await settle();
expect(loads).toHaveLength(2);
expect(requests.listOpportunities.mock.calls[1]?.[0]).toBe('2026-10-01T00:00:00Z');
});
+23 -11
View File
@@ -17,17 +17,21 @@ export interface ApiList<T> {
}
/**
* Loads a list with load, giving it a signal that aborts the request when
* the list is reloaded or its owner is cleaned up.
* Loads a list with load, given source's value, and loads it again whenever
* that changes. load is given a signal that aborts the request when the list
* is reloaded or its owner is cleaned up.
*/
function createApiList<T>(load: (signal: AbortSignal) => Promise<T[]>): ApiList<T> {
function createApiList<T, S>(
source: () => S,
load: (signal: AbortSignal, value: S) => Promise<T[]>,
): ApiList<T> {
let controller: AbortController | undefined;
onCleanup(() => controller?.abort());
const [resource, { mutate, refetch }] = createResource(() => {
const [resource, { mutate, refetch }] = createResource(source, (value) => {
controller?.abort();
controller = new AbortController();
return load(controller.signal);
return load(controller.signal, value);
});
return wrap(resource, refetch, (change) => {
mutate((items) => change(items ?? []));
@@ -66,8 +70,12 @@ export interface OpportunityList extends ApiList<Opportunity> {
catchUp: () => void;
}
/** Every stored opportunity, loaded from the backend and kept up to date with catchUp. */
export function createOpportunities(): OpportunityList {
/**
* The stored opportunities released at or after releasedFrom (an RFC 3339
* time; "" for all of them), loaded from the backend, loaded again when
* releasedFrom changes, and kept up to date with catchUp.
*/
export function createOpportunities(releasedFrom: () => string): OpportunityList {
// The change batch the list is up to date with; "" before it has loaded,
// or if the backend has recorded no changes.
let batch = '';
@@ -78,10 +86,10 @@ export function createOpportunities(): OpportunityList {
const [isWanted, setIsWanted] = createSignal(false);
const [isCatchingUp, setIsCatchingUp] = createSignal(false);
const list = createApiList(async (signal) => {
const list = createApiList(releasedFrom, async (signal, from) => {
loads += 1;
controller?.abort();
const loaded = await api.listOpportunities(signal);
const loaded = await api.listOpportunities(from, signal);
batch = loaded.batch;
return loaded.opportunities;
});
@@ -97,7 +105,7 @@ export function createOpportunities(): OpportunityList {
const { signal } = controller;
setIsCatchingUp(true);
try {
const changes = await api.listChangedOpportunities(batch, signal);
const changes = await api.listChangedOpportunities(batch, releasedFrom(), signal);
if (load !== loads) return;
batch = changes.batch;
list.update((items) => mergeOpportunities(items, changes.opportunities));
@@ -124,4 +132,8 @@ export function createOpportunities(): OpportunityList {
/** Every classification some opportunity has, loaded from the backend. */
export const createClassifications = (): ApiList<ClassificationCode> =>
createApiList((signal) => api.listClassifications(signal));
createApiList(
// Never changes: the classifications are only loaded again by reload.
() => true,
(signal) => api.listClassifications(signal),
);
+6 -2
View File
@@ -72,7 +72,9 @@ export interface paths {
* releases or with changed marks), and batch is the newest
* batch they include. offset and limit can't be given with
* changed_after. An unknown batch is 410 Gone: reload the
* whole list.
* whole list. released_from (RFC 3339, inclusive) leaves out
* opportunities whose latest release is before it, in a page
* or with changed_after.
*/
get: {
parameters: {
@@ -83,6 +85,8 @@ export interface paths {
limit?: number;
/** @description List the opportunities changed after this batch instead */
changed_after?: string;
/** @description Only opportunities whose latest release is at or after this time */
released_from?: string;
};
header?: never;
path?: never;
@@ -99,7 +103,7 @@ export interface paths {
"application/json": components["schemas"]["api.OpportunityPage"];
};
};
/** @description Invalid offset or limit, or either with changed_after */
/** @description Invalid offset, limit or released_from, or offset or limit with changed_after */
400: {
headers: {
[name: string]: unknown;