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'), + })), + }; +}