Added a "Published in the last X days" filter, applied by the backend

GET /api/opportunities takes released_from, an RFC 3339 time, and lists
only the opportunities whose latest release is at or after it. It works
with paging and with changed_after, which keeps the same batch either
way; an invalid value is 400.

The frontend's filter defaults to 7 days, from midnight UK time, and
sends it with every load and catch-up, so a year of opportunities isn't
downloaded to show a week's. Changing the days loads the list again;
Clear filters goes back to 7.
This commit is contained in:
bwilliams committed 2026-10-19 10:05:45 +01:00
1 parent b8244da2e5
commit 746fa0a9c1
19 files changed
+333 -64

No files matched your search

+11
View File
@@ -7,6 +7,7 @@ import {
emptyFilters,
filterOpportunities,
NOT_MARKED,
releasedFrom,
statusLabel,
statusOptions,
} from './filterOpportunities';
@@ -73,6 +74,16 @@ test('activeFilterCount counts the filters in use', () => {
expect(
activeFilterCount({ ...emptyFilters(), title: ' ', smeSuitable: true, showHidden: true }),
).toBe(2);
expect(activeFilterCount({ ...emptyFilters(), releasedWithinDays: 30 })).toBe(1);
});
test('releasedFrom is the start of the day that many days ago, in UK time', () => {
// 19 October 2026 is in British Summer Time; 12 October's midnight is 23:00 UTC.
const now = new Date('2026-10-19T09:30:00Z');
expect(releasedFrom(7, now)).toBe('2026-10-11T23:00:00.000Z');
expect(releasedFrom(0, now)).toBe('2026-10-18T23:00:00.000Z');
// Across the end of BST (25 October), midnight is 00:00 UTC again.
expect(releasedFrom(7, new Date('2026-11-02T12:00:00Z'))).toBe('2026-10-26T00:00:00.000Z');
});
test('statusOptions lists the known statuses, then others sorted', () => {
+28 -2
View File
@@ -3,12 +3,21 @@ import type { Opportunity, OpportunityStatus } from '@/types/Opportunities';
import { OPPORTUNITY_STATUSES } from '@/types/Opportunities';
import { parseDate } from './format';
/** How many days back opportunities are loaded from, unless the user chooses otherwise. */
export const DEFAULT_RELEASED_WITHIN_DAYS = 7;
/** Marks filter option for opportunities with no marks at all. */
export const NOT_MARKED = 'none';
export type MarkFilter = OpportunityMark | typeof NOT_MARKED;
/** What the table is filtered by. */
export interface OpportunityFilters {
/**
* Only opportunities with a release in the last this many days, and
* today. The backend applies it (see releasedFrom); the rest are applied
* here.
*/
releasedWithinDays: number;
title: string;
/** Empty means every status. */
statuses: ReadonlySet<string>;
@@ -21,8 +30,12 @@ export interface OpportunityFilters {
showHidden: boolean;
}
/** Filters that let every opportunity through, apart from hidden ones. */
/**
* The filters to start with: the last DEFAULT_RELEASED_WITHIN_DAYS days'
* opportunities, apart from hidden ones.
*/
export const emptyFilters = (): OpportunityFilters => ({
releasedWithinDays: DEFAULT_RELEASED_WITHIN_DAYS,
title: '',
statuses: new Set(),
marks: new Set(),
@@ -31,9 +44,13 @@ export const emptyFilters = (): OpportunityFilters => ({
showHidden: false,
});
/** How many separate filters are in use, for the collapsed panel's badge. */
/**
* How many separate filters are changed from emptyFilters, for the collapsed
* panel's badge.
*/
export const activeFilterCount = (filters: OpportunityFilters): number =>
[
filters.releasedWithinDays !== DEFAULT_RELEASED_WITHIN_DAYS,
filters.title.trim() !== '',
filters.statuses.size > 0,
filters.marks.size > 0,
@@ -46,6 +63,15 @@ export const activeFilterCount = (filters: OpportunityFilters): number =>
export const hasActiveFilters = (filters: OpportunityFilters): boolean =>
activeFilterCount(filters) > 0;
/**
* The start of the day days before now, in local time, as an RFC 3339 time
* for the backend's released_from: 7 days on a Monday is from the Monday
* before, so a whole week and today.
*/
export function releasedFrom(days: number, now: Date = new Date()): string {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - days).toISOString();
}
/** The opportunities that pass filters, newest release first. */
export function filterOpportunities(
opportunities: readonly Opportunity[],