diff --git a/web/src/App.tsx b/web/src/App.tsx
index 34fe463..c1619ff 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -17,6 +17,7 @@ import {
statusOptions,
} from '@/lib/filterOpportunities';
import { formatCount } from '@/lib/format';
+import { parseSort, serialiseSort, sortOpportunities } from '@/lib/sortOpportunities';
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
@@ -35,6 +36,8 @@ export function App(): JSX.Element {
parseFilters(readStored(STORAGE_KEYS.filters)),
);
createEffect(() => writeStored(STORAGE_KEYS.filters, serialiseFilters(filters())));
+ const [sort, setSort] = createSignal(parseSort(readStored(STORAGE_KEYS.sort)));
+ createEffect(() => writeStored(STORAGE_KEYS.sort, serialiseSort(sort())));
// 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));
@@ -55,9 +58,14 @@ export function App(): JSX.Element {
filters().classificationIds,
),
);
- // annotations.get reads from a store, so hiding an opportunity re-runs this.
+ // annotations.get reads from a store, so hiding or marking an opportunity
+ // re-runs this.
const rows = createMemo(() =>
- filterOpportunities(opportunities.items(), filters(), annotations.get),
+ sortOpportunities(
+ filterOpportunities(opportunities.items(), filters(), annotations.get),
+ sort(),
+ annotations.get,
+ ),
);
const clear = (): void => {
setFilters(emptyFilters());
@@ -116,6 +124,8 @@ export function App(): JSX.Element {
}
diff --git a/web/src/components/OpportunityTable.tsx b/web/src/components/OpportunityTable.tsx
index 7669605..188b4bb 100644
--- a/web/src/components/OpportunityTable.tsx
+++ b/web/src/components/OpportunityTable.tsx
@@ -1,13 +1,18 @@
+import ArrowDown from 'lucide-solid/icons/arrow-down';
+import ArrowUp from 'lucide-solid/icons/arrow-up';
+import ChevronsUpDown from 'lucide-solid/icons/chevrons-up-down';
import ExternalLink from 'lucide-solid/icons/external-link';
import Info from 'lucide-solid/icons/info';
import type { JSX } from 'solid-js';
-import { createMemo, createSignal, For, Show } from 'solid-js';
+import { createMemo, createSignal, For, Match, Show, Switch } from 'solid-js';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import { findATenderNoticeUrl, isFromFindATender, latestRelease } from '@/lib/opportunities';
+import type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities';
+import { nextSort } from '@/lib/sortOpportunities';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
@@ -17,6 +22,10 @@ import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
opportunities: Opportunity[];
annotations: OpportunityAnnotations;
+ /** The order the opportunities are in, shown on the headers. */
+ sort: OpportunitySort;
+ /** Called with the sort a header's click asks for. */
+ onSort: (next: OpportunitySort) => void;
/** Called with a message when an opportunity's notice couldn't be opened. */
onError: (message: string) => void;
}
@@ -24,11 +33,53 @@ interface Props {
const th =
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
const td = 'border-b border-line-soft p-3 align-middle';
+const sortIcon = { size: 14, strokeWidth: 2, 'aria-hidden': true } as const;
const iconButton =
'flex rounded-md border border-line bg-surface p-1 text-ink hover:enabled:border-muted disabled:opacity-60';
+interface HeaderProps {
+ column: SortColumn;
+ sort: OpportunitySort;
+ onSort: (next: OpportunitySort) => void;
+ class?: string;
+ children: JSX.Element;
+}
+
+// A column's header: a button that sorts the table by the column, or the
+// other way if it's sorted by it already.
+function SortableHeader(props: HeaderProps): JSX.Element {
+ const direction = () => (props.sort.column === props.column ? props.sort.direction : undefined);
+
+ return (
+
+ props.onSort(nextSort(props.sort, props.column))}
+ >
+ {props.children}
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ );
+}
+
/**
- * The table of opportunities, each with its marks, a hide button, a button
+ * The table of opportunities, sortable by any column, each with its marks, a hide button, a button
* that opens its details and, for those from Find a Tender, a link to its
* latest notice there.
*/
@@ -48,33 +99,57 @@ export function OpportunityTable(props: Props): JSX.Element {
-
+ props.onSort(next)}
+ class="min-w-80"
+ >
Title
-
-
+
+ props.onSort(next)}>
Status
-
-
+
+ props.onSort(next)}
+ class="text-center"
+ >
SME suitable
-
-
+
+ props.onSort(next)}
+ >
Published
-
-
+
+ props.onSort(next)}>
Start
-
-
+
+ props.onSort(next)}>
End
-
-
+
+ props.onSort(next)}
+ class="text-right"
+ >
Value
-
-
+
+ props.onSort(next)}>
Mark
-
-
+
+ props.onSort(next)}
+ class="text-center"
+ >
Hide
-
+
diff --git a/web/src/lib/filterOpportunities.test.ts b/web/src/lib/filterOpportunities.test.ts
index d12b1a5..3244a14 100644
--- a/web/src/lib/filterOpportunities.test.ts
+++ b/web/src/lib/filterOpportunities.test.ts
@@ -49,14 +49,14 @@ const filtered = (filters: Partial): string[] =>
);
describe('filterOpportunities', () => {
- test('hides hidden opportunities unless asked, newest release first', () => {
- expect(filtered({})).toEqual(['c', 'a']);
- expect(filtered({ showHidden: true })).toEqual(['b', 'c', 'a']);
+ test('hides hidden opportunities unless asked, keeping the order given', () => {
+ expect(filtered({})).toEqual(['a', 'c']);
+ expect(filtered({ showHidden: true })).toEqual(['a', 'b', 'c']);
});
test.each<{ name: string; filters: Partial; want: string[] }>([
- { name: 'title, ignoring case and spaces', filters: { title: ' CLOUD ' }, want: ['c', 'a'] },
- { name: 'statuses', filters: { statuses: new Set(['active', 'planned']) }, want: ['c', 'a'] },
+ { name: 'title, ignoring case and spaces', filters: { title: ' CLOUD ' }, want: ['a', 'c'] },
+ { name: 'statuses', filters: { statuses: new Set(['active', 'planned']) }, want: ['a', 'c'] },
{ name: 'a mark', filters: { marks: new Set(['interested']) }, want: ['a'] },
{ name: 'no marks', filters: { marks: new Set([NOT_MARKED]) }, want: ['c'] },
{ name: 'classification', filters: { classificationIds: new Set([1]) }, want: ['a'] },
diff --git a/web/src/lib/filterOpportunities.ts b/web/src/lib/filterOpportunities.ts
index a7fb504..841f3f6 100644
--- a/web/src/lib/filterOpportunities.ts
+++ b/web/src/lib/filterOpportunities.ts
@@ -2,7 +2,6 @@ import type { Annotation, OpportunityMark } from '@/types/Annotations';
import { isOpportunityMark } from '@/types/Annotations';
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;
@@ -129,7 +128,7 @@ 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, in the order given. */
export function filterOpportunities(
opportunities: readonly Opportunity[],
filters: OpportunityFilters,
@@ -137,33 +136,25 @@ export function filterOpportunities(
): Opportunity[] {
const query = filters.title.trim().toLocaleLowerCase();
- return opportunities
- .filter((opportunity) => {
- const annotation = annotationFor(opportunity.uuid);
- if (!filters.showHidden && annotation.hidden) return false;
- if (query && !opportunity.title.toLocaleLowerCase().includes(query)) return false;
- if (filters.statuses.size > 0 && !filters.statuses.has(opportunity.status)) return false;
- if (filters.smeSuitable && !opportunity.sme_suitable) return false;
- if (filters.marks.size > 0) {
- const matches =
- annotation.marks.length === 0
- ? filters.marks.has(NOT_MARKED)
- : annotation.marks.some((mark) => filters.marks.has(mark));
- if (!matches) return false;
- }
- if (filters.classificationIds.size > 0) {
- const codes = opportunity.classifications ?? [];
- if (!codes.some((code) => filters.classificationIds.has(code.id))) return false;
- }
- return true;
- })
- .sort(byNewestRelease);
-}
-
-function byNewestRelease(first: Opportunity, second: Opportunity): number {
- const firstTime = parseDate(first.latest_release_date)?.getTime() ?? 0;
- const secondTime = parseDate(second.latest_release_date)?.getTime() ?? 0;
- return secondTime - firstTime;
+ return opportunities.filter((opportunity) => {
+ const annotation = annotationFor(opportunity.uuid);
+ if (!filters.showHidden && annotation.hidden) return false;
+ if (query && !opportunity.title.toLocaleLowerCase().includes(query)) return false;
+ if (filters.statuses.size > 0 && !filters.statuses.has(opportunity.status)) return false;
+ if (filters.smeSuitable && !opportunity.sme_suitable) return false;
+ if (filters.marks.size > 0) {
+ const matches =
+ annotation.marks.length === 0
+ ? filters.marks.has(NOT_MARKED)
+ : annotation.marks.some((mark) => filters.marks.has(mark));
+ if (!matches) return false;
+ }
+ if (filters.classificationIds.size > 0) {
+ const codes = opportunity.classifications ?? [];
+ if (!codes.some((code) => filters.classificationIds.has(code.id))) return false;
+ }
+ return true;
+ });
}
const isKnownStatus = (status: string): status is OpportunityStatus =>
diff --git a/web/src/lib/sortOpportunities.test.ts b/web/src/lib/sortOpportunities.test.ts
new file mode 100644
index 0000000..c036912
--- /dev/null
+++ b/web/src/lib/sortOpportunities.test.ts
@@ -0,0 +1,120 @@
+import { expect, test } from 'vitest';
+
+import type { Annotation } from '@/types/Annotations';
+import type { OpportunitySort, SortColumn } from './sortOpportunities';
+import {
+ DEFAULT_SORT,
+ nextSort,
+ parseSort,
+ serialiseSort,
+ sortOpportunities,
+} from './sortOpportunities';
+import { testOpportunity } from './testOpportunity';
+
+const opportunities = [
+ testOpportunity('a', {
+ title: 'road repairs',
+ status: 'complete',
+ sme_suitable: true,
+ latest_release_date: '2026-10-01T12:00:00Z',
+ start_date: '2027-01-01T00:00:00Z',
+ gross_value: 5000,
+ }),
+ testOpportunity('b', {
+ title: 'Cloud hosting 10',
+ status: 'planning',
+ latest_release_date: '2026-10-03T12:00:00Z',
+ end_date: '2027-06-01T00:00:00Z',
+ gross_value: 0,
+ }),
+ testOpportunity('c', {
+ title: 'Cloud hosting 9',
+ status: 'active',
+ latest_release_date: '2026-10-02T12:00:00Z',
+ start_date: '0001-01-01T00:00:00Z',
+ gross_value: 120000,
+ }),
+ testOpportunity('d', {
+ title: '',
+ status: 'tendering',
+ latest_release_date: '2026-10-04T12:00:00Z',
+ start_date: '2026-11-01T00:00:00Z',
+ end_date: '2026-12-01T00:00:00Z',
+ gross_value: 300,
+ }),
+];
+
+const annotations: Record = {
+ a: { uuid: 'a', marks: ['won'], hidden: false },
+ b: { uuid: 'b', marks: ['interested', 'lost'], hidden: true },
+ c: { uuid: 'c', marks: ['interested'], hidden: false },
+};
+const annotationFor = (uuid: string): Annotation =>
+ annotations[uuid] ?? { uuid, marks: [], hidden: false };
+
+const sorted = (column: SortColumn, direction: OpportunitySort['direction']): string[] =>
+ sortOpportunities(opportunities, { column, direction }, annotationFor).map(
+ (opportunity) => opportunity.uuid,
+ );
+
+test.each<{ column: SortColumn; ascending: string[]; descending: string[] }>([
+ // Natural order (9 before 10), ignoring case; untitled last.
+ { column: 'title', ascending: ['c', 'b', 'a', 'd'], descending: ['a', 'b', 'c', 'd'] },
+ // The filters' order; an unknown status after the known ones.
+ { column: 'status', ascending: ['b', 'c', 'a', 'd'], descending: ['d', 'a', 'c', 'b'] },
+ // Ties are newest release first.
+ { column: 'smeSuitable', ascending: ['d', 'b', 'c', 'a'], descending: ['a', 'd', 'b', 'c'] },
+ { column: 'published', ascending: ['a', 'c', 'b', 'd'], descending: ['d', 'b', 'c', 'a'] },
+ // No date, or Go's zero time, last both ways.
+ { column: 'start', ascending: ['d', 'a', 'b', 'c'], descending: ['a', 'd', 'b', 'c'] },
+ { column: 'end', ascending: ['d', 'b', 'c', 'a'], descending: ['b', 'd', 'c', 'a'] },
+ // No value (0) last both ways.
+ { column: 'value', ascending: ['d', 'a', 'c', 'b'], descending: ['c', 'a', 'd', 'b'] },
+ // Menu order, a second mark after none; unmarked last.
+ { column: 'mark', ascending: ['c', 'b', 'a', 'd'], descending: ['a', 'b', 'c', 'd'] },
+ { column: 'hidden', ascending: ['d', 'c', 'a', 'b'], descending: ['b', 'd', 'c', 'a'] },
+])('sorts by $column', ({ column, ascending, descending }) => {
+ expect(sorted(column, 'ascending')).toEqual(ascending);
+ expect(sorted(column, 'descending')).toEqual(descending);
+});
+
+test('sortOpportunities leaves its input as it was', () => {
+ const before = opportunities.map((opportunity) => opportunity.uuid);
+ sorted('title', 'ascending');
+ expect(opportunities.map((opportunity) => opportunity.uuid)).toEqual(before);
+});
+
+test('nextSort flips the direction, or starts a new column in its first direction', () => {
+ expect(nextSort(DEFAULT_SORT, 'published')).toEqual({
+ column: 'published',
+ direction: 'ascending',
+ });
+ expect(nextSort(DEFAULT_SORT, 'title')).toEqual({ column: 'title', direction: 'ascending' });
+ expect(nextSort(DEFAULT_SORT, 'value')).toEqual({ column: 'value', direction: 'descending' });
+ expect(nextSort({ column: 'title', direction: 'ascending' }, 'title')).toEqual({
+ column: 'title',
+ direction: 'descending',
+ });
+});
+
+test('parseSort reads serialiseSort back, and falls back to DEFAULT_SORT', () => {
+ const sort: OpportunitySort = { column: 'end', direction: 'ascending' };
+ expect(parseSort(serialiseSort(sort))).toEqual(sort);
+ expect(parseSort(null)).toEqual(DEFAULT_SORT);
+ expect(parseSort('not json')).toEqual(DEFAULT_SORT);
+ expect(parseSort('{"column":"buyer","direction":"ascending"}')).toEqual(DEFAULT_SORT);
+ expect(parseSort('{"column":"title","direction":"up"}')).toEqual(DEFAULT_SORT);
+ expect(parseSort('["title"]')).toEqual(DEFAULT_SORT);
+});
+
+test('titles sort by their words, ignoring punctuation', () => {
+ const titled = ['"Medisoft" system', '-H&F heating', 'Library books'].map((title, i) =>
+ testOpportunity(String(i), { title }),
+ );
+ const titles = sortOpportunities(
+ titled,
+ { column: 'title', direction: 'ascending' },
+ annotationFor,
+ ).map((opportunity) => opportunity.title);
+ expect(titles).toEqual(['-H&F heating', 'Library books', '"Medisoft" system']);
+});
diff --git a/web/src/lib/sortOpportunities.ts b/web/src/lib/sortOpportunities.ts
new file mode 100644
index 0000000..cdb382b
--- /dev/null
+++ b/web/src/lib/sortOpportunities.ts
@@ -0,0 +1,164 @@
+import type { Annotation } from '@/types/Annotations';
+import { OPPORTUNITY_MARKS } from '@/types/Annotations';
+import type { Opportunity } from '@/types/Opportunities';
+import { OPPORTUNITY_STATUSES } from '@/types/Opportunities';
+import { parseDate } from './format';
+
+/** The table's columns, each of which it can be sorted by. */
+export const SORT_COLUMNS = [
+ 'title',
+ 'status',
+ 'smeSuitable',
+ 'published',
+ 'start',
+ 'end',
+ 'value',
+ 'mark',
+ 'hidden',
+] as const;
+
+export type SortColumn = (typeof SORT_COLUMNS)[number];
+
+/** Named as aria-sort names them, so a header can use the value as it is. */
+export type SortDirection = 'ascending' | 'descending';
+
+/** What the table is sorted by. */
+export interface OpportunitySort {
+ column: SortColumn;
+ direction: SortDirection;
+}
+
+/** Newest release first, as the table was before it could be sorted. */
+export const DEFAULT_SORT: OpportunitySort = { column: 'published', direction: 'descending' };
+
+// The direction a column is sorted in when it's first chosen: text A to Z,
+// and the rest with the most useful first (latest dates, highest values,
+// Yes before No, marks in the menu's order).
+const FIRST_DIRECTION: Record = {
+ title: 'ascending',
+ status: 'ascending',
+ smeSuitable: 'descending',
+ published: 'descending',
+ start: 'descending',
+ end: 'descending',
+ value: 'descending',
+ mark: 'ascending',
+ hidden: 'descending',
+};
+
+/** The sort after column's header is clicked: the other direction, or a new column. */
+export function nextSort(current: OpportunitySort, column: SortColumn): OpportunitySort {
+ if (current.column !== column) return { column, direction: FIRST_DIRECTION[column] };
+ return {
+ column,
+ direction: current.direction === 'ascending' ? 'descending' : 'ascending',
+ };
+}
+
+/** The sort as text to store. */
+export const serialiseSort = (sort: OpportunitySort): string => JSON.stringify(sort);
+
+const isSortColumn = (value: unknown): value is SortColumn =>
+ (SORT_COLUMNS as readonly unknown[]).includes(value);
+
+/** A sort read back from serialiseSort's text, or DEFAULT_SORT if it isn't one. */
+export function parseSort(text: string | null): OpportunitySort {
+ let stored: unknown;
+ try {
+ stored = JSON.parse(text ?? 'null');
+ } catch {
+ return DEFAULT_SORT;
+ }
+ if (typeof stored !== 'object' || stored === null) return DEFAULT_SORT;
+ if (!('column' in stored) || !('direction' in stored)) return DEFAULT_SORT;
+ const { column, direction } = stored;
+ if (!isSortColumn(column) || (direction !== 'ascending' && direction !== 'descending')) {
+ return DEFAULT_SORT;
+ }
+ return { column, direction };
+}
+
+// A value to compare rows by; null is a missing value (no date, no value,
+// no marks), which goes last whichever way the column is sorted.
+type SortKey = string | number | readonly number[] | null;
+
+const dateKey = (value: string | null): number | null => parseDate(value)?.getTime() ?? null;
+
+const statusOrder = (status: string): number => {
+ const index = (OPPORTUNITY_STATUSES as readonly string[]).indexOf(status);
+ // Statuses the app doesn't know go after the known ones.
+ return index === -1 ? OPPORTUNITY_STATUSES.length : index;
+};
+
+interface SortRow {
+ opportunity: Opportunity;
+ annotation: Annotation;
+}
+
+// Statuses and marks sort in the order the filters list them, not
+// alphabetically; booleans sort false before true.
+const SORT_KEYS: Record SortKey> = {
+ title: ({ opportunity }) => opportunity.title.trim() || null,
+ status: ({ opportunity }) => (opportunity.status ? statusOrder(opportunity.status) : null),
+ smeSuitable: ({ opportunity }) => Number(opportunity.sme_suitable),
+ published: ({ opportunity }) => dateKey(opportunity.latest_release_date),
+ start: ({ opportunity }) => dateKey(opportunity.start_date),
+ end: ({ opportunity }) => dateKey(opportunity.end_date),
+ value: ({ opportunity }) => (opportunity.gross_value > 0 ? opportunity.gross_value : null),
+ mark: ({ annotation }) =>
+ annotation.marks.length > 0
+ ? annotation.marks.map((mark) => OPPORTUNITY_MARKS.indexOf(mark))
+ : null,
+ hidden: ({ annotation }) => Number(annotation.hidden),
+};
+
+// Punctuation is ignored, so a quoted title ("Medisoft" system) sorts by its words.
+const collator = new Intl.Collator('en-GB', {
+ numeric: true,
+ sensitivity: 'base',
+ ignorePunctuation: true,
+});
+
+function compareKeys(first: Exclude, second: Exclude): number {
+ if (typeof first === 'string' && typeof second === 'string') {
+ return collator.compare(first, second);
+ }
+ if (typeof first === 'number' && typeof second === 'number') return first - second;
+ if (Array.isArray(first) && Array.isArray(second)) {
+ for (let i = 0; i < Math.min(first.length, second.length); i++) {
+ const difference = (first[i] ?? 0) - (second[i] ?? 0);
+ if (difference !== 0) return difference;
+ }
+ return first.length - second.length;
+ }
+ return 0;
+}
+
+/**
+ * The opportunities in sort's order. Rows with no value in the column go
+ * last, and rows that tie are newest release first.
+ */
+export function sortOpportunities(
+ opportunities: readonly Opportunity[],
+ sort: OpportunitySort,
+ annotationFor: (uuid: string) => Annotation,
+): Opportunity[] {
+ const sign = sort.direction === 'ascending' ? 1 : -1;
+ const keyOf = SORT_KEYS[sort.column];
+ const rows = opportunities.map((opportunity) => ({
+ opportunity,
+ key: keyOf({ opportunity, annotation: annotationFor(opportunity.uuid) }),
+ published: dateKey(opportunity.latest_release_date) ?? 0,
+ }));
+
+ rows.sort((first, second) => {
+ if (first.key === null || second.key === null) {
+ if (first.key !== second.key) return first.key === null ? 1 : -1;
+ } else {
+ const difference = compareKeys(first.key, second.key);
+ if (difference !== 0) return sign * difference;
+ }
+ return second.published - first.published;
+ });
+ return rows.map((row) => row.opportunity);
+}
diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts
index ea91489..72b8a7b 100644
--- a/web/src/lib/storage.ts
+++ b/web/src/lib/storage.ts
@@ -24,4 +24,5 @@ export const STORAGE_KEYS = {
theme: 'job_search.theme',
filtersOpen: 'job_search.filtersOpen',
filters: 'job_search.filters',
+ sort: 'job_search.sort',
} as const;