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:
1 parent
b8244da2e5
commit
746fa0a9c1
19 files changed
+333
-64
No files matched your search
@@ -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
@@ -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`}>
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user