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
+331
-62
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -125,6 +138,7 @@ func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
|
||||
store.OpportunityFilter{
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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`
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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