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.
This commit is contained in:
1 parent
6fbf7e56b2
commit
f8648d765a
12 files changed
+847
-10
No files matched your search
@@ -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('—');
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user