From f8648d765a7a59db59638a3e02c750f13a8ce83a Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Mon, 19 Oct 2026 10:32:33 +0100 Subject: [PATCH] Added a dialog with each opportunity's details and releases 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. --- web/README.md | 13 +- web/src/components/JsonView.tsx | 35 ++++ web/src/components/OpportunityDialog.tsx | 194 +++++++++++++++++++ web/src/components/OpportunityTable.tsx | 42 ++++- web/src/components/ReleaseTabs.tsx | 230 +++++++++++++++++++++++ web/src/index.css | 10 + web/src/lib/format.test.ts | 22 ++- web/src/lib/format.ts | 32 ++++ web/src/lib/jsonTokens.test.ts | 39 ++++ web/src/lib/jsonTokens.ts | 46 +++++ web/src/lib/ocdsRelease.test.ts | 78 ++++++++ web/src/lib/ocdsRelease.ts | 116 ++++++++++++ 12 files changed, 847 insertions(+), 10 deletions(-) create mode 100644 web/src/components/JsonView.tsx create mode 100644 web/src/components/OpportunityDialog.tsx create mode 100644 web/src/components/ReleaseTabs.tsx create mode 100644 web/src/lib/jsonTokens.test.ts create mode 100644 web/src/lib/jsonTokens.ts create mode 100644 web/src/lib/ocdsRelease.test.ts create mode 100644 web/src/lib/ocdsRelease.ts diff --git a/web/README.md b/web/README.md index 92cb04c..f0cff90 100644 --- a/web/README.md +++ b/web/README.md @@ -91,21 +91,28 @@ token when it first needs it, and a new one if a request is refused for it. - `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 +- `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, ThemeToggle, ToastList; `styles.ts` holds shared Tailwind class + 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 -Two preferences are kept in the browser's localStorage (`src/lib/storage.ts`): +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. diff --git a/web/src/components/JsonView.tsx b/web/src/components/JsonView.tsx new file mode 100644 index 0000000..cc22e4b --- /dev/null +++ b/web/src/components/JsonView.tsx @@ -0,0 +1,35 @@ +import type { JSX } from 'solid-js'; +import { createMemo, For } from 'solid-js'; + +import type { JsonTokenKind } from '@/lib/jsonTokens'; +import { tokeniseJson } from '@/lib/jsonTokens'; + +interface Props { + /** JSON text, shown as it is: pretty-print it first. */ + json: string; +} + +const TOKEN_CLASS: Record = { + key: 'text-code-key', + string: 'text-code-string', + number: 'text-code-number', + literal: 'text-code-literal', + plain: '', +}; + +/** + * JSON text, syntax highlighted. Each token is a text span, never HTML, as + * releases come from outside sources. + */ +export function JsonView(props: Props): JSX.Element { + const tokens = createMemo(() => tokeniseJson(props.json)); + return ( +
+      
+        
+          {(token) => {token.text}}
+        
+      
+    
+ ); +} diff --git a/web/src/components/OpportunityDialog.tsx b/web/src/components/OpportunityDialog.tsx new file mode 100644 index 0000000..c34025e --- /dev/null +++ b/web/src/components/OpportunityDialog.tsx @@ -0,0 +1,194 @@ +import X from 'lucide-solid/icons/x'; +import type { JSX } from 'solid-js'; +import { + createEffect, + createResource, + createUniqueId, + For, + Match, + onCleanup, + Show, + Switch, +} from 'solid-js'; + +import { api } from '@/api/client'; +import { errorText } from '@/lib/errors'; +import { statusLabel } from '@/lib/filterOpportunities'; +import { formatDate, formatMoney } from '@/lib/format'; +import { isFromFindATender } from '@/lib/opportunities'; +import type { Opportunity } from '@/types/Opportunities'; +import { ReleaseTabs } from './ReleaseTabs'; +import { buttonPrimary, statusBadge } from './styles'; + +interface Props { + /** The opportunity to show, from the list; undefined when the dialog is closed. */ + opportunity: Opportunity | undefined; + onClose: () => void; + /** Called with a message when the opportunity's releases didn't load. */ + onError: (message: string) => void; +} + +const detailList = 'grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1.5 text-sm'; +const sectionHeading = 'mb-2 font-semibold'; + +const yesNo = (value: boolean): string => (value ? 'Yes' : 'No'); + +/** + * A modal dialog with an opportunity's details, and its releases as tabs. + * The details come from the list, so they show at once; the releases aren't + * in the list, so the opportunity is fetched with them when the dialog + * opens. The native keeps focus inside, closes on Escape and + * returns focus to the button that opened it. + */ +export function OpportunityDialog(props: Props): JSX.Element { + let dialog!: HTMLDialogElement; + const titleId = createUniqueId(); + let controller: AbortController | undefined; + onCleanup(() => controller?.abort()); + + const [loaded, { refetch }] = createResource( + () => props.opportunity?.uuid, + (uuid) => { + controller?.abort(); + controller = new AbortController(); + return api.getOpportunity(uuid, controller.signal); + }, + ); + // Reading an errored resource throws, so check its state first. + const releases = () => (loaded.state === 'ready' ? (loaded().releases ?? []) : []); + const loadError = () => (loaded.state === 'errored' ? errorText(loaded.error) : undefined); + + createEffect(() => { + if (props.opportunity && !dialog.open) dialog.showModal(); + else if (!props.opportunity && dialog.open) dialog.close(); + }); + createEffect(() => { + const err = loadError(); + if (err !== undefined) props.onError(`The opportunity's releases didn't load: ${err}`); + }); + + return ( + props.onClose()} + // A click on the backdrop lands on the dialog itself, outside its content. + onClick={(e) => { + if (e.target === dialog) dialog.close(); + }} + > + + {(opportunity) => ( +
+
+
+

+ {opportunity().title || 'Untitled opportunity'} +

+

{opportunity().buyer_name}

+
+ +
+ +
+
+
Status
+
+ + + {statusLabel(opportunity().status)} + + +
+
Published
+
{formatDate(opportunity().latest_release_date)}
+
Start
+
{formatDate(opportunity().start_date)}
+
End
+
{formatDate(opportunity().end_date)}
+
Value
+
{formatMoney(opportunity().gross_value)}
+
Procurement method
+
{opportunity().procurement_method || '—'}
+
Category
+
{opportunity().main_category || '—'}
+
+
+
Multiple lots
+
{yesNo(opportunity().multi_lot)}
+
SME suitable
+
{yesNo(opportunity().sme_suitable)}
+
VCSE suitable
+
{yesNo(opportunity().vcse_suitable)}
+
Documents
+
{yesNo(opportunity().has_documents)}
+
Source
+
{opportunity().source}
+
OCID
+
{opportunity().uuid}
+
+
+ + +
+

Description

+

{opportunity().description}

+
+
+ + 0}> +
+

