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

+6
View File
@@ -36,6 +36,7 @@ The frontend uses, through `src/api/client.ts`:
| ------ | ------------------------------------------ | ---------------------------------------------- |
| GET | `/api/opportunities?offset=&limit=` | A page of opportunities, with their marks |
| GET | `/api/opportunities?changed_after={batch}` | The opportunities changed since a change batch |
| GET | `/api/opportunities/{uuid}` | One opportunity, with its releases |
| GET | `/api/classifications` | Every classification in use, by code |
| PUT | `/api/opportunities/{uuid}/marks/{mark}` | Sets one mark; returns all the marks |
| DELETE | `/api/opportunities/{uuid}/marks/{mark}` | Clears one mark; returns all the marks |
@@ -61,6 +62,11 @@ the batch it's up to date with, and asks for everything changed after it
their marks changed. If the backend doesn't know the batch (410), or has
recorded none yet, the whole list is reloaded instead.
Both list requests send `released_from` (an RFC 3339 time), so only the
opportunities published in the last few days the filter names (7 by default,
from midnight UK time) are loaded; changing the number of days loads the list
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
+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;