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 ( + + + + ); +} + /** - * 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 { - - - - - - - - - + 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;
+ 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 -