Classifications

+
    + + {(code) => ( +
  • + {code.code}{' '} + {code.classification} +
  • + )} +
    +
+
+
+ +
+

Releases

+ + +

Loading releases…

+
+ + {(err) => ( + + )} + + + + +
+
+
+ )} +
+
+ ); +} diff --git a/web/src/components/OpportunityTable.tsx b/web/src/components/OpportunityTable.tsx index b735b92..2bcd67b 100644 --- a/web/src/components/OpportunityTable.tsx +++ b/web/src/components/OpportunityTable.tsx @@ -1,6 +1,7 @@ import ExternalLink from 'lucide-solid/icons/external-link'; +import Info from 'lucide-solid/icons/info'; import type { JSX } from 'solid-js'; -import { createSignal, For, Show } from 'solid-js'; +import { createMemo, createSignal, For, Show } from 'solid-js'; import { api } from '@/api/client'; import { errorText } from '@/lib/errors'; @@ -10,6 +11,7 @@ import { findATenderNoticeUrl, isFromFindATender, latestRelease } from '@/lib/op import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations'; import type { Opportunity } from '@/types/Opportunities'; import { MarkMenu } from './MarkMenu'; +import { OpportunityDialog } from './OpportunityDialog'; import { buttonQuiet, MARK_BG, statusBadge } from './styles'; interface Props { @@ -26,10 +28,21 @@ const iconButton = 'flex rounded-md border border-line bg-surface p-1 text-ink hover:enabled:border-muted disabled:opacity-60'; /** - * The table of opportunities, each with its marks, a hide button and, for - * those from Find a Tender, a link to its latest notice there. + * The table of opportunities, each with its marks, a hide button, a button + * that opens its details and, for those from Find a Tender, a link to its + * latest notice there. */ export function OpportunityTable(props: Props): JSX.Element { + // One dialog for the table, showing the opportunity chosen by its UUID, so + // it follows the list's updates while it's open. + const [detailsUuid, setDetailsUuid] = createSignal(); + const details = createMemo(() => { + const uuid = detailsUuid(); + return uuid === undefined + ? undefined + : props.opportunities.find((opportunity) => opportunity.uuid === uuid); + }); + return (
@@ -71,11 +84,17 @@ export function OpportunityTable(props: Props): JSX.Element { opportunity={opportunity} annotations={props.annotations} onError={props.onError} + onShowDetails={() => setDetailsUuid(opportunity.uuid)} /> )}
+ setDetailsUuid(undefined)} + onError={props.onError} + />
); } @@ -84,6 +103,7 @@ interface RowProps { opportunity: Opportunity; annotations: OpportunityAnnotations; onError: (message: string) => void; + onShowDetails: () => void; } function OpportunityRow(props: RowProps): JSX.Element { @@ -135,10 +155,10 @@ function OpportunityRow(props: RowProps): JSX.Element { - {/* The link's place is kept when it's hidden, so every title starts at - the same point. */} + {/* The icons' column is kept when the link is hidden, so every title + starts at the same point. */}
- + +
diff --git a/web/src/components/ReleaseTabs.tsx b/web/src/components/ReleaseTabs.tsx new file mode 100644 index 0000000..32e7968 --- /dev/null +++ b/web/src/components/ReleaseTabs.tsx @@ -0,0 +1,230 @@ +import ExternalLink from 'lucide-solid/icons/external-link'; +import type { JSX } from 'solid-js'; +import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js'; + +import { statusLabel } from '@/lib/filterOpportunities'; +import { formatDate, formatDateTime, formatValue } from '@/lib/format'; +import type { ReleaseValue } from '@/lib/ocdsRelease'; +import { parseReleaseData, summariseRelease } from '@/lib/ocdsRelease'; +import { findATenderNoticeUrl } from '@/lib/opportunities'; +import type { OpportunityRelease } from '@/types/Opportunities'; +import { JsonView } from './JsonView'; + +interface Props { + releases: OpportunityRelease[]; + /** Whether the releases are Find a Tender notices, so each can link to its notice. */ + isFromFindATender: boolean; +} + +const tabClass = + 'shrink-0 border-b-2 border-transparent px-3 py-2 text-sm whitespace-nowrap text-muted hover:text-ink aria-selected:border-accent aria-selected:font-semibold aria-selected:text-ink'; +const sectionHeading = 'mb-1.5 text-sm font-semibold'; +const summaryList = 'grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm'; + +const value = (money: ReleaseValue | undefined): string => + money ? formatValue(money.amount, money.currency) : '—'; + +const tagsOf = (release: OpportunityRelease): string => + (release.release_tags ?? []).map((tag) => tag.tag).join(', '); + +interface FieldProps { + term: string; + children: JSX.Element; +} + +/** A term and its description in a summary list. */ +function Field(props: FieldProps): JSX.Element { + return ( + <> +
{props.term}
+
{props.children}
+ + ); +} + +interface ReleasePanelProps { + release: OpportunityRelease; + isFromFindATender: boolean; +} + +/** One release: its notice, a summary of its tender, awards and contracts, and its JSON. */ +function ReleasePanel(props: ReleasePanelProps): JSX.Element { + const parsed = createMemo(() => parseReleaseData(props.release.data)); + const summary = createMemo(() => summariseRelease(parsed())); + // The JSON is only laid out once it's opened: a release can be 100KB or more. + const [isJsonOpen, setIsJsonOpen] = createSignal(false); + const json = () => { + const data = parsed(); + return data === undefined ? props.release.data : JSON.stringify(data, null, 2); + }; + + return ( +
+
+
+ {props.release.release_id} + {formatDateTime(props.release.release_date)} + {tagsOf(props.release) || '—'} +
+ + + Open notice on Find a Tender + + + +
+ + + {(tender) => ( +
+

Tender

+
+ {tender().title || '—'} + {tender().status ? statusLabel(tender().status) : '—'} + {value(tender().value)} + {formatDate(tender().deadline)} + {tender().lotCount || '—'} +
+
+ )} +
+ + 0}> +
+

