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

+19
View File
@@ -3,6 +3,7 @@ package api
import (
"fmt"
"strconv"
"time"
)
// intParam parses the query parameter text as an integer from low to high,
@@ -21,3 +22,21 @@ func intParam(text string, fallback, low, high int) (int, error) {
return number, nil
}
// timeParam parses the query parameter name's text as an RFC 3339 time, or
// returns the zero time if it is empty. Its error is meant for the client.
func timeParam(name, text string) (time.Time, error) {
if text == "" {
return time.Time{}, nil
}
when, err := time.Parse(time.RFC3339, text)
if err != nil {
return time.Time{}, fmt.Errorf(
"%s must be an RFC 3339 date and time, e.g. 2026-10-12T00:00:00+01:00",
name,
)
}
return when, nil
}
+26 -11
View File
@@ -7,6 +7,7 @@ import (
"reflect"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -71,7 +72,8 @@ func (h *opportunityRoutes) Routes(router chi.Router) {
// list serves GET /opportunities: a page of opportunities, newest release
// first, with their classifications and marks but not their releases. The
// page is set by the offset and limit (default 50, at most 100) query
// parameters. With changed_after, it serves the opportunities changed since
// parameters, and released_from leaves out opportunities whose latest release
// is before it. With changed_after, it serves the opportunities changed since
// that batch instead (see listChanged).
//
// @Summary List opportunities
@@ -85,27 +87,38 @@ func (h *opportunityRoutes) Routes(router chi.Router) {
// @Description releases or with changed marks), and batch is the newest
// @Description batch they include. offset and limit can't be given with
// @Description changed_after. An unknown batch is 410 Gone: reload the
// @Description whole list.
// @Description whole list. released_from (RFC 3339, inclusive) leaves out
// @Description opportunities whose latest release is before it, in a page
// @Description or with changed_after.
// @Tags opportunities
// @Produce json
// @Param offset query int false "Opportunities to skip" minimum(0) default(0)
// @Param limit query int false "Page size" minimum(1) maximum(100) default(50)
// @Param changed_after query string false "List the opportunities changed after this batch instead"
// @Param released_from query string false "Only opportunities whose latest release is at or after this time" format(date-time)
// @Success 200 {object} OpportunityPage
// @Failure 400 {object} httpx.Problem "Invalid offset or limit, or either with changed_after"
// @Failure 400 {object} httpx.Problem "Invalid offset, limit or released_from, or offset or limit with changed_after"
// @Failure 410 {object} httpx.Problem "Unknown batch"
// @Failure 500 {object} httpx.Problem
// @Router /opportunities [get]
func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
releasedFrom, err := timeParam(
"released_from", query.Get("released_from"),
)
if err != nil {
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
return
}
if query.Has("changed_after") {
h.listChanged(w, r)
h.listChanged(w, r, releasedFrom)
return
}
offset, offsetErr := intParam(query.Get("offset"), 0, 0, 1<<30)
limit, limitErr := intParam(query.Get("limit"), 50, 1, 100)
err := errors.Join(offsetErr, limitErr)
err = errors.Join(offsetErr, limitErr)
if err != nil {
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
return
@@ -123,8 +136,9 @@ func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
opportunities, err := h.store.ListOpportunities(
r.Context(),
store.OpportunityFilter{
Limit: limit + 1,
Offset: offset,
Limit: limit + 1,
Offset: offset,
ReleasedFrom: releasedFrom,
},
)
if err != nil {
@@ -148,10 +162,11 @@ func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
// listChanged serves GET /opportunities?changed_after=batch: every
// opportunity changed after the change batch, as one page, with the newest
// batch it includes. An unknown batch, such as one from another database, is
// 410 Gone, as the client can't catch up from it.
// batch it includes, leaving out those whose latest release is before
// releasedFrom unless it is zero. An unknown batch, such as one from another
// database, is 410 Gone, as the client can't catch up from it.
func (h *opportunityRoutes) listChanged(
w http.ResponseWriter, r *http.Request,
w http.ResponseWriter, r *http.Request, releasedFrom time.Time,
) {
query := r.URL.Query()
if query.Has("offset") || query.Has("limit") {
@@ -165,7 +180,7 @@ func (h *opportunityRoutes) listChanged(
}
opportunities, batch, err := h.store.ListChangedOpportunities(
r.Context(), query.Get("changed_after"),
r.Context(), query.Get("changed_after"), releasedFrom,
)
if errors.Is(err, store.ErrUnknownBatch) {
httpx.WriteProblem(
+52 -3
View File
@@ -104,7 +104,8 @@ func uuids(page OpportunityPage) []string {
return ids
}
// Opportunities are listed newest first, a page at a time.
// Opportunities are listed newest first, a page at a time, from
// released_from if given (ocds-N is released 2026-10-0N at 12:00 UTC).
func TestListOpportunitiesPaging(t *testing.T) {
url := opportunitiesServer(t, 5)
all := []string{"ocds-5", "ocds-4", "ocds-3", "ocds-2", "ocds-1"}
@@ -120,6 +121,12 @@ func TestListOpportunitiesPaging(t *testing.T) {
{"?limit=5", all, false},
{"?offset=10", nil, false},
{"", all, false},
{"?released_from=", all, false},
{"?released_from=2026-10-03T12:00:00Z", []string{"ocds-5", "ocds-4", "ocds-3"}, false},
{"?released_from=2026-10-03T13:00:00%2B01:00", []string{"ocds-5", "ocds-4", "ocds-3"}, false},
{"?released_from=2026-10-03T12:00:01Z", []string{"ocds-5", "ocds-4"}, false},
{"?released_from=2026-10-02T00:00:00Z&limit=1", []string{"ocds-5"}, true},
{"?released_from=2026-10-02T00:00:00Z&limit=2&offset=2", []string{"ocds-3", "ocds-2"}, false},
}
for _, tt := range tests {
var page OpportunityPage
@@ -161,9 +168,19 @@ func TestListOpportunitiesEmpty(t *testing.T) {
}
}
// Bad paging or released_from is 400. An unencoded "+" in released_from
// arrives as a space, so it is refused too.
func TestListOpportunitiesBadPaging(t *testing.T) {
url := opportunitiesServer(t, 1)
queries := []string{"?limit=0", "?limit=101", "?limit=x", "?offset=-1"}
queries := []string{
"?limit=0",
"?limit=101",
"?limit=x",
"?offset=-1",
"?released_from=x",
"?released_from=2026-10-03",
"?released_from=2026-10-03T13:00:00+01:00",
}
for _, query := range queries {
status := request(t, "GET", url+"/opportunities"+query, nil)
if status != 400 {
@@ -211,6 +228,36 @@ func TestListChangedOpportunities(t *testing.T) {
)
}
// released_from leaves out ocds-2 (released on the 2nd), not the batch.
var bounded OpportunityPage
status = request(
t,
"GET",
url+"/opportunities?released_from=2026-10-03T00:00:00Z"+
"&changed_after="+page.Batch,
&bounded,
)
if status != 200 || len(bounded.Opportunities) != 0 ||
bounded.Batch != changed.Batch {
t.Errorf(
"released_from the 3rd: status %d, %+v; want [] and batch %q",
status, bounded, changed.Batch,
)
}
status = request(
t,
"GET",
url+"/opportunities?changed_after="+page.Batch+
"&released_from=2026-10-02T12:00:00Z",
&bounded,
)
if status != 200 || strings.Join(uuids(bounded), ",") != "ocds-2" {
t.Errorf(
"released_from the 2nd: status %d, %+v; want ocds-2",
status, bounded,
)
}
var latest OpportunityPage
request(t, "GET", url+"/opportunities?limit=1", &latest)
if latest.Batch != changed.Batch {
@@ -218,7 +265,8 @@ func TestListChangedOpportunities(t *testing.T) {
}
}
// An unknown batch is 410 Gone, and paging can't be used with changed_after.
// An unknown batch is 410 Gone, and paging can't be used with changed_after;
// a bad released_from is 400 there too.
func TestListChangedOpportunitiesRefused(t *testing.T) {
url := opportunitiesServer(t, 1)
var page OpportunityPage
@@ -232,6 +280,7 @@ func TestListChangedOpportunitiesRefused(t *testing.T) {
{"?changed_after=", http.StatusGone},
{"?changed_after=" + page.Batch + "&limit=5", http.StatusBadRequest},
{"?offset=0&changed_after=" + page.Batch, http.StatusBadRequest},
{"?changed_after=" + page.Batch + "&released_from=x", http.StatusBadRequest},
}
for _, tt := range tests {
status := request(t, "GET", url+"/opportunities"+tt.query, nil)
+2 -2
View File
@@ -110,7 +110,7 @@ ts, err := s.ListOpportunities(ctx, store.OpportunityFilter{
})
rs, err := s.ListReleases(ctx, uuid) // oldest first, with tags
batch, err := s.LatestChangeBatch(ctx) // hash of the newest change batch, or ""
ts, batch, err = s.ListChangedOpportunities(ctx, batch) // changed since; ErrUnknownBatch
ts, batch, err = s.ListChangedOpportunities(ctx, batch, time.Time{}) // changed since; ErrUnknownBatch
ok, err := s.ReleaseExists(ctx, uuid, releaseID)
```
@@ -127,7 +127,7 @@ ok, err := s.ReleaseExists(ctx, uuid, releaseID)
**Listing.** `ListOpportunities` filters by status, classification code, title text, a range on `LatestReleaseDate` and each mark (`MarkSet` or `MarkUnset`; an opportunity with no marks stored has every mark unset), newest first, 50 per page by default. It loads classifications and marks but not releases (which can be large); those are loaded by `ListReleases` or `GetOpportunity`.
`LatestChangeBatch` returns the hash of the newest change batch ("" if there is none). Read before listing, it is the batch to give `ListChangedOpportunities`, which returns the opportunities in every later batch, once each, unpaged and ordered as `ListOpportunities` orders them, with the hash of the newest of those batches to give it next time. Batches recorded while it runs are left for next time rather than missed. A hash it doesn't know, such as a deleted batch's, is `ErrUnknownBatch`.
`LatestChangeBatch` returns the hash of the newest change batch ("" if there is none). Read before listing, it is the batch to give `ListChangedOpportunities`, which returns the opportunities in every later batch, once each, unpaged and ordered as `ListOpportunities` orders them, with the hash of the newest of those batches to give it next time. A non-zero `releasedFrom` leaves out opportunities whose latest release is before it, as `OpportunityFilter.ReleasedFrom` does; the hash is the same either way. Batches recorded while it runs are left for next time rather than missed. A hash it doesn't know, such as a deleted batch's, is `ErrUnknownBatch`.
**Title search.** The words are matched against the title using FTS5: every word must appear, each also matches as a prefix, case and accents are ignored, and "migrating" finds "migration", etc. Input is sanitised, so FTS5 operators and quotes typed by a user are searched as plain words, never interpreted.
+2 -2
View File
@@ -46,7 +46,7 @@ func TestChangeBatchAfterClockWentBack(t *testing.T) {
t.Errorf("batch created at %v, not after %v", latest.CreatedAt, future)
}
got, hash, err := s.ListChangedOpportunities(ctx, "future")
got, hash, err := s.ListChangedOpportunities(ctx, "future", time.Time{})
if err != nil {
t.Fatal(err)
}
@@ -101,7 +101,7 @@ func TestChangeBatchRetention(t *testing.T) {
&records,
`SELECT COUNT(*) FROM opportunity_change_batch_opportunities`,
)
_, _, err = s.ListChangedOpportunities(ctx, old)
_, _, err = s.ListChangedOpportunities(ctx, old, time.Time{})
if retention > 0 {
if batches != 1 || records != 1 ||
+7 -2
View File
@@ -313,7 +313,8 @@ ON CONFLICT DO NOTHING`
// selectOpportunitiesChangedBetween reads, into opportunityRows, the
// opportunities in the batches created after the first argument and at or
// before the second, once each, ordered as ListOpportunities orders them.
// before the second, once each. ListChangedOpportunities may add AND
// clauses, then orderOpportunitiesChanged.
const selectOpportunitiesChangedBetween = `
SELECT t.*
FROM opportunities t
@@ -322,5 +323,9 @@ WHERE t.uuid IN (
FROM opportunity_change_batch_opportunities bo
JOIN opportunity_change_batches b ON b.id = bo.batch_id
WHERE b.created_at > ? AND b.created_at <= ?
)
)`
// orderOpportunitiesChanged ends selectOpportunitiesChangedBetween, ordering
// as ListOpportunities orders.
const orderOpportunitiesChanged = `
ORDER BY t.latest_release_date DESC, t.uuid`
+5 -1
View File
@@ -124,7 +124,11 @@ var checks = map[string]queryCheck{
// opportunity_change_batch_opportunities
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
"selectOpportunitiesChangedBetween": {
sql: selectOpportunitiesChangedBetween,
sql: selectOpportunitiesChangedBetween + orderOpportunitiesChanged,
},
"orderOpportunitiesChanged": {
sql: selectOpportunitiesChangedBetween + "\nAND " +
filterOpportunityReleasedFrom + orderOpportunitiesChanged,
},
}
+14 -8
View File
@@ -776,10 +776,14 @@ func (s *Store) LatestChangeBatch(ctx context.Context) (string, error) {
// those batches, to give it next time. With no newer batches, it returns no
// opportunities and after. An unknown hash is ErrUnknownBatch.
//
// Unless releasedFrom is zero, only opportunities whose latest release is on
// or after it are returned (as OpportunityFilter.ReleasedFrom); the hash
// returned is the same either way.
//
// Batches recorded while it runs are left for next time, so none is missed:
// only those up to the newest batch when it starts are read.
func (s *Store) ListChangedOpportunities(
ctx context.Context, after string,
ctx context.Context, after string, releasedFrom time.Time,
) ([]types.Opportunity, string, error) {
var since changeBatch
err := s.db.GetContext(ctx, &since, selectChangeBatchByHash, after)
@@ -796,14 +800,16 @@ func (s *Store) ListChangedOpportunities(
return nil, "", fmt.Errorf("reading the latest change batch: %w", err)
}
query := selectOpportunitiesChangedBetween
args := []any{since.CreatedAt, latest.CreatedAt}
if !releasedFrom.IsZero() {
query += "\nAND " + filterOpportunityReleasedFrom
args = append(args, releasedFrom.UTC())
}
query += orderOpportunitiesChanged
var rows []opportunityRow
err = s.db.SelectContext(
ctx,
&rows,
selectOpportunitiesChangedBetween,
since.CreatedAt,
latest.CreatedAt,
)
err = s.db.SelectContext(ctx, &rows, query, args...)
if err != nil {
return nil, "", fmt.Errorf("listing changed opportunities: %w", err)
}
+16 -2
View File
@@ -1020,7 +1020,9 @@ func TestChangeBatches(t *testing.T) {
}
changed := func(after string) ([]types.Opportunity, string) {
t.Helper()
opportunities, latest, err := s.ListChangedOpportunities(ctx, after)
opportunities, latest, err := s.ListChangedOpportunities(
ctx, after, time.Time{},
)
if err != nil {
t.Fatal(err)
}
@@ -1074,6 +1076,18 @@ func TestChangeBatches(t *testing.T) {
t.Errorf("changed opportunity without classifications: %+v", got[1])
}
// releasedFrom leaves out ocds-2 (released day 2), not the batch.
got, hash, err := s.ListChangedOpportunities(ctx, first, day(3))
if err != nil {
t.Fatal(err)
}
if !equal(uuids(got), []string{"ocds-3"}) || hash != second {
t.Errorf(
"changed from day 3: %v, %q; want [ocds-3], %q",
uuids(got), hash, second,
)
}
mustSetMarks(t, s, types.OpportunityMarks{
UUID: "ocds-1",
Skipped: true,
@@ -1091,7 +1105,7 @@ func TestChangeBatches(t *testing.T) {
t.Errorf("changed after latest: %v, %q; want [], %q", got, again, hash)
}
_, _, err := s.ListChangedOpportunities(ctx, "unknown")
_, _, err = s.ListChangedOpportunities(ctx, "unknown", time.Time{})
if !errors.Is(err, store.ErrUnknownBatch) {
t.Errorf("unknown batch: err %v, want ErrUnknownBatch", err)
}
+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;