Added the frontend in web/, following its code conventions, with linting and generated backend types

web/CODE_CONVENTIONS.md covers the Vite, SolidJS, Tailwind and TypeScript
frontend: tooling, files, imports, exports, naming, TypeScript, formatting,
Solid's reactivity, styling, the backend's types, API calls, errors,
security, formatting values, comments, accessibility, storage,
dependencies and tests. npm run check (strict tsc -b, ESLint and Prettier)
passes, and npm run build builds it.

ESLint (typescript-eslint with type information, eslint-plugin-solid,
simple-import-sort, eslint-comments, and rules for the naming, type import,
export type and promise conventions) and Prettier (with the Tailwind class
plugin) check what they can. Tool versions are pinned exactly. Imports
between folders use @/, which works in Vite as well as TypeScript.

The backend's types are generated from its OpenAPI description into
src/types/api.ts by npm run generate:api, which turns swag's x-nullable
into null types first; src/types/Opportunities.ts names them for the app.

The app works against the backend's API: it fetches every page of
/api/opportunities with each opportunity's marks, the classifications to
filter by from /api/classifications, sets and clears marks (hidden among
them) with the CSRF token, and shows the OCDS tender statuses. Its lists'
requests are aborted when reloaded or no longer needed. The dev server
proxies /api and /ws to job-search serve, by default at localhost:9393.
web/go.mod keeps web/ out of the Go module, as an npm package in
node_modules ships Go code.

