Every row has a details icon under the Find a Tender link. It opens a
dialog with the opportunity's details from the list, and fetches the
opportunity with its releases, shown as tabs, newest first. Each tab
summarises the release's tender, awards and contracts, links to its
Find a Tender notice where it is one, and has the release's JSON,
pretty-printed and highlighted, in a section laid out only when it's
opened.
The JSON is highlighted by splitting it into tokens shown as text, not
HTML, as releases come from outside sources. The README's saved
preferences now include the filters.
It lists the classifications the opportunities in the days window have,
not every one stored, plus any already chosen, so a selection stays
visible and can be removed when nothing in the window has it. The other
filters don't narrow it, so choosing a status doesn't make
classifications vanish.
Every filter, the days and title included, is stored under
job_search.filters when it changes and read back when the page loads.
A stored field that isn't valid (missing, from an older version or
edited by hand) falls back to its default, keeping the rest. Clear
filters still goes back to the defaults.
GET /api/opportunities takes released_from, an RFC 3339 time, and lists
only the opportunities whose latest release is at or after it. It works
with paging and with changed_after, which keeps the same batch either
way; an invalid value is 400.
The frontend's filter defaults to 7 days, from midnight UK time, and
sends it with every load and catch-up, so a year of opportunities isn't
downloaded to show a week's. Changing the days loads the list again;
Clear filters goes back to 7.
It hides the opportunities the filters show that aren't hidden already,
after asking for confirmation with their count. Those the filters leave
out keep their marks, so clearing the filters shows them as before.
The backend has no bulk route, so each opportunity is hidden with its
own request. The rows go at once; every mark change is now saved at
most six at a time (lib/concurrency.ts), so hundreds of requests aren't
sent together. Any that fail are shown again, with one toast for them
all.
Opportunities from Find a Tender have a link icon before their title.
Clicking it fetches the opportunity with its releases (the list leaves
them out) and opens its newest release's notice,
www.find-tender.service.gov.uk/notice/<release_id>, in a new tab. The
tab is opened on the click and pointed at the notice when the request
returns, so it isn't blocked as a popup; if the request fails, or there
are no releases, it's closed and a toast says why. Opportunities from
other sources have no link for now, but keep its space so the titles
line up.
The icon is lucide-solid's, added for it at 1.52.0.
`npm run sync` in web/ copies the build output (web/dist, from npm run build)
to internal/frontend/dist, which the binary embeds. Go can't embed from
outside the package's directory (web/ is also outside the Go module), hence
the copy. Only dist/.gitkeep is committed, so a fresh clone still compiles,
and a binary built without syncing first serves no frontend.
job-search serve serves server.frontend.dir if it is set, or else the
embedded build, or warns that there is no frontend. scripts/build.sh builds
and syncs the frontend before building, running npm ci first for a release
or when web/node_modules is missing.
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.