The classifications are grouped by kind (CPV, then Other) and, within CPV, nested by the CPV hierarchy: each code sits under its nearest ancestor in the list, so one whose parent isn't in use isn't lost. The other codes have no hierarchy, so they're listed flat. Branches start closed; a search shows the matching codes with their ancestors, opened when no more than 150 match. Ticking a code selects it and every code beneath it, and ticking a whole selected branch clears it. The checkboxes are tri-state, showing a dash when only part of a branch is selected, and a closed branch that is partly selected says how many codes beneath it are. The selection above the tree shows a whole selected branch as one chip, with the number of codes beneath it, and removing that chip clears the branch. The filter is unchanged: a set of classification ids, so a branch is the codes in the picker when it's ticked.
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.
npm run build type-checks and builds the app into dist/. npm run sync
then copies dist/ to ../internal/frontend/dist/, where the backend embeds
it: the next Go build of job-search serves it. The copy isn't committed.
Before committing, npm run check type-checks, lints (ESLint), checks
formatting (Prettier) and runs the tests (Vitest); npm run format formats
everything, and npm run test:watch reruns the tests as files change. See
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/opportunities/{uuid} |
One opportunity, with its releases |
| 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.
Both list requests send released_from (an RFC 3339 time), so only the
opportunities published in the last few days the filter names (7 by default,
from midnight UK time) are loaded; changing the number of days loads the list
again.
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 every one some
stored opportunity has; the filter offers only those the loaded opportunities
(the days window) have, and any already chosen. 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 backendsrc/api/events.ts– the WebSocket connection and its eventssrc/types/api.ts– the backend's types, generated (don't edit)src/types/Opportunities.ts– the app's names for them, and the known statusessrc/types/Annotations.ts– the marks, their labels, and an opportunity's marks as the UI uses themsrc/primitives/createOpportunities.ts– loads the opportunities (and catches them up) and the classificationssrc/primitives/createLiveUpdates.ts– applies the backend's eventssrc/primitives/createOpportunityAnnotations.ts– changing marks and hiding (applied at once, undone if the save fails)src/primitives/createToasts.ts– the toasts errors are shown in, bottom right, for 15 seconds or until dismissedsrc/lib/filterOpportunities.ts– search and filter logicsrc/lib/opportunities.ts– merging changed opportunities into the list, and Find a Tender notice linkssrc/lib/ocdsRelease.ts– what the details dialog reads from a release's OCDS JSONsrc/lib/jsonTokens.ts– splits JSON into tokens for highlightingsrc/components/– FilterPanel, ClassificationPicker, OpportunityTable, MarkMenu, OpportunityDialog (an opportunity's details), ReleaseTabs, JsonView, ThemeToggle, ToastList;styles.tsholds shared Tailwind class strings
Theme colours and the font are set in the @theme block of src/index.css.
Saved preferences
Three preferences are kept in the browser's localStorage (src/lib/storage.ts):
job_search.theme:lightordark. If nothing is saved, the app follows the OS setting. A small script inindex.htmlapplies the saved theme before the first paint, so there's no flash.job_search.filtersOpen: whether the filter panel is expanded.job_search.filters: the filters, as JSON. A field that isn't valid is read as its default.
Dark mode works by swapping the colour tokens under .dark in
src/index.css, so components don't need dark: variants.