The page stays up to date over the backend's WebSocket (src/api/events.ts,
with hand-written event types in src/types/Events.ts), reconnecting with a
growing delay. ClassificationsUpdated changes the filter's classifications
in place and OpportunityMarksUpdated sets an opportunity's marks (unless
this page is still saving its own change). OpportunitiesUpdated catches the
list up: it asks for the opportunities changed after the last change batch
it has seen (changed_after) and merges them in, reloading the whole list
only when the backend doesn't know the batch. A reconnect catches up the
same way and reloads the classifications.
This commit is contained in:
bwilliams committed 2026-10-16 16:01:38 +01:00
1 parent 9905ffb4cb
commit 1832ea7437
42 files changed
+8019

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
import type { JSX } from 'solid-js';
import { createMemo, createSignal, Match, Show, Switch } from 'solid-js';
import { FilterPanel } from '@/components/FilterPanel';
import { OpportunityTable } from '@/components/OpportunityTable';
import { buttonPrimary, linkButton } from '@/components/styles';
import { ThemeToggle } from '@/components/ThemeToggle';
import type { OpportunityFilters } from '@/lib/filterOpportunities';
import { emptyFilters, filterOpportunities, statusOptions } from '@/lib/filterOpportunities';
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import { createTheme } from '@/primitives/createTheme';
const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text-center';
/** The page: the filters, any save error, and the table of opportunities. */
export function App(): JSX.Element {
const opportunities = createOpportunities();
const classifications = createClassifications();
const annotations = createOpportunityAnnotations(opportunities.items);
createLiveUpdates({ opportunities, classifications, annotations });
const theme = createTheme();
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
const statuses = createMemo(() => statusOptions(opportunities.items()));
// annotations.get reads from a store, so hiding an opportunity re-runs this.
const rows = createMemo(() =>
filterOpportunities(opportunities.items(), filters(), annotations.get),
);
const clear = (): void => {
setFilters(emptyFilters());
};
return (
<div class="mx-auto max-w-[1440px] px-3 pt-4 pb-12 sm:px-6 sm:pt-7 sm:pb-16">
<header class="mb-5 flex items-center justify-between gap-4">
<h1 class="text-[1.75rem] font-bold tracking-tight">Opportunities</h1>
<ThemeToggle theme={theme.theme()} onChange={theme.setTheme} />
</header>
<FilterPanel
filters={filters()}
onChange={setFilters}
onClear={clear}
statuses={statuses()}
classifications={classifications.items()}
isLoadingClassifications={classifications.isLoading()}
classificationsError={classifications.error()}
onRetryClassifications={classifications.reload}
shown={rows().length}
total={opportunities.items().length}
/>
<Show when={annotations.error()}>
{(message) => (
<div
class="mb-4 flex items-center justify-between gap-4 rounded-md bg-danger-soft px-3.5 py-2.5 text-danger"
role="alert"
>
<span>{message()}</span>
<button type="button" class={linkButton} onClick={annotations.dismissError}>
Dismiss
</button>
</div>
)}
</Show>
<main>
<Switch fallback={<OpportunityTable opportunities={rows()} annotations={annotations} />}>
<Match when={opportunities.isLoading() && opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>Loading opportunities…</p>
</Match>
<Match when={opportunities.error()}>
{(err) => (
<div class={`${statePanel} text-danger`} role="alert">
<p class="mb-3">Opportunities didn't load: {err().message}</p>
<button type="button" class={buttonPrimary} onClick={opportunities.reload}>
Try again
</button>
</div>
)}
</Match>
<Match when={opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>There are no opportunities yet.</p>
</Match>
<Match when={rows().length === 0}>
<div class={`${statePanel} text-muted`}>
<p class="mb-3">No opportunities match these filters.</p>
<button type="button" class={buttonPrimary} onClick={clear}>
Clear filters
</button>
</div>
</Match>
</Switch>
</main>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import type { MarkName } from '@/types/Annotations';
import type {
ClassificationCode,
Opportunity,
OpportunityMarks,
OpportunityPage,
Problem,
} from '@/types/Opportunities';
/**
* Every request to the backend goes through here. The routes and shapes are
* the backend's OpenAPI description (/api/openapi.json):
*
* GET {base}/opportunities?offset=&limit= -> OpportunityPage
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
* GET {base}/classifications -> ClassificationCode[]
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
* DELETE {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (clears it)
* GET {base}/csrf -> the CSRF token for PUT and DELETE
*
* Marks are set and cleared one at a time, so quick changes to different
* marks can't overwrite each other, and a failed one is undone exactly.
*/
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '');
/** The largest page the backend serves. */
const PAGE_LIMIT = 100;
/**
* Opportunities, and the change batch to ask for later changes after
* ("" if the backend has recorded none).
*/
export interface OpportunityChanges {
opportunities: Opportunity[];
batch: string;
}
/** A request the backend refused or failed, with its problem details if it sent them. */
export class ApiError extends Error {
readonly status: number;
readonly problem: Problem | undefined;
constructor(path: string, status: number, problem: Problem | undefined) {
super(problem?.detail ?? `${path} returned ${status}`);
this.name = 'ApiError';
this.status = status;
this.problem = problem;
}
}
interface CsrfToken {
token: string;
header: string;
}
/** The CSRF token for unsafe requests, fetched when first needed. */
let csrfToken: Promise<CsrfToken> | undefined;
async function fetchCsrfToken(): Promise<CsrfToken> {
const res = await fetch(`${API_BASE}/csrf`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new ApiError('/csrf', res.status, await readProblem(res));
return (await res.json()) as CsrfToken;
}
async function readProblem(res: Response): Promise<Problem | undefined> {
try {
return (await res.json()) as Problem;
} catch {
return undefined;
}
}
/**
* Sends a request and returns its JSON. An unsafe request carries the CSRF
* token; if it's refused (the token expired), a new token is fetched and the
* request sent once more.
*/
async function request<T>(path: string, init: RequestInit = {}, isRetry = false): Promise<T> {
const method = init.method ?? 'GET';
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
const isUnsafe = method !== 'GET' && method !== 'HEAD';
if (isUnsafe) {
csrfToken ??= fetchCsrfToken();
const { token, header } = await csrfToken.catch((err: unknown) => {
csrfToken = undefined;
throw err;
});
headers.set(header, token);
}
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
if (res.status === 403 && isUnsafe && !isRetry) {
csrfToken = undefined;
return request<T>(path, init, true);
}
if (!res.ok) throw new ApiError(path, res.status, await readProblem(res));
return (await res.json()) as T;
}
const opportunityPath = (uuid: string): string => `/opportunities/${encodeURIComponent(uuid)}`;
/** The backend's operations the app uses. */
export const api = {
/**
* Every stored opportunity, 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<OpportunityChanges> {
const opportunities: Opportunity[] = [];
let batch = '';
for (let offset = 0; ; offset += PAGE_LIMIT) {
const page = await request<OpportunityPage>(
`/opportunities?offset=${offset}&limit=${PAGE_LIMIT}`,
{ signal },
);
// Later pages may name newer batches. Keeping the first page's means
// changes made while the pages were read are asked for again, rather
// than missed.
if (offset === 0) batch = page.batch;
opportunities.push(...page.opportunities);
if (!page.has_more) return { opportunities, batch };
}
},
/**
* The opportunities 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<OpportunityChanges> {
const page = await request<OpportunityPage>(
`/opportunities?changed_after=${encodeURIComponent(batch)}`,
{ signal },
);
return { opportunities: page.opportunities, batch: page.batch };
},
/** Every classification some opportunity has, ordered by code. */
listClassifications(signal?: AbortSignal): Promise<ClassificationCode[]> {
return request<ClassificationCode[]>('/classifications', { signal });
},
/** Sets or clears one of an opportunity's marks, and returns all of its marks. */
setMark(uuid: string, mark: MarkName, isSet: boolean): Promise<OpportunityMarks> {
return request<OpportunityMarks>(`${opportunityPath(uuid)}/marks/${encodeURIComponent(mark)}`, {
method: isSet ? 'PUT' : 'DELETE',
});
},
};
+82
View File
@@ -0,0 +1,82 @@
import type { ServerEvent } from '@/types/Events';
import { isEventType } from '@/types/Events';
/** The shortest and longest wait before reconnecting, in milliseconds. */
const RETRY_FIRST = 1_000;
const RETRY_MAX = 30_000;
/** What connectEvents calls as events arrive and the connection recovers. */
export interface EventHandlers {
/** Called with each event the app handles, in the order they arrive. */
onEvent: (event: ServerEvent) => void;
/**
* Called when the connection is made again after dropping: events sent
* meanwhile were missed, so whatever they'd have updated is stale.
*/
onReconnect: () => void;
}
/** The URL of the backend's WebSocket: /ws on the page's own host. */
function eventsUrl(): string {
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${window.location.host}/ws`;
}
/** The event in a message, or undefined for anything else (welcome, pong, unknown events). */
function parseEvent(data: unknown): ServerEvent | undefined {
if (typeof data !== 'string') return undefined;
let message: unknown;
try {
message = JSON.parse(data);
} catch {
return undefined;
}
if (typeof message !== 'object' || message === null) return undefined;
const envelope = message as Record<string, unknown>;
if (envelope.type !== 'event' || !isEventType(envelope.eventType)) return undefined;
// The backend's events (internal/events) match ServerEvent's payloads.
return { eventType: envelope.eventType, payload: envelope.payload } as ServerEvent;
}
/**
* Connects to the backend's WebSocket and passes its events to handlers,
* reconnecting with a growing delay whenever the connection drops. Returns a
* function that disconnects for good.
*/
export function connectEvents(handlers: EventHandlers): () => void {
let socket: WebSocket | undefined;
let retryDelay = RETRY_FIRST;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let hasConnected = false;
let isClosed = false;
const connect = (): void => {
socket = new WebSocket(eventsUrl());
socket.addEventListener('open', () => {
retryDelay = RETRY_FIRST;
if (hasConnected) handlers.onReconnect();
hasConnected = true;
});
socket.addEventListener('message', (e: MessageEvent) => {
const event = parseEvent(e.data);
if (event) handlers.onEvent(event);
});
socket.addEventListener('close', () => {
if (isClosed) return;
retryTimer = setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, RETRY_MAX);
});
};
connect();
return () => {
isClosed = true;
clearTimeout(retryTimer);
socket?.close();
};
}
+145
View File
@@ -0,0 +1,145 @@
import type { JSX } from 'solid-js';
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
import type { ClassificationCode } from '@/types/Opportunities';
import { fieldLabel, input, linkButton } from './styles';
const MAX_VISIBLE = 150;
interface Props {
classifications: ClassificationCode[];
isLoading: boolean;
error: Error | undefined;
onRetry: () => void;
selected: ReadonlySet<number>;
onChange: (next: Set<number>) => void;
class?: string;
}
/** A searchable list of classifications to filter by, with the chosen ones above it. */
export function ClassificationPicker(props: Props): JSX.Element {
const [query, setQuery] = createSignal('');
const inputId = createUniqueId();
const sorted = createMemo(() =>
[...props.classifications].sort((a, b) => a.code.localeCompare(b.code)),
);
const byId = createMemo(() => new Map(sorted().map((c) => [c.id, c])));
const matches = createMemo(() => {
const q = query().trim().toLocaleLowerCase();
if (!q) return sorted();
return sorted().filter(
(code) =>
code.code.toLocaleLowerCase().startsWith(q) ||
code.classification.toLocaleLowerCase().includes(q),
);
});
const toggle = (id: number): void => {
const next = new Set(props.selected);
if (next.has(id)) next.delete(id);
else next.add(id);
props.onChange(next);
};
const isUnavailable = (): boolean => props.isLoading || !!props.error;
return (
<div class={`min-w-0 ${props.class ?? ''}`}>
<label class={fieldLabel} for={inputId}>
Classification
</label>
<Show when={props.selected.size > 0}>
<ul class="mb-2 flex flex-wrap gap-1.5" aria-label="Selected classifications">
<For each={[...props.selected]}>
{(id) => {
const code = (): ClassificationCode | undefined => byId().get(id);
return (
<li>
<button
type="button"
class="inline-flex max-w-72 items-baseline gap-1.5 rounded-md bg-accent-soft px-2 py-1 text-accent"
onClick={() => toggle(id)}
title="Remove this classification"
>
<span class="text-sm tabular-nums">{code()?.code ?? id}</span>
<span class="truncate">{code()?.classification ?? 'Unknown'}</span>
<span aria-hidden="true" class="font-bold">
×
</span>
<span class="sr-only">Remove</span>
</button>
</li>
);
}}
</For>
</ul>
</Show>
<input
id={inputId}
type="search"
class={input}
placeholder="Find by code or name, e.g. 72000000 or software"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
disabled={isUnavailable()}
/>
<Show when={props.isLoading}>
<p class="mt-1.5 text-sm text-muted">Loading classifications…</p>
</Show>
<Show when={props.error}>
<p class="mt-1.5 text-sm text-mark-lost">
Classifications didn't load.{' '}
<button type="button" class={linkButton} onClick={() => props.onRetry()}>
Try again
</button>
</p>
</Show>
<Show when={!isUnavailable()}>
<ul
class="mt-1.5 max-h-72 overflow-y-auto rounded-md border border-line-soft p-1"
role="group"
aria-label="Classifications"
>
<For
each={matches().slice(0, MAX_VISIBLE)}
fallback={
<li class="p-1.5 text-sm text-muted">No classifications match “{query()}”.</li>
}
>
{(code) => (
<li>
<label class="grid cursor-pointer grid-cols-[auto_auto_1fr_auto] items-baseline gap-2 rounded px-1.5 py-1 hover:bg-paper">
<input
type="checkbox"
checked={props.selected.has(code.id)}
onChange={() => toggle(code.id)}
/>
<span class="text-sm text-muted tabular-nums">{code.code}</span>
<span class="truncate">{code.classification}</span>
<Show when={code.is_cpv}>
<span class="text-xs text-muted" title="Common Procurement Vocabulary">
CPV
</span>
</Show>
</label>
</li>
)}
</For>
</ul>
<Show when={matches().length > MAX_VISIBLE}>
<p class="mt-1.5 text-sm text-muted">
Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow
the list.
</p>
</Show>
</Show>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import type { JSX } from 'solid-js';
import { createSignal, createUniqueId, For, Show } from 'solid-js';
import type { MarkFilter, OpportunityFilters } from '@/lib/filterOpportunities';
import {
activeFilterCount,
hasActiveFilters,
NOT_MARKED,
statusLabel,
} from '@/lib/filterOpportunities';
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import type { ClassificationCode } from '@/types/Opportunities';
import { ClassificationPicker } from './ClassificationPicker';
import { buttonQuiet, fieldLabel, input, MARK_BG } from './styles';
interface Props {
filters: OpportunityFilters;
onChange: (next: OpportunityFilters) => void;
onClear: () => void;
statuses: string[];
classifications: ClassificationCode[];
isLoadingClassifications: boolean;
classificationsError: Error | undefined;
onRetryClassifications: () => void;
shown: number;
total: number;
}
const toggleChip =
'inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3.5 py-1.5 hover:border-muted aria-pressed:border-ink aria-pressed:bg-ink aria-pressed:text-surface';
function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
}
/**
* The collapsible panel of filters (title, status, marks, classification,
* hidden), with the count of opportunities shown.
*/
export function FilterPanel(props: Props): JSX.Element {
const titleId = createUniqueId();
const bodyId = createUniqueId();
// Open by default; the last choice is remembered.
const [isOpen, setIsOpen] = createSignal(readStored(STORAGE_KEYS.filtersOpen) !== 'false');
const toggleOpen = (): void => {
const next = !isOpen();
setIsOpen(next);
writeStored(STORAGE_KEYS.filtersOpen, String(next));
};
const update = (patch: Partial<OpportunityFilters>): void =>
props.onChange({ ...props.filters, ...patch });
return (
<section class="mb-4 rounded-[10px] border border-line bg-surface" aria-label="Filters">
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 sm:px-4">
<button
type="button"
class="-mx-1 inline-flex items-center gap-2 rounded-md px-1 py-1 font-semibold"
aria-expanded={isOpen()}
aria-controls={bodyId}
onClick={toggleOpen}
>
<svg
viewBox="0 0 16 16"
class="size-4 text-muted transition-transform duration-200 motion-reduce:transition-none"
classList={{ 'rotate-90': isOpen() }}
fill="currentColor"
aria-hidden="true"
>
<path d="M6 3.5 10.5 8 6 12.5z" />
</svg>
Filters
<Show when={activeFilterCount(props.filters) > 0}>
<span class="rounded-full bg-accent px-2 text-xs leading-5 font-semibold text-on-accent">
{activeFilterCount(props.filters)}
<span class="sr-only"> active</span>
</span>
</Show>
</button>
<p class="ml-auto text-muted tabular-nums" aria-live="polite">
{props.shown === props.total
? `${props.total.toLocaleString('en-GB')} opportunities`
: `${props.shown.toLocaleString('en-GB')} of ${props.total.toLocaleString('en-GB')} opportunities`}
</p>
<button
type="button"
class={buttonQuiet}
onClick={() => props.onClear()}
disabled={!hasActiveFilters(props.filters)}
>
Clear filters
</button>
</div>
{/* Animating grid rows from 0fr to 1fr collapses to the content's real height. */}
<div
id={bodyId}
class="grid transition-[grid-template-rows] duration-200 motion-reduce:transition-none"
classList={{ 'grid-rows-[1fr]': isOpen(), 'grid-rows-[0fr]': !isOpen() }}
inert={!isOpen()}
>
<div class="min-h-0 overflow-hidden">
<div class="border-t border-line-soft">
<div class="grid grid-cols-1 items-start gap-x-8 gap-y-6 p-4 sm:p-5 md:grid-cols-2">
<div class="flex min-w-0 flex-col gap-5">
<div>
<label class={fieldLabel} for={titleId}>
Title
</label>
<input
id={titleId}
type="search"
class={`${input} px-3 py-2.5 text-base`}
placeholder="Search opportunity titles"
value={props.filters.title}
onInput={(e) => update({ title: e.currentTarget.value })}
/>
</div>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class={fieldLabel}>Status</legend>
<div class="flex flex-wrap gap-1.5">
<For each={props.statuses}>
{(status) => (
<button
type="button"
class={toggleChip}
aria-pressed={props.filters.statuses.has(status)}
onClick={() =>
update({ statuses: toggled(props.filters.statuses, status) })
}
>
{statusLabel(status)}
</button>
)}
</For>
</div>
</fieldset>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class={fieldLabel}>Marks</legend>
<div class="flex flex-wrap gap-1.5">
<For each={[...OPPORTUNITY_MARKS, NOT_MARKED] as MarkFilter[]}>
{(mark) => (
<button
type="button"
class={toggleChip}
aria-pressed={props.filters.marks.has(mark)}
onClick={() => update({ marks: toggled(props.filters.marks, mark) })}
>
<Show
when={mark !== NOT_MARKED}
fallback={
<span
class="size-2 rounded-full border border-current"
aria-hidden="true"
/>
}
>
<span
class={`size-2 rounded-full ${MARK_BG[mark as OpportunityMark]}`}
aria-hidden="true"
/>
</Show>
{mark === NOT_MARKED ? 'Not marked' : MARK_LABELS[mark]}
</button>
)}
</For>
</div>
</fieldset>
</div>
<ClassificationPicker
classifications={props.classifications}
isLoading={props.isLoadingClassifications}
error={props.classificationsError}
onRetry={props.onRetryClassifications}
selected={props.filters.classificationIds}
onChange={(classificationIds) => update({ classificationIds })}
/>
</div>
<div class="border-t border-line-soft px-4 py-3 sm:px-5">
<label class="inline-flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={props.filters.showHidden}
onChange={(e) => update({ showHidden: e.currentTarget.checked })}
/>
Show hidden opportunities
</label>
</div>
</div>
</div>
</div>
</section>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { JSX } from 'solid-js';
import { createEffect, createSignal, createUniqueId, For, onCleanup, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import { MARK_BG } from './styles';
interface Props {
marks: OpportunityMark[];
onToggle: (mark: OpportunityMark) => void;
/** Used in the button's accessible name. */
opportunityTitle: string;
}
const MENU_WIDTH = 200;
const GAP = 4;
/**
* A dropdown of mark checkboxes. Each mark toggles on its own and the menu
* stays open, so several can be set in one go. The menu is portalled to
* <body> with fixed positioning so the table's scroll container can't clip it.
*/
export function MarkMenu(props: Props): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false);
const [pos, setPos] = createSignal({ top: 0, left: 0, isAbove: false });
const menuId = createUniqueId();
let button!: HTMLButtonElement;
let menu: HTMLDivElement | undefined;
const place = () => {
const rect = button.getBoundingClientRect();
const height = menu?.offsetHeight ?? 220;
const isAbove = rect.bottom + GAP + height > window.innerHeight && rect.top - GAP - height > 0;
const left = Math.min(Math.max(8, rect.right - MENU_WIDTH), window.innerWidth - MENU_WIDTH - 8);
setPos({ top: isAbove ? rect.top - GAP - height : rect.bottom + GAP, left, isAbove });
};
const close = (shouldRefocus = false) => {
setIsOpen(false);
if (shouldRefocus) button.focus();
};
createEffect(() => {
if (!isOpen()) return;
place();
requestAnimationFrame(() => {
place(); // re-measure now the menu has a real height
menu?.querySelector<HTMLInputElement>('input')?.focus();
});
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (!button.contains(target) && !menu?.contains(target)) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(true);
};
const onScroll = (e: Event) => {
if (!menu?.contains(e.target as Node)) close();
};
document.addEventListener('pointerdown', onPointer);
document.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', place);
onCleanup(() => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', place);
});
});
const summary = (): string =>
props.marks.length === 0
? 'not marked'
: props.marks.map((mark) => MARK_LABELS[mark]).join(', ');
return (
<>
<button
ref={button}
type="button"
class="flex min-h-9 w-44 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
aria-haspopup="true"
aria-expanded={isOpen()}
aria-controls={isOpen() ? menuId : undefined}
aria-label={`Marks for ${props.opportunityTitle}: ${summary()}`}
onClick={() => setIsOpen(!isOpen())}
>
<span class="flex min-w-0 flex-1 flex-wrap gap-1">
<Show when={props.marks.length > 0} fallback={<span class="text-muted">Not marked</span>}>
<For each={props.marks}>
{(mark) => (
<span class="inline-flex items-center gap-1 rounded bg-paper px-1.5 text-xs leading-5 whitespace-nowrap text-ink">
<span class={`size-2 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</span>
)}
</For>
</Show>
</span>
<svg
viewBox="0 0 16 16"
class="size-3.5 shrink-0 text-muted"
fill="currentColor"
aria-hidden="true"
>
<path d="M3.5 6 8 10.5 12.5 6z" />
</svg>
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={menu}
id={menuId}
role="group"
aria-label={`Marks for ${props.opportunityTitle}`}
class="fixed z-50 rounded-lg border border-line bg-surface p-1 text-ink shadow-[0_8px_24px_rgb(0_0_0/0.18)]"
style={{ top: `${pos().top}px`, left: `${pos().left}px`, width: `${MENU_WIDTH}px` }}
>
<For each={OPPORTUNITY_MARKS}>
{(mark) => (
<label class="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-paper">
<input
type="checkbox"
checked={props.marks.includes(mark)}
onChange={() => props.onToggle(mark)}
/>
<span class={`size-2.5 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</label>
)}
</For>
</div>
</Portal>
</Show>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
opportunities: Opportunity[];
annotations: OpportunityAnnotations;
}
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';
/** The table of opportunities, each with its marks and a hide button. */
export function OpportunityTable(props: Props): JSX.Element {
return (
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
<table class="w-full border-separate border-spacing-0 tabular-nums">
<thead>
<tr>
<th scope="col" class={`${th} min-w-80`}>
Title
</th>
<th scope="col" class={th}>
Status
</th>
<th scope="col" class={`${th} text-center`}>
SME suitable
</th>
<th scope="col" class={th}>
Published
</th>
<th scope="col" class={th}>
Start
</th>
<th scope="col" class={th}>
End
</th>
<th scope="col" class={`${th} text-right`}>
Value
</th>
<th scope="col" class={th}>
Mark
</th>
<th scope="col" class={`${th} text-center`}>
Hide
</th>
</tr>
</thead>
<tbody class="[&>tr:last-child>td]:border-b-0">
<For each={props.opportunities}>
{(opportunity) => (
<OpportunityRow opportunity={opportunity} annotations={props.annotations} />
)}
</For>
</tbody>
</table>
</div>
);
}
interface RowProps {
opportunity: Opportunity;
annotations: OpportunityAnnotations;
}
function OpportunityRow(props: RowProps): JSX.Element {
const opportunity = (): Opportunity => props.opportunity;
const annotation = () => props.annotations.get(opportunity().uuid);
const marks = () => annotation().marks;
const hidden = () => annotation().hidden;
return (
<tr
classList={{
'text-muted': hidden() || marks().includes('skipped'),
'bg-[repeating-linear-gradient(-45deg,transparent_0_8px,var(--color-paper)_8px_9px)]':
hidden(),
'opacity-70': props.annotations.isSaving(opportunity().uuid),
}}
aria-busy={props.annotations.isSaving(opportunity().uuid) || undefined}
>
{/* The marks colour the leading edge of each row, one segment per
mark, so a scan down the left side shows where every opportunity stands. */}
<td class={`${td} relative max-w-[520px] pl-[18px]`}>
<span class="absolute inset-y-0 left-0 flex w-1 flex-col" aria-hidden="true">
<For each={marks()}>{(m) => <span class={`flex-1 ${MARK_BG[m]}`} />}</For>
</span>
<span class="block font-semibold" title={opportunity().description || undefined}>
{opportunity().title || 'Untitled opportunity'}
</span>
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
</td>
<td class={td}>
<span
class={`inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</td>
<td class={`${td} text-center`}>
{opportunity().sme_suitable ? (
<span class="font-semibold text-mark-won">Yes</span>
) : (
<span class="text-muted">No</span>
)}
</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().latest_release_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().start_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
<td class={td}>
<MarkMenu
marks={marks()}
opportunityTitle={opportunity().title}
onToggle={(mark) => props.annotations.toggleMark(opportunity().uuid, mark)}
/>
</td>
<td class={`${td} text-center`}>
<button
type="button"
class={`${buttonQuiet} px-2.5 py-1 text-sm text-ink`}
aria-pressed={hidden()}
aria-label={`${hidden() ? 'Show again' : 'Hide'}: ${opportunity().title}`}
onClick={() => props.annotations.setHidden(opportunity().uuid, !hidden())}
>
{hidden() ? 'Unhide' : 'Hide'}
</button>
</td>
</tr>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import type { Theme } from '@/primitives/createTheme';
interface Props {
theme: Theme;
onChange: (theme: Theme) => void;
}
const OPTIONS: { value: Theme; label: string; icon: string }[] = [
{
value: 'light',
label: 'Light',
icon: 'M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z',
},
{
value: 'dark',
label: 'Dark',
icon: 'M20.35 14.35A8.5 8.5 0 0 1 9.65 3.65 8.5 8.5 0 1 0 20.35 14.35Z',
},
];
/** A pair of buttons choosing the light or dark theme. */
export function ThemeToggle(props: Props): JSX.Element {
return (
<div
class="inline-flex rounded-full border border-line bg-surface p-0.5"
role="group"
aria-label="Colour theme"
>
<For each={OPTIONS}>
{(option) => (
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm text-muted hover:text-ink aria-pressed:bg-ink aria-pressed:text-surface"
aria-pressed={props.theme === option.value}
onClick={() => props.onChange(option.value)}
>
<svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d={option.icon} />
</svg>
{option.label}
</button>
)}
</For>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { OpportunityMark } from '@/types/Annotations';
// Tailwind only generates classes it can see written out in full, so these
// lookups list every class literally rather than building names at runtime.
/** Badge colours for each known status; others get a neutral badge. */
export const STATUS_BADGE: Record<string, string> = {
planning: 'bg-status-planning-soft text-status-planning',
planned: 'bg-status-planning-soft text-status-planning',
active: 'bg-status-open-soft text-status-open',
complete: 'bg-accent-soft text-accent',
};
/** The badge classes for status. */
export const statusBadge = (status: string): string => STATUS_BADGE[status] ?? 'bg-paper text-ink';
/** Fill colour for each mark: row stripe segments, menu dots, pills. */
export const MARK_BG: Record<OpportunityMark, string> = {
interested: 'bg-mark-interested',
skipped: 'bg-mark-skipped',
bid_submitted: 'bg-mark-bid-submitted',
lost: 'bg-mark-lost',
won: 'bg-mark-won',
};
export const input =
'w-full rounded-md border border-line bg-surface px-2.5 py-2 placeholder:text-muted disabled:bg-paper';
export const buttonQuiet =
'rounded-md border border-line bg-surface px-3.5 py-1.5 hover:enabled:border-muted disabled:opacity-60 disabled:text-muted';
export const buttonPrimary = 'rounded-md bg-accent px-3.5 py-1.5 font-semibold text-on-accent';
export const linkButton = 'text-accent underline';
export const fieldLabel = 'mb-1.5 block p-0 text-sm font-semibold';
+79
View File
@@ -0,0 +1,79 @@
@import 'tailwindcss';
/* `dark:` utilities follow the .dark class on <html>, not the OS setting. */
@custom-variant dark (&:where(.dark, .dark *));
/* Every colour is a token. The .dark block below swaps the values, so
components use bg-surface, text-ink etc. and never need dark: variants. */
@theme {
--font-sans: 'Public Sans', 'Segoe UI', system-ui, -apple-system, sans-serif;
--color-paper: #eef1f4;
--color-surface: #ffffff;
--color-ink: #18232b;
--color-muted: #5b6873;
--color-line: #d5dce2;
--color-line-soft: #e7ecf0;
--color-accent: #23508e;
--color-accent-soft: #e3ebf6;
--color-on-accent: #ffffff;
--color-danger: #7d2626;
--color-danger-soft: #f8e9e9;
--color-status-planning: #4b4592;
--color-status-planning-soft: #ecebf6;
--color-status-open: #23633a;
--color-status-open-soft: #e2f1e7;
--color-mark-interested: #23508e;
--color-mark-skipped: #8a949c;
--color-mark-bid-submitted: #b8730a;
--color-mark-lost: #b23b3b;
--color-mark-won: #2f7d4a;
}
@layer base {
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
--color-paper: #141b21;
--color-surface: #1c252d;
--color-ink: #e4e9ed;
--color-muted: #93a0aa;
--color-line: #34414b;
--color-line-soft: #27323b;
--color-accent: #8db3ea;
--color-accent-soft: #1f3350;
--color-on-accent: #0f1d2e;
--color-danger: #f2a7a7;
--color-danger-soft: #3a1f23;
--color-status-planning: #bdb7f2;
--color-status-planning-soft: #2b2948;
--color-status-open: #93d6aa;
--color-status-open-soft: #1d3527;
--color-mark-interested: #8db3ea;
--color-mark-skipped: #6f7a83;
--color-mark-bid-submitted: #e2a640;
--color-mark-lost: #e47676;
--color-mark-won: #63c286;
}
body {
@apply bg-paper font-sans text-[15px] leading-normal text-ink antialiased;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
button:not(:disabled),
select,
[role='button'] {
cursor: pointer;
}
}
+8
View File
@@ -0,0 +1,8 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import { App } from './App';
// index.html always has the root element.
render(() => <App />, document.getElementById('root')!);
+17
View File
@@ -0,0 +1,17 @@
import type { ClassificationsUpdatedPayload } from '@/types/Events';
import type { ClassificationCode } from '@/types/Opportunities';
/**
* The classifications after change: without the removed ids, with the added
* codes (replacing any with the same id), ordered by code as the backend
* orders them.
*/
export function applyClassificationChanges(
classifications: readonly ClassificationCode[],
change: ClassificationsUpdatedPayload,
): ClassificationCode[] {
const gone = new Set([...change.removed, ...change.added.map((code) => code.id)]);
return [...classifications.filter((code) => !gone.has(code.id)), ...change.added].sort(
(first, second) => first.code.localeCompare(second.code) || first.id - second.id,
);
}
+4
View File
@@ -0,0 +1,4 @@
/** An error's message for the user, whatever was thrown. */
export function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+95
View File
@@ -0,0 +1,95 @@
import type { Annotation, OpportunityMark } from '@/types/Annotations';
import type { Opportunity, OpportunityStatus } from '@/types/Opportunities';
import { OPPORTUNITY_STATUSES } from '@/types/Opportunities';
import { parseDate } from './format';
/** 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 {
title: string;
/** Empty means every status. */
statuses: ReadonlySet<string>;
/** Empty means any marks. Otherwise an opportunity needs at least one of these. */
marks: ReadonlySet<MarkFilter>;
/** Classification ids; empty means every classification. */
classificationIds: ReadonlySet<number>;
showHidden: boolean;
}
/** Filters that let every opportunity through, apart from hidden ones. */
export const emptyFilters = (): OpportunityFilters => ({
title: '',
statuses: new Set(),
marks: new Set(),
classificationIds: new Set(),
showHidden: false,
});
/** How many separate filters are in use, for the collapsed panel's badge. */
export const activeFilterCount = (filters: OpportunityFilters): number =>
[
filters.title.trim() !== '',
filters.statuses.size > 0,
filters.marks.size > 0,
filters.classificationIds.size > 0,
filters.showHidden,
].filter(Boolean).length;
/** Whether any filter is in use. */
export const hasActiveFilters = (filters: OpportunityFilters): boolean =>
activeFilterCount(filters) > 0;
/** The opportunities that pass filters, newest release first. */
export function filterOpportunities(
opportunities: readonly Opportunity[],
filters: OpportunityFilters,
annotationFor: (uuid: string) => Annotation,
): 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.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;
}
const isKnownStatus = (status: string): status is OpportunityStatus =>
(OPPORTUNITY_STATUSES as readonly string[]).includes(status);
/** The statuses to filter by: the known ones first, then anything else the API returned. */
export function statusOptions(opportunities: readonly Opportunity[]): string[] {
const extra = new Set<string>();
for (const opportunity of opportunities) {
if (opportunity.status && !isKnownStatus(opportunity.status)) extra.add(opportunity.status);
}
return [...OPPORTUNITY_STATUSES, ...[...extra].sort()];
}
/** A status as shown on screen: "active" is "Active". */
export const statusLabel = (status: string): string =>
status.charAt(0).toLocaleUpperCase() + status.slice(1);
+33
View File
@@ -0,0 +1,33 @@
import type { DateTimeString } from '@/types/Opportunities';
const ZERO_TIME_PREFIX = '0001-01-01';
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const moneyFormat = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
});
/** Treats null and Go's zero time.Time as "no date". */
export function parseDate(value: DateTimeString | null | undefined): Date | null {
if (!value || value.startsWith(ZERO_TIME_PREFIX)) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
/** A date for display, e.g. "3 Mar 2026", or a dash for no date. */
export function formatDate(value: DateTimeString | null | undefined): string {
const d = parseDate(value);
return d ? dateFormat.format(d) : '—';
}
/** An amount in pounds for display, e.g. "£125,001", or a dash for none. */
export function formatMoney(value: number): string {
return value > 0 ? moneyFormat.format(value) : '—';
}
+14
View File
@@ -0,0 +1,14 @@
import type { Opportunity } from '@/types/Opportunities';
/**
* The opportunities with changed merged in: each replaces the opportunity
* with its UUID, in its place, or is added at the end if there's none.
*/
export function mergeOpportunities(
opportunities: readonly Opportunity[],
changed: readonly Opportunity[],
): Opportunity[] {
const byUuid = new Map(opportunities.map((opportunity) => [opportunity.uuid, opportunity]));
for (const opportunity of changed) byUuid.set(opportunity.uuid, opportunity);
return [...byUuid.values()];
}
+26
View File
@@ -0,0 +1,26 @@
// localStorage can throw (privacy modes, storage disabled, quota), so every
// access is guarded and falls back to "nothing stored".
/** The stored value of key, or null if there is none or storage is unavailable. */
export function readStored(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/** Stores value under key, if storage is available. */
export function writeStored(key: string, value: string): void {
try {
window.localStorage.setItem(key, value);
} catch {
/* preference just won't persist */
}
}
// index.html reads the theme key too, before the app loads.
export const STORAGE_KEYS = {
theme: 'job_search.theme',
filtersOpen: 'job_search.filtersOpen',
} as const;
+54
View File
@@ -0,0 +1,54 @@
import { onCleanup } from 'solid-js';
import { connectEvents } from '@/api/events';
import { applyClassificationChanges } from '@/lib/classifications';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { ServerEvent } from '@/types/Events';
import type { ClassificationCode } from '@/types/Opportunities';
import type { ApiList, OpportunityList } from './createOpportunities';
/** What createLiveUpdates keeps up to date. */
export interface LiveData {
opportunities: OpportunityList;
classifications: ApiList<ClassificationCode>;
annotations: OpportunityAnnotations;
}
/**
* Keeps data up to date from the backend's events, for as long as the
* calling component lives:
*
* - ClassificationsUpdated changes the classifications in place.
* - OpportunityMarksUpdated sets the opportunity's marks.
* - OpportunitiesUpdated catches the opportunities up: the changed ones are
* fetched and merged in, as the event only names them.
*
* After the connection drops and is made again, events may have been missed:
* the opportunities catch up, and the classifications are reloaded.
*/
export function createLiveUpdates(data: LiveData): void {
const handle = (event: ServerEvent): void => {
switch (event.eventType) {
case 'ClassificationsUpdated':
data.classifications.update((classifications) =>
applyClassificationChanges(classifications, event.payload),
);
break;
case 'OpportunityMarksUpdated':
data.annotations.applyMarks(event.payload);
break;
case 'OpportunitiesUpdated':
data.opportunities.catchUp();
break;
}
};
const disconnect = connectEvents({
onEvent: handle,
onReconnect: () => {
data.opportunities.catchUp();
data.classifications.reload();
},
});
onCleanup(disconnect);
}
+127
View File
@@ -0,0 +1,127 @@
import type { Resource } from 'solid-js';
import { createEffect, createMemo, createResource, createSignal, onCleanup } from 'solid-js';
import { api } from '@/api/client';
import { mergeOpportunities } from '@/lib/opportunities';
import type { ClassificationCode, Opportunity } from '@/types/Opportunities';
/** A list loaded from the API, with its loading and error state. */
export interface ApiList<T> {
/** Loaded items, or [] while loading or after a failure. Never throws. */
items: () => T[];
isLoading: () => boolean;
error: () => Error | undefined;
reload: () => void;
/** Changes the loaded items in place, without reloading them. */
update: (change: (items: T[]) => T[]) => void;
}
/**
* Loads a list with load, giving it a signal that aborts the request when
* the list is reloaded or its owner is cleaned up.
*/
function createApiList<T>(load: (signal: AbortSignal) => Promise<T[]>): ApiList<T> {
let controller: AbortController | undefined;
onCleanup(() => controller?.abort());
const [resource, { mutate, refetch }] = createResource(() => {
controller?.abort();
controller = new AbortController();
return load(controller.signal);
});
return wrap(resource, refetch, (change) => {
mutate((items) => change(items ?? []));
});
}
function wrap<T>(
resource: Resource<T[]>,
refetch: () => unknown,
update: ApiList<T>['update'],
): ApiList<T> {
// Reading an errored resource throws, so check state first.
const items = createMemo(() =>
resource.state === 'ready' || resource.state === 'refreshing' ? (resource() ?? []) : [],
);
return {
items,
isLoading: () => resource.loading,
error: () => (resource.state === 'errored' ? toError(resource.error) : undefined),
reload: () => void refetch(),
update,
};
}
const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
/** The opportunities, which can catch up with changes on the backend. */
export interface OpportunityList extends ApiList<Opportunity> {
/**
* Gets the opportunities changed since the list was loaded or last caught
* up, and merges them in. Calls while a load or catch-up is under way
* lead to one more catch-up after it. When the backend can't say what
* changed (it has recorded no changes yet, or doesn't know the batch),
* the list is reloaded instead.
*/
catchUp: () => void;
}
/** Every stored opportunity, loaded from the backend and kept up to date with catchUp. */
export function createOpportunities(): OpportunityList {
// The change batch the list is up to date with; "" before it has loaded,
// or if the backend has recorded no changes.
let batch = '';
// Counts loads, so a catch-up that a load overtook is dropped.
let loads = 0;
let controller: AbortController | undefined;
onCleanup(() => controller?.abort());
const [isWanted, setIsWanted] = createSignal(false);
const [isCatchingUp, setIsCatchingUp] = createSignal(false);
const list = createApiList(async (signal) => {
loads += 1;
controller?.abort();
const loaded = await api.listOpportunities(signal);
batch = loaded.batch;
return loaded.opportunities;
});
async function catchUpNow(): Promise<void> {
if (batch === '') {
list.reload();
return;
}
const load = loads;
controller = new AbortController();
const { signal } = controller;
setIsCatchingUp(true);
try {
const changes = await api.listChangedOpportunities(batch, signal);
if (load !== loads) return;
batch = changes.batch;
list.update((items) => mergeOpportunities(items, changes.opportunities));
} catch {
// An unknown batch or a failure: the list may be missing changes, so
// it's reloaded, which shows any error.
if (!signal.aborted && load === loads) list.reload();
} finally {
setIsCatchingUp(false);
}
}
// One catch-up at a time, and none while loading: the load's batch is
// the one to catch up from.
createEffect(() => {
if (isWanted() && !isCatchingUp() && !list.isLoading()) {
setIsWanted(false);
void catchUpNow();
}
});
return { ...list, catchUp: () => setIsWanted(true) };
}
/** Every classification some opportunity has, loaded from the backend. */
export const createClassifications = (): ApiList<ClassificationCode> =>
createApiList((signal) => api.listClassifications(signal));
@@ -0,0 +1,131 @@
import { createEffect, createSignal } from 'solid-js';
import { createStore, produce, reconcile } from 'solid-js/store';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import type { Annotation, MarkName, OpportunityMark } from '@/types/Annotations';
import { annotationFrom, MARK_LABELS, normaliseMarks } from '@/types/Annotations';
import type { Opportunity, OpportunityMarks } from '@/types/Opportunities';
type AnnotationMap = Record<string, Annotation>;
const blank = (uuid: string): Annotation => ({ uuid, marks: [], hidden: false });
/** The marks on every opportunity, and the actions that change them. */
export interface OpportunityAnnotations {
/** Reactive: reading inside a tracking scope re-runs when it changes. */
get: (uuid: string) => Annotation;
/** Sets or clears one mark. The UI updates at once, and reverts if the request fails. */
toggleMark: (uuid: string, mark: OpportunityMark) => void;
/** Hides or shows an opportunity. The UI updates at once, and reverts if the request fails. */
setHidden: (uuid: string, hidden: boolean) => void;
isSaving: (uuid: string) => boolean;
/**
* Sets an opportunity's marks to those the backend reports, as an event
* does after a change elsewhere. An opportunity with a change of this
* client's still being saved is left alone, so the event can't undo it.
*/
applyMarks: (marks: OpportunityMarks) => void;
/** The last save failure; cleared by dismissError or the next successful save. */
error: () => string | null;
dismissError: () => void;
}
/**
* The user's marks on the opportunities, starting from the marks the
* opportunities were loaded with. Each change is saved to the backend.
*/
export function createOpportunityAnnotations(
opportunities: () => readonly Opportunity[],
): OpportunityAnnotations {
const [byUuid, setByUuid] = createStore<AnnotationMap>({});
const [saving, setSaving] = createStore<Record<string, number>>({});
const [error, setError] = createSignal<string | null>(null);
// A (re)load of the opportunities brings the marks the backend has.
createEffect(() => {
const loaded: AnnotationMap = {};
for (const opportunity of opportunities()) {
loaded[opportunity.uuid] = annotationFrom({ ...opportunity.marks, uuid: opportunity.uuid });
}
setByUuid(reconcile(loaded));
});
const ensure = (uuid: string): void => {
if (!byUuid[uuid]) setByUuid(uuid, blank(uuid));
};
/** Applies a change now, saves it, and undoes it if the save fails. */
async function save(
uuid: string,
change: { apply: () => void; undo: () => void; mark: MarkName; isSet: boolean },
failure: string,
): Promise<void> {
ensure(uuid);
change.apply();
setSaving(uuid, (count) => (count ?? 0) + 1);
try {
await api.setMark(uuid, change.mark, change.isSet);
setError(null);
} catch (err) {
change.undo();
setError(`${failure}: ${errorText(err)}`);
} finally {
setSaving(
produce((counts) => {
const count = counts[uuid] ?? 0;
if (count <= 1) delete counts[uuid];
else counts[uuid] = count - 1;
}),
);
}
}
const setMarked = (uuid: string, mark: OpportunityMark, isSet: boolean): void =>
setByUuid(uuid, 'marks', (marks) =>
normaliseMarks(isSet ? [...marks, mark] : marks.filter((m) => m !== mark)),
);
// Each change touches only its own mark, so undoing one failed change
// can't disturb other changes that are still being saved.
const toggleMark = (uuid: string, mark: OpportunityMark): void => {
const isSet = !(byUuid[uuid]?.marks ?? []).includes(mark);
const label = MARK_LABELS[mark];
void save(
uuid,
{
apply: () => setMarked(uuid, mark, isSet),
undo: () => setMarked(uuid, mark, !isSet),
mark,
isSet,
},
isSet ? `"${label}" wasn't added` : `"${label}" wasn't removed`,
);
};
const setHidden = (uuid: string, hidden: boolean): void => {
const previous = byUuid[uuid]?.hidden ?? false;
void save(
uuid,
{
apply: () => setByUuid(uuid, 'hidden', hidden),
undo: () => setByUuid(uuid, 'hidden', previous),
mark: 'hidden',
isSet: hidden,
},
hidden ? "The opportunity wasn't hidden" : "The opportunity wasn't shown again",
);
};
return {
get: (uuid) => byUuid[uuid] ?? blank(uuid),
toggleMark,
setHidden,
isSaving: (uuid) => Boolean(saving[uuid]),
applyMarks: (marks) => {
if (!saving[marks.uuid]) setByUuid(marks.uuid, annotationFrom(marks));
},
error,
dismissError: () => setError(null),
};
}
+54
View File
@@ -0,0 +1,54 @@
import { createEffect, createSignal, onCleanup } from 'solid-js';
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
export type Theme = 'light' | 'dark';
/** The theme in use, and the way to change it. */
export interface ThemeChoice {
/** The theme in use: the user's choice, or the system's until they choose. */
theme: () => Theme;
/** Chooses a theme and remembers it. */
setTheme: (next: Theme) => void;
}
const parse = (stored: string | null): Theme | null =>
stored === 'light' || stored === 'dark' ? stored : null;
/**
* The chosen theme, saved in localStorage. Until the user picks one, it
* follows the operating system's setting. index.html applies the saved
* theme before first paint so the page doesn't flash.
*/
export function createTheme(): ThemeChoice {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const [chosen, setChosen] = createSignal<Theme | null>(parse(readStored(STORAGE_KEYS.theme)));
const [systemDark, setSystemDark] = createSignal(media.matches);
const onSystemChange = (e: MediaQueryListEvent): void => {
setSystemDark(e.matches);
};
// Keep other open tabs in step.
const onStorage = (e: StorageEvent): void => {
if (e.key === STORAGE_KEYS.theme) setChosen(parse(e.newValue));
};
media.addEventListener('change', onSystemChange);
window.addEventListener('storage', onStorage);
onCleanup(() => {
media.removeEventListener('change', onSystemChange);
window.removeEventListener('storage', onStorage);
});
const theme = (): Theme => chosen() ?? (systemDark() ? 'dark' : 'light');
createEffect(() => {
document.documentElement.classList.toggle('dark', theme() === 'dark');
});
const setTheme = (next: Theme): void => {
setChosen(next);
writeStored(STORAGE_KEYS.theme, next);
};
return { theme, setTheme };
}
+47
View File
@@ -0,0 +1,47 @@
// The user's own marks on an opportunity, as the app works with them. The
// backend sends them as OpportunityMarks, a boolean per mark; the app keeps
// the marks that are set as a list, and hidden apart, as the UI shows it
// separately.
import type { OpportunityMarks } from '@/types/Opportunities';
/** The marks shown as marks, in display order, named as the backend names them. */
export const OPPORTUNITY_MARKS = ['interested', 'skipped', 'bid_submitted', 'lost', 'won'] as const;
export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number];
/** Every mark the backend accepts in /opportunities/{uuid}/marks/{mark}. */
export type MarkName = OpportunityMark | 'hidden';
/** How each mark is named on screen. */
export const MARK_LABELS: Record<OpportunityMark, string> = {
interested: 'Interested',
skipped: 'Skipped',
bid_submitted: 'Bid submitted',
lost: 'Lost',
won: 'Won',
};
/** An opportunity's marks as the UI uses them. */
export interface Annotation {
uuid: string;
/** The marks that are set, in OPPORTUNITY_MARKS order. */
marks: OpportunityMark[];
hidden: boolean;
}
/** Whether value is one of OPPORTUNITY_MARKS. */
export const isOpportunityMark = (value: unknown): value is OpportunityMark =>
(OPPORTUNITY_MARKS as readonly unknown[]).includes(value);
/** Known marks only, without duplicates, in OPPORTUNITY_MARKS order. */
export const normaliseMarks = (marks: readonly unknown[]): OpportunityMark[] =>
OPPORTUNITY_MARKS.filter((mark) => marks.includes(mark));
/** The annotation for an opportunity's marks, as the backend sends them. */
export function annotationFrom(marks: OpportunityMarks): Annotation {
return {
uuid: marks.uuid,
marks: OPPORTUNITY_MARKS.filter((mark) => marks[mark]),
hidden: marks.hidden,
};
}
+45
View File
@@ -0,0 +1,45 @@
// The events the backend pushes over its WebSocket (/ws), each as
// {"type":"event","eventType":...,"payload":...}. They aren't in the
// backend's OpenAPI description, which only covers HTTP routes, so these
// types are written by hand; their contents are the generated types. They
// match the backend's internal/events package.
import type { ClassificationCode, OpportunityMarks } from '@/types/Opportunities';
/** The event types the app handles; others are ignored. */
export const EVENT_TYPES = [
'OpportunitiesUpdated',
'OpportunityMarksUpdated',
'ClassificationsUpdated',
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
/**
* Opportunities stored for the first time (new) or given new releases
* (updated), by UUID. Over 50 of them, large is true and the lists are
* empty; summary still counts them. batch names the change batch recording
* them, for GET /api/opportunities?changed_after=.
*/
export interface OpportunitiesUpdatedPayload {
new: string[];
updated: string[];
large: boolean;
summary: { new: number; updated: number };
batch: string;
}
/** Classification codes that came into use, in full, and the ids of those that went out of use. */
export interface ClassificationsUpdatedPayload {
added: ClassificationCode[];
removed: number[];
}
/** An event from the backend, by type. */
export type ServerEvent =
| { eventType: 'OpportunitiesUpdated'; payload: OpportunitiesUpdatedPayload }
| { eventType: 'OpportunityMarksUpdated'; payload: OpportunityMarks }
| { eventType: 'ClassificationsUpdated'; payload: ClassificationsUpdatedPayload };
/** Whether value is one of EVENT_TYPES. */
export const isEventType = (value: unknown): value is EventType =>
(EVENT_TYPES as readonly unknown[]).includes(value);
+45
View File
@@ -0,0 +1,45 @@
// The backend's types, under the names the app uses. They're generated from
// the backend's OpenAPI description into api.ts (npm run generate:api), so
// they can't drift from it; only aliases and frontend-only types live here.
//
// The JSON is what Go's encoding/json produces:
// - time.Time is an RFC 3339 string, e.g. "2026-03-01T09:30:00Z". A zero
// time.Time is "0001-01-01T00:00:00Z", not null.
// - A nil pointer (*time.Time) or a nil slice is null; the generated types
// say which fields can be.
// - int64 and float64 are both number. int64 values above 2^53 would lose
// precision in JavaScript; database ids are nowhere near that.
import type { components } from '@/types/api';
type Schemas = components['schemas'];
/** An RFC 3339 timestamp string as produced by Go's time.Time. */
export type DateTimeString = string;
/**
* The tender statuses the app knows, from the OCDS tenderStatus codelist.
* The backend's status is any string, so others may appear.
*/
export const OPPORTUNITY_STATUSES = [
'planning',
'planned',
'active',
'complete',
'cancelled',
'unsuccessful',
'withdrawn',
] as const;
export type OpportunityStatus = (typeof OPPORTUNITY_STATUSES)[number];
export type Opportunity = Schemas['types.Opportunity'];
export type OpportunityMarks = Schemas['types.OpportunityMarks'];
export type ClassificationCode = Schemas['types.ClassificationCode'];
export type OpportunityRelease = Schemas['types.OpportunityRelease'];
export type OpportunityReleaseTag = Schemas['types.OpportunityReleaseTag'];
/** A page of GET /api/opportunities. */
export type OpportunityPage = Schemas['api.OpportunityPage'];
/** An RFC 9457 problem details body, as the API returns for errors. */
export type Problem = Schemas['httpx.Problem'];
+416
View File
@@ -0,0 +1,416 @@
// Code generated by npm run generate:api from the backend's OpenAPI
// description. DO NOT EDIT: change the Go types or handlers, then
// regenerate (see scripts/generateApiTypes.ts).
export interface paths {
"/classifications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List classifications
* @description Every classification code (CPV and others) that at least one
* stored opportunity has, once each, ordered by code: the
* codes worth filtering opportunities by.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.ClassificationCode"][];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/opportunities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List opportunities
* @description A page of opportunities, newest release first, with their
* classifications and marks but not their releases. has_more
* says whether another page follows, at offset+limit. batch
* is the newest change batch when the page was read.
* With changed_after (a batch from a page or an
* OpportunitiesUpdated event), the page is instead every
* opportunity changed since that batch (new, given new
* releases or with changed marks), and batch is the newest
* batch they include. offset and limit can't be given with
* changed_after. An unknown batch is 410 Gone: reload the
* whole list.
*/
get: {
parameters: {
query?: {
/** @description Opportunities to skip */
offset?: number;
/** @description Page size */
limit?: number;
/** @description List the opportunities changed after this batch instead */
changed_after?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["api.OpportunityPage"];
};
};
/** @description Invalid offset or limit, or either with changed_after */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Unknown batch */
410: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/opportunities/{uuid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get an opportunity
* @description One opportunity with its classifications, marks and all of its
* releases.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The opportunity's UUID (its OCID) */
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.Opportunity"];
};
};
/** @description No such opportunity */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/opportunities/{uuid}/marks/{mark}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Set a mark
* @description Sets one of the opportunity's marks, keeping the others, and
* returns all of them. Setting a mark that is already set
* changes nothing. won and lost exclude each other.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The opportunity's UUID (its OCID) */
uuid: string;
/** @description The mark */
mark: "hidden" | "skipped" | "interested" | "bid_submitted" | "won" | "lost";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.OpportunityMarks"];
};
};
/** @description No valid CSRF token */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description No such opportunity or mark */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description won and lost together */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
post?: never;
/**
* Clear a mark
* @description Clears one of the opportunity's marks, keeping the others, and
* returns all of them. Clearing a mark that isn't set changes
* nothing.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The opportunity's UUID (its OCID) */
uuid: string;
/** @description The mark */
mark: "hidden" | "skipped" | "interested" | "bid_submitted" | "won" | "lost";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.OpportunityMarks"];
};
};
/** @description No valid CSRF token */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description No such opportunity or mark */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
"api.OpportunityPage": {
batch: string;
has_more: boolean;
limit: number;
offset: number;
opportunities: components["schemas"]["types.Opportunity"][];
};
"httpx.Problem": {
detail?: string;
instance?: string;
requestId?: string;
status: number;
title: string;
type?: string;
};
"types.ClassificationCode": {
classification: string;
code: string;
id: number;
is_cpv: boolean;
};
"types.Opportunity": {
buyer_id: string;
buyer_name: string;
classifications: components["schemas"]["types.ClassificationCode"][] | null;
description: string;
end_date: string | null;
gross_value: number;
has_documents: boolean;
latest_release_date: string;
main_category: string;
marks: components["schemas"]["types.OpportunityMarks"];
multi_lot: boolean;
procurement_method: string;
releases: components["schemas"]["types.OpportunityRelease"][] | null;
sme_suitable: boolean;
source: string;
start_date: string | null;
status: string;
title: string;
uuid: string;
uuid_type: string;
vcse_suitable: boolean;
};
"types.OpportunityMarks": {
bid_submitted: boolean;
hidden: boolean;
interested: boolean;
lost: boolean;
skipped: boolean;
uuid: string;
won: boolean;
};
"types.OpportunityRelease": {
data: string;
release_date: string;
release_id: string;
release_tags: components["schemas"]["types.OpportunityReleaseTag"][] | null;
uuid: string;
};
"types.OpportunityReleaseTag": {
release_id: string;
tag: string;
uuid: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}