diff --git a/internal/api/common.go b/internal/api/common.go index 01b87cf..00dad29 100644 --- a/internal/api/common.go +++ b/internal/api/common.go @@ -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 +} diff --git a/internal/api/opportunities.go b/internal/api/opportunities.go index 6cd7443..7a317fa 100644 --- a/internal/api/opportunities.go +++ b/internal/api/opportunities.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" "sync" + "time" "github.com/go-chi/chi/v5" @@ -71,7 +72,8 @@ func (h *opportunityRoutes) Routes(router chi.Router) { // list serves GET /opportunities: a page of opportunities, newest release // first, with their classifications and marks but not their releases. The // page is set by the offset and limit (default 50, at most 100) query -// parameters. With changed_after, it serves the opportunities changed since +// parameters, and released_from leaves out opportunities whose latest release +// is before it. With changed_after, it serves the opportunities changed since // that batch instead (see listChanged). // // @Summary List opportunities @@ -85,27 +87,38 @@ func (h *opportunityRoutes) Routes(router chi.Router) { // @Description releases or with changed marks), and batch is the newest // @Description batch they include. offset and limit can't be given with // @Description changed_after. An unknown batch is 410 Gone: reload the -// @Description whole list. +// @Description whole list. released_from (RFC 3339, inclusive) leaves out +// @Description opportunities whose latest release is before it, in a page +// @Description or with changed_after. // @Tags opportunities // @Produce json // @Param offset query int false "Opportunities to skip" minimum(0) default(0) // @Param limit query int false "Page size" minimum(1) maximum(100) default(50) // @Param changed_after query string false "List the opportunities changed after this batch instead" +// @Param released_from query string false "Only opportunities whose latest release is at or after this time" format(date-time) // @Success 200 {object} OpportunityPage -// @Failure 400 {object} httpx.Problem "Invalid offset or limit, or either with changed_after" +// @Failure 400 {object} httpx.Problem "Invalid offset, limit or released_from, or offset or limit with changed_after" // @Failure 410 {object} httpx.Problem "Unknown batch" // @Failure 500 {object} httpx.Problem // @Router /opportunities [get] func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() + releasedFrom, err := timeParam( + "released_from", query.Get("released_from"), + ) + if err != nil { + httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error()) + return + } + if query.Has("changed_after") { - h.listChanged(w, r) + h.listChanged(w, r, releasedFrom) return } offset, offsetErr := intParam(query.Get("offset"), 0, 0, 1<<30) limit, limitErr := intParam(query.Get("limit"), 50, 1, 100) - err := errors.Join(offsetErr, limitErr) + err = errors.Join(offsetErr, limitErr) if err != nil { httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error()) return @@ -123,8 +136,9 @@ func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) { opportunities, err := h.store.ListOpportunities( r.Context(), store.OpportunityFilter{ - Limit: limit + 1, - Offset: offset, + Limit: limit + 1, + Offset: offset, + ReleasedFrom: releasedFrom, }, ) if err != nil { @@ -148,10 +162,11 @@ func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) { // listChanged serves GET /opportunities?changed_after=batch: every // opportunity changed after the change batch, as one page, with the newest -// batch it includes. An unknown batch, such as one from another database, is -// 410 Gone, as the client can't catch up from it. +// batch it includes, leaving out those whose latest release is before +// releasedFrom unless it is zero. An unknown batch, such as one from another +// database, is 410 Gone, as the client can't catch up from it. func (h *opportunityRoutes) listChanged( - w http.ResponseWriter, r *http.Request, + w http.ResponseWriter, r *http.Request, releasedFrom time.Time, ) { query := r.URL.Query() if query.Has("offset") || query.Has("limit") { @@ -165,7 +180,7 @@ func (h *opportunityRoutes) listChanged( } opportunities, batch, err := h.store.ListChangedOpportunities( - r.Context(), query.Get("changed_after"), + r.Context(), query.Get("changed_after"), releasedFrom, ) if errors.Is(err, store.ErrUnknownBatch) { httpx.WriteProblem( diff --git a/internal/api/opportunities_test.go b/internal/api/opportunities_test.go index 603dba6..f780912 100644 --- a/internal/api/opportunities_test.go +++ b/internal/api/opportunities_test.go @@ -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) diff --git a/internal/store/README.md b/internal/store/README.md index 4b07ea2..da99a43 100644 --- a/internal/store/README.md +++ b/internal/store/README.md @@ -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. diff --git a/internal/store/batches_test.go b/internal/store/batches_test.go index 6ed005e..f753f28 100644 --- a/internal/store/batches_test.go +++ b/internal/store/batches_test.go @@ -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 || diff --git a/internal/store/queries.go b/internal/store/queries.go index f83c5e9..457321f 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -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` diff --git a/internal/store/queries_test.go b/internal/store/queries_test.go index 6f3cdd5..82196c6 100644 --- a/internal/store/queries_test.go +++ b/internal/store/queries_test.go @@ -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, }, } diff --git a/internal/store/store.go b/internal/store/store.go index 46e4bfb..ad3f4ce 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index f21b25b..bbd4f5c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) } diff --git a/web/README.md b/web/README.md index 8c10975..40cfe2a 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ceac28..2003bf9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(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(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 { )} -

There are no opportunities yet.

+

+ No opportunities were published in the last {days(filters().releasedWithinDays)}. +

diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 6ef0a03..ead12df 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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' }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 80c0b2b..4c8956f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(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 { + async listOpportunities(releasedFrom: string, signal?: AbortSignal): Promise { const opportunities: Opportunity[] = []; let batch = ''; for (let offset = 0; ; offset += PAGE_LIMIT) { const page = await request( - `/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 { + async listChangedOpportunities( + batch: string, + releasedFrom: string, + signal?: AbortSignal, + ): Promise { const page = await request( - `/opportunities?changed_after=${encodeURIComponent(batch)}`, + `/opportunities?changed_after=${encodeURIComponent(batch)}${releasedFromParam(releasedFrom)}`, { signal }, ); return { opportunities: page.opportunities, batch: page.batch }; diff --git a/web/src/components/FilterPanel.tsx b/web/src/components/FilterPanel.tsx index 2525cf5..6ef7284 100644 --- a/web/src/components/FilterPanel.tsx +++ b/web/src/components/FilterPanel.tsx @@ -48,6 +48,7 @@ function toggled(set: ReadonlySet, value: T): Set { */ 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): 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 (
@@ -123,6 +133,24 @@ export function FilterPanel(props: Props): JSX.Element {
+
+ + + commitDays(e.currentTarget)} + /> + days + +
+