Made the table's columns sortable

Every column's header is a button that sorts the table by it, and
clicking it again sorts the other way. A column starts with the most
useful first: titles A to Z, the latest dates and highest values, Yes
before No, hidden first, and marks in the menu's order. Statuses sort
in the filters' order rather than alphabetically, and titles by their
words and numbers, ignoring case and punctuation. Rows with no value in
the column (no date, no value, no marks) go last either way, and rows
that tie are newest release first.

The sort is remembered with the other preferences, and is newest
release first until another is chosen, as before. Sorting has moved
out of filterOpportunities, which now keeps the order it's given.
This commit is contained in:
bwilliams committed 2026-10-21 12:47:25 +01:00
1 parent c09c30ae71
commit 5c62376b6e
7 files changed
+417 -56

No files matched your search

+12 -2
View File
@@ -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 {
<OpportunityTable
opportunities={rows()}
annotations={annotations}
sort={sort()}
onSort={setSort}
onError={toasts.show}
/>
}
+95 -20
View File
@@ -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 (
<th scope="col" class={`${th} ${props.class ?? ''}`} aria-sort={direction()}>
<button
type="button"
class="group inline-flex items-center gap-1 rounded hover:text-ink"
classList={{ 'text-ink': direction() !== undefined }}
onClick={() => props.onSort(nextSort(props.sort, props.column))}
>
{props.children}
<Switch
fallback={
<span class="opacity-40 group-hover:opacity-100">
<ChevronsUpDown {...sortIcon} />
</span>
}
>
<Match when={direction() === 'ascending'}>
<ArrowUp {...sortIcon} />
</Match>
<Match when={direction() === 'descending'}>
<ArrowDown {...sortIcon} />
</Match>
</Switch>
</button>
</th>
);
}
/**
* 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 {
<table class="w-full border-separate border-spacing-0 tabular-nums">
<thead>
<tr>
<th scope="col" class={`${th} min-w-80`}>
<SortableHeader
column="title"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="min-w-80"
>
Title
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="status" sort={props.sort} onSort={(next) => props.onSort(next)}>
Status
</th>
<th scope="col" class={`${th} text-center`}>
</SortableHeader>
<SortableHeader
column="smeSuitable"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-center"
>
SME suitable
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader
column="published"
sort={props.sort}
onSort={(next) => props.onSort(next)}
>
Published
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="start" sort={props.sort} onSort={(next) => props.onSort(next)}>
Start
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="end" sort={props.sort} onSort={(next) => props.onSort(next)}>
End
</th>
<th scope="col" class={`${th} text-right`}>
</SortableHeader>
<SortableHeader
column="value"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-right"
>
Value
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="mark" sort={props.sort} onSort={(next) => props.onSort(next)}>
Mark
</th>
<th scope="col" class={`${th} text-center`}>
</SortableHeader>
<SortableHeader
column="hidden"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-center"
>
Hide
</th>
</SortableHeader>
</tr>
</thead>
<tbody class="[&>tr:last-child>td]:border-b-0">
+5 -5
View File
@@ -49,14 +49,14 @@ const filtered = (filters: Partial<OpportunityFilters>): 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<OpportunityFilters>; 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'] },
+20 -29
View File
@@ -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 =>
+120
View File
@@ -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<string, Annotation> = {
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']);
});
+164
View File
@@ -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<SortColumn, SortDirection> = {
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<SortColumn, (row: SortRow) => 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<SortKey, null>, second: Exclude<SortKey, null>): 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);
}
+1
View File
@@ -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;