diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..3fa40b9 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,6 @@ +# Base path or URL the browser uses for API calls. Defaults to /api. +# VITE_API_BASE_URL=/api + +# Dev server only: where /api is proxied to. Defaults to job-search serve's +# default address. +# API_PROXY_TARGET=http://localhost:9393 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..ed79140 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Vite dependency cache +.vite diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..7bde7f9 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +dist +.vite +node_modules +package-lock.json +# Generated by npm run generate:api +src/types/api.ts diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..67241b5 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 100, + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./src/index.css" +} diff --git a/web/CODE_CONVENTIONS.md b/web/CODE_CONVENTIONS.md new file mode 100644 index 0000000..417c1a0 --- /dev/null +++ b/web/CODE_CONVENTIONS.md @@ -0,0 +1,378 @@ +# Frontend code conventions + +These are the conventions for the frontend in `web/`: a Vite, SolidJS, +Tailwind CSS (v4) and TypeScript app. + +TypeScript, ESLint and Prettier check what they can (see +[Tooling](#tooling)); the rest are applied by hand and checked in review. + +## Tooling + +```bash +npm run check # typecheck, lint and format:check; run before committing +npm run typecheck # tsc -b: both tsconfigs (plain tsc would check neither) +npm run lint # eslint . +npm run format:check # prettier --check . +npm run format # prettier --write .: formats every file +npm run generate:api # regenerate src/types/api.ts from the backend's OpenAPI description +npm run build # typecheck, then vite build +npm run dev # dev server; /api and /ws go to the backend (API_PROXY_TARGET, default localhost:9393) +``` + +- **TypeScript** runs in `strict` mode, with `noUncheckedIndexedAccess` + (an indexed value may be `undefined` until checked). `tsconfig.app.json` + also sets `verbatimModuleSyntax`, `erasableSyntaxOnly`, + `noUnusedLocals`, `noUnusedParameters` and `noFallthroughCasesInSwitch`: + in particular, no TypeScript-only runtime syntax such as constructor + parameter properties or `enum`, and types are imported with + `import type`. `tsconfig.node.json` checks `vite.config.ts` and + `scripts/`. +- **ESLint** (`eslint.config.js`): ESLint's and typescript-eslint's + recommended rules, with type information, so it catches unsafe uses of + `any` and promises that are dropped; eslint-plugin-solid's rules; + eslint-comments' rules; simple-import-sort; and the rules below. +- **Prettier** (`.prettierrc.json`) formats everything: see + [Formatting](#formatting). `eslint-config-prettier` turns off the ESLint + rules that would disagree with it. +- Generated code (`src/types/api.ts`) isn't linted or formatted, and is + never edited by hand. + +### Linters that check these conventions + +| Rule | Checks | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `simple-import-sort/imports`, `/exports` | Imports in two groups, each sorted ([Imports](#imports)); `--fix` sorts them | +| `no-restricted-imports` | Imports from another folder use `@/`, not `../` ([Imports](#imports)) | +| `@typescript-eslint/consistent-type-imports` | Types are imported as types, never as values ([Imports](#imports)) | +| `@typescript-eslint/naming-convention` | camelCase, PascalCase and UPPER_SNAKE_CASE where [Naming](#naming) says; backend keys are exempt | +| `@typescript-eslint/no-explicit-any`, `no-unsafe-*` | No `any`, and no untyped values used as if typed ([TypeScript](#typescript)) | +| `@typescript-eslint/explicit-module-boundary-types` | Exported functions have their parameter and return types written out ([TypeScript](#typescript)) | +| `@typescript-eslint/no-misused-promises`, `no-floating-promises` | Promises are awaited or handled, including in event handlers ([SolidJS](#solidjs)) | +| `solid/no-destructure`, `solid/reactivity` | Props aren't destructured, and reactive values are read where they can update ([SolidJS](#solidjs)) | +| `solid/prefer-for` | Lists use `` ([SolidJS](#solidjs)) | +| `solid/no-innerhtml` | No `innerHTML` from data ([Security](#security)) | +| `@eslint-community/eslint-comments/require-description` | Every `eslint-disable` comment says why | +| `eqeqeq` | `===` and `!==`, never `==` | + +ESLint's `no-unassigned-vars` is off for `.tsx` files: Solid assigns refs +itself (`ref={button}`), which ESLint can't see. Any other rule is turned +off only for a line, naming the rule and saying why after `--`: + +```ts +// eslint-disable-next-line rule-name -- why the rule is wrong here +``` + +Most `no-unsafe-*` reports don't need one: give the `any` value a type of +`unknown` and narrow it, as for `JSON.parse`: + +```ts +const parsed: unknown = JSON.parse(release.data); +if (isRelease(parsed)) { ... } +``` + +## Files and folders + +| Folder | Holds | +| ----------------- | -------------------------------------------------------------------------------------------------- | +| `src/api/` | The only code that talks to the backend: HTTP (`client.ts`) and its WebSocket events (`events.ts`) | +| `src/types/` | The backend's types (generated, see [Backend types](#backend-types)) and the app's own | +| `src/primitives/` | Solid primitives: state and loading, no markup (`createX.ts`) | +| `src/components/` | Components, one exported component per file | +| `src/lib/` | Plain functions: no Solid, no DOM where avoidable | +| `scripts/` | Development scripts run by npm, in TypeScript (`generateApiTypes.ts`) | + +- Components' files are PascalCase, named after the component + (`MarkMenu.tsx`). Other files are usually camelCase (`format.ts`); the + type files' PascalCase names (`Opportunities.ts`) are fine too. +- A file is named after what it exports, exactly: `OpportunityTable.tsx` + exports `OpportunityTable`. An import must name the file as it is + spelt on disk. +- The top of `src/` holds only the entry point (`index.tsx`), the app's + layout (`App.tsx`), the stylesheet (`index.css`) and Vite's type + declarations (`vite-env.d.ts`). +- `components/styles.ts` holds the Tailwind class strings shared by + several components (see [Styling](#styling)). + +## Imports + +Two groups, separated by a blank line: packages (`solid-js`, `node:fs`, +...) first, then the app's own modules, those from other folders (`@/`) +before those from the same folder (`./`). Each group is sorted by module; +a type import comes before the value import from the same module. +simple-import-sort checks this, and `npm run lint -- --fix` sorts them. + +```ts +import { createSignal, onCleanup } from 'solid-js'; + +import { api } from '@/api/client'; +import type { OpportunityMark } from '@/types/Annotations'; +import { normaliseMarks } from '@/types/Annotations'; +import { MARK_BG } from './styles'; +``` + +- An import from another folder of `src` starts with `@/`, which is + `src/` (set in both `tsconfig.app.json` and `vite.config.ts`): + `@/api/client`, not `../api/client`. One from the same folder starts + with `./`. So an import reads the same from every file, and doesn't + change when the importing file moves. `../` isn't used + (`no-restricted-imports` reports it). +- Types are imported with `import type`, in their own statement rather + than inline (`import { api, type Api }`). `consistent-type-imports` + checks that types are imported as types; the separate statement is + kept by hand. + +## Exports + +- Exports are named: `export function MarkMenu`, not + `export default function`. A named export is imported under one name + everywhere, so renaming and searching are reliable. +- A default export only where a tool requires one. + +## Naming + +Names are descriptive: `opportunity`, `classification`, `controller`. +Short names are fine where the meaning is obvious from a few lines +around them: a one-line callback's parameter (`(mark) =>` or +`(m) =>`), an event (`e` or `event`), a caught error (`err`), a plain +index (`i`). + +| Kind | Case | Example | +| ------------------------------------------------------------------ | ------------------------------- | --------------------------------------- | +| Variables, functions, props | camelCase | `toggleMark`, `opportunityTitle` | +| Components, types, interfaces | PascalCase | `MarkMenu`, `OpportunityAnnotation` | +| Module-level constants holding fixed data (numbers, lookups, keys) | UPPER_SNAKE_CASE | `MENU_WIDTH`, `MARK_BG`, `STORAGE_KEYS` | +| Tailwind class strings | camelCase | `buttonQuiet`, `fieldLabel` | +| Primitives | `create` + PascalCase | `createOpportunityAnnotations` | +| Event props | `on` + PascalCase | `onToggle`, `onChange` | +| Booleans | `is`, `has`, `can`, `should`... | `isSaving`, `hasDocuments` | + +- A word keeps one case throughout a name: `opportunityTitle`, not + `OpportunityTitle` for a prop, and `OPPORTUNITY_MARKS`, not + `Opportunity_MARKS`. A local variable is never PascalCase + (`opportunities`, not `Opportunities`). +- Initialisms keep one case: `apiBase`, `API_BASE`, `uuid`, `OCDSRelease`. +- Backend field names are kept as the backend sends them (`buyer_name`, + `has_documents`): see [Backend types](#backend-types). A boolean's + prefix applies to the app's own names. + +## TypeScript + +- No `any`. A value of unknown shape is `unknown`, narrowed before use + (`err instanceof Error`, a type guard such as `isOpportunityMark`). +- A type assertion (`as T`) only where TypeScript can't know a fact the + code does, such as the shape of `fetch` JSON; never to silence an error. + Non-null assertions (`!`) only for elements that must exist, such as the + root element, and Solid refs assigned before use + (`let button!: HTMLButtonElement`). +- With `noUncheckedIndexedAccess`, a value read by index or key + (`marks[0]`, `byUuid[uuid]`) may be `undefined`: check it, or give a + default with `??`. +- Fixed lists are `as const` arrays, with a union type derived from them, + rather than an `enum` (which `erasableSyntaxOnly` forbids): + + ```ts + export const OPPORTUNITY_MARKS = ['hidden', 'skipped'] as const; + export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number]; + ``` + +- Object shapes are `interface`s; unions and derived types are `type`s. +- Exported functions have their parameter and return types written out + (`explicit-module-boundary-types`); others where the type isn't + obvious. + +## Formatting + +Prettier formats every file (`npm run format`), so formatting isn't +reviewed. Its settings: two-space indentation, semicolons, single quotes +in TypeScript and double quotes in JSX attributes, trailing commas, and a +100-character line. prettier-plugin-tailwindcss sorts the classes in +`class` strings into Tailwind's order, so the same classes always read the +same way. + +Blank lines are still applied by hand: one between a file's top-level +declarations, and between the steps of a long function. + +## SolidJS + +Solid components run once; only reactive reads (signals, store fields, +`props.x`) update the page. The conventions keep reactivity working: + +- **Props are never destructured**, in the parameter list or the body: + `props.marks`, not `({ marks })`. Destructuring reads the value once and + stops it updating. Derived values are functions: + `const marks = () => annotation().marks`. +- Default prop values come from `mergeProps`, and props passed on to a + child are separated with `splitProps`: both keep the props reactive, + where destructuring wouldn't. +- A component's props are an `interface Props` in its file. Callbacks are + `on…` props; components don't reach into shared state they weren't + given. +- Lists use ``, conditions `` and ``/``, not + `.map()` or `&&` in JSX. +- State lives in primitives (`src/primitives/`): a `createX()` function + returning an interface of accessors and actions, documented on the + interface. Components call primitives; primitives don't render. +- Data is loaded with `createResource`, which tracks loading and errors + and refetches when its source changes. `` shows a placeholder + while resources load, and an `` around a part of the + page shows an error in it instead of breaking the whole page. +- Work started in a primitive or component is cleaned up with + `onCleanup`: listeners removed, timers cleared, and fetches aborted + through an `AbortController`. An aborted request isn't reported as an + error. +- A change may be applied optimistically, before the request, where + instant feedback matters and the request almost always succeeds (as + marks do in `createOpportunityAnnotations`). It is then undone if the + request fails, and the failure shown. +- An event handler doesn't return a promise: Solid ignores it, so a + rejection would go unnoticed. A handler calls an async function that + handles its own errors, and marks the call with `void`: + `onClick={() => void annotations.toggleMark(uuid, mark)}`. + +## Styling + +- Styles are Tailwind utility classes in `class`. Conditional classes use + `classList`. No CSS files beyond `src/index.css`. +- Class names are always written out in full in the source: Tailwind only + generates classes it can see, so never build them at runtime + (`` `bg-mark-${mark}` ``). A lookup from a value to its classes is a + record with every class written out, as `MARK_BG` in `styles.ts` is. +- Colours and fonts are theme tokens in the `@theme` block of + `src/index.css` (`bg-surface`, `text-muted`), not raw values. Dark mode + swaps the tokens under `.dark`, so components never use `dark:` + variants. +- Class strings used by more than one component go in + `components/styles.ts`; ones used by one component are constants in its + file. + +## Backend types + +The backend's types are generated from its OpenAPI 3.1 description +(`/api/openapi.json`) into `src/types/api.ts`, by `npm run generate:api` +(`scripts/generateApiTypes.ts`). They're never written by hand (with one +exception, the WebSocket events, below), so they can't drift from the +backend. + +- `src/types/Opportunities.ts` gives the generated types the names the + app uses (`Opportunity`, `OpportunityPage`, `Problem`); code imports + them from there, not from `api.ts`. +- After a change to the backend's types or handlers, regenerate: run + `go generate ./...` in the repository, then `npm run generate:api`, and + commit `api.ts` with the change. With the backend running, + `npm run generate:api -- http://localhost:9393/api/openapi.json` reads + the description from it instead. +- Keys are the Go structs' `json` tags, snake_case included + (`latest_release_date`). +- A field the backend can send as `null` (a Go pointer, or a slice that + can be nil) is typed `| null`, from the description's `x-nullable`, so + TypeScript makes the code handle it: a list is read with `?? []` + (`opportunity.classifications ?? []`). +- Times are RFC 3339 strings (`DateTimeString`), converted to `Date` only + for display. +- The one exception is the WebSocket events (`src/types/Events.ts`): the + OpenAPI description only covers HTTP routes, so the events' envelopes are + written by hand, to match the backend's events, and use the generated + types for their contents. Messages are parsed as `unknown` and narrowed + (`src/api/events.ts`); unknown events are ignored. + +## API calls + +- All requests go through `src/api/client.ts`: one function per + operation on the exported `api` object, and one `request` helper that + builds the URL from `VITE_API_BASE_URL` (default `/api`), sets the + headers and turns a failed response into an `ApiError`. +- Every function that loads data takes an optional `AbortSignal`. +- Path segments taken from data are encoded with `encodeURIComponent`. +- Unsafe requests (PUT, DELETE, POST) send the CSRF token the backend + issues at `GET /api/csrf`, in the header it names; the client fetches + and keeps it, so callers don't handle it. + +## Errors + +- Errors are caught as `unknown` and turned into text with one helper + (`errorText`), never by assuming they are `Error`s. +- A message shown to the user says what didn't happen, in plain words, + and then the cause: `Your marks didn't load: …`. Plain or typographic + quotes and apostrophes are both fine. +- Errors are shown where the user is looking, with `role="alert"`, and can + be retried or dismissed; they're never only logged to the console. + +## Security + +- Data from the API is shown as text, never as HTML: no `innerHTML` + with it (`solid/no-innerhtml` reports one). Releases and descriptions + come from outside sources, so HTML in them could run scripts. +- Only settings that are safe to publish go in `VITE_` variables: Vite + builds them into the JavaScript every visitor downloads. Nothing + secret is ever in the frontend. +- `.env` holds local settings and isn't committed; `.env.example` + lists every variable, with a comment on what it does. + +## Formatting values + +- Dates, money and numbers are formatted for display by the functions in + `lib/format.ts`, with `Intl` and a fixed locale (`en-GB`) and currency + (`GBP`), so they read the same on every machine. Components don't call + `toLocaleString` or format by hand. +- A missing or zero value (a null date, the zero time) is shown as a dash + or left empty, not as a misleading default such as `01/01/0001`. + +## Comments + +- Exported functions, components, primitives, interfaces and their + non-obvious fields have a `/** … */` comment saying what they are or do. +- `//` comments explain why code is the way it is + (`// Tailwind only generates classes it can see …`), not what the next + line does. +- JSX comments use `{/* … */}`. + +### Spelling + +Comments, documentation and user-facing text use British English +("normalise", "colour", "behaviour"). Names that must match something +external keep its spelling (`Authorization`, CSS `color`). + +## Accessibility + +- Every interactive element is a real ` + + )} + + +
+ }> + +

