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

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

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

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

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

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

No files matched your search

+6
View File
@@ -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
+27
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
dist
.vite
node_modules
package-lock.json
# Generated by npm run generate:api
src/types/api.ts
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/index.css"
}
+378
View File
@@ -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 `<For>` ([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 `<For>`, conditions `<Show>` and `<Switch>`/`<Match>`, 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. `<Suspense>` shows a placeholder
while resources load, and an `<ErrorBoundary>` 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 `<button>` (with `type="button"`),
`<input>`, `<label>` or link, so it works with a keyboard.
- Controls without visible text have an `aria-label`; one whose text is
ambiguous in a table row includes what it belongs to ("Marks for
<title>").
- Menus and popups set `aria-expanded` and `aria-controls`, close on
Escape, and return focus to the control that opened them.
- Status that changes on its own (loading, saving, errors) uses
`role="alert"`, `aria-busy` or a live region.
## Browser storage
- `localStorage` is only used through `lib/storage.ts`
(`readStored`, `writeStored`), which never throws: storage can be
disabled or full, and a preference then just isn't kept.
- Keys are listed in `STORAGE_KEYS`, prefixed with the app's name.
- Only preferences are stored (theme, panel state); data comes from the
API every time.
## Dependencies
- `package-lock.json` is committed, and installs use `npm ci`, so every
machine gets the same versions.
- Development tools (linters, formatters, generators) are pinned to exact
versions, as a new version can report new problems or generate
different code. The app's own dependencies use `^` ranges, held by the
lock file.
- A new or upgraded package is a release at least two weeks old, and is
added deliberately, in its own change.
## Tests
There are no tests yet. When they're added:
- They use Vitest, which shares Vite's configuration, and sit next to the
file they test as `name.test.ts`.
- They start with the code in `src/lib/` and `src/api/`, which has no UI
to render.
- Components are tested with `@solidjs/testing-library`, through what
the user sees and does (text, roles, clicks), not their internals.
- No ESLint plugin checks accessibility in Solid well, so component tests
run an accessibility check (`axe`) on what they render.
+96
View File
@@ -0,0 +1,96 @@
# JobSearch - Frontend
A Vite + SolidJS + Tailwind CSS (v4) + TypeScript table of opportunities with
title search, status, mark and classification filters, and per-opportunity
mark and hide controls.
## Run
npm ci
npm run dev
The dev server proxies `/api` to the backend, `job-search serve`, at its
default address (`http://localhost:9393`). To use another, copy `.env.example`
to `.env` and set `API_PROXY_TARGET`. `VITE_API_BASE_URL` changes the `/api`
prefix for production builds.
Before committing, `npm run check` type-checks, lints (ESLint) and checks
formatting (Prettier); `npm run format` formats everything. See
[CODE_CONVENTIONS.md](CODE_CONVENTIONS.md).
The backend's types in `src/types/api.ts` are generated from its OpenAPI
description: after changing the Go types or handlers, run `go generate ./...`
in the repository, then `npm run generate:api`.
## API
The backend describes its API at `/api/openapi.json`, viewable at `/api/docs`.
The frontend uses, through `src/api/client.ts`:
| Method | Path | Response |
| ------ | ------------------------------------------ | ---------------------------------------------- |
| GET | `/api/opportunities?offset=&limit=` | A page of opportunities, with their marks |
| GET | `/api/opportunities?changed_after={batch}` | The opportunities changed since a change batch |
| GET | `/api/classifications` | Every classification in use, by code |
| PUT | `/api/opportunities/{uuid}/marks/{mark}` | Sets one mark; returns all the marks |
| DELETE | `/api/opportunities/{uuid}/marks/{mark}` | Clears one mark; returns all the marks |
| GET | `/api/csrf` | The CSRF token PUT and DELETE need |
The page also listens on the backend's WebSocket (`/ws`, through
`src/api/events.ts`), so it stays up to date without polling:
| Event | The page |
| ------------------------- | -------------------------------------------------------------- |
| `ClassificationsUpdated` | Adds and removes classifications in the filter, in place |
| `OpportunityMarksUpdated` | Shows the opportunity's new marks (unless it's saving its own) |
| `OpportunitiesUpdated` | Catches up: fetches what changed and merges it in |
If the connection drops, it reconnects (waiting 1s, doubling up to 30s),
catches the opportunities up and reloads the classifications, as it may have
missed events.
Catching up: every page of opportunities, and every `OpportunitiesUpdated`
event, names the backend's newest change batch (a hash). The page remembers
the batch it's up to date with, and asks for everything changed after it
(`changed_after`): opportunities that are new, have new releases or had
their marks changed. If the backend doesn't know the batch (410), or has
recorded none yet, the whole list is reloaded instead.
The list is fetched a page (100) at a time until there are no more. The marks
are `interested`, `skipped`, `bid_submitted`, `lost`, `won` and `hidden`;
Won and Lost exclude each other (409). The classifications are the ones the
filter offers: only those some opportunity has. The client fetches the CSRF
token when it first needs it, and a new one if a request is refused for it.
## Where things are
- `src/api/client.ts` – every request to the backend
- `src/api/events.ts` – the WebSocket connection and its events
- `src/types/api.ts` – the backend's types, generated (don't edit)
- `src/types/Opportunities.ts` – the app's names for them, and the known
statuses
- `src/types/Annotations.ts` – the marks, their labels, and an opportunity's
marks as the UI uses them
- `src/primitives/createOpportunities.ts` – loads the opportunities (and
catches them up) and the classifications
- `src/primitives/createLiveUpdates.ts` – applies the backend's events
- `src/primitives/createOpportunityAnnotations.ts` – changing marks and hiding
(applied at once, undone if the save fails)
- `src/lib/filterOpportunities.ts` – search and filter logic
- `src/lib/opportunities.ts` – merging changed opportunities into the list
- `src/components/` – FilterPanel, ClassificationPicker, OpportunityTable,
MarkMenu, ThemeToggle; `styles.ts` holds shared Tailwind class strings
Theme colours and the font are set in the `@theme` block of `src/index.css`.
## Saved preferences
Two preferences are kept in the browser's localStorage (`src/lib/storage.ts`):
- `job_search.theme`: `light` or `dark`. If nothing is saved, the app follows
the OS setting. A small script in `index.html` applies the saved theme
before the first paint, so there's no flash.
- `job_search.filtersOpen`: whether the filter panel is expanded.
Dark mode works by swapping the colour tokens under `.dark` in
`src/index.css`, so components don't need `dark:` variants.
+103
View File
@@ -0,0 +1,103 @@
// ESLint checks the rules in CODE_CONVENTIONS.md that a linter can; see its
// Tooling section for which. Formatting is Prettier's (.prettierrc.json), so
// eslint-config-prettier comes last and turns off rules that would clash.
import js from '@eslint/js';
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
import { defineConfig } from 'eslint/config';
import prettier from 'eslint-config-prettier';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import solid from 'eslint-plugin-solid/configs/typescript';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig(
// src/types/api.ts is generated (npm run generate:api).
{ ignores: ['dist', '.vite', 'node_modules', 'src/types/api.ts'] },
js.configs.recommended,
comments.recommended,
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['src/**/*.{ts,tsx}'],
...solid,
languageOptions: {
...solid.languageOptions,
globals: globals.browser,
},
},
{
files: ['src/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{ selector: 'default', format: ['camelCase'] },
// Module-level constants may be UPPER_SNAKE_CASE; no variable is
// PascalCase.
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE'] },
// Components are PascalCase functions.
{ selector: 'function', format: ['camelCase', 'PascalCase'] },
{ selector: 'typeLike', format: ['PascalCase'] },
// Backend JSON keys (snake_case) and header names keep their own case.
{ selector: ['objectLiteralProperty', 'typeProperty'], format: null },
{ selector: 'import', format: null },
],
// Types are imported with import type, in their own statement.
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
],
eqeqeq: 'error',
// Exported functions have their parameter and return types written out.
'@typescript-eslint/explicit-module-boundary-types': 'error',
// Imports from another folder of src use @/, not ../.
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*'], message: 'Import from another folder with @/.' }] },
],
},
},
{
// Solid assigns refs itself (ref={button} compiles to an assignment ESLint
// can't see), so a ref variable looks never assigned.
files: ['src/**/*.tsx'],
rules: {
'no-unassigned-vars': 'off',
},
},
{
// Imports: packages, then a blank line, then the app's own modules (@/,
// then ./), each sorted.
plugins: { 'simple-import-sort': simpleImportSort },
rules: {
'simple-import-sort/imports': [
'error',
{
groups: [
['^\\u0000@?\\w', '^@?\\w'],
['^\\u0000', '^@/', '^\\.'],
],
},
],
'simple-import-sort/exports': 'error',
},
},
{
// Every eslint-disable comment says why, after --.
rules: {
'@eslint-community/eslint-comments/require-description': 'error',
},
},
{
// Plain JavaScript (this file) has no types for the type-aware rules.
files: ['**/*.js'],
extends: [tseslint.configs.disableTypeChecked],
},
prettier,
);
+6
View File
@@ -0,0 +1,6 @@
// This go.mod only keeps web/ out of the repository's Go module: some npm
// packages in node_modules contain Go code (flatted does), which
// go build ./..., go test ./... and golangci-lint would otherwise include.
module git.avsdev.uk/Go/job-search/web
go 1.26.8
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>JobSearch</title>
<script>
// Apply the saved theme before first paint to avoid a light flash.
try {
var t = localStorage.getItem('job_search.theme');
if (t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
</script>
</head>
<body class="bg-paper">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+4928
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "job-search-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run typecheck && npm run lint && npm run format:check",
"generate:api": "node scripts/generateApiTypes.ts"
},
"dependencies": {
"solid-js": "^1.9.3"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "10.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^20.14.0",
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-simple-import-sort": "14.0.0",
"eslint-plugin-solid": "0.18.0",
"globals": "17.13.0",
"openapi-typescript": "7.13.0",
"prettier": "3.9.9",
"prettier-plugin-tailwindcss": "0.8.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.4",
"typescript-eslint": "8.71.0",
"vite": "^6.0.0",
"vite-plugin-solid": "^2.11.0"
}
}
+56
View File
@@ -0,0 +1,56 @@
// Generates src/types/api.ts, the TypeScript types of the backend's API, from
// its OpenAPI 3.1 description:
//
// npm run generate:api [-- <path or URL of openapi.json>]
//
// The default is the backend's generated file, ../internal/api/openapi/
// swagger.json (run go generate ./... in the repository first); a running
// server's http://localhost:9393/api/openapi.json works too.
//
// swag marks a Go field that can be null (a pointer, or a slice that can be
// nil) with the x-nullable extension, which openapi-typescript ignores, so
// those properties are rewritten into OpenAPI 3.1's own form first.
import { readFile, writeFile } from 'node:fs/promises';
import type { OpenAPI3 } from 'openapi-typescript';
import openapiTS, { astToString } from 'openapi-typescript';
const defaultSource = '../internal/api/openapi/swagger.json';
const output = 'src/types/api.ts';
const header = `// 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).
`;
type Schema = Record<string, unknown>;
/** Rewrites every x-nullable property of the description's schemas so it allows null. */
function allowNulls(description: OpenAPI3): void {
const schemas = (description.components?.schemas ?? {}) as Record<string, Schema>;
for (const schema of Object.values(schemas)) {
const properties = (schema.properties ?? {}) as Record<string, Schema>;
for (const [name, property] of Object.entries(properties)) {
if (property['x-nullable'] !== true) continue;
delete property['x-nullable'];
properties[name] =
typeof property.type === 'string' && property.type !== 'array'
? { ...property, type: [property.type, 'null'] }
: { oneOf: [property, { type: 'null' }] };
}
}
}
async function readDescription(source: string): Promise<OpenAPI3> {
const text = /^https?:\/\//.test(source)
? await (await fetch(source)).text()
: await readFile(source, 'utf8');
return JSON.parse(text) as OpenAPI3;
}
const source = process.argv[2] ?? defaultSource;
const description = await readDescription(source);
allowNulls(description);
const types = astToString(await openapiTS(description));
await writeFile(output, header + types);
console.log(`Wrote ${output} from ${source}`);
+99
View File
@@ -0,0 +1,99 @@
import type { JSX } from 'solid-js';
import { createMemo, createSignal, Match, Show, Switch } from 'solid-js';
import { FilterPanel } from '@/components/FilterPanel';
import { OpportunityTable } from '@/components/OpportunityTable';
import { buttonPrimary, linkButton } from '@/components/styles';
import { ThemeToggle } from '@/components/ThemeToggle';
import type { OpportunityFilters } from '@/lib/filterOpportunities';
import { emptyFilters, filterOpportunities, statusOptions } from '@/lib/filterOpportunities';
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import { createTheme } from '@/primitives/createTheme';
const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text-center';
/** The page: the filters, any save error, and the table of opportunities. */
export function App(): JSX.Element {
const opportunities = createOpportunities();
const classifications = createClassifications();
const annotations = createOpportunityAnnotations(opportunities.items);
createLiveUpdates({ opportunities, classifications, annotations });
const theme = createTheme();
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
const statuses = createMemo(() => statusOptions(opportunities.items()));
// annotations.get reads from a store, so hiding an opportunity re-runs this.
const rows = createMemo(() =>
filterOpportunities(opportunities.items(), filters(), annotations.get),
);
const clear = (): void => {
setFilters(emptyFilters());
};
return (
<div class="mx-auto max-w-[1440px] px-3 pt-4 pb-12 sm:px-6 sm:pt-7 sm:pb-16">
<header class="mb-5 flex items-center justify-between gap-4">
<h1 class="text-[1.75rem] font-bold tracking-tight">Opportunities</h1>
<ThemeToggle theme={theme.theme()} onChange={theme.setTheme} />
</header>
<FilterPanel
filters={filters()}
onChange={setFilters}
onClear={clear}
statuses={statuses()}
classifications={classifications.items()}
isLoadingClassifications={classifications.isLoading()}
classificationsError={classifications.error()}
onRetryClassifications={classifications.reload}
shown={rows().length}
total={opportunities.items().length}
/>
<Show when={annotations.error()}>
{(message) => (
<div
class="mb-4 flex items-center justify-between gap-4 rounded-md bg-danger-soft px-3.5 py-2.5 text-danger"
role="alert"
>
<span>{message()}</span>
<button type="button" class={linkButton} onClick={annotations.dismissError}>
Dismiss
</button>
</div>
)}
</Show>
<main>
<Switch fallback={<OpportunityTable opportunities={rows()} annotations={annotations} />}>
<Match when={opportunities.isLoading() && opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>Loading opportunities…</p>
</Match>
<Match when={opportunities.error()}>
{(err) => (
<div class={`${statePanel} text-danger`} role="alert">
<p class="mb-3">Opportunities didn't load: {err().message}</p>
<button type="button" class={buttonPrimary} onClick={opportunities.reload}>
Try again
</button>
</div>
)}
</Match>
<Match when={opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>There are no opportunities yet.</p>
</Match>
<Match when={rows().length === 0}>
<div class={`${statePanel} text-muted`}>
<p class="mb-3">No opportunities match these filters.</p>
<button type="button" class={buttonPrimary} onClick={clear}>
Clear filters
</button>
</div>
</Match>
</Switch>
</main>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import type { MarkName } from '@/types/Annotations';
import type {
ClassificationCode,
Opportunity,
OpportunityMarks,
OpportunityPage,
Problem,
} from '@/types/Opportunities';
/**
* Every request to the backend goes through here. The routes and shapes are
* the backend's OpenAPI description (/api/openapi.json):
*
* GET {base}/opportunities?offset=&limit= -> OpportunityPage
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
* GET {base}/classifications -> ClassificationCode[]
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
* DELETE {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (clears it)
* GET {base}/csrf -> the CSRF token for PUT and DELETE
*
* Marks are set and cleared one at a time, so quick changes to different
* marks can't overwrite each other, and a failed one is undone exactly.
*/
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '');
/** The largest page the backend serves. */
const PAGE_LIMIT = 100;
/**
* Opportunities, and the change batch to ask for later changes after
* ("" if the backend has recorded none).
*/
export interface OpportunityChanges {
opportunities: Opportunity[];
batch: string;
}
/** A request the backend refused or failed, with its problem details if it sent them. */
export class ApiError extends Error {
readonly status: number;
readonly problem: Problem | undefined;
constructor(path: string, status: number, problem: Problem | undefined) {
super(problem?.detail ?? `${path} returned ${status}`);
this.name = 'ApiError';
this.status = status;
this.problem = problem;
}
}
interface CsrfToken {
token: string;
header: string;
}
/** The CSRF token for unsafe requests, fetched when first needed. */
let csrfToken: Promise<CsrfToken> | undefined;
async function fetchCsrfToken(): Promise<CsrfToken> {
const res = await fetch(`${API_BASE}/csrf`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new ApiError('/csrf', res.status, await readProblem(res));
return (await res.json()) as CsrfToken;
}
async function readProblem(res: Response): Promise<Problem | undefined> {
try {
return (await res.json()) as Problem;
} catch {
return undefined;
}
}
/**
* Sends a request and returns its JSON. An unsafe request carries the CSRF
* token; if it's refused (the token expired), a new token is fetched and the
* request sent once more.
*/
async function request<T>(path: string, init: RequestInit = {}, isRetry = false): Promise<T> {
const method = init.method ?? 'GET';
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
const isUnsafe = method !== 'GET' && method !== 'HEAD';
if (isUnsafe) {
csrfToken ??= fetchCsrfToken();
const { token, header } = await csrfToken.catch((err: unknown) => {
csrfToken = undefined;
throw err;
});
headers.set(header, token);
}
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
if (res.status === 403 && isUnsafe && !isRetry) {
csrfToken = undefined;
return request<T>(path, init, true);
}
if (!res.ok) throw new ApiError(path, res.status, await readProblem(res));
return (await res.json()) as T;
}
const opportunityPath = (uuid: string): string => `/opportunities/${encodeURIComponent(uuid)}`;
/** The backend's operations the app uses. */
export const api = {
/**
* Every stored opportunity, newest release first, fetched a page at a
* time, and the newest change batch before the first page was read.
*/
async listOpportunities(signal?: AbortSignal): Promise<OpportunityChanges> {
const opportunities: Opportunity[] = [];
let batch = '';
for (let offset = 0; ; offset += PAGE_LIMIT) {
const page = await request<OpportunityPage>(
`/opportunities?offset=${offset}&limit=${PAGE_LIMIT}`,
{ signal },
);
// Later pages may name newer batches. Keeping the first page's means
// changes made while the pages were read are asked for again, rather
// than missed.
if (offset === 0) batch = page.batch;
opportunities.push(...page.opportunities);
if (!page.has_more) return { opportunities, batch };
}
},
/**
* The opportunities changed after a change batch, and the newest batch
* they include. An unknown batch is an ApiError with status 410.
*/
async listChangedOpportunities(batch: string, signal?: AbortSignal): Promise<OpportunityChanges> {
const page = await request<OpportunityPage>(
`/opportunities?changed_after=${encodeURIComponent(batch)}`,
{ signal },
);
return { opportunities: page.opportunities, batch: page.batch };
},
/** Every classification some opportunity has, ordered by code. */
listClassifications(signal?: AbortSignal): Promise<ClassificationCode[]> {
return request<ClassificationCode[]>('/classifications', { signal });
},
/** Sets or clears one of an opportunity's marks, and returns all of its marks. */
setMark(uuid: string, mark: MarkName, isSet: boolean): Promise<OpportunityMarks> {
return request<OpportunityMarks>(`${opportunityPath(uuid)}/marks/${encodeURIComponent(mark)}`, {
method: isSet ? 'PUT' : 'DELETE',
});
},
};
+82
View File
@@ -0,0 +1,82 @@
import type { ServerEvent } from '@/types/Events';
import { isEventType } from '@/types/Events';
/** The shortest and longest wait before reconnecting, in milliseconds. */
const RETRY_FIRST = 1_000;
const RETRY_MAX = 30_000;
/** What connectEvents calls as events arrive and the connection recovers. */
export interface EventHandlers {
/** Called with each event the app handles, in the order they arrive. */
onEvent: (event: ServerEvent) => void;
/**
* Called when the connection is made again after dropping: events sent
* meanwhile were missed, so whatever they'd have updated is stale.
*/
onReconnect: () => void;
}
/** The URL of the backend's WebSocket: /ws on the page's own host. */
function eventsUrl(): string {
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${window.location.host}/ws`;
}
/** The event in a message, or undefined for anything else (welcome, pong, unknown events). */
function parseEvent(data: unknown): ServerEvent | undefined {
if (typeof data !== 'string') return undefined;
let message: unknown;
try {
message = JSON.parse(data);
} catch {
return undefined;
}
if (typeof message !== 'object' || message === null) return undefined;
const envelope = message as Record<string, unknown>;
if (envelope.type !== 'event' || !isEventType(envelope.eventType)) return undefined;
// The backend's events (internal/events) match ServerEvent's payloads.
return { eventType: envelope.eventType, payload: envelope.payload } as ServerEvent;
}
/**
* Connects to the backend's WebSocket and passes its events to handlers,
* reconnecting with a growing delay whenever the connection drops. Returns a
* function that disconnects for good.
*/
export function connectEvents(handlers: EventHandlers): () => void {
let socket: WebSocket | undefined;
let retryDelay = RETRY_FIRST;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let hasConnected = false;
let isClosed = false;
const connect = (): void => {
socket = new WebSocket(eventsUrl());
socket.addEventListener('open', () => {
retryDelay = RETRY_FIRST;
if (hasConnected) handlers.onReconnect();
hasConnected = true;
});
socket.addEventListener('message', (e: MessageEvent) => {
const event = parseEvent(e.data);
if (event) handlers.onEvent(event);
});
socket.addEventListener('close', () => {
if (isClosed) return;
retryTimer = setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, RETRY_MAX);
});
};
connect();
return () => {
isClosed = true;
clearTimeout(retryTimer);
socket?.close();
};
}
+145
View File
@@ -0,0 +1,145 @@
import type { JSX } from 'solid-js';
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
import type { ClassificationCode } from '@/types/Opportunities';
import { fieldLabel, input, linkButton } from './styles';
const MAX_VISIBLE = 150;
interface Props {
classifications: ClassificationCode[];
isLoading: boolean;
error: Error | undefined;
onRetry: () => void;
selected: ReadonlySet<number>;
onChange: (next: Set<number>) => void;
class?: string;
}
/** A searchable list of classifications to filter by, with the chosen ones above it. */
export function ClassificationPicker(props: Props): JSX.Element {
const [query, setQuery] = createSignal('');
const inputId = createUniqueId();
const sorted = createMemo(() =>
[...props.classifications].sort((a, b) => a.code.localeCompare(b.code)),
);
const byId = createMemo(() => new Map(sorted().map((c) => [c.id, c])));
const matches = createMemo(() => {
const q = query().trim().toLocaleLowerCase();
if (!q) return sorted();
return sorted().filter(
(code) =>
code.code.toLocaleLowerCase().startsWith(q) ||
code.classification.toLocaleLowerCase().includes(q),
);
});
const toggle = (id: number): void => {
const next = new Set(props.selected);
if (next.has(id)) next.delete(id);
else next.add(id);
props.onChange(next);
};
const isUnavailable = (): boolean => props.isLoading || !!props.error;
return (
<div class={`min-w-0 ${props.class ?? ''}`}>
<label class={fieldLabel} for={inputId}>
Classification
</label>
<Show when={props.selected.size > 0}>
<ul class="mb-2 flex flex-wrap gap-1.5" aria-label="Selected classifications">
<For each={[...props.selected]}>
{(id) => {
const code = (): ClassificationCode | undefined => byId().get(id);
return (
<li>
<button
type="button"
class="inline-flex max-w-72 items-baseline gap-1.5 rounded-md bg-accent-soft px-2 py-1 text-accent"
onClick={() => toggle(id)}
title="Remove this classification"
>
<span class="text-sm tabular-nums">{code()?.code ?? id}</span>
<span class="truncate">{code()?.classification ?? 'Unknown'}</span>
<span aria-hidden="true" class="font-bold">
×
</span>
<span class="sr-only">Remove</span>
</button>
</li>
);
}}
</For>
</ul>
</Show>
<input
id={inputId}
type="search"
class={input}
placeholder="Find by code or name, e.g. 72000000 or software"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
disabled={isUnavailable()}
/>
<Show when={props.isLoading}>
<p class="mt-1.5 text-sm text-muted">Loading classifications…</p>
</Show>
<Show when={props.error}>
<p class="mt-1.5 text-sm text-mark-lost">
Classifications didn't load.{' '}
<button type="button" class={linkButton} onClick={() => props.onRetry()}>
Try again
</button>
</p>
</Show>
<Show when={!isUnavailable()}>
<ul
class="mt-1.5 max-h-72 overflow-y-auto rounded-md border border-line-soft p-1"
role="group"
aria-label="Classifications"
>
<For
each={matches().slice(0, MAX_VISIBLE)}
fallback={
<li class="p-1.5 text-sm text-muted">No classifications match “{query()}”.</li>
}
>
{(code) => (
<li>
<label class="grid cursor-pointer grid-cols-[auto_auto_1fr_auto] items-baseline gap-2 rounded px-1.5 py-1 hover:bg-paper">
<input
type="checkbox"
checked={props.selected.has(code.id)}
onChange={() => toggle(code.id)}
/>
<span class="text-sm text-muted tabular-nums">{code.code}</span>
<span class="truncate">{code.classification}</span>
<Show when={code.is_cpv}>
<span class="text-xs text-muted" title="Common Procurement Vocabulary">
CPV
</span>
</Show>
</label>
</li>
)}
</For>
</ul>
<Show when={matches().length > MAX_VISIBLE}>
<p class="mt-1.5 text-sm text-muted">
Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow
the list.
</p>
</Show>
</Show>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import type { JSX } from 'solid-js';
import { createSignal, createUniqueId, For, Show } from 'solid-js';
import type { MarkFilter, OpportunityFilters } from '@/lib/filterOpportunities';
import {
activeFilterCount,
hasActiveFilters,
NOT_MARKED,
statusLabel,
} from '@/lib/filterOpportunities';
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import type { ClassificationCode } from '@/types/Opportunities';
import { ClassificationPicker } from './ClassificationPicker';
import { buttonQuiet, fieldLabel, input, MARK_BG } from './styles';
interface Props {
filters: OpportunityFilters;
onChange: (next: OpportunityFilters) => void;
onClear: () => void;
statuses: string[];
classifications: ClassificationCode[];
isLoadingClassifications: boolean;
classificationsError: Error | undefined;
onRetryClassifications: () => void;
shown: number;
total: number;
}
const toggleChip =
'inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3.5 py-1.5 hover:border-muted aria-pressed:border-ink aria-pressed:bg-ink aria-pressed:text-surface';
function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
}
/**
* The collapsible panel of filters (title, status, marks, classification,
* hidden), with the count of opportunities shown.
*/
export function FilterPanel(props: Props): JSX.Element {
const titleId = createUniqueId();
const bodyId = createUniqueId();
// Open by default; the last choice is remembered.
const [isOpen, setIsOpen] = createSignal(readStored(STORAGE_KEYS.filtersOpen) !== 'false');
const toggleOpen = (): void => {
const next = !isOpen();
setIsOpen(next);
writeStored(STORAGE_KEYS.filtersOpen, String(next));
};
const update = (patch: Partial<OpportunityFilters>): void =>
props.onChange({ ...props.filters, ...patch });
return (
<section class="mb-4 rounded-[10px] border border-line bg-surface" aria-label="Filters">
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 sm:px-4">
<button
type="button"
class="-mx-1 inline-flex items-center gap-2 rounded-md px-1 py-1 font-semibold"
aria-expanded={isOpen()}
aria-controls={bodyId}
onClick={toggleOpen}
>
<svg
viewBox="0 0 16 16"
class="size-4 text-muted transition-transform duration-200 motion-reduce:transition-none"
classList={{ 'rotate-90': isOpen() }}
fill="currentColor"
aria-hidden="true"
>
<path d="M6 3.5 10.5 8 6 12.5z" />
</svg>
Filters
<Show when={activeFilterCount(props.filters) > 0}>
<span class="rounded-full bg-accent px-2 text-xs leading-5 font-semibold text-on-accent">
{activeFilterCount(props.filters)}
<span class="sr-only"> active</span>
</span>
</Show>
</button>
<p class="ml-auto text-muted tabular-nums" aria-live="polite">
{props.shown === props.total
? `${props.total.toLocaleString('en-GB')} opportunities`
: `${props.shown.toLocaleString('en-GB')} of ${props.total.toLocaleString('en-GB')} opportunities`}
</p>
<button
type="button"
class={buttonQuiet}
onClick={() => props.onClear()}
disabled={!hasActiveFilters(props.filters)}
>
Clear filters
</button>
</div>
{/* Animating grid rows from 0fr to 1fr collapses to the content's real height. */}
<div
id={bodyId}
class="grid transition-[grid-template-rows] duration-200 motion-reduce:transition-none"
classList={{ 'grid-rows-[1fr]': isOpen(), 'grid-rows-[0fr]': !isOpen() }}
inert={!isOpen()}
>
<div class="min-h-0 overflow-hidden">
<div class="border-t border-line-soft">
<div class="grid grid-cols-1 items-start gap-x-8 gap-y-6 p-4 sm:p-5 md:grid-cols-2">
<div class="flex min-w-0 flex-col gap-5">
<div>
<label class={fieldLabel} for={titleId}>
Title
</label>
<input
id={titleId}
type="search"
class={`${input} px-3 py-2.5 text-base`}
placeholder="Search opportunity titles"
value={props.filters.title}
onInput={(e) => update({ title: e.currentTarget.value })}
/>
</div>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class={fieldLabel}>Status</legend>
<div class="flex flex-wrap gap-1.5">
<For each={props.statuses}>
{(status) => (
<button
type="button"
class={toggleChip}
aria-pressed={props.filters.statuses.has(status)}
onClick={() =>
update({ statuses: toggled(props.filters.statuses, status) })
}
>
{statusLabel(status)}
</button>
)}
</For>
</div>
</fieldset>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class={fieldLabel}>Marks</legend>
<div class="flex flex-wrap gap-1.5">
<For each={[...OPPORTUNITY_MARKS, NOT_MARKED] as MarkFilter[]}>
{(mark) => (
<button
type="button"
class={toggleChip}
aria-pressed={props.filters.marks.has(mark)}
onClick={() => update({ marks: toggled(props.filters.marks, mark) })}
>
<Show
when={mark !== NOT_MARKED}
fallback={
<span
class="size-2 rounded-full border border-current"
aria-hidden="true"
/>
}
>
<span
class={`size-2 rounded-full ${MARK_BG[mark as OpportunityMark]}`}
aria-hidden="true"
/>
</Show>
{mark === NOT_MARKED ? 'Not marked' : MARK_LABELS[mark]}
</button>
)}
</For>
</div>
</fieldset>
</div>
<ClassificationPicker
classifications={props.classifications}
isLoading={props.isLoadingClassifications}
error={props.classificationsError}
onRetry={props.onRetryClassifications}
selected={props.filters.classificationIds}
onChange={(classificationIds) => update({ classificationIds })}
/>
</div>
<div class="border-t border-line-soft px-4 py-3 sm:px-5">
<label class="inline-flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={props.filters.showHidden}
onChange={(e) => update({ showHidden: e.currentTarget.checked })}
/>
Show hidden opportunities
</label>
</div>
</div>
</div>
</div>
</section>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { JSX } from 'solid-js';
import { createEffect, createSignal, createUniqueId, For, onCleanup, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import { MARK_BG } from './styles';
interface Props {
marks: OpportunityMark[];
onToggle: (mark: OpportunityMark) => void;
/** Used in the button's accessible name. */
opportunityTitle: string;
}
const MENU_WIDTH = 200;
const GAP = 4;
/**
* A dropdown of mark checkboxes. Each mark toggles on its own and the menu
* stays open, so several can be set in one go. The menu is portalled to
* <body> with fixed positioning so the table's scroll container can't clip it.
*/
export function MarkMenu(props: Props): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false);
const [pos, setPos] = createSignal({ top: 0, left: 0, isAbove: false });
const menuId = createUniqueId();
let button!: HTMLButtonElement;
let menu: HTMLDivElement | undefined;
const place = () => {
const rect = button.getBoundingClientRect();
const height = menu?.offsetHeight ?? 220;
const isAbove = rect.bottom + GAP + height > window.innerHeight && rect.top - GAP - height > 0;
const left = Math.min(Math.max(8, rect.right - MENU_WIDTH), window.innerWidth - MENU_WIDTH - 8);
setPos({ top: isAbove ? rect.top - GAP - height : rect.bottom + GAP, left, isAbove });
};
const close = (shouldRefocus = false) => {
setIsOpen(false);
if (shouldRefocus) button.focus();
};
createEffect(() => {
if (!isOpen()) return;
place();
requestAnimationFrame(() => {
place(); // re-measure now the menu has a real height
menu?.querySelector<HTMLInputElement>('input')?.focus();
});
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (!button.contains(target) && !menu?.contains(target)) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(true);
};
const onScroll = (e: Event) => {
if (!menu?.contains(e.target as Node)) close();
};
document.addEventListener('pointerdown', onPointer);
document.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', place);
onCleanup(() => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', place);
});
});
const summary = (): string =>
props.marks.length === 0
? 'not marked'
: props.marks.map((mark) => MARK_LABELS[mark]).join(', ');
return (
<>
<button
ref={button}
type="button"
class="flex min-h-9 w-44 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
aria-haspopup="true"
aria-expanded={isOpen()}
aria-controls={isOpen() ? menuId : undefined}
aria-label={`Marks for ${props.opportunityTitle}: ${summary()}`}
onClick={() => setIsOpen(!isOpen())}
>
<span class="flex min-w-0 flex-1 flex-wrap gap-1">
<Show when={props.marks.length > 0} fallback={<span class="text-muted">Not marked</span>}>
<For each={props.marks}>
{(mark) => (
<span class="inline-flex items-center gap-1 rounded bg-paper px-1.5 text-xs leading-5 whitespace-nowrap text-ink">
<span class={`size-2 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</span>
)}
</For>
</Show>
</span>
<svg
viewBox="0 0 16 16"
class="size-3.5 shrink-0 text-muted"
fill="currentColor"
aria-hidden="true"
>
<path d="M3.5 6 8 10.5 12.5 6z" />
</svg>
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={menu}
id={menuId}
role="group"
aria-label={`Marks for ${props.opportunityTitle}`}
class="fixed z-50 rounded-lg border border-line bg-surface p-1 text-ink shadow-[0_8px_24px_rgb(0_0_0/0.18)]"
style={{ top: `${pos().top}px`, left: `${pos().left}px`, width: `${MENU_WIDTH}px` }}
>
<For each={OPPORTUNITY_MARKS}>
{(mark) => (
<label class="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-paper">
<input
type="checkbox"
checked={props.marks.includes(mark)}
onChange={() => props.onToggle(mark)}
/>
<span class={`size-2.5 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</label>
)}
</For>
</div>
</Portal>
</Show>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
opportunities: Opportunity[];
annotations: OpportunityAnnotations;
}
const th =
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
const td = 'border-b border-line-soft p-3 align-middle';
/** The table of opportunities, each with its marks and a hide button. */
export function OpportunityTable(props: Props): JSX.Element {
return (
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
<table class="w-full border-separate border-spacing-0 tabular-nums">
<thead>
<tr>
<th scope="col" class={`${th} min-w-80`}>
Title
</th>
<th scope="col" class={th}>
Status
</th>
<th scope="col" class={`${th} text-center`}>
SME suitable
</th>
<th scope="col" class={th}>
Published
</th>
<th scope="col" class={th}>
Start
</th>
<th scope="col" class={th}>
End
</th>
<th scope="col" class={`${th} text-right`}>
Value
</th>
<th scope="col" class={th}>
Mark
</th>
<th scope="col" class={`${th} text-center`}>
Hide
</th>
</tr>
</thead>
<tbody class="[&>tr:last-child>td]:border-b-0">
<For each={props.opportunities}>
{(opportunity) => (
<OpportunityRow opportunity={opportunity} annotations={props.annotations} />
)}
</For>
</tbody>
</table>
</div>
);
}
interface RowProps {
opportunity: Opportunity;
annotations: OpportunityAnnotations;
}
function OpportunityRow(props: RowProps): JSX.Element {
const opportunity = (): Opportunity => props.opportunity;
const annotation = () => props.annotations.get(opportunity().uuid);
const marks = () => annotation().marks;
const hidden = () => annotation().hidden;
return (
<tr
classList={{
'text-muted': hidden() || marks().includes('skipped'),
'bg-[repeating-linear-gradient(-45deg,transparent_0_8px,var(--color-paper)_8px_9px)]':
hidden(),
'opacity-70': props.annotations.isSaving(opportunity().uuid),
}}
aria-busy={props.annotations.isSaving(opportunity().uuid) || undefined}
>
{/* The marks colour the leading edge of each row, one segment per
mark, so a scan down the left side shows where every opportunity stands. */}
<td class={`${td} relative max-w-[520px] pl-[18px]`}>
<span class="absolute inset-y-0 left-0 flex w-1 flex-col" aria-hidden="true">
<For each={marks()}>{(m) => <span class={`flex-1 ${MARK_BG[m]}`} />}</For>
</span>
<span class="block font-semibold" title={opportunity().description || undefined}>
{opportunity().title || 'Untitled opportunity'}
</span>
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
</td>
<td class={td}>
<span
class={`inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</td>
<td class={`${td} text-center`}>
{opportunity().sme_suitable ? (
<span class="font-semibold text-mark-won">Yes</span>
) : (
<span class="text-muted">No</span>
)}
</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().latest_release_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().start_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
<td class={td}>
<MarkMenu
marks={marks()}
opportunityTitle={opportunity().title}
onToggle={(mark) => props.annotations.toggleMark(opportunity().uuid, mark)}
/>
</td>
<td class={`${td} text-center`}>
<button
type="button"
class={`${buttonQuiet} px-2.5 py-1 text-sm text-ink`}
aria-pressed={hidden()}
aria-label={`${hidden() ? 'Show again' : 'Hide'}: ${opportunity().title}`}
onClick={() => props.annotations.setHidden(opportunity().uuid, !hidden())}
>
{hidden() ? 'Unhide' : 'Hide'}
</button>
</td>
</tr>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import type { Theme } from '@/primitives/createTheme';
interface Props {
theme: Theme;
onChange: (theme: Theme) => void;
}
const OPTIONS: { value: Theme; label: string; icon: string }[] = [
{
value: 'light',
label: 'Light',
icon: 'M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z',
},
{
value: 'dark',
label: 'Dark',
icon: 'M20.35 14.35A8.5 8.5 0 0 1 9.65 3.65 8.5 8.5 0 1 0 20.35 14.35Z',
},
];
/** A pair of buttons choosing the light or dark theme. */
export function ThemeToggle(props: Props): JSX.Element {
return (
<div
class="inline-flex rounded-full border border-line bg-surface p-0.5"
role="group"
aria-label="Colour theme"
>
<For each={OPTIONS}>
{(option) => (
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm text-muted hover:text-ink aria-pressed:bg-ink aria-pressed:text-surface"
aria-pressed={props.theme === option.value}
onClick={() => props.onChange(option.value)}
>
<svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d={option.icon} />
</svg>
{option.label}
</button>
)}
</For>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { OpportunityMark } from '@/types/Annotations';
// Tailwind only generates classes it can see written out in full, so these
// lookups list every class literally rather than building names at runtime.
/** Badge colours for each known status; others get a neutral badge. */
export const STATUS_BADGE: Record<string, string> = {
planning: 'bg-status-planning-soft text-status-planning',
planned: 'bg-status-planning-soft text-status-planning',
active: 'bg-status-open-soft text-status-open',
complete: 'bg-accent-soft text-accent',
};
/** The badge classes for status. */
export const statusBadge = (status: string): string => STATUS_BADGE[status] ?? 'bg-paper text-ink';
/** Fill colour for each mark: row stripe segments, menu dots, pills. */
export const MARK_BG: Record<OpportunityMark, string> = {
interested: 'bg-mark-interested',
skipped: 'bg-mark-skipped',
bid_submitted: 'bg-mark-bid-submitted',
lost: 'bg-mark-lost',
won: 'bg-mark-won',
};
export const input =
'w-full rounded-md border border-line bg-surface px-2.5 py-2 placeholder:text-muted disabled:bg-paper';
export const buttonQuiet =
'rounded-md border border-line bg-surface px-3.5 py-1.5 hover:enabled:border-muted disabled:opacity-60 disabled:text-muted';
export const buttonPrimary = 'rounded-md bg-accent px-3.5 py-1.5 font-semibold text-on-accent';
export const linkButton = 'text-accent underline';
export const fieldLabel = 'mb-1.5 block p-0 text-sm font-semibold';
+79
View File
@@ -0,0 +1,79 @@
@import 'tailwindcss';
/* `dark:` utilities follow the .dark class on <html>, not the OS setting. */
@custom-variant dark (&:where(.dark, .dark *));
/* Every colour is a token. The .dark block below swaps the values, so
components use bg-surface, text-ink etc. and never need dark: variants. */
@theme {
--font-sans: 'Public Sans', 'Segoe UI', system-ui, -apple-system, sans-serif;
--color-paper: #eef1f4;
--color-surface: #ffffff;
--color-ink: #18232b;
--color-muted: #5b6873;
--color-line: #d5dce2;
--color-line-soft: #e7ecf0;
--color-accent: #23508e;
--color-accent-soft: #e3ebf6;
--color-on-accent: #ffffff;
--color-danger: #7d2626;
--color-danger-soft: #f8e9e9;
--color-status-planning: #4b4592;
--color-status-planning-soft: #ecebf6;
--color-status-open: #23633a;
--color-status-open-soft: #e2f1e7;
--color-mark-interested: #23508e;
--color-mark-skipped: #8a949c;
--color-mark-bid-submitted: #b8730a;
--color-mark-lost: #b23b3b;
--color-mark-won: #2f7d4a;
}
@layer base {
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
--color-paper: #141b21;
--color-surface: #1c252d;
--color-ink: #e4e9ed;
--color-muted: #93a0aa;
--color-line: #34414b;
--color-line-soft: #27323b;
--color-accent: #8db3ea;
--color-accent-soft: #1f3350;
--color-on-accent: #0f1d2e;
--color-danger: #f2a7a7;
--color-danger-soft: #3a1f23;
--color-status-planning: #bdb7f2;
--color-status-planning-soft: #2b2948;
--color-status-open: #93d6aa;
--color-status-open-soft: #1d3527;
--color-mark-interested: #8db3ea;
--color-mark-skipped: #6f7a83;
--color-mark-bid-submitted: #e2a640;
--color-mark-lost: #e47676;
--color-mark-won: #63c286;
}
body {
@apply bg-paper font-sans text-[15px] leading-normal text-ink antialiased;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
button:not(:disabled),
select,
[role='button'] {
cursor: pointer;
}
}
+8
View File
@@ -0,0 +1,8 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import { App } from './App';
// index.html always has the root element.
render(() => <App />, document.getElementById('root')!);
+17
View File
@@ -0,0 +1,17 @@
import type { ClassificationsUpdatedPayload } from '@/types/Events';
import type { ClassificationCode } from '@/types/Opportunities';
/**
* The classifications after change: without the removed ids, with the added
* codes (replacing any with the same id), ordered by code as the backend
* orders them.
*/
export function applyClassificationChanges(
classifications: readonly ClassificationCode[],
change: ClassificationsUpdatedPayload,
): ClassificationCode[] {
const gone = new Set([...change.removed, ...change.added.map((code) => code.id)]);
return [...classifications.filter((code) => !gone.has(code.id)), ...change.added].sort(
(first, second) => first.code.localeCompare(second.code) || first.id - second.id,
);
}
+4
View File
@@ -0,0 +1,4 @@
/** An error's message for the user, whatever was thrown. */
export function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+95
View File
@@ -0,0 +1,95 @@
import type { Annotation, OpportunityMark } from '@/types/Annotations';
import type { Opportunity, OpportunityStatus } from '@/types/Opportunities';
import { OPPORTUNITY_STATUSES } from '@/types/Opportunities';
import { parseDate } from './format';
/** Marks filter option for opportunities with no marks at all. */
export const NOT_MARKED = 'none';
export type MarkFilter = OpportunityMark | typeof NOT_MARKED;
/** What the table is filtered by. */
export interface OpportunityFilters {
title: string;
/** Empty means every status. */
statuses: ReadonlySet<string>;
/** Empty means any marks. Otherwise an opportunity needs at least one of these. */
marks: ReadonlySet<MarkFilter>;
/** Classification ids; empty means every classification. */
classificationIds: ReadonlySet<number>;
showHidden: boolean;
}
/** Filters that let every opportunity through, apart from hidden ones. */
export const emptyFilters = (): OpportunityFilters => ({
title: '',
statuses: new Set(),
marks: new Set(),
classificationIds: new Set(),
showHidden: false,
});
/** How many separate filters are in use, for the collapsed panel's badge. */
export const activeFilterCount = (filters: OpportunityFilters): number =>
[
filters.title.trim() !== '',
filters.statuses.size > 0,
filters.marks.size > 0,
filters.classificationIds.size > 0,
filters.showHidden,
].filter(Boolean).length;
/** Whether any filter is in use. */
export const hasActiveFilters = (filters: OpportunityFilters): boolean =>
activeFilterCount(filters) > 0;
/** The opportunities that pass filters, newest release first. */
export function filterOpportunities(
opportunities: readonly Opportunity[],
filters: OpportunityFilters,
annotationFor: (uuid: string) => Annotation,
): Opportunity[] {
const query = filters.title.trim().toLocaleLowerCase();
return opportunities
.filter((opportunity) => {
const annotation = annotationFor(opportunity.uuid);
if (!filters.showHidden && annotation.hidden) return false;
if (query && !opportunity.title.toLocaleLowerCase().includes(query)) return false;
if (filters.statuses.size > 0 && !filters.statuses.has(opportunity.status)) return false;
if (filters.marks.size > 0) {
const matches =
annotation.marks.length === 0
? filters.marks.has(NOT_MARKED)
: annotation.marks.some((mark) => filters.marks.has(mark));
if (!matches) return false;
}
if (filters.classificationIds.size > 0) {
const codes = opportunity.classifications ?? [];
if (!codes.some((code) => filters.classificationIds.has(code.id))) return false;
}
return true;
})
.sort(byNewestRelease);
}
function byNewestRelease(first: Opportunity, second: Opportunity): number {
const firstTime = parseDate(first.latest_release_date)?.getTime() ?? 0;
const secondTime = parseDate(second.latest_release_date)?.getTime() ?? 0;
return secondTime - firstTime;
}
const isKnownStatus = (status: string): status is OpportunityStatus =>
(OPPORTUNITY_STATUSES as readonly string[]).includes(status);
/** The statuses to filter by: the known ones first, then anything else the API returned. */
export function statusOptions(opportunities: readonly Opportunity[]): string[] {
const extra = new Set<string>();
for (const opportunity of opportunities) {
if (opportunity.status && !isKnownStatus(opportunity.status)) extra.add(opportunity.status);
}
return [...OPPORTUNITY_STATUSES, ...[...extra].sort()];
}
/** A status as shown on screen: "active" is "Active". */
export const statusLabel = (status: string): string =>
status.charAt(0).toLocaleUpperCase() + status.slice(1);
+33
View File
@@ -0,0 +1,33 @@
import type { DateTimeString } from '@/types/Opportunities';
const ZERO_TIME_PREFIX = '0001-01-01';
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const moneyFormat = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
});
/** Treats null and Go's zero time.Time as "no date". */
export function parseDate(value: DateTimeString | null | undefined): Date | null {
if (!value || value.startsWith(ZERO_TIME_PREFIX)) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
/** A date for display, e.g. "3 Mar 2026", or a dash for no date. */
export function formatDate(value: DateTimeString | null | undefined): string {
const d = parseDate(value);
return d ? dateFormat.format(d) : '—';
}
/** An amount in pounds for display, e.g. "£125,001", or a dash for none. */
export function formatMoney(value: number): string {
return value > 0 ? moneyFormat.format(value) : '—';
}
+14
View File
@@ -0,0 +1,14 @@
import type { Opportunity } from '@/types/Opportunities';
/**
* The opportunities with changed merged in: each replaces the opportunity
* with its UUID, in its place, or is added at the end if there's none.
*/
export function mergeOpportunities(
opportunities: readonly Opportunity[],
changed: readonly Opportunity[],
): Opportunity[] {
const byUuid = new Map(opportunities.map((opportunity) => [opportunity.uuid, opportunity]));
for (const opportunity of changed) byUuid.set(opportunity.uuid, opportunity);
return [...byUuid.values()];
}
+26
View File
@@ -0,0 +1,26 @@
// localStorage can throw (privacy modes, storage disabled, quota), so every
// access is guarded and falls back to "nothing stored".
/** The stored value of key, or null if there is none or storage is unavailable. */
export function readStored(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/** Stores value under key, if storage is available. */
export function writeStored(key: string, value: string): void {
try {
window.localStorage.setItem(key, value);
} catch {
/* preference just won't persist */
}
}
// index.html reads the theme key too, before the app loads.
export const STORAGE_KEYS = {
theme: 'job_search.theme',
filtersOpen: 'job_search.filtersOpen',
} as const;
+54
View File
@@ -0,0 +1,54 @@
import { onCleanup } from 'solid-js';
import { connectEvents } from '@/api/events';
import { applyClassificationChanges } from '@/lib/classifications';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { ServerEvent } from '@/types/Events';
import type { ClassificationCode } from '@/types/Opportunities';
import type { ApiList, OpportunityList } from './createOpportunities';
/** What createLiveUpdates keeps up to date. */
export interface LiveData {
opportunities: OpportunityList;
classifications: ApiList<ClassificationCode>;
annotations: OpportunityAnnotations;
}
/**
* Keeps data up to date from the backend's events, for as long as the
* calling component lives:
*
* - ClassificationsUpdated changes the classifications in place.
* - OpportunityMarksUpdated sets the opportunity's marks.
* - OpportunitiesUpdated catches the opportunities up: the changed ones are
* fetched and merged in, as the event only names them.
*
* After the connection drops and is made again, events may have been missed:
* the opportunities catch up, and the classifications are reloaded.
*/
export function createLiveUpdates(data: LiveData): void {
const handle = (event: ServerEvent): void => {
switch (event.eventType) {
case 'ClassificationsUpdated':
data.classifications.update((classifications) =>
applyClassificationChanges(classifications, event.payload),
);
break;
case 'OpportunityMarksUpdated':
data.annotations.applyMarks(event.payload);
break;
case 'OpportunitiesUpdated':
data.opportunities.catchUp();
break;
}
};
const disconnect = connectEvents({
onEvent: handle,
onReconnect: () => {
data.opportunities.catchUp();
data.classifications.reload();
},
});
onCleanup(disconnect);
}
+127
View File
@@ -0,0 +1,127 @@
import type { Resource } from 'solid-js';
import { createEffect, createMemo, createResource, createSignal, onCleanup } from 'solid-js';
import { api } from '@/api/client';
import { mergeOpportunities } from '@/lib/opportunities';
import type { ClassificationCode, Opportunity } from '@/types/Opportunities';
/** A list loaded from the API, with its loading and error state. */
export interface ApiList<T> {
/** Loaded items, or [] while loading or after a failure. Never throws. */
items: () => T[];
isLoading: () => boolean;
error: () => Error | undefined;
reload: () => void;
/** Changes the loaded items in place, without reloading them. */
update: (change: (items: T[]) => T[]) => void;
}
/**
* Loads a list with load, giving it a signal that aborts the request when
* the list is reloaded or its owner is cleaned up.
*/
function createApiList<T>(load: (signal: AbortSignal) => Promise<T[]>): ApiList<T> {
let controller: AbortController | undefined;
onCleanup(() => controller?.abort());
const [resource, { mutate, refetch }] = createResource(() => {
controller?.abort();
controller = new AbortController();
return load(controller.signal);
});
return wrap(resource, refetch, (change) => {
mutate((items) => change(items ?? []));
});
}
function wrap<T>(
resource: Resource<T[]>,
refetch: () => unknown,
update: ApiList<T>['update'],
): ApiList<T> {
// Reading an errored resource throws, so check state first.
const items = createMemo(() =>
resource.state === 'ready' || resource.state === 'refreshing' ? (resource() ?? []) : [],
);
return {
items,
isLoading: () => resource.loading,
error: () => (resource.state === 'errored' ? toError(resource.error) : undefined),
reload: () => void refetch(),
update,
};
}
const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
/** The opportunities, which can catch up with changes on the backend. */
export interface OpportunityList extends ApiList<Opportunity> {
/**
* Gets the opportunities changed since the list was loaded or last caught
* up, and merges them in. Calls while a load or catch-up is under way
* lead to one more catch-up after it. When the backend can't say what
* changed (it has recorded no changes yet, or doesn't know the batch),
* the list is reloaded instead.
*/
catchUp: () => void;
}
/** Every stored opportunity, loaded from the backend and kept up to date with catchUp. */
export function createOpportunities(): OpportunityList {
// The change batch the list is up to date with; "" before it has loaded,
// or if the backend has recorded no changes.
let batch = '';
// Counts loads, so a catch-up that a load overtook is dropped.
let loads = 0;
let controller: AbortController | undefined;
onCleanup(() => controller?.abort());
const [isWanted, setIsWanted] = createSignal(false);
const [isCatchingUp, setIsCatchingUp] = createSignal(false);
const list = createApiList(async (signal) => {
loads += 1;
controller?.abort();
const loaded = await api.listOpportunities(signal);
batch = loaded.batch;
return loaded.opportunities;
});
async function catchUpNow(): Promise<void> {
if (batch === '') {
list.reload();
return;
}
const load = loads;
controller = new AbortController();
const { signal } = controller;
setIsCatchingUp(true);
try {
const changes = await api.listChangedOpportunities(batch, signal);
if (load !== loads) return;
batch = changes.batch;
list.update((items) => mergeOpportunities(items, changes.opportunities));
} catch {
// An unknown batch or a failure: the list may be missing changes, so
// it's reloaded, which shows any error.
if (!signal.aborted && load === loads) list.reload();
} finally {
setIsCatchingUp(false);
}
}
// One catch-up at a time, and none while loading: the load's batch is
// the one to catch up from.
createEffect(() => {
if (isWanted() && !isCatchingUp() && !list.isLoading()) {
setIsWanted(false);
void catchUpNow();
}
});
return { ...list, catchUp: () => setIsWanted(true) };
}
/** Every classification some opportunity has, loaded from the backend. */
export const createClassifications = (): ApiList<ClassificationCode> =>
createApiList((signal) => api.listClassifications(signal));
@@ -0,0 +1,131 @@
import { createEffect, createSignal } from 'solid-js';
import { createStore, produce, reconcile } from 'solid-js/store';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import type { Annotation, MarkName, OpportunityMark } from '@/types/Annotations';
import { annotationFrom, MARK_LABELS, normaliseMarks } from '@/types/Annotations';
import type { Opportunity, OpportunityMarks } from '@/types/Opportunities';
type AnnotationMap = Record<string, Annotation>;
const blank = (uuid: string): Annotation => ({ uuid, marks: [], hidden: false });
/** The marks on every opportunity, and the actions that change them. */
export interface OpportunityAnnotations {
/** Reactive: reading inside a tracking scope re-runs when it changes. */
get: (uuid: string) => Annotation;
/** Sets or clears one mark. The UI updates at once, and reverts if the request fails. */
toggleMark: (uuid: string, mark: OpportunityMark) => void;
/** Hides or shows an opportunity. The UI updates at once, and reverts if the request fails. */
setHidden: (uuid: string, hidden: boolean) => void;
isSaving: (uuid: string) => boolean;
/**
* Sets an opportunity's marks to those the backend reports, as an event
* does after a change elsewhere. An opportunity with a change of this
* client's still being saved is left alone, so the event can't undo it.
*/
applyMarks: (marks: OpportunityMarks) => void;
/** The last save failure; cleared by dismissError or the next successful save. */
error: () => string | null;
dismissError: () => void;
}
/**
* The user's marks on the opportunities, starting from the marks the
* opportunities were loaded with. Each change is saved to the backend.
*/
export function createOpportunityAnnotations(
opportunities: () => readonly Opportunity[],
): OpportunityAnnotations {
const [byUuid, setByUuid] = createStore<AnnotationMap>({});
const [saving, setSaving] = createStore<Record<string, number>>({});
const [error, setError] = createSignal<string | null>(null);
// A (re)load of the opportunities brings the marks the backend has.
createEffect(() => {
const loaded: AnnotationMap = {};
for (const opportunity of opportunities()) {
loaded[opportunity.uuid] = annotationFrom({ ...opportunity.marks, uuid: opportunity.uuid });
}
setByUuid(reconcile(loaded));
});
const ensure = (uuid: string): void => {
if (!byUuid[uuid]) setByUuid(uuid, blank(uuid));
};
/** Applies a change now, saves it, and undoes it if the save fails. */
async function save(
uuid: string,
change: { apply: () => void; undo: () => void; mark: MarkName; isSet: boolean },
failure: string,
): Promise<void> {
ensure(uuid);
change.apply();
setSaving(uuid, (count) => (count ?? 0) + 1);
try {
await api.setMark(uuid, change.mark, change.isSet);
setError(null);
} catch (err) {
change.undo();
setError(`${failure}: ${errorText(err)}`);
} finally {
setSaving(
produce((counts) => {
const count = counts[uuid] ?? 0;
if (count <= 1) delete counts[uuid];
else counts[uuid] = count - 1;
}),
);
}
}
const setMarked = (uuid: string, mark: OpportunityMark, isSet: boolean): void =>
setByUuid(uuid, 'marks', (marks) =>
normaliseMarks(isSet ? [...marks, mark] : marks.filter((m) => m !== mark)),
);
// Each change touches only its own mark, so undoing one failed change
// can't disturb other changes that are still being saved.
const toggleMark = (uuid: string, mark: OpportunityMark): void => {
const isSet = !(byUuid[uuid]?.marks ?? []).includes(mark);
const label = MARK_LABELS[mark];
void save(
uuid,
{
apply: () => setMarked(uuid, mark, isSet),
undo: () => setMarked(uuid, mark, !isSet),
mark,
isSet,
},
isSet ? `"${label}" wasn't added` : `"${label}" wasn't removed`,
);
};
const setHidden = (uuid: string, hidden: boolean): void => {
const previous = byUuid[uuid]?.hidden ?? false;
void save(
uuid,
{
apply: () => setByUuid(uuid, 'hidden', hidden),
undo: () => setByUuid(uuid, 'hidden', previous),
mark: 'hidden',
isSet: hidden,
},
hidden ? "The opportunity wasn't hidden" : "The opportunity wasn't shown again",
);
};
return {
get: (uuid) => byUuid[uuid] ?? blank(uuid),
toggleMark,
setHidden,
isSaving: (uuid) => Boolean(saving[uuid]),
applyMarks: (marks) => {
if (!saving[marks.uuid]) setByUuid(marks.uuid, annotationFrom(marks));
},
error,
dismissError: () => setError(null),
};
}
+54
View File
@@ -0,0 +1,54 @@
import { createEffect, createSignal, onCleanup } from 'solid-js';
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
export type Theme = 'light' | 'dark';
/** The theme in use, and the way to change it. */
export interface ThemeChoice {
/** The theme in use: the user's choice, or the system's until they choose. */
theme: () => Theme;
/** Chooses a theme and remembers it. */
setTheme: (next: Theme) => void;
}
const parse = (stored: string | null): Theme | null =>
stored === 'light' || stored === 'dark' ? stored : null;
/**
* The chosen theme, saved in localStorage. Until the user picks one, it
* follows the operating system's setting. index.html applies the saved
* theme before first paint so the page doesn't flash.
*/
export function createTheme(): ThemeChoice {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const [chosen, setChosen] = createSignal<Theme | null>(parse(readStored(STORAGE_KEYS.theme)));
const [systemDark, setSystemDark] = createSignal(media.matches);
const onSystemChange = (e: MediaQueryListEvent): void => {
setSystemDark(e.matches);
};
// Keep other open tabs in step.
const onStorage = (e: StorageEvent): void => {
if (e.key === STORAGE_KEYS.theme) setChosen(parse(e.newValue));
};
media.addEventListener('change', onSystemChange);
window.addEventListener('storage', onStorage);
onCleanup(() => {
media.removeEventListener('change', onSystemChange);
window.removeEventListener('storage', onStorage);
});
const theme = (): Theme => chosen() ?? (systemDark() ? 'dark' : 'light');
createEffect(() => {
document.documentElement.classList.toggle('dark', theme() === 'dark');
});
const setTheme = (next: Theme): void => {
setChosen(next);
writeStored(STORAGE_KEYS.theme, next);
};
return { theme, setTheme };
}
+47
View File
@@ -0,0 +1,47 @@
// The user's own marks on an opportunity, as the app works with them. The
// backend sends them as OpportunityMarks, a boolean per mark; the app keeps
// the marks that are set as a list, and hidden apart, as the UI shows it
// separately.
import type { OpportunityMarks } from '@/types/Opportunities';
/** The marks shown as marks, in display order, named as the backend names them. */
export const OPPORTUNITY_MARKS = ['interested', 'skipped', 'bid_submitted', 'lost', 'won'] as const;
export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number];
/** Every mark the backend accepts in /opportunities/{uuid}/marks/{mark}. */
export type MarkName = OpportunityMark | 'hidden';
/** How each mark is named on screen. */
export const MARK_LABELS: Record<OpportunityMark, string> = {
interested: 'Interested',
skipped: 'Skipped',
bid_submitted: 'Bid submitted',
lost: 'Lost',
won: 'Won',
};
/** An opportunity's marks as the UI uses them. */
export interface Annotation {
uuid: string;
/** The marks that are set, in OPPORTUNITY_MARKS order. */
marks: OpportunityMark[];
hidden: boolean;
}
/** Whether value is one of OPPORTUNITY_MARKS. */
export const isOpportunityMark = (value: unknown): value is OpportunityMark =>
(OPPORTUNITY_MARKS as readonly unknown[]).includes(value);
/** Known marks only, without duplicates, in OPPORTUNITY_MARKS order. */
export const normaliseMarks = (marks: readonly unknown[]): OpportunityMark[] =>
OPPORTUNITY_MARKS.filter((mark) => marks.includes(mark));
/** The annotation for an opportunity's marks, as the backend sends them. */
export function annotationFrom(marks: OpportunityMarks): Annotation {
return {
uuid: marks.uuid,
marks: OPPORTUNITY_MARKS.filter((mark) => marks[mark]),
hidden: marks.hidden,
};
}
+45
View File
@@ -0,0 +1,45 @@
// The events the backend pushes over its WebSocket (/ws), each as
// {"type":"event","eventType":...,"payload":...}. They aren't in the
// backend's OpenAPI description, which only covers HTTP routes, so these
// types are written by hand; their contents are the generated types. They
// match the backend's internal/events package.
import type { ClassificationCode, OpportunityMarks } from '@/types/Opportunities';
/** The event types the app handles; others are ignored. */
export const EVENT_TYPES = [
'OpportunitiesUpdated',
'OpportunityMarksUpdated',
'ClassificationsUpdated',
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
/**
* Opportunities stored for the first time (new) or given new releases
* (updated), by UUID. Over 50 of them, large is true and the lists are
* empty; summary still counts them. batch names the change batch recording
* them, for GET /api/opportunities?changed_after=.
*/
export interface OpportunitiesUpdatedPayload {
new: string[];
updated: string[];
large: boolean;
summary: { new: number; updated: number };
batch: string;
}
/** Classification codes that came into use, in full, and the ids of those that went out of use. */
export interface ClassificationsUpdatedPayload {
added: ClassificationCode[];
removed: number[];
}
/** An event from the backend, by type. */
export type ServerEvent =
| { eventType: 'OpportunitiesUpdated'; payload: OpportunitiesUpdatedPayload }
| { eventType: 'OpportunityMarksUpdated'; payload: OpportunityMarks }
| { eventType: 'ClassificationsUpdated'; payload: ClassificationsUpdatedPayload };
/** Whether value is one of EVENT_TYPES. */
export const isEventType = (value: unknown): value is EventType =>
(EVENT_TYPES as readonly unknown[]).includes(value);
+45
View File
@@ -0,0 +1,45 @@
// The backend's types, under the names the app uses. They're generated from
// the backend's OpenAPI description into api.ts (npm run generate:api), so
// they can't drift from it; only aliases and frontend-only types live here.
//
// The JSON is what Go's encoding/json produces:
// - time.Time is an RFC 3339 string, e.g. "2026-03-01T09:30:00Z". A zero
// time.Time is "0001-01-01T00:00:00Z", not null.
// - A nil pointer (*time.Time) or a nil slice is null; the generated types
// say which fields can be.
// - int64 and float64 are both number. int64 values above 2^53 would lose
// precision in JavaScript; database ids are nowhere near that.
import type { components } from '@/types/api';
type Schemas = components['schemas'];
/** An RFC 3339 timestamp string as produced by Go's time.Time. */
export type DateTimeString = string;
/**
* The tender statuses the app knows, from the OCDS tenderStatus codelist.
* The backend's status is any string, so others may appear.
*/
export const OPPORTUNITY_STATUSES = [
'planning',
'planned',
'active',
'complete',
'cancelled',
'unsuccessful',
'withdrawn',
] as const;
export type OpportunityStatus = (typeof OPPORTUNITY_STATUSES)[number];
export type Opportunity = Schemas['types.Opportunity'];
export type OpportunityMarks = Schemas['types.OpportunityMarks'];
export type ClassificationCode = Schemas['types.ClassificationCode'];
export type OpportunityRelease = Schemas['types.OpportunityRelease'];
export type OpportunityReleaseTag = Schemas['types.OpportunityReleaseTag'];
/** A page of GET /api/opportunities. */
export type OpportunityPage = Schemas['api.OpportunityPage'];
/** An RFC 9457 problem details body, as the API returns for errors. */
export type Problem = Schemas['httpx.Problem'];
+416
View File
@@ -0,0 +1,416 @@
// Code generated by npm run generate:api from the backend's OpenAPI
// description. DO NOT EDIT: change the Go types or handlers, then
// regenerate (see scripts/generateApiTypes.ts).
export interface paths {
"/classifications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List classifications
* @description Every classification code (CPV and others) that at least one
* stored opportunity has, once each, ordered by code: the
* codes worth filtering opportunities by.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.ClassificationCode"][];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/opportunities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List opportunities
* @description A page of opportunities, newest release first, with their
* classifications and marks but not their releases. has_more
* says whether another page follows, at offset+limit. batch
* is the newest change batch when the page was read.
* With changed_after (a batch from a page or an
* OpportunitiesUpdated event), the page is instead every
* opportunity changed since that batch (new, given new
* releases or with changed marks), and batch is the newest
* batch they include. offset and limit can't be given with
* changed_after. An unknown batch is 410 Gone: reload the
* whole list.
*/
get: {
parameters: {
query?: {
/** @description Opportunities to skip */
offset?: number;
/** @description Page size */
limit?: number;
/** @description List the opportunities changed after this batch instead */
changed_after?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["api.OpportunityPage"];
};
};
/** @description Invalid offset or limit, or either with changed_after */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Unknown batch */
410: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/opportunities/{uuid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get an opportunity
* @description One opportunity with its classifications, marks and all of its
* releases.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The opportunity's UUID (its OCID) */
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.Opportunity"];
};
};
/** @description No such opportunity */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/opportunities/{uuid}/marks/{mark}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Set a mark
* @description Sets one of the opportunity's marks, keeping the others, and
* returns all of them. Setting a mark that is already set
* changes nothing. won and lost exclude each other.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The opportunity's UUID (its OCID) */
uuid: string;
/** @description The mark */
mark: "hidden" | "skipped" | "interested" | "bid_submitted" | "won" | "lost";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.OpportunityMarks"];
};
};
/** @description No valid CSRF token */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description No such opportunity or mark */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description won and lost together */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
post?: never;
/**
* Clear a mark
* @description Clears one of the opportunity's marks, keeping the others, and
* returns all of them. Clearing a mark that isn't set changes
* nothing.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The opportunity's UUID (its OCID) */
uuid: string;
/** @description The mark */
mark: "hidden" | "skipped" | "interested" | "bid_submitted" | "won" | "lost";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["types.OpportunityMarks"];
};
};
/** @description No valid CSRF token */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description No such opportunity or mark */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["httpx.Problem"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
"api.OpportunityPage": {
batch: string;
has_more: boolean;
limit: number;
offset: number;
opportunities: components["schemas"]["types.Opportunity"][];
};
"httpx.Problem": {
detail?: string;
instance?: string;
requestId?: string;
status: number;
title: string;
type?: string;
};
"types.ClassificationCode": {
classification: string;
code: string;
id: number;
is_cpv: boolean;
};
"types.Opportunity": {
buyer_id: string;
buyer_name: string;
classifications: components["schemas"]["types.ClassificationCode"][] | null;
description: string;
end_date: string | null;
gross_value: number;
has_documents: boolean;
latest_release_date: string;
main_category: string;
marks: components["schemas"]["types.OpportunityMarks"];
multi_lot: boolean;
procurement_method: string;
releases: components["schemas"]["types.OpportunityRelease"][] | null;
sme_suitable: boolean;
source: string;
start_date: string | null;
status: string;
title: string;
uuid: string;
uuid_type: string;
vcse_suitable: boolean;
};
"types.OpportunityMarks": {
bid_submitted: boolean;
hidden: boolean;
interested: boolean;
lost: boolean;
skipped: boolean;
uuid: string;
won: boolean;
};
"types.OpportunityRelease": {
data: string;
release_date: string;
release_id: string;
release_tags: components["schemas"]["types.OpportunityReleaseTag"][] | null;
uuid: string;
};
"types.OpportunityReleaseTag": {
release_id: string;
tag: string;
uuid: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+32
View File
@@ -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"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+24
View File
@@ -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"]
}
+27
View File
@@ -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 },
},
},
};
});