Awards

+
    + + {(award) => ( +
  • +
    + {award.title || award.id || '—'} + {award.status ? statusLabel(award.status) : '—'} + {award.suppliers.join(', ') || '—'} + {value(award.value)} + {formatDate(award.date)} +
    +
  • + )} +
    +
+
+
+ + 0}> +
+

Contracts

+
    + + {(contract) => ( +
  • +
    + {contract.title || contract.id || '—'} + + {contract.status ? statusLabel(contract.status) : '—'} + + {value(contract.value)} + {formatDate(contract.dateSigned)} + + {formatDate(contract.start)} to {formatDate(contract.end)} + +
    +
  • + )} +
    +
+
+
+ +
setIsJsonOpen(e.currentTarget.open)}> + Release JSON +
+ + + +
+
+
+ ); +} + +/** + * An opportunity's releases as tabs, newest first, each with a summary and + * its JSON. The tabs follow the ARIA tabs pattern: the arrow keys, Home and + * End move between them, and Tab moves into the open one. + */ +export function ReleaseTabs(props: Props): JSX.Element { + const id = createUniqueId(); + const releases = createMemo(() => + [...props.releases].sort( + (first, second) => Date.parse(second.release_date) - Date.parse(first.release_date), + ), + ); + const [selected, setSelected] = createSignal(0); + const tabs: HTMLButtonElement[] = []; + + const select = (index: number): void => { + const count = releases().length; + const next = (index + count) % count; + setSelected(next); + tabs[next]?.focus(); + }; + const onKeyDown = (event: KeyboardEvent): void => { + const moves: Record = { + ArrowRight: selected() + 1, + ArrowLeft: selected() - 1, + Home: 0, + End: releases().length - 1, + }; + const index = moves[event.key]; + if (index === undefined) return; + event.preventDefault(); + select(index); + }; + + return ( + 0} + fallback={

This opportunity has no releases.

} + > +
+ + {(release, index) => ( + + )} + +
+
+ + {(release) => ( + + )} + +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index a81b3d5..b5e4b25 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -30,6 +30,11 @@ --color-mark-bid-submitted: #b8730a; --color-mark-lost: #b23b3b; --color-mark-won: #2f7d4a; + + --color-code-key: #23508e; + --color-code-string: #2f6b3a; + --color-code-number: #9a5800; + --color-code-literal: #7a2e8c; } @layer base { @@ -62,6 +67,11 @@ --color-mark-bid-submitted: #e2a640; --color-mark-lost: #e47676; --color-mark-won: #63c286; + + --color-code-key: #8db3ea; + --color-code-string: #93d6aa; + --color-code-number: #e2b46a; + --color-code-literal: #d3a2e8; } body { diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index 6939164..ba45158 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -1,6 +1,13 @@ import { expect, test } from 'vitest'; -import { formatCount, formatDate, formatMoney, parseDate } from './format'; +import { + formatCount, + formatDate, + formatDateTime, + formatMoney, + formatValue, + parseDate, +} from './format'; test.each([ { name: 'null', value: null }, @@ -31,3 +38,16 @@ test('formatCount groups thousands', () => { expect(formatCount(4854)).toBe('4,854'); expect(formatCount(7)).toBe('7'); }); + +test('formatValue shows the amount in its currency', () => { + expect(formatValue(1200.5, 'EUR')).toBe('€1,201'); + expect(formatValue(250000, 'GBP')).toBe('£250,000'); + expect(formatValue(250000, '')).toBe('£250,000'); + expect(formatValue(1500, 'not a code')).toBe('1,500 not a code'); + expect(formatValue(0, 'EUR')).toBe('—'); +}); + +test('formatDateTime shows the UK time', () => { + expect(formatDateTime('2026-10-15T09:05:26Z')).toBe('15 Oct 2026, 10:05'); + expect(formatDateTime(null)).toBe('—'); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 2653529..e5ed286 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -8,6 +8,14 @@ const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', }); +const dateTimeFormat = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); + const moneyFormat = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', @@ -29,6 +37,12 @@ export function formatDate(value: DateTimeString | null | undefined): string { return d ? dateFormat.format(d) : '—'; } +/** A date and time for display, e.g. "3 Mar 2026, 09:30", or a dash for none. */ +export function formatDateTime(value: DateTimeString | null | undefined): string { + const d = parseDate(value); + return d ? dateTimeFormat.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) : '—'; @@ -38,3 +52,21 @@ export function formatMoney(value: number): string { export function formatCount(value: number): string { return countFormat.format(value); } + +/** + * An amount in its currency for display, e.g. "€1,201", or a dash for none. + * A currency code Intl doesn't know is shown after the number. + */ +export function formatValue(amount: number, currency: string): string { + if (amount <= 0) return '—'; + if (currency === '' || currency === 'GBP') return moneyFormat.format(amount); + try { + return new Intl.NumberFormat('en-GB', { + style: 'currency', + currency, + maximumFractionDigits: 0, + }).format(amount); + } catch { + return `${countFormat.format(Math.round(amount))} ${currency}`; + } +} diff --git a/web/src/lib/jsonTokens.test.ts b/web/src/lib/jsonTokens.test.ts new file mode 100644 index 0000000..5b1743a --- /dev/null +++ b/web/src/lib/jsonTokens.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest'; + +import { tokeniseJson } from './jsonTokens'; + +test('tells keys, strings, numbers and literals apart', () => { + const text = JSON.stringify({ id: 'a "b"', amount: -1.5e3, ok: true, none: null }, null, 2); + const tokens = tokeniseJson(text); + + expect(tokens.map((token) => token.text).join('')).toBe(text); + expect(tokens.filter((token) => token.kind !== 'plain')).toEqual([ + { kind: 'key', text: '"id"' }, + { kind: 'string', text: '"a \\"b\\""' }, + { kind: 'key', text: '"amount"' }, + { kind: 'number', text: '-1500' }, + { kind: 'key', text: '"ok"' }, + { kind: 'literal', text: 'true' }, + { kind: 'key', text: '"none"' }, + { kind: 'literal', text: 'null' }, + ]); +}); + +test('joins neighbouring plain text', () => { + expect(tokeniseJson('[1, 2]')).toEqual([ + { kind: 'plain', text: '[' }, + { kind: 'number', text: '1' }, + { kind: 'plain', text: ', ' }, + { kind: 'number', text: '2' }, + { kind: 'plain', text: ']' }, + ]); +}); + +test('keeps text that isn’t JSON whole', () => { + const text = 'not { json "at all'; + expect( + tokeniseJson(text) + .map((token) => token.text) + .join(''), + ).toBe(text); +}); diff --git a/web/src/lib/jsonTokens.ts b/web/src/lib/jsonTokens.ts new file mode 100644 index 0000000..b6c4deb --- /dev/null +++ b/web/src/lib/jsonTokens.ts @@ -0,0 +1,46 @@ +/** What a piece of JSON text is, for highlighting. */ +export type JsonTokenKind = 'key' | 'string' | 'number' | 'literal' | 'plain'; + +/** A run of JSON text of one kind. */ +export interface JsonToken { + kind: JsonTokenKind; + text: string; +} + +// A string (a key when a colon follows), a number, a literal, or anything +// else (punctuation and spaces), which is plain. +const TOKEN = + /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b/g; + +/** + * The tokens of JSON text, in order, so it can be highlighted as text rather + * than as HTML. Neighbouring tokens of the same kind are joined, so a + * document gives as few as possible. Text that isn't valid JSON still comes + * back whole, with what doesn't match as plain. + */ +export function tokeniseJson(text: string): JsonToken[] { + const tokens: JsonToken[] = []; + const push = (kind: JsonTokenKind, part: string): void => { + if (part === '') return; + const last = tokens.at(-1); + if (last?.kind === kind) last.text += part; + else tokens.push({ kind, text: part }); + }; + + let end = 0; + for (const match of text.matchAll(TOKEN)) { + push('plain', text.slice(end, match.index)); + const [whole, string, colon, number, literal] = match; + if (string !== undefined) { + push(colon === undefined ? 'string' : 'key', string); + push('plain', colon ?? ''); + } else if (number !== undefined) { + push('number', number); + } else if (literal !== undefined) { + push('literal', literal); + } + end = match.index + whole.length; + } + push('plain', text.slice(end)); + return tokens; +} diff --git a/web/src/lib/ocdsRelease.test.ts b/web/src/lib/ocdsRelease.test.ts new file mode 100644 index 0000000..b80a66c --- /dev/null +++ b/web/src/lib/ocdsRelease.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from 'vitest'; + +import { parseReleaseData, summariseRelease } from './ocdsRelease'; + +test('summarises the tender, awards and contracts', () => { + const release = parseReleaseData( + JSON.stringify({ + tender: { + title: 'Civils works', + status: 'complete', + value: { amount: 250000, currency: 'GBP' }, + tenderPeriod: { endDate: '2026-11-01T12:00:00Z' }, + lots: [{ id: '1' }, { id: '2' }], + }, + awards: [ + { + id: 'a1', + status: 'active', + suppliers: [{ name: 'Aqua Ltd' }, { id: 'no-name' }], + value: { amount: 1200.5, currency: 'EUR' }, + }, + ], + contracts: [ + { + id: 'c1', + title: 'Lot 1', + dateSigned: '2026-09-23T00:00:00+01:00', + period: { startDate: '2026-10-01', endDate: '2027-09-30' }, + }, + ], + }), + ); + + expect(summariseRelease(release)).toEqual({ + tender: { + title: 'Civils works', + status: 'complete', + value: { amount: 250000, currency: 'GBP' }, + deadline: '2026-11-01T12:00:00Z', + lotCount: 2, + }, + awards: [ + { + id: 'a1', + title: '', + status: 'active', + value: { amount: 1200.5, currency: 'EUR' }, + date: '', + suppliers: ['Aqua Ltd'], + }, + ], + contracts: [ + { + id: 'c1', + title: 'Lot 1', + status: '', + value: undefined, + dateSigned: '2026-09-23T00:00:00+01:00', + start: '2026-10-01', + end: '2027-09-30', + }, + ], + }); +}); + +test('reads parts of the wrong shape as missing', () => { + const release = { tender: 'none', awards: [5, { id: 7, value: { amount: '1' } }], contracts: {} }; + expect(summariseRelease(release)).toEqual({ + tender: undefined, + awards: [{ id: '', title: '', status: '', value: undefined, date: '', suppliers: [] }], + contracts: [], + }); +}); + +test('data that isn’t JSON parses as undefined', () => { + expect(parseReleaseData('{broken')).toBeUndefined(); + expect(summariseRelease(undefined)).toEqual({ tender: undefined, awards: [], contracts: [] }); +}); diff --git a/web/src/lib/ocdsRelease.ts b/web/src/lib/ocdsRelease.ts new file mode 100644 index 0000000..09de415 --- /dev/null +++ b/web/src/lib/ocdsRelease.ts @@ -0,0 +1,116 @@ +// A release's data is an OCDS release document (https://standard.open-contracting.org), +// stored as the source sent it. Only the parts the details dialog shows are +// read here, and every one is checked, as a source may leave any out. + +/** An amount of money, in the currency's ISO 4217 code. */ +export interface ReleaseValue { + amount: number; + currency: string; +} + +/** The tender a release describes. */ +export interface ReleaseTender { + title: string; + status: string; + value: ReleaseValue | undefined; + /** When tenders must be in by (tenderPeriod.endDate), or "". */ + deadline: string; + lotCount: number; +} + +/** An award a release describes. */ +export interface ReleaseAward { + id: string; + title: string; + status: string; + value: ReleaseValue | undefined; + date: string; + suppliers: string[]; +} + +/** A contract a release describes. */ +export interface ReleaseContract { + id: string; + title: string; + status: string; + value: ReleaseValue | undefined; + dateSigned: string; + /** The contract's period, each "" if not given. */ + start: string; + end: string; +} + +/** What a release says about its tender, awards and contracts. */ +export interface ReleaseSummary { + tender: ReleaseTender | undefined; + awards: ReleaseAward[]; + contracts: ReleaseContract[]; +} + +type JsonObject = Record; + +const isObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const objectAt = (value: unknown, key: string): JsonObject | undefined => { + const field = isObject(value) ? value[key] : undefined; + return isObject(field) ? field : undefined; +}; + +const objectsAt = (value: unknown, key: string): JsonObject[] => { + const field = isObject(value) ? value[key] : undefined; + return Array.isArray(field) ? field.filter(isObject) : []; +}; + +const textAt = (value: unknown, key: string): string => { + const field = isObject(value) ? value[key] : undefined; + return typeof field === 'string' ? field : ''; +}; + +function valueAt(value: unknown, key: string): ReleaseValue | undefined { + const money = objectAt(value, key); + const amount = money?.amount; + return typeof amount === 'number' ? { amount, currency: textAt(money, 'currency') } : undefined; +} + +/** The release's data parsed, or undefined if it isn't JSON. */ +export function parseReleaseData(data: string): unknown { + try { + return JSON.parse(data) as unknown; + } catch { + return undefined; + } +} + +/** What a parsed release says about its tender, awards and contracts. */ +export function summariseRelease(release: unknown): ReleaseSummary { + const tender = objectAt(release, 'tender'); + return { + tender: tender && { + title: textAt(tender, 'title'), + status: textAt(tender, 'status'), + value: valueAt(tender, 'value'), + deadline: textAt(objectAt(tender, 'tenderPeriod'), 'endDate'), + lotCount: objectsAt(tender, 'lots').length, + }, + awards: objectsAt(release, 'awards').map((award) => ({ + id: textAt(award, 'id'), + title: textAt(award, 'title'), + status: textAt(award, 'status'), + value: valueAt(award, 'value'), + date: textAt(award, 'date'), + suppliers: objectsAt(award, 'suppliers') + .map((supplier) => textAt(supplier, 'name')) + .filter((name) => name !== ''), + })), + contracts: objectsAt(release, 'contracts').map((contract) => ({ + id: textAt(contract, 'id'), + title: textAt(contract, 'title'), + status: textAt(contract, 'status'), + value: valueAt(contract, 'value'), + dateSigned: textAt(contract, 'dateSigned'), + start: textAt(objectAt(contract, 'period'), 'startDate'), + end: textAt(objectAt(contract, 'period'), 'endDate'), + })), + }; +}