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

+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;