Files
job-search/web/README.md
T
bwilliams b0230e6847 Replaced the Title filter with a general search, run by the backend
GET /api/opportunities/search?q= returns the UUIDs of the matching
opportunities, and the frontend filters its list by them. Words without
a prompt search the title and description; title:, buyer:, buyer_id:
and cpv: search only their own field, and a leading ! leaves out what a
term matches. Repeats of one prompt are OR, everything else AND. CPV
terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72
that doesn't start 7221.

Unknown prompts are reported, and every response carries the help that
the search box's "?" panel shows. Migration 00011 adds the description
and buyer name to the full-text index, and indexes CPV descriptions.
2026-10-22 13:10:25 +01:00

6.2 KiB
Raw Permalink Blame History

JobSearch - Frontend

A Vite + SolidJS + Tailwind CSS (v4) + TypeScript table of opportunities with a general search (run by the backend, with title:, buyer:, buyer_id: and cpv: prompts), 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 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/primitives/createToasts.ts – the toasts errors are shown in, bottom right, for 15 seconds or until dismissed
  • src/lib/filterOpportunities.ts – search and filter logic
  • src/lib/opportunities.ts – merging changed opportunities into the list, and Find a Tender notice links
  • src/lib/ocdsRelease.ts – what the details dialog reads from a release's OCDS JSON
  • src/lib/jsonTokens.ts – splits JSON into tokens for highlighting
  • src/components/ – FilterPanel, ClassificationPicker, OpportunityTable, MarkMenu, OpportunityDialog (an opportunity's details), ReleaseTabs, JsonView, ThemeToggle, ToastList; styles.ts holds 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: 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.
  • 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.