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
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// intParam parses the query parameter text as an integer from low to high,
|
// 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
|
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"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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
|
// list serves GET /opportunities: a page of opportunities, newest release
|
||||||
// first, with their classifications and marks but not their releases. The
|
// 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
|
// 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).
|
// that batch instead (see listChanged).
|
||||||
//
|
//
|
||||||
// @Summary List opportunities
|
// @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 releases or with changed marks), and batch is the newest
|
||||||
// @Description batch they include. offset and limit can't be given with
|
// @Description batch they include. offset and limit can't be given with
|
||||||
// @Description changed_after. An unknown batch is 410 Gone: reload the
|
// @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
|
// @Tags opportunities
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param offset query int false "Opportunities to skip" minimum(0) default(0)
|
// @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 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 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
|
// @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 410 {object} httpx.Problem "Unknown batch"
|
||||||
// @Failure 500 {object} httpx.Problem
|
// @Failure 500 {object} httpx.Problem
|
||||||
// @Router /opportunities [get]
|
// @Router /opportunities [get]
|
||||||
func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
|
func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
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") {
|
if query.Has("changed_after") {
|
||||||
h.listChanged(w, r)
|
h.listChanged(w, r, releasedFrom)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
offset, offsetErr := intParam(query.Get("offset"), 0, 0, 1<<30)
|
offset, offsetErr := intParam(query.Get("offset"), 0, 0, 1<<30)
|
||||||
limit, limitErr := intParam(query.Get("limit"), 50, 1, 100)
|
limit, limitErr := intParam(query.Get("limit"), 50, 1, 100)
|
||||||
err := errors.Join(offsetErr, limitErr)
|
err = errors.Join(offsetErr, limitErr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
|
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -123,8 +136,9 @@ func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
opportunities, err := h.store.ListOpportunities(
|
opportunities, err := h.store.ListOpportunities(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
store.OpportunityFilter{
|
store.OpportunityFilter{
|
||||||
Limit: limit + 1,
|
Limit: limit + 1,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
|
ReleasedFrom: releasedFrom,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
// listChanged serves GET /opportunities?changed_after=batch: every
|
||||||
// opportunity changed after the change batch, as one page, with the newest
|
// 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
|
// batch it includes, leaving out those whose latest release is before
|
||||||
// 410 Gone, as the client can't catch up from it.
|
// 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(
|
func (h *opportunityRoutes) listChanged(
|
||||||
w http.ResponseWriter, r *http.Request,
|
w http.ResponseWriter, r *http.Request, releasedFrom time.Time,
|
||||||
) {
|
) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
if query.Has("offset") || query.Has("limit") {
|
if query.Has("offset") || query.Has("limit") {
|
||||||
@@ -165,7 +180,7 @@ func (h *opportunityRoutes) listChanged(
|
|||||||
}
|
}
|
||||||
|
|
||||||
opportunities, batch, err := h.store.ListChangedOpportunities(
|
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) {
|
if errors.Is(err, store.ErrUnknownBatch) {
|
||||||
httpx.WriteProblem(
|
httpx.WriteProblem(
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ func uuids(page OpportunityPage) []string {
|
|||||||
return ids
|
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) {
|
func TestListOpportunitiesPaging(t *testing.T) {
|
||||||
url := opportunitiesServer(t, 5)
|
url := opportunitiesServer(t, 5)
|
||||||
all := []string{"ocds-5", "ocds-4", "ocds-3", "ocds-2", "ocds-1"}
|
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},
|
{"?limit=5", all, false},
|
||||||
{"?offset=10", nil, false},
|
{"?offset=10", nil, false},
|
||||||
{"", all, 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 {
|
for _, tt := range tests {
|
||||||
var page OpportunityPage
|
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) {
|
func TestListOpportunitiesBadPaging(t *testing.T) {
|
||||||
url := opportunitiesServer(t, 1)
|
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 {
|
for _, query := range queries {
|
||||||
status := request(t, "GET", url+"/opportunities"+query, nil)
|
status := request(t, "GET", url+"/opportunities"+query, nil)
|
||||||
if status != 400 {
|
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
|
var latest OpportunityPage
|
||||||
request(t, "GET", url+"/opportunities?limit=1", &latest)
|
request(t, "GET", url+"/opportunities?limit=1", &latest)
|
||||||
if latest.Batch != changed.Batch {
|
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) {
|
func TestListChangedOpportunitiesRefused(t *testing.T) {
|
||||||
url := opportunitiesServer(t, 1)
|
url := opportunitiesServer(t, 1)
|
||||||
var page OpportunityPage
|
var page OpportunityPage
|
||||||
@@ -232,6 +280,7 @@ func TestListChangedOpportunitiesRefused(t *testing.T) {
|
|||||||
{"?changed_after=", http.StatusGone},
|
{"?changed_after=", http.StatusGone},
|
||||||
{"?changed_after=" + page.Batch + "&limit=5", http.StatusBadRequest},
|
{"?changed_after=" + page.Batch + "&limit=5", http.StatusBadRequest},
|
||||||
{"?offset=0&changed_after=" + page.Batch, http.StatusBadRequest},
|
{"?offset=0&changed_after=" + page.Batch, http.StatusBadRequest},
|
||||||
|
{"?changed_after=" + page.Batch + "&released_from=x", http.StatusBadRequest},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
status := request(t, "GET", url+"/opportunities"+tt.query, nil)
|
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
|
rs, err := s.ListReleases(ctx, uuid) // oldest first, with tags
|
||||||
batch, err := s.LatestChangeBatch(ctx) // hash of the newest change batch, or ""
|
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)
|
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`.
|
**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.
|
**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)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func TestChangeBatchRetention(t *testing.T) {
|
|||||||
&records,
|
&records,
|
||||||
`SELECT COUNT(*) FROM opportunity_change_batch_opportunities`,
|
`SELECT COUNT(*) FROM opportunity_change_batch_opportunities`,
|
||||||
)
|
)
|
||||||
_, _, err = s.ListChangedOpportunities(ctx, old)
|
_, _, err = s.ListChangedOpportunities(ctx, old, time.Time{})
|
||||||
|
|
||||||
if retention > 0 {
|
if retention > 0 {
|
||||||
if batches != 1 || records != 1 ||
|
if batches != 1 || records != 1 ||
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ ON CONFLICT DO NOTHING`
|
|||||||
|
|
||||||
// selectOpportunitiesChangedBetween reads, into opportunityRows, the
|
// selectOpportunitiesChangedBetween reads, into opportunityRows, the
|
||||||
// opportunities in the batches created after the first argument and at or
|
// 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 = `
|
const selectOpportunitiesChangedBetween = `
|
||||||
SELECT t.*
|
SELECT t.*
|
||||||
FROM opportunities t
|
FROM opportunities t
|
||||||
@@ -322,5 +323,9 @@ WHERE t.uuid IN (
|
|||||||
FROM opportunity_change_batch_opportunities bo
|
FROM opportunity_change_batch_opportunities bo
|
||||||
JOIN opportunity_change_batches b ON b.id = bo.batch_id
|
JOIN opportunity_change_batches b ON b.id = bo.batch_id
|
||||||
WHERE b.created_at > ? AND b.created_at <= ?
|
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`
|
ORDER BY t.latest_release_date DESC, t.uuid`
|
||||||
@@ -124,7 +124,11 @@ var checks = map[string]queryCheck{
|
|||||||
// opportunity_change_batch_opportunities
|
// opportunity_change_batch_opportunities
|
||||||
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
|
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
|
||||||
"selectOpportunitiesChangedBetween": {
|
"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
|
// those batches, to give it next time. With no newer batches, it returns no
|
||||||
// opportunities and after. An unknown hash is ErrUnknownBatch.
|
// 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:
|
// 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.
|
// only those up to the newest batch when it starts are read.
|
||||||
func (s *Store) ListChangedOpportunities(
|
func (s *Store) ListChangedOpportunities(
|
||||||
ctx context.Context, after string,
|
ctx context.Context, after string, releasedFrom time.Time,
|
||||||
) ([]types.Opportunity, string, error) {
|
) ([]types.Opportunity, string, error) {
|
||||||
var since changeBatch
|
var since changeBatch
|
||||||
err := s.db.GetContext(ctx, &since, selectChangeBatchByHash, after)
|
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)
|
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
|
var rows []opportunityRow
|
||||||
err = s.db.SelectContext(
|
err = s.db.SelectContext(ctx, &rows, query, args...)
|
||||||
ctx,
|
|
||||||
&rows,
|
|
||||||
selectOpportunitiesChangedBetween,
|
|
||||||
since.CreatedAt,
|
|
||||||
latest.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("listing changed opportunities: %w", err)
|
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) {
|
changed := func(after string) ([]types.Opportunity, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
opportunities, latest, err := s.ListChangedOpportunities(ctx, after)
|
opportunities, latest, err := s.ListChangedOpportunities(
|
||||||
|
ctx, after, time.Time{},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1074,6 +1076,18 @@ func TestChangeBatches(t *testing.T) {
|
|||||||
t.Errorf("changed opportunity without classifications: %+v", got[1])
|
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{
|
mustSetMarks(t, s, types.OpportunityMarks{
|
||||||
UUID: "ocds-1",
|
UUID: "ocds-1",
|
||||||
Skipped: true,
|
Skipped: true,
|
||||||
@@ -1091,7 +1105,7 @@ func TestChangeBatches(t *testing.T) {
|
|||||||
t.Errorf("changed after latest: %v, %q; want [], %q", got, again, hash)
|
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) {
|
if !errors.Is(err, store.ErrUnknownBatch) {
|
||||||
t.Errorf("unknown batch: err %v, want ErrUnknownBatch", err)
|
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?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?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 |
|
| GET | `/api/classifications` | Every classification in use, by code |
|
||||||
| PUT | `/api/opportunities/{uuid}/marks/{mark}` | Sets one mark; returns all the marks |
|
| 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 |
|
| 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
|
their marks changed. If the backend doesn't know the batch (410), or has
|
||||||
recorded none yet, the whole list is reloaded instead.
|
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
|
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`;
|
are `interested`, `skipped`, `bid_submitted`, `lost`, `won` and `hidden`;
|
||||||
Won and Lost exclude each other (409). The classifications are the ones the
|
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 { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
import { ToastList } from '@/components/ToastList';
|
import { ToastList } from '@/components/ToastList';
|
||||||
import type { OpportunityFilters } from '@/lib/filterOpportunities';
|
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 { formatCount } from '@/lib/format';
|
||||||
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
|
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
|
||||||
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
|
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
|
||||||
@@ -15,17 +20,22 @@ import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnno
|
|||||||
import { createTheme } from '@/primitives/createTheme';
|
import { createTheme } from '@/primitives/createTheme';
|
||||||
import { createToasts } from '@/primitives/createToasts';
|
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';
|
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. */
|
/** The page: the filters, the table of opportunities, and toasts for errors. */
|
||||||
export function App(): JSX.Element {
|
export function App(): JSX.Element {
|
||||||
const toasts = createToasts();
|
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 classifications = createClassifications();
|
||||||
const annotations = createOpportunityAnnotations(opportunities.items, toasts.show);
|
const annotations = createOpportunityAnnotations(opportunities.items, toasts.show);
|
||||||
createLiveUpdates({ opportunities, classifications, annotations });
|
createLiveUpdates({ opportunities, classifications, annotations });
|
||||||
const theme = createTheme();
|
const theme = createTheme();
|
||||||
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
|
|
||||||
|
|
||||||
const statuses = createMemo(() => statusOptions(opportunities.items()));
|
const statuses = createMemo(() => statusOptions(opportunities.items()));
|
||||||
// annotations.get reads from a store, so hiding an opportunity re-runs this.
|
// annotations.get reads from a store, so hiding an opportunity re-runs this.
|
||||||
@@ -107,7 +117,9 @@ export function App(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={opportunities.items().length === 0}>
|
<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>
|
||||||
<Match when={rows().length === 0}>
|
<Match when={rows().length === 0}>
|
||||||
<div class={`${statePanel} text-muted`}>
|
<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([
|
expect(sent.map((request) => request.url)).toEqual([
|
||||||
'/api/opportunities?offset=0&limit=100',
|
'/api/opportunities?offset=0&limit=100',
|
||||||
@@ -73,6 +73,18 @@ describe('listOpportunities', () => {
|
|||||||
expect(loaded.batch).toBe('batch-0');
|
expect(loaded.batch).toBe('batch-0');
|
||||||
expect(sent[0]?.headers.get('Accept')).toBe('application/json');
|
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', () => {
|
describe('listChangedOpportunities', () => {
|
||||||
@@ -87,19 +99,32 @@ describe('listChangedOpportunities', () => {
|
|||||||
batch: 'newer',
|
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(sent[0]?.url).toBe('/api/opportunities?changed_after=a%20b');
|
||||||
expect(changes.batch).toBe('newer');
|
expect(changes.batch).toBe('newer');
|
||||||
expect(changes.opportunities.map((o) => o.uuid)).toEqual(['ocds-1']);
|
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 () => {
|
test('an unknown batch is an ApiError with status 410', async () => {
|
||||||
const client = await loadClient();
|
const client = await loadClient();
|
||||||
respond = () =>
|
respond = () =>
|
||||||
json({ type: 'about:blank', status: 410, title: 'Gone', detail: 'unknown batch' }, 410);
|
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).toBeInstanceOf(client.ApiError);
|
||||||
expect(err).toMatchObject({ status: 410, message: 'unknown batch' });
|
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?offset=&limit= -> OpportunityPage
|
||||||
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
|
* 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}/opportunities/{uuid} -> Opportunity (with its releases)
|
||||||
* GET {base}/classifications -> ClassificationCode[]
|
* GET {base}/classifications -> ClassificationCode[]
|
||||||
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
|
* 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)}`;
|
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. */
|
/** The backend's operations the app uses. */
|
||||||
export const api = {
|
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.
|
* 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[] = [];
|
const opportunities: Opportunity[] = [];
|
||||||
let batch = '';
|
let batch = '';
|
||||||
for (let offset = 0; ; offset += PAGE_LIMIT) {
|
for (let offset = 0; ; offset += PAGE_LIMIT) {
|
||||||
const page = await request<OpportunityPage>(
|
const page = await request<OpportunityPage>(
|
||||||
`/opportunities?offset=${offset}&limit=${PAGE_LIMIT}`,
|
`/opportunities?offset=${offset}&limit=${PAGE_LIMIT}${releasedFromParam(releasedFrom)}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
// Later pages may name newer batches. Keeping the first page's means
|
// 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
|
* The opportunities released at or after releasedFrom ("" for any) that
|
||||||
* they include. An unknown batch is an ApiError with status 410.
|
* 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>(
|
const page = await request<OpportunityPage>(
|
||||||
`/opportunities?changed_after=${encodeURIComponent(batch)}`,
|
`/opportunities?changed_after=${encodeURIComponent(batch)}${releasedFromParam(releasedFrom)}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
return { opportunities: page.opportunities, batch: page.batch };
|
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 {
|
export function FilterPanel(props: Props): JSX.Element {
|
||||||
const titleId = createUniqueId();
|
const titleId = createUniqueId();
|
||||||
|
const daysId = createUniqueId();
|
||||||
const bodyId = createUniqueId();
|
const bodyId = createUniqueId();
|
||||||
|
|
||||||
// Open by default; the last choice is remembered.
|
// 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 =>
|
const update = (patch: Partial<OpportunityFilters>): void =>
|
||||||
props.onChange({ ...props.filters, ...patch });
|
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 (
|
return (
|
||||||
<section class="mb-4 rounded-[10px] border border-line bg-surface" aria-label="Filters">
|
<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">
|
<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="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="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 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>
|
<div>
|
||||||
<label class={fieldLabel} for={titleId}>
|
<label class={fieldLabel} for={titleId}>
|
||||||
Title
|
Title
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
emptyFilters,
|
emptyFilters,
|
||||||
filterOpportunities,
|
filterOpportunities,
|
||||||
NOT_MARKED,
|
NOT_MARKED,
|
||||||
|
releasedFrom,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
} from './filterOpportunities';
|
} from './filterOpportunities';
|
||||||
@@ -73,6 +74,16 @@ test('activeFilterCount counts the filters in use', () => {
|
|||||||
expect(
|
expect(
|
||||||
activeFilterCount({ ...emptyFilters(), title: ' ', smeSuitable: true, showHidden: true }),
|
activeFilterCount({ ...emptyFilters(), title: ' ', smeSuitable: true, showHidden: true }),
|
||||||
).toBe(2);
|
).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', () => {
|
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 { OPPORTUNITY_STATUSES } from '@/types/Opportunities';
|
||||||
import { parseDate } from './format';
|
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. */
|
/** Marks filter option for opportunities with no marks at all. */
|
||||||
export const NOT_MARKED = 'none';
|
export const NOT_MARKED = 'none';
|
||||||
export type MarkFilter = OpportunityMark | typeof NOT_MARKED;
|
export type MarkFilter = OpportunityMark | typeof NOT_MARKED;
|
||||||
|
|
||||||
/** What the table is filtered by. */
|
/** What the table is filtered by. */
|
||||||
export interface OpportunityFilters {
|
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;
|
title: string;
|
||||||
/** Empty means every status. */
|
/** Empty means every status. */
|
||||||
statuses: ReadonlySet<string>;
|
statuses: ReadonlySet<string>;
|
||||||
@@ -21,8 +30,12 @@ export interface OpportunityFilters {
|
|||||||
showHidden: boolean;
|
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 => ({
|
export const emptyFilters = (): OpportunityFilters => ({
|
||||||
|
releasedWithinDays: DEFAULT_RELEASED_WITHIN_DAYS,
|
||||||
title: '',
|
title: '',
|
||||||
statuses: new Set(),
|
statuses: new Set(),
|
||||||
marks: new Set(),
|
marks: new Set(),
|
||||||
@@ -31,9 +44,13 @@ export const emptyFilters = (): OpportunityFilters => ({
|
|||||||
showHidden: false,
|
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 =>
|
export const activeFilterCount = (filters: OpportunityFilters): number =>
|
||||||
[
|
[
|
||||||
|
filters.releasedWithinDays !== DEFAULT_RELEASED_WITHIN_DAYS,
|
||||||
filters.title.trim() !== '',
|
filters.title.trim() !== '',
|
||||||
filters.statuses.size > 0,
|
filters.statuses.size > 0,
|
||||||
filters.marks.size > 0,
|
filters.marks.size > 0,
|
||||||
@@ -46,6 +63,15 @@ export const activeFilterCount = (filters: OpportunityFilters): number =>
|
|||||||
export const hasActiveFilters = (filters: OpportunityFilters): boolean =>
|
export const hasActiveFilters = (filters: OpportunityFilters): boolean =>
|
||||||
activeFilterCount(filters) > 0;
|
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. */
|
/** The opportunities that pass filters, newest release first. */
|
||||||
export function filterOpportunities(
|
export function filterOpportunities(
|
||||||
opportunities: readonly Opportunity[],
|
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 { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type * as clientModule from '@/api/client';
|
import type * as clientModule from '@/api/client';
|
||||||
@@ -10,9 +10,12 @@ import { createOpportunities } from './createOpportunities';
|
|||||||
|
|
||||||
// The two requests createOpportunities makes; each test answers them.
|
// The two requests createOpportunities makes; each test answers them.
|
||||||
const requests = vi.hoisted(() => ({
|
const requests = vi.hoisted(() => ({
|
||||||
listOpportunities: vi.fn<(signal?: AbortSignal) => Promise<OpportunityChanges>>(),
|
listOpportunities:
|
||||||
|
vi.fn<(releasedFrom: string, signal?: AbortSignal) => Promise<OpportunityChanges>>(),
|
||||||
listChangedOpportunities:
|
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) => ({
|
vi.mock('@/api/client', async (importOriginal) => ({
|
||||||
@@ -51,6 +54,7 @@ let loads: Deferred<OpportunityChanges>[];
|
|||||||
let catchUps: { batch: string; result: Deferred<OpportunityChanges> }[];
|
let catchUps: { batch: string; result: Deferred<OpportunityChanges> }[];
|
||||||
let list: OpportunityList;
|
let list: OpportunityList;
|
||||||
let dispose: () => void;
|
let dispose: () => void;
|
||||||
|
let setReleasedFrom: (from: string) => void;
|
||||||
|
|
||||||
const titles = (): string[] => list.items().map((opportunity) => opportunity.title);
|
const titles = (): string[] => list.items().map((opportunity) => opportunity.title);
|
||||||
|
|
||||||
@@ -69,7 +73,9 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
createRoot((disposeRoot) => {
|
createRoot((disposeRoot) => {
|
||||||
dispose = 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();
|
await settle();
|
||||||
expect(catchUps.at(-1)?.batch).toBe('b5');
|
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
|
* Loads a list with load, given source's value, and loads it again whenever
|
||||||
* the list is reloaded or its owner is cleaned up.
|
* 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;
|
let controller: AbortController | undefined;
|
||||||
onCleanup(() => controller?.abort());
|
onCleanup(() => controller?.abort());
|
||||||
|
|
||||||
const [resource, { mutate, refetch }] = createResource(() => {
|
const [resource, { mutate, refetch }] = createResource(source, (value) => {
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
return load(controller.signal);
|
return load(controller.signal, value);
|
||||||
});
|
});
|
||||||
return wrap(resource, refetch, (change) => {
|
return wrap(resource, refetch, (change) => {
|
||||||
mutate((items) => change(items ?? []));
|
mutate((items) => change(items ?? []));
|
||||||
@@ -66,8 +70,12 @@ export interface OpportunityList extends ApiList<Opportunity> {
|
|||||||
catchUp: () => void;
|
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,
|
// The change batch the list is up to date with; "" before it has loaded,
|
||||||
// or if the backend has recorded no changes.
|
// or if the backend has recorded no changes.
|
||||||
let batch = '';
|
let batch = '';
|
||||||
@@ -78,10 +86,10 @@ export function createOpportunities(): OpportunityList {
|
|||||||
const [isWanted, setIsWanted] = createSignal(false);
|
const [isWanted, setIsWanted] = createSignal(false);
|
||||||
const [isCatchingUp, setIsCatchingUp] = createSignal(false);
|
const [isCatchingUp, setIsCatchingUp] = createSignal(false);
|
||||||
|
|
||||||
const list = createApiList(async (signal) => {
|
const list = createApiList(releasedFrom, async (signal, from) => {
|
||||||
loads += 1;
|
loads += 1;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
const loaded = await api.listOpportunities(signal);
|
const loaded = await api.listOpportunities(from, signal);
|
||||||
batch = loaded.batch;
|
batch = loaded.batch;
|
||||||
return loaded.opportunities;
|
return loaded.opportunities;
|
||||||
});
|
});
|
||||||
@@ -97,7 +105,7 @@ export function createOpportunities(): OpportunityList {
|
|||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
setIsCatchingUp(true);
|
setIsCatchingUp(true);
|
||||||
try {
|
try {
|
||||||
const changes = await api.listChangedOpportunities(batch, signal);
|
const changes = await api.listChangedOpportunities(batch, releasedFrom(), signal);
|
||||||
if (load !== loads) return;
|
if (load !== loads) return;
|
||||||
batch = changes.batch;
|
batch = changes.batch;
|
||||||
list.update((items) => mergeOpportunities(items, changes.opportunities));
|
list.update((items) => mergeOpportunities(items, changes.opportunities));
|
||||||
@@ -124,4 +132,8 @@ export function createOpportunities(): OpportunityList {
|
|||||||
|
|
||||||
/** Every classification some opportunity has, loaded from the backend. */
|
/** Every classification some opportunity has, loaded from the backend. */
|
||||||
export const createClassifications = (): ApiList<ClassificationCode> =>
|
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
|
* releases or with changed marks), and batch is the newest
|
||||||
* batch they include. offset and limit can't be given with
|
* batch they include. offset and limit can't be given with
|
||||||
* changed_after. An unknown batch is 410 Gone: reload the
|
* 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: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -83,6 +85,8 @@ export interface paths {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description List the opportunities changed after this batch instead */
|
/** @description List the opportunities changed after this batch instead */
|
||||||
changed_after?: string;
|
changed_after?: string;
|
||||||
|
/** @description Only opportunities whose latest release is at or after this time */
|
||||||
|
released_from?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -99,7 +103,7 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["api.OpportunityPage"];
|
"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: {
|
400: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
|
|||||||
Reference in new issue
Block a user