Loading opportunities…

+
+ + {(err) => ( + + )} + + +

There are no opportunities yet.

+
+ +
+

No opportunities match these filters.

+ +
+
+
+
+ + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..e5cb83b --- /dev/null +++ b/web/src/api/client.ts @@ -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 | undefined; + +async function fetchCsrfToken(): Promise { + 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 { + 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(path: string, init: RequestInit = {}, isRetry = false): Promise { + 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(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 { + const opportunities: Opportunity[] = []; + let batch = ''; + for (let offset = 0; ; offset += PAGE_LIMIT) { + const page = await request( + `/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 { + const page = await request( + `/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 { + return request('/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 { + return request(`${opportunityPath(uuid)}/marks/${encodeURIComponent(mark)}`, { + method: isSet ? 'PUT' : 'DELETE', + }); + }, +}; diff --git a/web/src/api/events.ts b/web/src/api/events.ts new file mode 100644 index 0000000..8f461c2 --- /dev/null +++ b/web/src/api/events.ts @@ -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; + 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 | 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(); + }; +} diff --git a/web/src/components/ClassificationPicker.tsx b/web/src/components/ClassificationPicker.tsx new file mode 100644 index 0000000..1d2b12b --- /dev/null +++ b/web/src/components/ClassificationPicker.tsx @@ -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; + onChange: (next: Set) => 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 ( +
+ + + 0}> +
    + + {(id) => { + const code = (): ClassificationCode | undefined => byId().get(id); + return ( +
  • + +
  • + ); + }} +
    +
+
+ + setQuery(e.currentTarget.value)} + disabled={isUnavailable()} + /> + + +

Loading classifications…

+
+ + +

+ Classifications didn't load.{' '} + +

+
+ + +
    + No classifications match “{query()}”. + } + > + {(code) => ( +
  • + +
  • + )} +
    +
+ MAX_VISIBLE}> +

+ Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow + the list. +

+
+
+
+ ); +} diff --git a/web/src/components/FilterPanel.tsx b/web/src/components/FilterPanel.tsx new file mode 100644 index 0000000..7c6e2a2 --- /dev/null +++ b/web/src/components/FilterPanel.tsx @@ -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(set: ReadonlySet, value: T): Set { + 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): void => + props.onChange({ ...props.filters, ...patch }); + + return ( +
+
+ + +

+ {props.shown === props.total + ? `${props.total.toLocaleString('en-GB')} opportunities` + : `${props.shown.toLocaleString('en-GB')} of ${props.total.toLocaleString('en-GB')} opportunities`} +

+ +
+ + {/* Animating grid rows from 0fr to 1fr collapses to the content's real height. */} +
+
+
+
+
+
+ + update({ title: e.currentTarget.value })} + /> +
+ +
+ Status +
+ + {(status) => ( + + )} + +
+
+ +
+ Marks +
+ + {(mark) => ( + + )} + +
+
+
+ + update({ classificationIds })} + /> +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/web/src/components/MarkMenu.tsx b/web/src/components/MarkMenu.tsx new file mode 100644 index 0000000..c5bf44b --- /dev/null +++ b/web/src/components/MarkMenu.tsx @@ -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 + * 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('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 ( + <> + + + + +
+ + {(mark) => ( + + )} + +
+
+
+ + ); +} diff --git a/web/src/components/OpportunityTable.tsx b/web/src/components/OpportunityTable.tsx new file mode 100644 index 0000000..41efbbd --- /dev/null +++ b/web/src/components/OpportunityTable.tsx @@ -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 ( +
+ + + + + + + + + + + + + + + + + {(opportunity) => ( + + )} + + +
+ Title + + Status + + SME suitable + + Published + + Start + + End + + Value + + Mark + + Hide +
+
+ ); +} + +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 ( + + {/* 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. */} + + + + {opportunity().title || 'Untitled opportunity'} + + {opportunity().buyer_name} + + + + {statusLabel(opportunity().status)} + + + + {opportunity().sme_suitable ? ( + Yes + ) : ( + No + )} + + {formatDate(opportunity().latest_release_date)} + {formatDate(opportunity().start_date)} + {formatDate(opportunity().end_date)} + {formatMoney(opportunity().gross_value)} + + props.annotations.toggleMark(opportunity().uuid, mark)} + /> + + + + + + ); +} diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..63ee290 --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -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 ( +
+ + {(option) => ( + + )} + +
+ ); +} diff --git a/web/src/components/styles.ts b/web/src/components/styles.ts new file mode 100644 index 0000000..4c02840 --- /dev/null +++ b/web/src/components/styles.ts @@ -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 = { + 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 = { + 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'; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..a81b3d5 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,79 @@ +@import 'tailwindcss'; + +/* `dark:` utilities follow the .dark class on , 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; + } +} diff --git a/web/src/index.tsx b/web/src/index.tsx new file mode 100644 index 0000000..738439a --- /dev/null +++ b/web/src/index.tsx @@ -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(() => , document.getElementById('root')!); diff --git a/web/src/lib/classifications.ts b/web/src/lib/classifications.ts new file mode 100644 index 0000000..edab7b6 --- /dev/null +++ b/web/src/lib/classifications.ts @@ -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, + ); +} diff --git a/web/src/lib/errors.ts b/web/src/lib/errors.ts new file mode 100644 index 0000000..ad6384f --- /dev/null +++ b/web/src/lib/errors.ts @@ -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); +} diff --git a/web/src/lib/filterOpportunities.ts b/web/src/lib/filterOpportunities.ts new file mode 100644 index 0000000..4b76835 --- /dev/null +++ b/web/src/lib/filterOpportunities.ts @@ -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; + /** Empty means any marks. Otherwise an opportunity needs at least one of these. */ + marks: ReadonlySet; + /** Classification ids; empty means every classification. */ + classificationIds: ReadonlySet; + 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(); + 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); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..8e64b40 --- /dev/null +++ b/web/src/lib/format.ts @@ -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) : '—'; +} diff --git a/web/src/lib/opportunities.ts b/web/src/lib/opportunities.ts new file mode 100644 index 0000000..0f09af9 --- /dev/null +++ b/web/src/lib/opportunities.ts @@ -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()]; +} diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts new file mode 100644 index 0000000..facbde1 --- /dev/null +++ b/web/src/lib/storage.ts @@ -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; diff --git a/web/src/primitives/createLiveUpdates.ts b/web/src/primitives/createLiveUpdates.ts new file mode 100644 index 0000000..6d4e6b1 --- /dev/null +++ b/web/src/primitives/createLiveUpdates.ts @@ -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; + 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); +} diff --git a/web/src/primitives/createOpportunities.ts b/web/src/primitives/createOpportunities.ts new file mode 100644 index 0000000..7c740a9 --- /dev/null +++ b/web/src/primitives/createOpportunities.ts @@ -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 { + /** 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(load: (signal: AbortSignal) => Promise): ApiList { + 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( + resource: Resource, + refetch: () => unknown, + update: ApiList['update'], +): ApiList { + // 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 { + /** + * 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 { + 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 => + createApiList((signal) => api.listClassifications(signal)); diff --git a/web/src/primitives/createOpportunityAnnotations.ts b/web/src/primitives/createOpportunityAnnotations.ts new file mode 100644 index 0000000..c458236 --- /dev/null +++ b/web/src/primitives/createOpportunityAnnotations.ts @@ -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; + +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({}); + const [saving, setSaving] = createStore>({}); + const [error, setError] = createSignal(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 { + 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), + }; +} diff --git a/web/src/primitives/createTheme.ts b/web/src/primitives/createTheme.ts new file mode 100644 index 0000000..e919b6e --- /dev/null +++ b/web/src/primitives/createTheme.ts @@ -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(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 }; +} diff --git a/web/src/types/Annotations.ts b/web/src/types/Annotations.ts new file mode 100644 index 0000000..9b63a00 --- /dev/null +++ b/web/src/types/Annotations.ts @@ -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 = { + 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, + }; +} diff --git a/web/src/types/Events.ts b/web/src/types/Events.ts new file mode 100644 index 0000000..2657d0d --- /dev/null +++ b/web/src/types/Events.ts @@ -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); diff --git a/web/src/types/Opportunities.ts b/web/src/types/Opportunities.ts new file mode 100644 index 0000000..2d30575 --- /dev/null +++ b/web/src/types/Opportunities.ts @@ -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']; diff --git a/web/src/types/api.ts b/web/src/types/api.ts new file mode 100644 index 0000000..ac35adc --- /dev/null +++ b/web/src/types/api.ts @@ -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; +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; +export type operations = Record; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..30f2e8b --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,8 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..ac16aed --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "noUncheckedIndexedAccess": true, + + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..27961d4 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "scripts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..0d3d88d --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,27 @@ +import tailwindcss from '@tailwindcss/vite'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, loadEnv } from 'vite'; +import solid from 'vite-plugin-solid'; + +// The dev server proxies /api and the WebSocket at /ws to the backend: +// job-search serve at its default address, or API_PROXY_TARGET if it's set. +// /ws keeps the browser's Host header (changeOrigin: false), as the backend +// only accepts a WebSocket whose Origin matches the host it was asked for. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const target = env.API_PROXY_TARGET || 'http://localhost:9393'; + + return { + plugins: [solid(), tailwindcss()], + // @/ is src/, matching "paths" in tsconfig.app.json. + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + server: { + proxy: { + '/api': { target, changeOrigin: true }, + '/ws': { target, ws: true, changeOrigin: false }, + }, + }, + }; +});