From 1832ea74375b14b5e7bd112871ae2d59be2b28f3 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Fri, 16 Oct 2026 16:01:38 +0100 Subject: [PATCH] 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. --- web/.env.example | 6 + web/.gitignore | 27 + web/.prettierignore | 6 + web/.prettierrc.json | 6 + web/CODE_CONVENTIONS.md | 378 ++ web/README.md | 96 + web/eslint.config.js | 103 + web/go.mod | 6 + web/index.html | 27 + web/package-lock.json | 4928 +++++++++++++++++ web/package.json | 39 + web/scripts/generateApiTypes.ts | 56 + web/src/App.tsx | 99 + web/src/api/client.ts | 151 + web/src/api/events.ts | 82 + web/src/components/ClassificationPicker.tsx | 145 + web/src/components/FilterPanel.tsx | 206 + web/src/components/MarkMenu.tsx | 141 + web/src/components/OpportunityTable.tsx | 138 + web/src/components/ThemeToggle.tsx | 58 + web/src/components/styles.ts | 31 + web/src/index.css | 79 + web/src/index.tsx | 8 + web/src/lib/classifications.ts | 17 + web/src/lib/errors.ts | 4 + web/src/lib/filterOpportunities.ts | 95 + web/src/lib/format.ts | 33 + web/src/lib/opportunities.ts | 14 + web/src/lib/storage.ts | 26 + web/src/primitives/createLiveUpdates.ts | 54 + web/src/primitives/createOpportunities.ts | 127 + .../createOpportunityAnnotations.ts | 131 + web/src/primitives/createTheme.ts | 54 + web/src/types/Annotations.ts | 47 + web/src/types/Events.ts | 45 + web/src/types/Opportunities.ts | 45 + web/src/types/api.ts | 416 ++ web/src/vite-env.d.ts | 8 + web/tsconfig.app.json | 32 + web/tsconfig.json | 4 + web/tsconfig.node.json | 24 + web/vite.config.ts | 27 + 42 files changed, 8019 insertions(+) create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc.json create mode 100644 web/CODE_CONVENTIONS.md create mode 100644 web/README.md create mode 100644 web/eslint.config.js create mode 100644 web/go.mod create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/scripts/generateApiTypes.ts create mode 100644 web/src/App.tsx create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/events.ts create mode 100644 web/src/components/ClassificationPicker.tsx create mode 100644 web/src/components/FilterPanel.tsx create mode 100644 web/src/components/MarkMenu.tsx create mode 100644 web/src/components/OpportunityTable.tsx create mode 100644 web/src/components/ThemeToggle.tsx create mode 100644 web/src/components/styles.ts create mode 100644 web/src/index.css create mode 100644 web/src/index.tsx create mode 100644 web/src/lib/classifications.ts create mode 100644 web/src/lib/errors.ts create mode 100644 web/src/lib/filterOpportunities.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/opportunities.ts create mode 100644 web/src/lib/storage.ts create mode 100644 web/src/primitives/createLiveUpdates.ts create mode 100644 web/src/primitives/createOpportunities.ts create mode 100644 web/src/primitives/createOpportunityAnnotations.ts create mode 100644 web/src/primitives/createTheme.ts create mode 100644 web/src/types/Annotations.ts create mode 100644 web/src/types/Events.ts create mode 100644 web/src/types/Opportunities.ts create mode 100644 web/src/types/api.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts 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 }, + }, + }, + }; +});