diff --git a/apps/web/app/components/ComboTrendChart.test.ts b/apps/web/app/components/ComboTrendChart.test.ts new file mode 100644 index 00000000..5691db3d --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { monthYear } from '@sigma/shared'; +import { periodLabel, yearAxisTicks } from '../lib/trendAxis'; + +// Reference oracle: ComboTrendChart's x-axis tick logic before it was extracted into +// lib/trendAxis.ts (byte-identical to TrendChart's prior inline copy — that duplication is what +// review thread ComboTrendChart.tsx:62 / TrendChart.tsx:36 flagged). +function ticksBefore(points: TrendPoint[], granularity: TrendGranularity) { + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; + return points + .map((p, i) => ({ i, year: p.period.slice(0, 4) })) + .filter(({ i }) => yearStart == null || points[i]!.period.endsWith(yearStart)); +} + +// Reference oracle: ComboTrendChart's own periodLabel before the move to lib/trendAxis.ts. +function periodLabelBefore(period: string, granularity: TrendGranularity): string { + if (granularity === 'year') return period; + if (granularity === 'quarter') { + const [y, q] = period.split('-Q'); + return `Q${q} ${y}`; + } + return monthYear(period); +} + +const points: TrendPoint[] = [ + { period: '2023-11', valueEur: 10, contracts: 2, partial: false }, + { period: '2023-12', valueEur: 20, contracts: 3, partial: false }, + { period: '2024-01', valueEur: 30, contracts: 4, partial: false }, + { period: '2024-02', valueEur: 5, contracts: 1, partial: true }, +]; + +describe('yearAxisTicks (shared helper used by ComboTrendChart)', () => { + it('matches ComboTrendChart’s prior inline tick logic exactly across granularities', () => { + for (const granularity of ['month', 'quarter', 'year'] as const) { + expect(yearAxisTicks(points, granularity)).toEqual(ticksBefore(points, granularity)); + } + }); +}); + +describe('periodLabel (shared helper used by ComboTrendChart’s hover tooltip)', () => { + it('matches ComboTrendChart’s prior inline periodLabel exactly for every granularity', () => { + expect(periodLabel('2024-02', 'month')).toBe(periodLabelBefore('2024-02', 'month')); + expect(periodLabel('2024-Q1', 'quarter')).toBe(periodLabelBefore('2024-Q1', 'quarter')); + expect(periodLabel('2024', 'year')).toBe(periodLabelBefore('2024', 'year')); + }); +}); diff --git a/apps/web/app/components/ComboTrendChart.test.tsx b/apps/web/app/components/ComboTrendChart.test.tsx new file mode 100644 index 00000000..ab96a7f2 --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { TrendPoint } from '@sigma/api-contract'; + +import { ComboTrendChart } from './ComboTrendChart'; + +describe('ComboTrendChart', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + function comboLinePartial() { + return container.querySelector('path.combo-line-partial'); + } + + // Regression: the partial-is-always-last invariant can be violated upstream. When the + // partial period lands at index 0 instead of last, `hasPartial` must still detect it — + // `partialIdx > 0` treats index 0 as "no partial period" and silently renders the whole + // series as solid. + it('detects a partial period at index 0 and renders the dashed-partial path', () => { + const points: TrendPoint[] = [ + { period: '2024-01', valueEur: 5, contracts: 1, partial: true }, + { period: '2024-02', valueEur: 10, contracts: 2, partial: false }, + { period: '2024-03', valueEur: 20, contracts: 3, partial: false }, + ]; + act(() => { + root.render(); + }); + expect(comboLinePartial()).not.toBeNull(); + }); + + it('detects a partial period at the last index (the normal case)', () => { + const points: TrendPoint[] = [ + { period: '2024-01', valueEur: 5, contracts: 1, partial: false }, + { period: '2024-02', valueEur: 10, contracts: 2, partial: false }, + { period: '2024-03', valueEur: 20, contracts: 3, partial: true }, + ]; + act(() => { + root.render(); + }); + expect(comboLinePartial()).not.toBeNull(); + }); + + it('renders no dashed-partial path when nothing is partial', () => { + const points: TrendPoint[] = [ + { period: '2024-01', valueEur: 5, contracts: 1, partial: false }, + { period: '2024-02', valueEur: 10, contracts: 2, partial: false }, + ]; + act(() => { + root.render(); + }); + expect(comboLinePartial()).toBeNull(); + }); +}); diff --git a/apps/web/app/components/ComboTrendChart.tsx b/apps/web/app/components/ComboTrendChart.tsx new file mode 100644 index 00000000..76d664d9 --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { count, money } from '@sigma/shared'; +import { periodLabel, yearAxisTicks } from '../lib/trendAxis'; + +// Bar + line combo for the contracts overview (/trends): bars carry the contract count, the ink line +// the € volume. Server-rendered SVG like TrendChart; the only client behavior is the hover tooltip +// (React state after hydration — SSR renders the chart without it, so no-JS still gets the picture). +// The accessible data lives in the year cards next to the chart, matching the TrendChart pattern. + +const W = 1000; +const H = 300; +const TOP = 10; +const BOT = 272; +const PAD = 8; + +export function ComboTrendChart({ + points, + granularity, + cssHeight = 240, + interactive = true, + ariaLabel = 'Брой договори и € обем във времето', +}: { + points: TrendPoint[]; + granularity: TrendGranularity; + cssHeight?: number; + interactive?: boolean; + ariaLabel?: string; +}) { + const [hover, setHover] = useState(null); + if (points.length < 2) return null; + + const n = points.length; + const vMax = Math.max(1, ...points.map((p) => p.valueEur)) * 1.12; + const cMax = Math.max(1, ...points.map((p) => p.contracts)); + const x = (i: number) => (n > 1 ? PAD + (i * (W - 2 * PAD)) / (n - 1) : W / 2); + const yV = (v: number) => BOT - (v / vMax) * (BOT - TOP); + const yC = (c: number) => BOT - (c / cMax) * (BOT - TOP) * 0.62; + const bw = Math.max(2, ((W - 2 * PAD) / n) * 0.66); + + // Final period is partial (still filling): dashed line tail + faded bar, like TrendChart. + // findIndex returns -1 when no point is partial — that's the canonical "none" sentinel here, + // not 0, so `hasPartial` must not rely on index truthiness (a partial period at index 0 is a + // real partial period, not "no partial period"). + const partialIdx = points.findIndex((p) => p.partial); + const hasPartial = partialIdx !== -1; + if (import.meta.env.DEV && partialIdx === 0 && n > 1) { + console.warn( + `ComboTrendChart: partial period at index 0 of ${n} — the partial-is-always-last ` + + 'invariant is broken upstream; the chart will render with no solid segment before it.', + ); + } else if (import.meta.env.DEV && partialIdx > 0 && partialIdx !== n - 1) { + console.warn( + `ComboTrendChart: partial period at index ${partialIdx} of ${n} is not last — the ` + + 'partial-is-always-last invariant is broken upstream; the dashed tail will connect from the wrong point.', + ); + } + const solidEnd = hasPartial ? partialIdx - 1 : n - 1; + const xy = (i: number) => `${x(i).toFixed(1)} ${yV(points[i]!.valueEur).toFixed(1)}`; + const line = points + .slice(0, solidEnd + 1) + .map((_p, i) => `${i ? 'L' : 'M'}${xy(i)}`) + .join(' '); + const dashed = hasPartial && solidEnd >= 0 ? `M${xy(solidEnd)} L${xy(partialIdx)}` : ''; + + const ticks = yearAxisTicks(points, granularity); + + const hp = hover != null ? points[hover] : null; + + return ( +
interactive && setHover(null)}> + + {[0, 1 / 3, 2 / 3, 1].map((f) => ( + + ))} + {points.map((p, i) => ( + setHover(i) : undefined} + /> + ))} + + {hasPartial && ( + + )} + {hp && hover != null && ( + <> + + + + )} + + + {hp && hover != null && ( +
+
+ {periodLabel(hp.period, granularity)} + {hp.partial ? ' · частично' : ''} +
+
+ € обем + {money(hp.valueEur)} +
+
+ договори + {count(hp.contracts)} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/components/FullscreenButton.tsx b/apps/web/app/components/FullscreenButton.tsx new file mode 100644 index 00000000..efb2a85b --- /dev/null +++ b/apps/web/app/components/FullscreenButton.tsx @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Toggle the native Fullscreen API on a container ref. SSR-safe: the listener and the + * `document` reads only run in the browser effect. `requestFullscreen` is feature-detected, + * so the button no-ops gracefully where the API is unavailable. + */ +export function useFullscreen() { + const ref = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const onChange = () => setIsFullscreen(document.fullscreenElement === ref.current); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggle = useCallback(() => { + const el = ref.current; + if (!el) return; + if (document.fullscreenElement) { + document.exitFullscreen?.(); + } else { + el.requestFullscreen?.().catch(() => {}); + } + }, []); + + return { ref, isFullscreen, toggle }; +} + +export function FullscreenButton({ active, onToggle }: { active: boolean; onToggle: () => void }) { + return ( + + ); +} diff --git a/apps/web/app/components/MetricInfo.tsx b/apps/web/app/components/MetricInfo.tsx new file mode 100644 index 00000000..586c6406 --- /dev/null +++ b/apps/web/app/components/MetricInfo.tsx @@ -0,0 +1,91 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +// A small ⓘ affordance next to a metric label. For pointer users it reveals an elegant popover on +// hover or keyboard focus (pure CSS `:hover` / `:focus-within`). Because hover does not exist on +// touch, a click also toggles the popover open via an `is-open` class — and an outside-click or Esc +// closes it again. The button carries the full text as its aria-label, so screen-reader users get the +// same information without the visual popover (which is aria-hidden). SSR-safe: the initial render is +// closed and the toggle/effects only run on the client. +export function MetricInfo({ + title, + summary, + readout, + align = 'start', +}: { + title: string; + summary: string; + // Plain string so the readout is always reflected verbatim into the aria-label (all callers pass a + // string — the screen-reader text must never silently drop a non-string interpretation). + readout?: string; + // Which edge the popover anchors to — use 'end' for right-most metrics so it doesn't clip. + align?: 'start' | 'end'; +}) { + const aria = readout ? `${title}. ${summary} ${readout}`.trim() : `${title}. ${summary}`; + const [open, setOpen] = useState(false); + const ref = useRef(null); + const popRef = useRef(null); + // Horizontal shift (px) that keeps the click-opened popover inside the viewport on small screens + // (mobile audit: at 320px the fixed-width popover clips off-screen for edge-column metrics). + const [shift, setShift] = useState(0); + + const useIsoLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect; + + useIsoLayoutEffect(() => { + if (!open) { + setShift(0); + return; + } + const pop = popRef.current; + if (!pop) return; + const rect = pop.getBoundingClientRect(); + const vw = document.documentElement.clientWidth; + let dx = 0; + if (rect.right > vw - 8) dx = vw - 8 - rect.right; + if (rect.left + dx < 8) dx = 8 - rect.left; + setShift(Math.round(dx)); + }, [open]); + + // Close on outside-click / Esc while open (touch path — pointer users rely on CSS hover/focus). + useEffect(() => { + if (!open) return; + const onPointer = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('pointerdown', onPointer); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', onPointer); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( + + + + + ); +} diff --git a/apps/web/app/components/TrendChart.test.ts b/apps/web/app/components/TrendChart.test.ts new file mode 100644 index 00000000..d644cf59 --- /dev/null +++ b/apps/web/app/components/TrendChart.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { periodLabel, yearAxisTicks } from '../lib/trendAxis'; + +// Reference oracle: TrendChart's x-axis tick logic before it was extracted into +// lib/trendAxis.ts (the version this test file's namesake component used to inline). +function ticksBefore(points: TrendPoint[], granularity: TrendGranularity) { + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; + return points + .map((p, i) => ({ i, year: p.period.slice(0, 4) })) + .filter((_t, idx) => yearStart == null || points[idx]!.period.endsWith(yearStart)); +} + +const monthPoints: TrendPoint[] = [ + { period: '2023-11', valueEur: 1, contracts: 1, partial: false }, + { period: '2023-12', valueEur: 1, contracts: 1, partial: false }, + { period: '2024-01', valueEur: 1, contracts: 1, partial: false }, + { period: '2024-02', valueEur: 1, contracts: 1, partial: false }, + { period: '2025-01', valueEur: 1, contracts: 1, partial: true }, +]; + +const quarterPoints: TrendPoint[] = [ + { period: '2023-Q3', valueEur: 1, contracts: 1, partial: false }, + { period: '2023-Q4', valueEur: 1, contracts: 1, partial: false }, + { period: '2024-Q1', valueEur: 1, contracts: 1, partial: false }, + { period: '2024-Q2', valueEur: 1, contracts: 1, partial: true }, +]; + +const yearPoints: TrendPoint[] = [ + { period: '2022', valueEur: 1, contracts: 1, partial: false }, + { period: '2023', valueEur: 1, contracts: 1, partial: false }, + { period: '2024', valueEur: 1, contracts: 1, partial: true }, +]; + +describe('yearAxisTicks (shared helper used by TrendChart)', () => { + it('matches TrendChart’s prior inline month-grain tick logic exactly', () => { + expect(yearAxisTicks(monthPoints, 'month')).toEqual(ticksBefore(monthPoints, 'month')); + expect(yearAxisTicks(monthPoints, 'month')).toEqual([ + { i: 2, year: '2024' }, + { i: 4, year: '2025' }, + ]); + }); + + it('matches TrendChart’s prior inline quarter-grain tick logic exactly', () => { + expect(yearAxisTicks(quarterPoints, 'quarter')).toEqual(ticksBefore(quarterPoints, 'quarter')); + expect(yearAxisTicks(quarterPoints, 'quarter')).toEqual([{ i: 2, year: '2024' }]); + }); + + it('matches TrendChart’s prior inline year-grain tick logic exactly (a tick per point)', () => { + expect(yearAxisTicks(yearPoints, 'year')).toEqual(ticksBefore(yearPoints, 'year')); + expect(yearAxisTicks(yearPoints, 'year')).toEqual([ + { i: 0, year: '2022' }, + { i: 1, year: '2023' }, + { i: 2, year: '2024' }, + ]); + }); +}); + +describe('periodLabel', () => { + it('formats year/quarter/month periods as TrendChart’s tooltip and labels expect', () => { + expect(periodLabel('2024', 'year')).toBe('2024'); + expect(periodLabel('2024-Q1', 'quarter')).toBe('Q1 2024'); + }); +}); diff --git a/apps/web/app/components/TrendChart.tsx b/apps/web/app/components/TrendChart.tsx index 9248669d..520df384 100644 --- a/apps/web/app/components/TrendChart.tsx +++ b/apps/web/app/components/TrendChart.tsx @@ -1,4 +1,5 @@ -import type { TrendPoint } from '@sigma/api-contract'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { yearAxisTicks } from '../lib/trendAxis'; // Server-rendered area + line of spend over time (no chart JS, like SankeyDiagram). The accessible // data is the per-year table beside it; this SVG is a visual summary (role="img" + aria-label) with @@ -13,7 +14,7 @@ export function TrendChart({ granularity, }: { points: TrendPoint[]; - granularity: 'month' | 'year'; + granularity: TrendGranularity; }) { if (points.length < 2) return null; const max = Math.max(1, ...points.map((p) => p.valueEur)); @@ -32,10 +33,7 @@ export function TrendChart({ .join(''); const area = `${line}L${x(solidEnd).toFixed(1)},${H - PAD_B}L0,${H - PAD_B}Z`; const dashed = hasPartial ? `M${xy(solidEnd)}L${xy(partialIdx)}` : ''; - // x-axis ticks at the first month of each year (month granularity) or at every point (year). - const ticks = points - .map((p, i) => ({ i, year: p.period.slice(0, 4) })) - .filter((t, idx) => granularity === 'year' || points[idx]!.period.endsWith('-01')); + const ticks = yearAxisTicks(points, granularity); // viewBox carries 14px of horizontal bleed on each side so the first and last year labels, which are // centred on the edge ticks, are not clipped. diff --git a/apps/web/app/lib/analytics-lenses.ts b/apps/web/app/lib/analytics-lenses.ts index 8e14b031..53c8dec0 100644 --- a/apps/web/app/lib/analytics-lenses.ts +++ b/apps/web/app/lib/analytics-lenses.ts @@ -11,8 +11,8 @@ export const ANALYTICS_LENSES = [ }, { href: '/trends', - title: 'Тренд', - desc: 'Как се движат разходите във времето по месеци и години.', + title: 'Договори — обзор', + desc: 'Договорите във времето, по CPV код, или двете наведнъж — с типичните цени по група.', }, { href: '/competition', diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts index 158f1609..d5032e44 100644 --- a/apps/web/app/lib/filters.test.ts +++ b/apps/web/app/lib/filters.test.ts @@ -4,12 +4,17 @@ import { authorityListFilters, companyListFilters, contractListFilters, + cpvGroupSelection, getMulti, leaderboardRankOffset, + MAX_CPV_GROUP_SELECTION, MAX_MULTI_VALUES, pageNav, PARAM_ORDER, searchHref, + trendAngle, + trendSort, + trendStep, withParams, } from './filters'; import { CANONICAL_QUERY_PARAMS } from './query-params'; @@ -118,6 +123,62 @@ describe('getMulti', () => { }); }); +describe('cpvGroupSelection', () => { + it('parses repeatable and CSV ?cpv values into a deduped, canonically sorted set', () => { + expect(cpvGroupSelection(sp('cpv=45233&cpv=33600'))).toEqual(['33600', '45233']); + expect(cpvGroupSelection(sp('cpv=45233,33600'))).toEqual(['33600', '45233']); + expect(cpvGroupSelection(sp('cpv=45233&cpv=45233&cpv=33600'))).toEqual(['33600', '45233']); + expect(cpvGroupSelection(sp(''))).toEqual([]); + }); + + it('returns an identical sorted array regardless of ?cpv arrival order (edge-cache key stability)', () => { + expect(cpvGroupSelection(sp('cpv=33600&cpv=45233'))).toEqual( + cpvGroupSelection(sp('cpv=45233&cpv=33600')), + ); + expect(cpvGroupSelection(sp('cpv=33600&cpv=45233'))).toEqual(['33600', '45233']); + }); + + it('drops anything that is not exactly a 5-digit group code (CWE-349 key hygiene)', () => { + expect( + cpvGroupSelection(sp('cpv=4523&cpv=452333&cpv=abcde&cpv=45 33&cpv= 45233 &cpv=%27--')), + ).toEqual(['45233']); + }); + + it('caps the selection at MAX_CPV_GROUP_SELECTION so hostile spam stays bounded', () => { + const q = Array.from({ length: 40 }, (_, i) => `cpv=${10000 + i}`).join('&'); + const out = cpvGroupSelection(sp(q)); + expect(out).toHaveLength(MAX_CPV_GROUP_SELECTION); + expect(out[0]).toBe('10000'); + expect(out.at(-1)).toBe(String(10000 + MAX_CPV_GROUP_SELECTION - 1)); + }); +}); + +describe('trend param validation (angle/step/sort)', () => { + it('trendAngle passes through known values and falls back to "time" otherwise', () => { + expect(trendAngle(sp('angle=cpv'))).toBe('cpv'); + expect(trendAngle(sp('angle=cross'))).toBe('cross'); + expect(trendAngle(sp('angle=time'))).toBe('time'); + expect(trendAngle(sp(''))).toBe('time'); + expect(trendAngle(sp("angle='--drop table"))).toBe('time'); + expect(trendAngle(sp('angle=CPV'))).toBe('time'); + }); + + it('trendStep passes through known values and falls back to "q" otherwise', () => { + expect(trendStep(sp('step=m'))).toBe('m'); + expect(trendStep(sp('step=y'))).toBe('y'); + expect(trendStep(sp('step=q'))).toBe('q'); + expect(trendStep(sp(''))).toBe('q'); + expect(trendStep(sp('step=bogus'))).toBe('q'); + }); + + it('trendSort passes through known values and falls back to "date" otherwise', () => { + expect(trendSort(sp('sort=value'))).toBe('value'); + expect(trendSort(sp('sort=date'))).toBe('date'); + expect(trendSort(sp(''))).toBe('date'); + expect(trendSort(sp('sort=name'))).toBe('date'); + }); +}); + describe('searchHref', () => { it('sets q and resets cursor/page while preserving filters and sort', () => { const sp = new URLSearchParams('sort=name&year=2024&cursor=abc&page=3§or=45'); diff --git a/apps/web/app/lib/filters.ts b/apps/web/app/lib/filters.ts index 6e5d621b..400129f2 100644 --- a/apps/web/app/lib/filters.ts +++ b/apps/web/app/lib/filters.ts @@ -31,6 +31,68 @@ export function getMulti(params: URLSearchParams, key: string): string[] { .slice(0, MAX_MULTI_VALUES); } +// The /trends обзор multi-select is bounded to the visible top-10 CPV list; anything past the cap +// is dropped so hostile ?cpv spam cannot fan the loader out into unbounded per-group SQL work. +export const MAX_CPV_GROUP_SELECTION = 10; + +/** + * The обзор lenses' CPV multi-select (`?cpv=45233&cpv=33600` or `?cpv=45233,33600` on /trends): + * validated 5-digit group codes only, deduped, sorted into a canonical order, capped at + * MAX_CPV_GROUP_SELECTION. The sort makes `?cpv=a&cpv=b` and `?cpv=b&cpv=a` yield an identical + * array, so the same logical selection never mints divergent edge-cache key variants (CWE-349). + * Malformed or excess codes are dropped before they reach a filter or a cache key. + */ +export function cpvGroupSelection(sp: URLSearchParams): string[] { + const all = sp + .getAll('cpv') + .flatMap((v) => v.split(',')) + .map((v) => v.trim()); + return Array.from(new Set(all)) + .filter((v) => /^\d{5}$/.test(v)) + .sort() + .slice(0, MAX_CPV_GROUP_SELECTION); +} + +export const TREND_ANGLES = ['time', 'cpv', 'cross'] as const; +export type TrendAngle = (typeof TREND_ANGLES)[number]; + +export const TREND_STEPS = ['m', 'q', 'y'] as const; +export type TrendStep = (typeof TREND_STEPS)[number]; + +export const TREND_SORTS = ['date', 'value'] as const; +export type TrendSort = (typeof TREND_SORTS)[number]; + +export const TREND_CPV_SORTS = ['n', 'med', 'code'] as const; +export type TrendCpvSort = (typeof TREND_CPV_SORTS)[number]; + +function pickEnum(raw: string | null, allowed: readonly T[], fallback: T): T { + return raw != null && (allowed as readonly string[]).includes(raw) ? (raw as T) : fallback; +} + +/** + * The /trends обзор lens picker (`?angle=`): validated against the known allowlist, same + * validate-or-fallback discipline as {@link cpvGroupSelection} — an unrecognized value falls back + * to 'time' rather than flowing through unchecked. + */ +export function trendAngle(sp: URLSearchParams): TrendAngle { + return pickEnum(sp.get('angle'), TREND_ANGLES, 'time'); +} + +/** The /trends time-lens granularity toggle (`?step=`), validated the same way as {@link trendAngle}. */ +export function trendStep(sp: URLSearchParams): TrendStep { + return pickEnum(sp.get('step'), TREND_STEPS, 'q'); +} + +/** The /trends contract-list sort (`?sort=`), validated the same way as {@link trendAngle}. */ +export function trendSort(sp: URLSearchParams): TrendSort { + return pickEnum(sp.get('sort'), TREND_SORTS, 'date'); +} + +/** The /trends CPV-lens sort (`?cpvSort=`), validated the same way as {@link trendAngle}. */ +export function trendCpvSort(sp: URLSearchParams): TrendCpvSort { + return pickEnum(sp.get('cpvSort'), TREND_CPV_SORTS, 'n'); +} + /** * The contracts list filter set read from the URL — the SINGLE source of truth shared by the HTML * list loader (/contracts) and the CSV export loader (/contracts.csv). They previously parsed the URL @@ -184,7 +246,10 @@ export const PARAM_ORDER = [ 'type', 'kind', 'sector', - 'g', // trends granularity (month/year) + 'cpv', // /trends: repeatable CPV group multi-select facet + 'angle', // /trends: time | cpv | cross lens + 'step', // /trends: series granularity (m|q|y) + 'cur', // /trends: include the current (partial) period 'year', 'procedure', 'funding', @@ -197,6 +262,7 @@ export const PARAM_ORDER = [ 'top', 'count', 'sort', + 'cpvSort', // /trends: CPV list ordering 'cursor', 'page', 'p', // sitemap-contracts page diff --git a/apps/web/app/lib/query-params.ts b/apps/web/app/lib/query-params.ts index e7b603a3..820c1dbf 100644 --- a/apps/web/app/lib/query-params.ts +++ b/apps/web/app/lib/query-params.ts @@ -2,15 +2,18 @@ // one list means an unknown param (`?x=poison`) can neither poison the key nor ride a cached link // (#56 / #197). The cache-key.test.ts drift guard keeps it a complete superset of what the app reads. export const CANONICAL_QUERY_PARAMS = new Set([ + 'angle', // /trends: time | cpv | cross lens 'authority', 'bidder', 'bids', // single-bid filter — changes the result set + totals 'center', 'count', + 'cpv', // /trends: repeatable CPV group multi-select facet (CWE-349) + 'cpvSort', // /trends: CPV list ordering + 'cur', // /trends: include the current (partial) period — changes the chart, totals and year cards 'cursor', 'eu', 'funding', - 'g', 'kind', 'p', 'page', // keyed unconditionally — harmless over-key when there's no cursor @@ -18,6 +21,7 @@ export const CANONICAL_QUERY_PARAMS = new Set([ 'q', 'sector', 'sort', + 'step', // /trends: series granularity (m|q|y; replaced the old `g` param) 'top', // top-20 vs top-50 on /flows, /competition 'type', 'value', diff --git a/apps/web/app/lib/trendAxis.ts b/apps/web/app/lib/trendAxis.ts new file mode 100644 index 00000000..8da8839d --- /dev/null +++ b/apps/web/app/lib/trendAxis.ts @@ -0,0 +1,29 @@ +// X-axis helpers shared by ComboTrendChart and TrendChart — the two SVG chart components that plot +// TrendPoint series over time. Kept in one place so their year-start/tick logic and period labels +// cannot drift between the two implementations (NO CODE DUPLICATION). + +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { monthYear } from '@sigma/shared'; + +/** 'YYYY-MM' → 'март 2024', 'YYYY-Qn' → 'Q1 2024', 'YYYY' → '2024'. */ +export function periodLabel(period: string, granularity: TrendGranularity): string { + if (granularity === 'year') return period; + if (granularity === 'quarter') { + const [y, q] = period.split('-Q'); + return `Q${q} ${y}`; + } + return monthYear(period); +} + +export type AxisTick = { i: number; year: string }; + +/** + * X-axis year labels at the first period of each year (or every point at year grain): month grain + * ticks on '-01', quarter grain ticks on '-Q1', year grain ticks every point. + */ +export function yearAxisTicks(points: TrendPoint[], granularity: TrendGranularity): AxisTick[] { + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; + return points + .map((p, i) => ({ i, year: p.period.slice(0, 4) })) + .filter(({ i }) => yearStart == null || points[i]!.period.endsWith(yearStart)); +} diff --git a/apps/web/app/routes/trends.tsx b/apps/web/app/routes/trends.tsx index 42172a9c..05d003c0 100644 --- a/apps/web/app/routes/trends.tsx +++ b/apps/web/app/routes/trends.tsx @@ -1,23 +1,41 @@ -import { Form, useNavigation, useSearchParams, useSubmit } from 'react-router'; -import type { TrendYear } from '@sigma/api-contract'; -import { count, money, pct, signedPct } from '@sigma/shared'; -import { getSpendingTrend, getDb } from '@sigma/db'; +import { Link, useNavigation, useSearchParams } from 'react-router'; +import type { CpvGroupStat, TrendGranularity } from '@sigma/api-contract'; +import { count, date as fmtDate, money, plural } from '@sigma/shared'; +import { + getCpvGroupMedians, + getCpvGroupStats, + getDb, + getSpendingTrend, + listOverviewContracts, +} from '@sigma/db'; import type { Route } from './+types/trends'; import { Breadcrumbs } from '../components/Breadcrumbs'; import { PageHeader } from '../components/PageHeader'; -import { DataTable, type Column } from '../components/DataTable'; -import { TrendChart } from '../components/TrendChart'; -import { Callout, Section } from '../components/ui'; +import { TotalsStrip, type Total } from '../components/TotalsStrip'; +import { ComboTrendChart } from '../components/ComboTrendChart'; +import { Callout } from '../components/ui'; import { publicCache } from '../lib/cache'; -import { singleSelectFilters } from '../lib/filters'; +import { + cpvGroupSelection, + trendAngle, + trendCpvSort, + trendSort, + trendStep, + type TrendAngle, + type TrendStep, +} from '../lib/filters'; + +// „Договори — обзор": one list of contracts looked at from three angles (lenses) — in time, per CPV +// group, or both at once. Every control is a plain mutating the query string, so the page is +// fully SSR/no-JS capable; the only hydrated behavior is the chart hover tooltip. export function meta(_: Route.MetaArgs) { return [ - { title: 'Тренд във времето — СИГМА' }, + { title: 'Договори — обзор — СИГМА' }, { name: 'description', content: - 'Как се движат разходите за обществени поръчки във времето, по месеци и години, със сезонните пикове. Изцяло върху наличните данни.', + 'Един и същи списък договори — сортиран по време, срязан по CPV код, или двете наведнъж. Обем и брой по месеци, тримесечия и години; типични цени по CPV групи.', }, ]; } @@ -26,132 +44,588 @@ export function headers() { return { 'Cache-Control': publicCache(1800) }; } +type Angle = TrendAngle; +type Step = TrendStep; + +const STEP_GRANULARITY: Record = { + m: 'month', + q: 'quarter', + y: 'year', +}; + export async function loader({ request, context }: Route.LoaderArgs) { const sp = new URL(request.url).searchParams; - const { sector, funding, unknownSector } = singleSelectFilters(sp); - const granularity = sp.get('g') === 'year' ? 'year' : 'month'; const db = getDb(context.cloudflare.env); - const data = await getSpendingTrend(db, { sector, funding, granularity }); - return { data, unknownSector }; + + // angle/step/sort share the same validate-or-fallback discipline as cpvGroupSelection below + // (an unrecognized value falls back to a known-safe default rather than passing through). + const angle = trendAngle(sp); + const step = trendStep(sp); + const sort = trendSort(sp); + const cpvSort = trendCpvSort(sp); + const yearRaw = sp.get('year'); + const year = yearRaw && /^20\d\d$/.test(yearRaw) ? yearRaw : null; + // Repeatable ?cpv — the multi-select CPV facet. Validated + bounded by cpvGroupSelection so + // hostile input can neither poison the SQL scope nor mint unbounded cache-key variants (CWE-349). + const cpvSel = cpvGroupSelection(sp); + // „вкл. текущия месец": the current (incomplete) period is excluded from the chart by default; + // ?cur=1 opts back in (validated to exactly '1' so the edge-cache key space stays two-valued). + const cur = sp.get('cur') === '1'; + + // The cross lens always shows the compact quarterly picker; the time lens follows the step toggle. + const granularity = angle === 'cross' ? 'quarter' : STEP_GRANULARITY[step]; + + const [trend, stats, contracts] = await Promise.all([ + // Faceted by the selected CPV groups (one aggregate scan; all groups when nothing is selected), + // so the combo chart, year cards and totals all re-run server-side on real data. + getSpendingTrend( + db, + { granularity, cpvGroups: cpvSel, includeCurrent: cur }, + { includeSectors: false }, + ), + getCpvGroupStats(db, 10), + listOverviewContracts(db, { year, cpvGroups: cpvSel, sort, limit: 24 }), + ]); + + // „Спрямо типичното" baselines for card groups outside the top-N stats (bounded: distinct groups + // on one card page, plus the selected group so its filter chip can carry a name). + const known = new Set(stats.groups.map((g) => g.group)); + const missing = contracts + .map((c) => c.cpvGroup) + .filter((g): g is string => g != null && !known.has(g)); + for (const g of cpvSel) if (!known.has(g)) missing.push(g); + const medians = await getCpvGroupMedians(db, [...new Set(missing)]); + + return { angle, step, sort, cpvSort, year, cpvSel, cur, trend, stats, contracts, medians }; +} + +// ── Presentational helpers ──────────────────────────────────────────────────────────────────────── + +/** ×N with a Bulgarian decimal comma: 2.4 → '×2,4', 15 → '×15'. */ +function multText(mult: number): string { + if (mult >= 10) return `×${Math.round(mult)}`; + return `×${(Math.round(mult * 10) / 10).toString().replace('.', ',')}`; +} + +function relLabel(valueEur: number, medianEur: number): { text: string; cls: string } { + const mult = valueEur / medianEur; + if (mult >= 1.3) return { text: `${multText(mult)} типичното`, cls: 'ov-rel-hi' }; + if (mult <= 0.75) return { text: 'под типичното', cls: 'ov-rel-lo' }; + return { text: '≈ типичното', cls: 'ov-rel-mid' }; +} + +// Deterministic jitter for the dot cloud (presentation only — the x positions are real values). +function jitter(seedText: string, i: number): number { + let h = 2166136261; + for (const ch of `${seedText}:${i}`) h = Math.imul(h ^ ch.charCodeAt(0), 16777619); + return ((h >>> 8) % 1000) / 1000 - 0.5; } +const LOG_MIN = 1e3; + +function logMax(groups: CpvGroupStat[]): number { + const max = Math.max(1e6, ...groups.map((g) => g.maxEur)); + return 10 ** Math.ceil(Math.log10(max)); +} + +function axisLabel(v: number): string { + return v >= 1e6 ? `${v / 1e6}М` : `${v / 1e3}к`; +} + +/** log-€ → x in the 320-wide distribution strip. */ +function makeLx(gMax: number) { + const lo = Math.log10(LOG_MIN); + const hi = Math.log10(gMax); + return (v: number) => + 6 + ((Math.log10(Math.min(gMax, Math.max(LOG_MIN, v))) - lo) / (hi - lo)) * 308; +} + +// Per-group distribution strip: p10–p90 box, real-value dot cloud (log x), median line. Dots at +// ≥5× the group median are highlighted — the same "worth a look" cue as the card labels. +function DistStrip({ g, gMax }: { g: CpvGroupStat; gMax: number }) { + const lx = makeLx(gMax); + return ( + + ); +} + +function DistAxis({ gMax }: { gMax: number }) { + const lx = makeLx(gMax); + const ticks: number[] = []; + for (let v = LOG_MIN; v <= gMax; v *= 10) ticks.push(v); + return ( + + ); +} + +// ── Page ────────────────────────────────────────────────────────────────────────────────────────── + export default function Trends({ loaderData }: Route.ComponentProps) { - const { data, unknownSector } = loaderData; + const { angle, step, sort, cpvSort, year, cpvSel, cur, trend, stats, contracts, medians } = + loaderData; const [sp] = useSearchParams(); - const submit = useSubmit(); const navigating = useNavigation().state !== 'idle'; - const sel = (k: string) => sp.get(k) ?? ''; - const yearColumns: Column[] = [ - { - key: 'year', - header: 'Година', - isTitle: true, - cell: (r) => ( - <> - {r.year} - {r.partial && (частично)} - - ), - }, - { key: 'value', header: 'Стойност', align: 'money', cell: (r) => money(r.valueEur) }, - { key: 'contracts', header: 'Договори', align: 'num', cell: (r) => count(r.contracts) }, - { - key: 'yoy', - header: 'Спрямо предходната', - align: 'num', - cell: (r) => (r.yoyPct == null ? '' : signedPct(r.yoyPct)), - }, + // Every control is a Link that patches the query string (null deletes a key; an array replaces + // every occurrence of a repeatable key — the CPV multi-select). + const hrefWith = (patch: Record): string => { + const next = new URLSearchParams(sp); + for (const [k, v] of Object.entries(patch)) { + next.delete(k); + if (Array.isArray(v)) for (const item of v) next.append(k, item); + else if (v != null) next.set(k, v); + } + const qs = next.toString(); + return qs ? `/trends?${qs}` : '/trends'; + }; + + // Toggle one CPV group in/out of the multi-select (a plain GET Link — no-JS friendly). The set is + // written sorted so equal selections always share one canonical URL/edge-cache key. + const hrefToggleCpv = (group: string): string => { + const next = ( + cpvSel.includes(group) ? cpvSel.filter((g) => g !== group) : [...cpvSel, group] + ).sort(); + return hrefWith({ cpv: next.length ? next : null }); + }; + + // Cohort baseline per CPV group: top-N stats first, on-demand medians for the rest. + const cohorts = new Map(); + for (const m of medians) cohorts.set(m.group, { name: m.name, medianEur: m.medianEur }); + for (const g of stats.groups) cohorts.set(g.group, { name: g.name, medianEur: g.medianEur }); + + const datedContracts = trend.points.reduce((sum, p) => sum + p.contracts, 0); + const totals: Total[] = [ + { num: money(trend.totalValueEur), label: 'обща стойност' }, + { num: count(datedContracts), label: 'договора' }, + { num: count(stats.totalGroups), label: 'CPV групи' }, ]; + const gMax = logMax(stats.groups); + const cpvRows = [...stats.groups].sort((a, b) => + cpvSort === 'med' + ? b.medianEur - a.medianEur + : cpvSort === 'code' + ? a.group.localeCompare(b.group) + : b.contracts - a.contracts, + ); + + const chips: { label: string; clear: Record }[] = []; + for (const g of cpvSel) { + const rest = cpvSel.filter((c) => c !== g); + chips.push({ label: `CPV ${g}`, clear: { cpv: rest.length ? rest : null } }); + } + if (year) chips.push({ label: year, clear: { year: null } }); + const lensHint = + angle === 'time' + ? 'кликни година, за да филтрираш' + : angle === 'cpv' + ? 'кликни CPV ред, за да филтрираш' + : 'избери година и CPV код'; + + const scopeParts: string[] = []; + for (const g of cpvSel) scopeParts.push(`CPV ${g}`); + if (year) scopeParts.push(year); + const scopeText = scopeParts.length ? scopeParts.join(' · ') : 'всички договори'; + + const angles: { key: Angle; label: string }[] = [ + { key: 'time', label: 'Във времето' }, + { key: 'cpv', label: 'По CPV код' }, + { key: 'cross', label: 'Време × CPV' }, + ]; + // The toggle names the unit the chart steps in (the control lives on the time lens only). + const curLabel = + step === 'y' + ? 'вкл. текущата година' + : step === 'q' + ? 'вкл. текущото тримесечие' + : 'вкл. текущия месец'; + const steps: { key: Step; label: string }[] = [ + { key: 'm', label: 'Мес.' }, + { key: 'q', label: 'Трим.' }, + { key: 'y', label: 'Год.' }, + ]; + const cpvSorts = [ + { key: 'n', label: 'Договори' }, + { key: 'med', label: 'Типична' }, + { key: 'code', label: 'CPV' }, + ] as const; + const sorts = [ + { key: 'date', label: 'Най-нови' }, + { key: 'value', label: 'Стойност' }, + ] as const; + + const yearCards = trend.years.map((y) => ({ + ...y, + active: y.year === year, + href: hrefWith({ year: y.year === year ? null : y.year }), + })); + + const cpvPanel = (compact: boolean) => ( +
+
+
+

+ {compact ? ( + <> + Стеснѝ по CPV код + + ) : ( + <> + Цени по CPV код + + )} +

+ {!compact && ( +

+ Всеки код събира сходни поръчки. Разсейването е нормално — обемите варират. Кликни + ред, за да видиш договорите. Показани са {stats.groups.length}-те групи с най-много + договори. +

+ )} +
+ {!compact && ( +
+ {cpvSorts.map((s) => ( + + {s.label} + + ))} +
+ )} +
+ {!compact && ( + + )} + {cpvRows.map((g) => { + const active = cpvSel.includes(g.group); + return ( + + {compact && ( + + )} + {g.group} + + {g.name ?? `CPV група ${g.group}`} + {!compact && ( + + диапазон p10–p90 · {money(g.p10Eur)} – {money(g.p90Eur)} + + )} + + {money(g.medianEur)} + {!compact && ( + <> + {count(g.contracts)} + + + )} + + ); + })} + {!compact && ( +
+ +
+ )} +
+ ); + return ( <> - +
+ Договори, погледнати под различен ъгъл + + } + lede="Един и същи списък договори — сортиран по време, срязан по CPV код, или двете наведнъж. Изберѝ ъгъл; списъкът долу се сглобява от избора. Договорите без валидна дата или стойност не влизат в изгледа." /> -
submit(e.currentTarget)} - > - - - - -
+ + + + {/* Announce server-rendered chart/list updates when a CPV code or year is (de)selected. */}

- {navigating ? 'Обновяване на визуализацията…' : 'Визуализацията е обновена.'} + {navigating + ? 'Обновяване на данните…' + : cpvSel.length + ? `Графиката и списъкът показват ${count(cpvSel.length)} ${plural(cpvSel.length, 'избрана CPV група', 'избрани CPV групи')}.` + : 'Графиката и списъкът показват всички CPV групи.'}

- {unknownSector && ( - -

Избраният сектор не съществува. Показваме всички сектори.

-
+ {angle === 'time' && ( +
+
+

+ Разходи във времето +

+
+
+
+ {trend.points.length >= 2 ? ( + + ) : ( +

Няма достатъчно данни.

+ )} +
+ {yearCards.map((y) => ( + + + {y.year} + {y.partial && частично} + + {money(y.valueEur)} + + ))} +
+
)} -
- {data.points.length >= 2 ? ( - + {angle === 'cpv' && cpvPanel(false)} + + {angle === 'cross' && ( +
+
+

+ Избери година +

+

+ После стеснѝ по CPV код от съседния списък — графиката се преизчислява само върху + избраните групи. +

+ {trend.points.length < 2 && ( +

+ Няма достатъчно данни за избраните CPV групи. +

+ )} + {trend.points.length >= 2 && ( + + )} +
+ {yearCards.map((y) => ( + + {y.year} + + ))} +
+
+ {cpvPanel(true)} +
+ )} + +
+
+
+

+ {sort === 'value' ? 'Договори · по стойност' : 'Договори · най-нови'} +

+

+ {count(contracts.length)} {plural(contracts.length, 'договор', 'договора')} + {contracts.length === 24 ? ' (показани първите 24)' : ''} · {scopeText} +

+
+
+ Подредба +
+ {sorts.map((s) => ( + + {s.label} + + ))} +
+
+
+ {contracts.length ? ( +
    + {contracts.map((c) => { + const cohort = c.cpvGroup ? cohorts.get(c.cpvGroup) : undefined; + const rel = cohort ? relLabel(c.valueEur, cohort.medianEur) : null; + return ( +
  • + + + {fmtDate(c.signedAt)} + {money(c.valueEur)} + + {c.authorityName} + + + {c.bidderName} + + + {c.cpvGroup && CPV {c.cpvGroup}} + {cohort?.name ?? ''} + {rel && {rel.text}} + + +
  • + ); + })} +
) : ( -

Няма достатъчно данни за избраните филтри.

+

Няма договори за този избор.

)} -
- -
- r.year} - caption="Разходи по години" - /> -
+

+ „Спрямо типичното" сравнява стойността на договора с медианата за неговия CPV код. + Данните нямат количества, затова по-високата стойност често значи просто по-голям обем — + това е ориентир за разглеждане, не оценка. +

+

- Графиката включва договорите с валидна дата на сключване ({pct(data.coverage.pct)} от - тях). Последният период е непълен и е отбелязан като „частично". Виж методологията за - подробности. + Изгледът включва договорите с валидна дата на сключване и стойност в евро. Текущият + (непълен) период е скрит по подразбиране — контролът „вкл. текущия месец" го показва, + отбелязан като „частично". Виж методологията за подробности.

diff --git a/apps/web/app/styles/components.css b/apps/web/app/styles/components.css index 6acaffcf..35484704 100644 --- a/apps/web/app/styles/components.css +++ b/apps/web/app/styles/components.css @@ -1,5 +1,10 @@ /* Reusable UI components — atoms and patterns shared across routes. */ +:root { + /* The trend/CPV chip mono face mirrors the design mock's IBM Plex Mono, falling back to the app token. */ + --font-mono-plex: 'IBM Plex Mono', var(--font-mono); +} + /* Key facts panel — ink top rule, hairline rows, mono labels */ .facts { border-top: 1px solid var(--ink); @@ -20,7 +25,7 @@ } } .facts dt { - font: 500 10.5px/1.3 var(--font-mono); + font: 500 10.5px/1.3 var(--font-mono-plex); letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-soft); @@ -32,7 +37,7 @@ } .facts dd .sub { color: var(--ink-soft); - font: 12px/1.3 var(--font-mono); + font: 12px/1.3 var(--font-mono-plex); letter-spacing: 0.04em; display: block; margin-top: 2px; @@ -71,7 +76,7 @@ .totals .label { display: block; margin-top: var(--s-3); - font: 10px/1.2 var(--font-mono); + font: 10px/1.2 var(--font-mono-plex); letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-soft); @@ -97,7 +102,7 @@ .flag { display: inline-block; padding: 2px 8px; - font: 500 10.5px/1.5 var(--font-mono); + font: 500 10.5px/1.5 var(--font-mono-plex); letter-spacing: 0.12em; text-transform: uppercase; background: var(--accent-bg); @@ -132,7 +137,7 @@ a.flag:hover { .chip { display: inline-block; padding: 1px 8px; - font: 500 10.5px/1.6 var(--font-mono); + font: 500 10.5px/1.6 var(--font-mono-plex); letter-spacing: 0.1em; background: var(--paper-warm); color: var(--ink-mid); @@ -145,7 +150,7 @@ a.flag:hover { display: inline-flex; align-items: baseline; gap: 4px; - font-family: var(--font-mono); + font-family: var(--font-mono-plex); font-variant-numeric: tabular-nums; font-weight: 500; padding: 4px 10px; @@ -200,14 +205,14 @@ a.flag:hover { color: var(--ink); } .stat .label { - font: 10.5px/1.3 var(--font-mono); + font: 10.5px/1.3 var(--font-mono-plex); letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-soft); margin-top: var(--s-2); } .stat .sub { - font: 12px/1.4 var(--font-mono); + font: 12px/1.4 var(--font-mono-plex); color: var(--ink-soft); margin-top: var(--s-1); } @@ -229,7 +234,7 @@ a.flag:hover { white-space: nowrap; } .cap { - font: 500 10.5px/1.3 var(--font-mono); + font: 500 10.5px/1.3 var(--font-mono-plex); text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-soft); @@ -238,7 +243,7 @@ a.flag:hover { /* Inline source citation */ .source, .source-line { - font: 11px/1.4 var(--font-mono); + font: 11px/1.4 var(--font-mono-plex); letter-spacing: 0.04em; color: var(--ink-soft); margin-top: var(--s-3); @@ -257,7 +262,7 @@ a.flag:hover { flex-wrap: wrap; gap: var(--s-3); margin-top: var(--s-4); - font: 11px/1.4 var(--font-mono); + font: 11px/1.4 var(--font-mono-plex); letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-soft); @@ -359,7 +364,7 @@ a.flag:hover { text-decoration: none; margin-left: 4px; color: var(--ink-mid); - font: 500 10.5px/1.2 var(--font-mono); + font: 500 10.5px/1.2 var(--font-mono-plex); letter-spacing: 0.14em; } .paging .ctrl span { @@ -368,7 +373,7 @@ a.flag:hover { border: 1px solid var(--rule); margin-left: 4px; color: var(--ink-mid); - font: 500 10.5px/1.2 var(--font-mono); + font: 500 10.5px/1.2 var(--font-mono-plex); letter-spacing: 0.14em; } .paging .ctrl a:hover { @@ -382,7 +387,7 @@ a.flag:hover { /* Mini header for sub-sections — match list-eyebrow */ .subhead { - font: 500 11px/1 var(--font-mono); + font: 500 11px/1 var(--font-mono-plex); letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); @@ -435,7 +440,7 @@ a.flag:hover { .linklist li .sub { display: block; color: var(--ink-soft); - font: 11px/1.4 var(--font-mono); + font: 11px/1.4 var(--font-mono-plex); letter-spacing: 0.04em; margin-top: 2px; text-transform: uppercase; @@ -456,7 +461,7 @@ a.flag:hover { background: transparent; } .owner-card .role { - font: 500 10.5px/1.3 var(--font-mono); + font: 500 10.5px/1.3 var(--font-mono-plex); letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-soft); @@ -583,6 +588,512 @@ tbody td, opacity: 0.7; } +/* ===== trends-dashboard ===== + Static layout/typography chrome for /trends (routes/trends.tsx + components/TrendComboChart.tsx), + moved out of inline `style=` to keep the route CSP-clean (style-src; see docs/review-accessibility). + Only genuinely JS-computed values (bar widths, active-state colours, SVG fill/stroke var()s) stay + inline. The mono face mirrors the design mock's IBM Plex Mono, falling back to the app token. */ +/* vertical layout (design): a single column — full-width chart → year table → contracts grid */ +.trend-grid { + display: flex; + flex-direction: column; + gap: 20px; +} + +.trend-col { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +.trend-panel { + display: flex; + flex-direction: column; + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 4px; +} + +.trend-chart-panel { + padding: 14px 16px 10px; +} + +.trend-years-panel { + padding: 12px 16px; +} + +.trend-rail { + padding: 12px 0 0; +} + +/* KPI strip */ +/* combined header: title + lede on the left, KPIs inline on the right, one bordered row (design) */ +.trend-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin: 0 0 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--rule); +} + +.trend-header-main { + min-width: 0; +} + +.trend-header-kicker { + font: 600 10px/1 var(--font-mono-plex); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent); +} + +.trend-header-title { + margin: 8px 0 0; + font-family: var(--font-serif); + font-size: 30px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1; + color: var(--ink); +} + +.trend-header-title em { + font-style: italic; + color: var(--accent); +} + +.trend-header-lede { + margin: 7px 0 0; + max-width: 460px; + font-size: 12.5px; + line-height: 1.4; + color: var(--ink-mid); +} + +.trend-header-kpis { + display: flex; + flex: none; +} + +.trend-hk { + padding: 0 22px; + border-left: 1px solid var(--rule); +} + +.trend-hk:last-child { + padding-right: 0; +} + +.trend-hk-v { + font: 600 25px/1 var(--font-mono-plex); + color: var(--ink); +} + +.trend-hk-v--accent { + color: var(--accent); +} + +.trend-hk-l { + margin-top: 4px; + font: 500 9px/1 var(--font-mono-plex); + letter-spacing: 0.14em; + color: var(--ink-soft); +} + +@media (max-width: 760px) { + .trend-header { + flex-direction: column; + align-items: stretch; + gap: 14px; + } + + .trend-header-kpis { + flex-wrap: wrap; + } + + .trend-hk:first-child { + padding-left: 0; + border-left: none; + } +} + +/* filter bar */ +.trend-filterbar { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding: 10px 14px; + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 4px; + margin-bottom: 14px; +} + +.trend-steps { + display: flex; +} + +.trend-step { + font: 500 10px/1 var(--font-mono-plex); + letter-spacing: 0.08em; + padding: 7px 11px; + cursor: pointer; +} + +.trend-filter-form { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +/* both filters are identical bordered chips: uppercase mono caption + borderless select, + so they read as one consistent control row with the step toggle (matches the design mock). */ +.trend-filter-label { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: 3px; +} + +.trend-filter-label > span { + font: 500 8.5px/1 var(--font-mono-plex); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.trend-filter-label select { + font: 500 11px/1 var(--font-mono-plex); + color: var(--ink); + background: transparent; + border: none; + cursor: pointer; + padding: 0; + max-width: 16ch; +} + +.trend-filter-label select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.trend-year-chip { + display: flex; + align-items: center; + gap: 6px; + font: 500 10px/1 var(--font-mono-plex); + padding: 7px 10px; + background: var(--ink); + color: var(--paper); + border: none; + border-radius: 3px; + cursor: pointer; +} + +.trend-total { + margin-left: auto; + font: 400 11px/1 var(--font-mono-plex); + color: var(--ink-mid); +} + +.trend-total b { + color: var(--ink); +} + +/* panel headers + chart legend */ +.trend-panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.trend-panel-title { + margin: 0; + font-family: var(--font-serif, Georgia, serif); + font-size: 18px; + font-weight: 600; +} + +.trend-panel-title em { + font-style: italic; + color: var(--accent); +} + +.trend-hint { + font: 400 10px/1 var(--font-mono-plex); + color: var(--ink-soft); +} + +.trend-legend { + display: flex; + align-items: center; + gap: 11px; + font: 400 9.5px/1 var(--font-mono-plex); + color: var(--ink-soft); + flex-wrap: wrap; +} + +.trend-legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.trend-legend-meta { + color: var(--ink-mid); +} + +.trend-sw-box { + width: 9px; + height: 9px; + background: rgb(94 124 139 / 0.55); /* slate — matches the count bars */ + display: inline-block; + border-radius: 1px; +} + +.trend-sw-dashed { + width: 14px; + border-top: 1.6px dashed var(--accent); + display: inline-block; +} + +.trend-sw-line { + width: 14px; + height: 2.4px; + background: var(--ink); + display: inline-block; + border-radius: 2px; +} + +.trend-chart-body { + margin-top: 10px; + /* full-width chart in the vertical layout — give it real height (design ≈ 380px) */ + min-height: 380px; +} + +.trend-chart-empty { + padding: 24px 0; +} + +.trend-callout-p { + margin: 0; +} + +/* year table */ +.trend-years { + width: 100%; + border-collapse: collapse; +} + +.trend-years thead tr { + font: 500 8.5px/1 var(--font-mono-plex); + letter-spacing: 0.1em; + color: var(--ink-soft); +} + +.trend-years thead th { + padding: 7px 8px 6px; + text-align: right; + border-bottom: 1px solid var(--ink); +} + +.trend-years thead th:first-child { + text-align: left; + padding-left: 0; +} + +.trend-years thead th:last-child { + padding-right: 0; +} + +.trend-years tbody tr { + border-bottom: 1px solid var(--rule-soft); +} + +.trend-years td.c-year { + padding: 6px 8px 6px 0; +} + +.trend-year-btn { + font: 600 12px/1 var(--font-mono-plex); + color: var(--accent); + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.trend-years td.c-value { + text-align: right; + padding: 6px 8px; + font: 600 12px/1 var(--font-mono-plex); +} + +.trend-years td.c-num { + text-align: right; + padding: 6px 8px; + font: 400 11px/1 var(--font-mono-plex); + color: var(--ink-mid); +} + +.trend-years td.c-share { + text-align: right; + padding: 6px 8px; + font: 400 11px/1 var(--font-mono-plex); + color: var(--ink-soft); +} + +.trend-years td.c-yoy { + padding: 6px 0 6px 8px; +} + +.trend-yoy-cell { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.trend-yoy-bar { + height: 5px; + border-radius: 3px; +} + +.trend-yoy-pct { + font: 500 10.5px/1 var(--font-mono-plex); + min-width: 52px; + text-align: right; +} + +.trend-years-empty { + margin-top: 10px; +} + +/* right rail: newest contracts */ +.trend-rail-head { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 0 16px 10px; + border-bottom: 1px solid var(--rule); +} + +.trend-rail-rss { + font: 500 9px/1 var(--font-mono-plex); + letter-spacing: 0.1em; + color: var(--accent); +} + +.trend-rail-submeta { + padding: 6px 16px 8px; + font: 400 10px/1.4 var(--font-mono-plex); + color: var(--ink-soft); +} + +/* contracts as a full-width responsive card grid (design), not a narrow side list */ +.trend-rail-list { + list-style: none; + margin: 0; + padding: 8px 16px 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 12px; +} + +.trend-rail-item { + padding: 11px 13px; + border: 1px solid var(--rule-soft); + border-radius: 4px; +} + +.trend-rail-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.trend-rail-date { + font: 500 9.5px/1 var(--font-mono-plex); + color: var(--ink-soft); +} + +.trend-rail-val { + font: 600 11px/1 var(--font-mono-plex); + color: var(--ink); + white-space: nowrap; +} + +.trend-rail-buyer { + margin-top: 5px; + font-size: 11.5px; + font-weight: 500; +} + +.trend-rail-seller { + margin-top: 2px; + font-size: 11px; + color: var(--ink-mid); +} + +.trend-rail-seller-arrow { + color: var(--accent); +} + +/* compact metric tags replacing the verbose subject paragraph — fits more rows */ +.trend-rail-tags { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + margin-top: 5px; +} + +.trend-rail-sector { + font: 500 8.5px/1 var(--font-mono-plex); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.trend-rail-eu { + font: 600 8px/1 var(--font-mono-plex); + letter-spacing: 0.06em; + color: var(--accent); + border: 1px solid color-mix(in oklch, var(--accent) 40%, transparent); + border-radius: 2px; + padding: 2px 4px; +} + +.trend-rail-more { + margin-left: auto; + font: 500 8.5px/1 var(--font-mono-plex); + letter-spacing: 0.06em; + color: var(--ink-mid); +} + +.trend-rail-empty { + padding: 16px; +} + +.trend-rail-end { + grid-column: 1 / -1; + padding: 12px 16px 16px; + font: 400 10px/1 var(--font-mono-plex); + color: var(--ink-soft); + text-align: center; +} + /* Top route-progress bar. Was an inline style toggling transform/opacity on the navigation state; the two states now live in CSS, switched via [data-busy]. */ .route-progress { @@ -607,6 +1118,361 @@ tbody td, transform 1.2s ease-out, opacity 0.1s ease; } + +/* ===== end trends-dashboard ===== */ + +/* ===== chart-fullscreen ===== */ +/* .fs-btn and .trend-fs-btn are both inline-flex fullscreen-toggle buttons sharing the same face, + colors, border, and interaction states — only spacing/padding and .fs-btn's uppercase differ. */ +.fs-btn, +.trend-fs-btn { + display: inline-flex; + align-items: center; + gap: 5px; + font: 500 9px/1 var(--font-mono-plex); + letter-spacing: 0.08em; + color: var(--ink-mid); + background: var(--paper); + border: 1px solid var(--rule); + border-radius: 3px; + cursor: pointer; +} + +.fs-btn { + margin-left: 8px; + padding: 3px 8px; + text-transform: uppercase; + flex: none; +} + +.trend-fs-btn { + margin-left: 4px; + padding: 5px 9px; +} + +.fs-btn:hover, +.trend-fs-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.fs-btn:focus-visible, +.trend-fs-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* ===== end chart-fullscreen ===== */ + +/* ===== trends chart fullscreen modal ===== */ +.trend-fs-backdrop { + position: fixed; + inset: 0; + background: color-mix(in oklch, var(--ink) 50%, transparent); + backdrop-filter: blur(2px); + z-index: 55; +} + +.trend-chart-panel--full { + position: fixed; + inset: 28px; + z-index: 60; + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 6px; + padding: 26px 30px 18px; + box-shadow: 0 40px 100px color-mix(in oklch, var(--ink) 40%, transparent); + display: flex; + flex-direction: column; +} + +.trend-chart-panel--full .trend-chart-body { + flex: 1; + min-height: 0; +} + +.trend-fs-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + padding-bottom: 18px; + margin-bottom: 6px; + border-bottom: 1px solid var(--rule); + flex: none; +} + +.trend-fs-kicker { + font: 600 10px/1 var(--font-mono-plex); + letter-spacing: 0.2em; + color: var(--accent); +} + +.trend-fs-title { + margin-top: 9px; + margin-bottom: 0; + font-family: var(--font-serif); + font-size: 30px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1; +} + +.trend-fs-title em { + font-style: italic; + color: var(--accent); +} + +.trend-fs-meta { + margin-top: 8px; + font: 400 11px/1 var(--font-mono-plex); + color: var(--ink-mid); +} + +.trend-fs-head-aside { + display: flex; + align-items: center; + gap: 26px; + flex: none; +} + +.trend-fs-kpi { + text-align: right; +} + +.trend-fs-kpi-v { + font: 600 26px/1 var(--font-mono-plex); + color: var(--ink); +} + +.trend-fs-kpi-v--accent { + color: var(--accent); +} + +.trend-fs-kpi-l { + margin-top: 5px; + font: 500 8.5px/1 var(--font-mono-plex); + letter-spacing: 0.14em; + color: var(--ink-soft); +} + +.trend-fs-close { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 14px; + font: 500 10px/1 var(--font-mono-plex); + letter-spacing: 0.08em; + background: var(--ink); + color: var(--paper); + border: none; + border-radius: 3px; + cursor: pointer; +} + +@media (max-width: 720px) { + .trend-chart-panel--full { + inset: 10px; + padding: 16px; + } + + .trend-fs-head-aside { + gap: 14px; + } + + .trend-fs-title { + font-size: 22px; + } +} + +/* ===== end trends chart fullscreen modal ===== */ + +/* ===== metric-info popover ===== */ +.metric-info { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +/* ≥24px hit area via padding, pulled back with negative margin so the inline layout doesn't shift. */ +.metric-info-btn { + position: relative; /* anchors the ::after touch-target extension (pointer: coarse) */ + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: -6px -5px -6px 1px; + padding: 0; + border: none; + background: transparent; + color: var(--ink-soft); + cursor: help; + flex: none; + -webkit-tap-highlight-color: transparent; +} + +.metric-info-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + line-height: 1; +} + +.metric-info-btn:hover .metric-info-glyph, +.metric-info:focus-within .metric-info-btn .metric-info-glyph, +.metric-info.is-open .metric-info-btn .metric-info-glyph { + color: var(--accent); +} + +.metric-info-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -3px; + border-radius: 50%; +} + +.metric-info-pop { + position: absolute; + z-index: 40; + top: calc(100% + 8px); + left: 0; + width: 320px; + /* never wider than the viewport — the JS shift in MetricInfo.tsx handles the horizontal clamp */ + max-width: min(320px, calc(100vw - 16px)); + /* the popover often sits inside a `th` (white-space: nowrap); reset inherited wrapping so the + title/summary/readout always wrap inside the card instead of overflowing it */ + white-space: normal; + overflow-wrap: anywhere; + padding: 12px 14px; + background: var(--ink); + color: var(--paper); + border-radius: 5px; + box-shadow: 0 14px 34px color-mix(in oklch, var(--ink) 38%, transparent); + display: flex; + flex-direction: column; + gap: 7px; + text-align: left; + text-transform: none; + letter-spacing: normal; + opacity: 0; + visibility: hidden; + transform: translateY(-3px); + transition: + opacity 0.14s ease, + transform 0.14s ease, + visibility 0.14s; + pointer-events: none; +} + +.metric-info-pop.is-end { + left: auto; + right: 0; +} + +.metric-info:hover .metric-info-pop, +.metric-info:focus-within .metric-info-pop, +.metric-info.is-open .metric-info-pop { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.metric-info-title { + font: 600 9.5px/1 var(--font-mono-plex); + letter-spacing: 0.12em; + text-transform: uppercase; + color: color-mix(in oklch, var(--paper) 70%, var(--ink)); +} + +.metric-info-summary { + font-size: 12px; + line-height: 1.5; + color: var(--paper); +} + +.metric-info-readout { + margin-top: 1px; + padding-top: 7px; + border-top: 1px solid color-mix(in oklch, var(--paper) 22%, var(--ink)); + font: 500 11px/1.45 var(--font-mono-plex); + color: color-mix(in oklch, var(--accent) 70%, var(--paper)); +} + +@media (max-width: 600px) { + .metric-info-pop { + width: 264px; + } +} + +@media (pointer: coarse) { + /* invisible hit-area extension: 24px ⓘ glyph → ≥44px touch target */ + .metric-info-btn::after { + content: ''; + position: absolute; + inset: -10px; + } +} + +/* ===== end metric-info popover ===== */ + +/* ===== list-search (in-page search for /authorities, /companies, /contracts) ===== */ +.list-search { + display: flex; + gap: 8px; + margin-bottom: 14px; +} + +.list-search-field { + display: flex; + align-items: center; + flex: 1; + gap: 8px; + padding: 0 12px; + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: 4px; +} + +.list-search-field:focus-within { + border-color: var(--accent); + outline: 2px solid color-mix(in oklch, var(--accent) 28%, transparent); +} + +.list-search-icon { + color: var(--ink-soft); + font-size: 15px; +} + +.list-search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + padding: 10px 0; + font-size: 14px; + color: var(--ink); +} + +.list-search-btn { + flex: none; + padding: 0 16px; + font: 500 12px/1 var(--font-mono-plex); + letter-spacing: 0.04em; + background: var(--ink); + color: var(--paper); + border: 1px solid var(--ink); + border-radius: 4px; + cursor: pointer; +} + +.list-search-btn:hover { + background: var(--accent); + border-color: var(--accent); +} + /* EU-benchmark indicator block (authority page „Конкуренция"): identical shape for both indicators - hero share, meter with the two EU thresholds as hairline ticks, verdict and counts in text. The meter is decorative; the fill wears the accent only over the „high" diff --git a/apps/web/app/styles/pages.css b/apps/web/app/styles/pages.css index fd6341d3..580a535d 100644 --- a/apps/web/app/styles/pages.css +++ b/apps/web/app/styles/pages.css @@ -58,15 +58,14 @@ color: var(--text); } -/* ── Contract page ───────────────────────────────────────────────────────── */ - -/* Risk Indicators */ +/* Risk Indicators — used by RiskIndicators.tsx on the contract page */ .risk-indicators { margin: var(--s-6) 0; padding: var(--s-5); background: var(--warning-bg); border-left: 3px solid var(--warning); } + .risk-title { margin: 0 0 var(--s-3); font: 500 13px/1.2 var(--font-mono); @@ -77,9 +76,11 @@ align-items: center; gap: var(--s-2); } + .risk-title svg { flex: none; } + .risk-list { margin: 0; padding: 0; @@ -87,11 +88,13 @@ font: 400 14px/1.5 var(--font-sans); color: var(--ink); } + .risk-list li { margin: 0 0 var(--s-2); padding-left: 20px; position: relative; } + .risk-list li::before { content: '•'; position: absolute; @@ -99,6 +102,7 @@ color: var(--warning); font-weight: bold; } + .risk-list li:last-child { margin-bottom: 0; } @@ -534,6 +538,162 @@ height: auto; display: block; } + +/* Hydrated force view: the canvas is a pan/zoom surface; nodes are draggable. */ +.net-canvas { + position: relative; +} + +.network-svg.is-interactive { + cursor: grab; + touch-action: none; /* let d3-zoom own touch gestures instead of the page scrolling */ +} + +.network-svg.is-interactive:active { + cursor: grabbing; +} + +.network-svg.is-interactive a[data-draggable='1'] { + cursor: grab; +} + +/* Zoom controls — overlaid top-right of the canvas, client-only (rendered after hydration). */ +.net-zoom { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.net-zoom button { + width: 30px; + height: 30px; + font: 600 16px var(--font-mono, monospace); + line-height: 1; + color: var(--ink, #222); + background: var(--paper, #fff); + border: 1px solid var(--rule, #d8d6cf); + border-radius: 6px; + cursor: pointer; +} + +.net-zoom button:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Graph + side Information Card layout (mirrors /map). Card wraps under the graph on narrow screens. */ +.net-explore { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; +} + +.net-explore .net-canvas { + flex: 1 1 460px; + min-width: 0; +} + +/* Full-screen: the widget fills the viewport on a paper background; the graph grows to use the height, + the card sits beside it, and the controls/legend stay usable. */ +.net-graph:fullscreen { + background: var(--paper); + padding: 24px; + overflow: auto; +} + +.net-graph:fullscreen .net-explore { + min-height: calc(100vh - 160px); +} + +.net-graph:fullscreen .net-canvas { + display: flex; + align-items: center; + justify-content: center; +} + +.net-graph:fullscreen .network-svg { + max-height: calc(100vh - 180px); +} + +.net-card { + flex: 0 0 240px; + align-self: stretch; + border: 1px solid var(--rule, #d8d6cf); + border-radius: 8px; + padding: 16px 18px; + background: color-mix(in oklch, var(--ink) 3%, var(--paper)); +} + +.net-card-title { + margin: 0; + font-size: 1.05rem; +} + +.net-card-sub { + margin: 2px 0 12px; + font: 12px var(--font-mono, monospace); +} + +.net-card-stats { + margin: 0 0 12px; + display: grid; + gap: 10px; +} + +.net-card-stats div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + border-bottom: 1px dotted var(--rule, #d8d6cf); + padding-bottom: 6px; +} + +.net-card-stats dt { + font: 12px var(--font-mono, monospace); + color: var(--ink-soft, #555); +} + +.net-card-stats dd { + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.net-card-actions { + margin: 0; +} + +.net-card-hint { + margin: 0; + font-size: 0.9rem; +} + +/* Hover-to-explore emphasis: dim everything but the focused node + its neighbours; lift the focus. */ +.network-svg.is-hovering .is-dim { + opacity: 0.18; +} + +@media (prefers-reduced-motion: no-preference) { + .network-svg .node, + .network-svg .edge, + .network-svg .node-label, + .network-svg .edge-label, + .network-svg g[class], + .network-svg a { + transition: opacity 0.12s ease; + } +} + +.network-svg .is-focus .node { + stroke: var(--accent); + stroke-width: 2.5; +} .network-svg .edge { stroke: #d8d6cf; } @@ -545,6 +705,119 @@ fill: var(--ink, #111); font: 11px var(--font-mono, monospace); } + +/* Per-edge value label, rotated in the component to lie along the edge. A thick white halo + (paint-order: stroke) keeps the number readable where it crosses edges/nodes. Toggle hides it. */ +.network-svg .edge-label { + fill: var(--ink, #222); + font: 600 10px var(--font-mono, monospace); + paint-order: stroke; + stroke: #fff; + stroke-width: 3.25px; + stroke-linejoin: round; + pointer-events: none; +} + +/* Non-centre nodes are anchors to a profile page → show the click affordance and an accent ring. */ +.network-svg a { + cursor: pointer; +} + +/* With JS (issue #142) nodes are also draggable; show the grab affordance. Sighted-only — the + connections table stays the keyboard/AT path, so this is purely a pointer cue. */ +.network-svg a[data-draggable='1'] { + cursor: grab; +} + +.network-svg a[data-draggable='1']:active { + cursor: grabbing; +} + +.network-svg a:hover .node, +.network-svg a:focus-visible .node { + stroke: var(--accent); + stroke-width: 2.5; +} + +.network-svg a:focus-visible { + outline: none; +} + +/* Edge-label toggle (pure CSS, no JS): unchecked hides every .edge-label. */ +/* Controls row above the graph: the edge-value toggle plus, once you've browsed to another node, + the Open / Reset actions. Wraps on narrow screens. */ +.net-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + margin: 0 0 8px; +} + +.net-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font: 12px var(--font-mono, monospace); + color: var(--ink-soft, #555); + cursor: pointer; + user-select: none; +} + +.net-toggle input { + accent-color: var(--accent); +} + +.net-graph:has(.net-toggle input:not(:checked)) .edge-label { + display: none; +} + +/* Browse actions — appear only after a client-side re-centre (no JS = no recentre = hidden). */ +.net-actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.net-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font: 600 12px var(--font-mono, monospace); + color: #fff; + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 4px; + cursor: pointer; + text-decoration: none; +} + +.net-btn:hover { + filter: brightness(0.94); +} + +.net-btn-ghost { + color: var(--ink, #222); + background: transparent; + border-color: var(--rule, #d8d6cf); +} + +.net-loading { + font: 12px var(--font-mono, monospace); + color: var(--ink-soft, #555); +} + +.net-hint { + font: 12px var(--font-mono, monospace); + color: var(--ink-soft, #888); +} + +.net-error { + font: 12px var(--font-mono, monospace); + color: var(--accent); +} .net-legend { display: flex; flex-wrap: wrap; @@ -571,6 +844,12 @@ .net-legend .key.authority { border-radius: 50%; } + +.net-caption { + margin: 10px 0 0; + text-align: center; + font-size: 12px; +} .net-legend .key.center { background: var(--accent); } @@ -625,6 +904,20 @@ fill: var(--ink-soft, #555); } +/* Regional choropleth (/map): static styling only; the per-region tier fill is computed inline. */ +/* Map + Information Card side by side; the card wraps under the map on narrow screens. */ +.map-layout { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; +} + +.map-layout .map-wrap { + flex: 1 1 460px; + min-width: 0; +} + /* Regional choropleth (/map): static styling only; the per-region tier fill is computed inline. */ .map-wrap svg { width: 100%; @@ -637,6 +930,96 @@ stroke: #f7f7f4; stroke-width: 1; } + +/* Hover highlight for the focused region (mouse-driven; the table is the keyboard/AT path). */ +.map-wrap path.region { + transition: fill 0.1s ease; +} + +.map-wrap path.is-active { + stroke: var(--accent); + stroke-width: 2; +} + +/* The Information Card beside the map. */ +.map-card { + flex: 0 0 250px; + align-self: stretch; + border: 1px solid var(--rule, #d8d6cf); + border-radius: 8px; + padding: 16px 18px; + background: color-mix(in oklch, var(--ink) 3%, var(--paper)); +} + +/* Grouping toggle, kept inside the card so all map controls sit together. */ +.map-toggle { + display: flex; + gap: 0; + margin: 0 0 14px; + border: 1px solid var(--rule, #d8d6cf); + border-radius: 6px; + overflow: hidden; +} + +.map-toggle button { + flex: 1; + padding: 6px 8px; + font: 12px var(--font-mono, monospace); + color: var(--ink-soft, #555); + background: var(--paper, #fff); + border: 0; + cursor: pointer; +} + +.map-toggle button + button { + border-left: 1px solid var(--rule, #d8d6cf); +} + +.map-toggle button.is-on { + color: #fff; + background: var(--accent); +} + +.map-card-title { + margin: 0; + font-size: 1.05rem; +} + +.map-card-sub { + margin: 2px 0 12px; + font: 12px var(--font-mono, monospace); +} + +.map-card-stats { + margin: 0; + display: grid; + gap: 10px; +} + +.map-card-stats div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + border-bottom: 1px dotted var(--rule, #d8d6cf); + padding-bottom: 6px; +} + +.map-card-stats dt { + font: 12px var(--font-mono, monospace); + color: var(--ink-soft, #555); +} + +.map-card-stats dd { + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.map-card-hint { + margin: 0; + font-size: 0.9rem; +} .map-legend { display: flex; align-items: center; @@ -651,3 +1034,2691 @@ height: 12px; border-radius: 2px; } + +/* ===== analyze-landing ===== + The /analytics hub: an editorial masthead + five EQUAL full-width hero cards (one per analysis), + each pairing two real KPI figures with a decorative, aria-hidden thumbnail. Cards are real anchors + (keyboard-focusable, visible focus ring); the 380px thumbnail pane collapses under ~720px. */ +.analyze-landing { + max-width: 1120px; + margin: 0 auto; +} + +.az-masthead { + margin: 0 0 var(--s-7); +} + +.az-kicker { + margin: 0 0 var(--s-4); + font: 10px/1.3 var(--font-mono); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent); +} + +.az-title { + margin: 0 0 var(--s-4); + max-width: 18ch; + font: 600 40px/1.1 var(--font-serif); + letter-spacing: -0.01em; + color: var(--ink); +} + +.az-title em { + font-style: italic; + color: var(--accent); +} + +.az-lede { + margin: 0; + max-width: 600px; + font: 14px/1.55 var(--font-sans); + color: var(--ink-mid); +} + +.az-cards { + display: flex; + flex-direction: column; + gap: 18px; +} + +/* One hero card — left editorial pane + right thumbnail pane. */ +.az-card { + position: relative; + display: grid; + grid-template-columns: 1fr 380px; + min-height: 208px; + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 6px; + box-shadow: 0 1px 2px oklch(18% 0.012 70 / 0.04); + color: inherit; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + border-color 0.15s ease; +} + +.az-card:hover { + transform: translateY(-3px); + box-shadow: 0 10px 24px oklch(18% 0.012 70 / 0.1); + border-color: var(--ink-soft); +} + +/* stretched link: the whole card is clickable, but the stat ⓘ buttons sit above it (z-index) so they + stay independently operable. The focus ring renders on the card via the link's stretched ::after. */ +.az-card-stretch { + text-decoration: none; + color: inherit; +} + +.az-card-stretch::after { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + border-radius: 6px; +} + +.az-card-stretch:focus-visible::after { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.az-card .metric-info { + position: relative; + z-index: 1; +} + +@media (prefers-reduced-motion: reduce) { + .az-card { + transition: none; + } + + .az-card:hover { + transform: none; + } +} + +.az-card-main { + display: flex; + flex-direction: column; + padding: 26px 30px; + min-width: 0; +} + +.az-card-eyebrow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--s-2) var(--s-3); + margin: 0 0 var(--s-3); +} + +.az-card-index { + font: 600 10px/1.3 var(--font-mono); + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--accent); +} + +.az-card-cat { + font: 500 9px/1.3 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.az-card-title { + margin: 0 0 var(--s-2); + font: 600 25px/1.2 var(--font-serif); + color: var(--ink); +} + +.az-card-title em { + font-style: italic; + color: var(--accent); +} + +.az-card-title em.az-em-slate { + color: var(--slate); +} + +.az-card-desc { + margin: 0; + max-width: 420px; + font: 13px/1.5 var(--font-sans); + color: var(--ink-mid); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.az-card-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--s-4); + margin-top: auto; + padding-top: var(--s-5); +} + +.az-card-stats { + display: flex; + gap: var(--s-6); + margin: 0; +} + +.az-card-stats div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.az-stat-num { + margin: 0; + font: 600 19px/1.1 var(--font-mono); + color: var(--ink); +} + +.az-stat-num--accent { + color: var(--accent); +} + +.az-stat-label { + display: inline-flex; + align-items: center; + font: 500 8px/1.3 var(--font-mono); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.az-stat-hint { + max-width: 22ch; + margin: 1px 0 0; + font: 400 9px/1.3 var(--font-mono); + letter-spacing: 0; + text-transform: none; + color: var(--ink-soft); +} + +.az-card-cta { + flex: none; + font: 600 11px/1.3 var(--font-mono); + letter-spacing: 0.04em; + color: var(--accent); + white-space: nowrap; +} + +/* Right pane — a touch deeper than the card, with a decorative thumbnail. */ +.az-card-thumb { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: var(--paper-deep); + border-left: 1px solid var(--rule); + /* the card no longer clips overflow (so the ⓘ popover can escape), so round the thumb's own corners */ + border-radius: 0 6px 6px 0; +} + +.az-thumb { + display: block; + width: 100%; + max-width: 320px; + height: auto; +} + +.az-fill-ink { + fill: var(--ink); +} + +.az-fill-accent { + fill: var(--accent); +} + +.az-fill-slate { + fill: var(--slate); +} + +.az-fill-tan { + fill: var(--tan); +} + +.az-fill-rule { + fill: var(--rule); +} + +.az-thumb-soft { + opacity: 0.55; +} + +.az-thumb-faint { + opacity: 0.14; +} + +.az-stroke-ink, +.az-stroke-accent { + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.az-stroke-ink { + stroke: var(--ink); +} + +.az-stroke-accent { + stroke: var(--accent); +} + +.az-thumb-dash { + stroke-dasharray: 5 5; +} + +@media (max-width: 720px) { + .az-title { + font-size: 32px; + } + + .az-card { + grid-template-columns: 1fr; + } + + .az-card-thumb { + border-left: 0; + border-top: 1px solid var(--rule); + padding: 16px 20px; + } + + .az-thumb { + max-width: 240px; + } +} + +@media (max-width: 460px) { + .az-card-foot { + flex-direction: column; + align-items: flex-start; + gap: var(--s-3); + } +} + +/* ===== end analyze-landing ===== */ + +/* ===== price-anomaly („Раздути спрямо сходни") ===== + The CPV-cohort outlier dashboard: a masthead with 3 method KPIs, a 2-col top row (V2 cohort browse + + V3 distribution strips, both selecting a cohort via a real ?cohort= link) and a full-width grid of + flagged-contract scorecards faceted by the selection. Colours/typography mirror the Claude-Design + mock; every figure is real and the accent-red caveat never asserts wrongdoing. */ +.pa-page { + max-width: 1340px; + margin: 0 auto; +} + +.pa-mast { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--s-7); + flex-wrap: wrap; + margin: 0 0 var(--s-6); +} + +.pa-mast-main { + min-width: 0; + flex: 1 1 460px; +} + +.pa-mast-kicker { + margin: 0 0 var(--s-3); + font: 600 10px/1 var(--font-mono); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent); +} + +.pa-mast-title { + margin: 0; + font: 600 36px/1.02 var(--font-serif); + letter-spacing: -0.018em; + color: var(--ink); +} + +.pa-mast-title em { + font-style: italic; + color: var(--accent); +} + +.pa-mast-lede { + margin: var(--s-3) 0 0; + max-width: 560px; + font: 13px/1.5 var(--font-sans); + color: var(--ink-mid); +} + +.pa-mast-kpis { + display: flex; + flex: none; + margin: 0; +} + +.pa-hk { + padding: 0 22px; + border-left: 1px solid var(--rule); +} + +.pa-hk:first-child { + padding-left: 0; + border-left: 0; +} + +.pa-hk-v { + margin: 0; + font: 600 24px/1 var(--font-mono); + color: var(--ink); +} + +.pa-hk-v.accent { + color: var(--accent); +} + +.pa-hk-l { + display: inline-flex; + align-items: center; + margin-top: var(--s-2); + font: 500 9px/1 var(--font-mono); + letter-spacing: 0.12em; + color: var(--ink-soft); +} + +/* shared panel chrome */ +.pa-panel { + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 5px; + min-width: 0; +} + +.pa-panel-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--s-3); + padding: 15px 20px 13px; + border-bottom: 1px solid var(--rule); +} + +.pa-panel-head--col { + flex-direction: column; + align-items: flex-start; + gap: var(--s-2); +} + +.pa-panel-head--wrap { + flex-wrap: wrap; +} + +.pa-kicker { + font: 600 9px/1 var(--font-mono); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent); +} + +.pa-panel-title { + margin: var(--s-2) 0 0; + font: 600 18px/1 var(--font-serif); + color: var(--ink); +} + +.pa-panel-title em { + font-style: italic; + color: var(--accent); +} + +/* segmented sort tabs */ +.pa-seg { + display: flex; + flex: none; + border: 1px solid var(--rule); + border-radius: 3px; + overflow: hidden; +} + +.pa-seg a { + padding: 7px 9px; + font: 500 9px/1 var(--font-mono); + letter-spacing: 0.03em; + text-decoration: none; + color: var(--ink-mid); + background: var(--paper-raised); + border-left: 1px solid var(--rule); +} + +.pa-seg a:first-child { + border-left: 0; +} + +.pa-seg a[aria-current='true'] { + background: var(--ink); + color: var(--paper); +} + +/* cohort browse — ONE full-width table: stats + the inline distribution strip per row */ +.pa-browse { + margin-bottom: 14px; +} + +.pa-browse-headrow, +.pa-browse-row { + display: grid; + grid-template-columns: 52px minmax(110px, 1.3fr) 96px 60px 52px 124px minmax(190px, 1.7fr); + gap: 9px; + align-items: center; +} + +.pa-browse-headrow { + padding: 9px 20px 7px; + border-bottom: 1px solid var(--ink); + font: 500 7.5px/1.2 var(--font-mono); + letter-spacing: 0.06em; + color: var(--ink-soft); +} + +/* a header cell that carries an inline ⓘ — keep the glyph on the label's baseline, never wrap */ +.pa-th { + display: flex; + align-items: center; + gap: 1px; + min-width: 0; +} + +.pa-th-r { + justify-content: flex-end; +} + +.pa-r { + text-align: right; +} + +.pa-browse-list { + list-style: none; + margin: 0; + padding: 0; +} + +.pa-browse-row { + padding: 9px 20px; + border-bottom: 1px solid var(--rule-soft); + border-left: 2px solid transparent; + text-decoration: none; + color: var(--ink); +} + +.pa-browse-row:hover { + background: var(--accent-bg); +} + +.pa-browse-row.is-on { + background: var(--accent-bg); + border-left-color: var(--accent); +} + +.pa-browse-code { + font: 600 10px/1 var(--font-mono); + color: var(--ink-soft); +} + +.pa-browse-row.is-on .pa-browse-code { + color: var(--accent); +} + +.pa-browse-name { + font: 400 11.5px/1.3 var(--font-sans); +} + +.pa-browse-row.is-on .pa-browse-name { + font-weight: 600; +} + +.pa-browse-med { + font: 600 10.5px/1 var(--font-mono); + white-space: nowrap; +} + +.pa-browse-n { + font: 400 10px/1 var(--font-mono); + color: var(--ink-mid); + white-space: nowrap; +} + +.pa-browse-out { + font: 600 10px/1 var(--font-mono); + color: var(--accent); + white-space: nowrap; +} + +.pa-browse-share { + display: flex; + align-items: center; + gap: 6px; +} + +.pa-share-track { + flex: 1; + height: 6px; + background: var(--rule-soft); + border-radius: 4px; + overflow: hidden; +} + +.pa-share-fill { + display: block; + height: 100%; + background: var(--accent); +} + +.pa-share-pct { + width: 26px; + text-align: right; + font: 600 9px/1 var(--font-mono); + color: var(--ink); +} + +/* the inline distribution strip, rightmost cell of each browse row */ +.pa-browse-strip { + min-width: 0; +} + +.pa-strip { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.pa-strip-axis { + stroke: var(--rule-soft); + stroke-width: 1; +} + +.pa-strip-ticktext { + font-family: var(--font-mono); + font-size: 8.5px; + fill: var(--ink-soft); +} + +.pa-strip-med { + stroke: var(--accent); + stroke-width: 1.6; +} + +.pa-strip-med.is-dashed { + stroke-width: 1.4; + stroke-dasharray: 3 2; +} + +.pa-dot { + fill: var(--ink); + fill-opacity: 0.4; +} + +.pa-dot.is-big { + fill: var(--accent); + fill-opacity: 0.95; +} + +.pa-browse-legend { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 20px 14px; + border-top: 1px solid var(--rule-soft); + font: 400 9.5px/1 var(--font-mono); + color: var(--ink-mid); +} + +.pa-legend-item { + display: flex; + align-items: center; + gap: 5px; +} + +.pa-legend-med { + width: 14px; + height: 2px; + background: var(--accent); +} + +.pa-legend-big { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--accent); +} + +.pa-browse-selcount { + margin-left: auto; +} + +/* V4 — flagged-contract scorecards */ +.pa-scorecards { + overflow: hidden; +} + +.pa-filter { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.pa-filter-label { + font: 500 8.5px/1 var(--font-mono); + letter-spacing: 0.1em; + color: var(--ink-soft); +} + +.pa-filter-all { + font: 400 10px/1 var(--font-mono); + color: var(--ink-mid); +} + +.pa-chip { + display: flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 5px 8px; + font: 500 9.5px/1.2 var(--font-mono); + text-decoration: none; + border: 1px solid var(--accent); + border-radius: 3px; + background: var(--accent-bg); + color: var(--accent); +} + +.pa-clear { + padding: 6px 10px; + font: 500 9px/1 var(--font-mono); + letter-spacing: 0.04em; + text-decoration: none; + border: 1px solid var(--rule); + border-radius: 3px; + background: var(--paper-raised); + color: var(--ink-mid); + white-space: nowrap; +} + +.pa-clear:hover { + background: var(--ink); + color: var(--paper); +} + +/* ── selected-CPV summary header (top of the scorecards, one block per selected cohort) ── */ +.pa-cohort-summary { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 16px 20px 4px; +} + +.pa-sumcard { + border: 1px solid var(--accent); + border-radius: 4px; + background: var(--accent-bg); + padding: 13px 16px; +} + +.pa-sumcard-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.pa-sumcard-head .pa-card-cpv { + background: var(--paper); +} + +.pa-sumcard-name { + font: 600 13px/1.3 var(--font-sans); + color: var(--ink); +} + +.pa-sumcard-stats { + margin: 9px 0 0; + font: 400 12px/1.5 var(--font-sans); + color: var(--ink-mid); +} + +.pa-sumcard-stats strong { + font-weight: 600; + color: var(--ink); +} + +.pa-sumcard-link { + display: inline-block; + margin-top: 9px; + font: 600 11px/1 var(--font-mono); + letter-spacing: 0.02em; + color: var(--accent); + text-decoration: none; +} + +.pa-sumcard-link:hover, +.pa-sumcard-link:focus-visible { + text-decoration: underline; +} + +.pa-cards-grid { + list-style: none; + margin: 0; + padding: 18px 20px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(384px, 1fr)); + gap: 16px; +} + +.pa-card { + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 15px 16px 14px; +} + +.pa-card-top { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.pa-card-id { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.pa-card-rank { + font: 600 18px/1 var(--font-serif); + color: var(--accent); +} + +.pa-card-cpv { + font: 600 9px/1 var(--font-mono); + letter-spacing: 0.06em; + color: var(--ink-soft); + border: 1px solid var(--rule); + border-radius: 2px; + padding: 3px 5px; +} + +/* The card's CPV chip is a real link that toggles the ?cohort= facet (sibling of the title link). */ +a.pa-card-cpv { + text-decoration: none; + transition: + color 0.12s ease, + border-color 0.12s ease, + background 0.12s ease; +} + +a.pa-card-cpv:hover, +a.pa-card-cpv:focus-visible { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-bg); +} + +.pa-card-mult { + margin-left: auto; + text-align: right; + flex: none; +} + +.pa-card-mult-v { + font: 600 20px/1 var(--font-mono); + color: var(--accent); +} + +.pa-card-mult-l { + margin-top: 3px; + font: 500 8px/1 var(--font-mono); + letter-spacing: 0.08em; + color: var(--ink-soft); +} + +.pa-card-title { + margin-top: 11px; + font: 600 12.5px/1.32 var(--font-sans); + color: var(--ink); + min-height: 33px; +} + +.pa-card-title a { + color: inherit; + text-decoration: none; +} + +.pa-card-title a:hover { + color: var(--accent); + text-decoration: underline; +} + +.pa-card-buyer { + margin-top: 6px; + font: 400 9.5px/1.3 var(--font-mono); + color: var(--ink-soft); +} + +.pa-card-buyer a { + color: var(--ink-mid); + text-decoration: none; +} + +.pa-card-buyer a:hover { + color: var(--accent); +} + +.pa-card-strip { + display: block; + width: 100%; + height: auto; + overflow: visible; + margin-top: 12px; +} + +.pa-card-hi { + fill: var(--accent); + stroke: var(--paper-raised); + stroke-width: 1.5; +} + +.pa-card-figs { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + margin: 10px 0 0; + padding-top: 11px; + border-top: 1px solid var(--rule-soft); +} + +.pa-card-figs dt { + font: 500 7.5px/1 var(--font-mono); + letter-spacing: 0.08em; + color: var(--ink-soft); +} + +.pa-card-figs dd { + margin: 4px 0 0; + font: 600 12px/1 var(--font-mono); +} + +.pa-fig-val { + color: var(--ink); +} + +.pa-fig-med { + color: var(--ink-mid); +} + +.pa-fig-pct { + color: var(--accent); +} + +.pa-cards-empty { + padding: 26px 20px; + text-align: center; + font: 400 11px/1.5 var(--font-mono); + color: var(--ink-soft); +} + +.pa-caveat { + margin: 0; + padding: 11px 20px 14px; + background: var(--accent-bg); + border-top: 1px solid var(--accent); + font: 400 9.5px/1.45 var(--font-sans); + color: var(--ink-mid); +} + +.pa-caveat-strong { + font-weight: 600; + color: var(--accent); +} + +/* methodology block — the complete „как се смята" section */ +.pa-method { + margin-top: 22px; +} + +.pa-method-body { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 26px; + padding: 16px 20px 20px; +} + +.pa-method-block { + min-width: 0; +} + +.pa-method-block h3 { + margin: 0 0 6px; + font: 600 12px/1.3 var(--font-mono); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--accent); +} + +.pa-method-block p { + margin: 0 0 8px; + font: 400 12.5px/1.55 var(--font-sans); + color: var(--ink-mid); +} + +.pa-method-block p:last-child { + margin-bottom: 0; +} + +.pa-method-block strong { + font-weight: 600; + color: var(--ink); +} + +.pa-method-block code { + font: 500 11.5px/1.4 var(--font-mono); + color: var(--ink); + background: var(--paper); + border: 1px solid var(--rule); + border-radius: 3px; + padding: 0 4px; +} + +.pa-method-block ul { + margin: 0; + padding-left: 16px; + list-style: disc; +} + +.pa-method-block li { + margin: 0 0 6px; + font: 400 12.5px/1.5 var(--font-sans); + color: var(--ink-mid); +} + +.pa-method-block li:last-child { + margin-bottom: 0; +} + +@media (max-width: 900px) { + .pa-method-body { + grid-template-columns: 1fr; + } + + .pa-mast-title { + font-size: 30px; + } +} + +@media (max-width: 820px) { + .pa-browse-headrow, + .pa-browse-row { + grid-template-columns: 48px minmax(90px, 1.3fr) 88px 54px 48px 100px; + } + + .pa-th-strip, + .pa-browse-strip { + display: none; + } +} + +@media (max-width: 560px) { + .pa-cards-grid { + grid-template-columns: 1fr; + } + + /* Drop the РАЗДУТ ДЯЛ column too — 5 stat columns left, gap tightened. */ + .pa-browse-headrow, + .pa-browse-row { + grid-template-columns: 44px 1fr 70px 42px 40px; + gap: 6px; + } + + .pa-th-share, + .pa-browse-share { + display: none; + } +} + +/* ===== end trends-dashboard ===== */ + +/* ===== overruns-dashboard ===== */ +/* Static layout/typography for /overruns + the /analytics „Раздуване" hero. Ported from the route + files (no new inline style=, per docs/review-accessibility.md). Only data-driven values (bar/ + scatter geometry, active accent) remain inline. All colours via app tokens. */ + +/* shared primitives */ +.ov-panel { + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 4px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.ov-mono-label { + font: 500 9px/1 var(--font-mono); + letter-spacing: 0.12em; + color: var(--ink-soft); + text-transform: uppercase; +} + +.ov-accent { + color: var(--accent); +} + +/* page column: keep the dashboard within the 1200px editorial measure of the design mock */ +.ov-page { + max-width: 1200px; +} + +/* masthead — kicker + title + lede on the left, the three headline KPIs inline on the right (design, + same composition as .trend-header). */ +.ov-mast { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin: 0 0 14px; + padding-bottom: 16px; + border-bottom: 1px solid var(--ink); +} + +.ov-mast-main { + min-width: 0; +} + +.ov-mast-kicker { + margin: 0; + font: 600 10px/1 var(--font-mono); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent); +} + +.ov-mast-title { + margin: 10px 0 0; + font-family: var(--font-serif); + font-size: 38px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.02; + color: var(--ink); +} + +.ov-mast-title em { + font-style: italic; + color: var(--accent); +} + +.ov-mast-lede { + margin: 9px 0 0; + max-width: 540px; + font-size: 12.5px; + line-height: 1.45; + color: var(--ink-mid); +} + +.ov-mast-kpis { + display: flex; + flex: none; + margin: 0; +} + +.ov-hk { + padding: 0 22px; + border-left: 1px solid var(--rule); +} + +.ov-hk:last-child { + padding-right: 0; +} + +.ov-hk-v { + margin: 0; + font: 600 25px/1 var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--ink); +} + +.ov-hk-v.accent { + color: var(--accent); +} + +.ov-hk-l { + margin-top: 5px; + font: 500 9px/1 var(--font-mono); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-soft); +} + +@media (max-width: 760px) { + .ov-mast { + flex-direction: column; + align-items: stretch; + gap: 14px; + } + + .ov-mast-kpis { + flex-wrap: wrap; + } + + .ov-hk:first-child { + padding-left: 0; + border-left: none; + } +} + +/* sticky filter bar — „ПОДРЕДИ ПО" + segmented toggle (drives ?by=) + before→now legend */ +.ov-filterbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + padding: 10px 14px; + margin-bottom: var(--s-4); + background: var(--paper-warm); + border: 1px solid var(--rule); + border-radius: 4px; +} + +.ov-filterbar-label { + font: 500 9px/1 var(--font-mono); + letter-spacing: 0.12em; + color: var(--ink-soft); + text-transform: uppercase; +} + +.ov-seg { + display: inline-flex; + border: 1px solid var(--rule); + border-radius: 3px; + overflow: hidden; +} + +.ov-seg a { + font: 500 10px/1 var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 7px 12px; + color: var(--ink-mid); + background: var(--paper); + text-decoration: none; +} + +.ov-seg a + a { + border-left: 1px solid var(--rule); +} + +.ov-seg a[aria-current='true'] { + background: var(--ink); + color: var(--paper); +} + +.ov-legend { + margin-left: auto; + display: inline-flex; + flex-wrap: wrap; + gap: 16px; + font: 400 10px/1 var(--font-mono); + color: var(--ink-mid); +} + +.ov-legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.ov-swatch { + width: 10px; + height: 10px; + border-radius: 1px; +} + +.ov-swatch.ink { + background: var(--ink); +} + +.ov-swatch.accent { + background: var(--accent); +} + +/* dashboard frame */ +/* single-line and two-line ellipsis truncation (ported from the design's .clamp1/.clamp2 — they were + referenced across overruns/trends but never defined, so long subjects wrapped and overflowed). */ +.clamp1 { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.clamp2 { + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* section rhythm: each of the four design sections is a serif heading + mono note, then its panel(s) */ +.ov-section { + margin-top: var(--s-6); +} + +.ov-sec-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); +} + +.ov-sec-title { + margin: 0; + font-family: var(--font-serif); + font-size: 21px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink); +} + +.ov-sec-title em { + font-style: italic; + color: var(--accent); +} + +.ov-sec-note { + font: 400 10px/1.3 var(--font-mono); + color: var(--ink-soft); +} + +/* two-up figure grid: a wide visual (scatter / treemap) beside a narrower data panel (inspector / + ranked list), per the design's minmax(0,1.3fr) minmax(360px,1fr). */ +.ov-figure-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(360px, 1fr); + gap: 14px; + align-items: start; +} + +@media (max-width: 860px) { + .ov-figure-grid { + grid-template-columns: 1fr; + } +} + +/* leaderboard board */ +.ov-board-head { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 13px 16px 8px; +} + +.ov-board-title { + font: 600 16px/1.2 var(--font-serif); + color: var(--ink); +} + +.ov-board-title em { + font-style: italic; + color: var(--accent); +} + +.ov-board-scale { + font: 400 10px/1 var(--font-mono); + color: var(--ink-soft); +} + +.ov-board-list { + list-style: none; + margin: 0; + padding: 0 8px 8px; + overflow-y: auto; + overflow-x: hidden; + max-height: 560px; +} + +.ov-row { + display: grid; + grid-template-columns: 32px 1fr; + gap: 12px; + align-items: center; + width: 100%; + text-align: left; + padding: 9px 8px; + border: none; + border-bottom: 1px solid var(--rule-soft); + border-left: 2px solid transparent; + background: transparent; + cursor: pointer; + font: inherit; +} + +.ov-row[aria-pressed='true'] { + border-left-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.ov-row-rank { + font: 600 22px/1 var(--font-serif); + color: var(--ink-mid); + text-align: center; +} + +.ov-row[aria-pressed='true'] .ov-row-rank { + color: var(--accent); +} + +/* per-row growth: neutral by default so the accent isn't diluted; reserved for the largest grower + and the selected row (see Fix: accent-red overload). */ +.ov-row-pct { + color: var(--ink-mid); +} + +.ov-row-pct.is-top { + color: var(--accent); +} + +.ov-row[aria-pressed='true'] .ov-row-pct { + color: var(--accent); +} + +.ov-arrow { + color: var(--ink-mid); +} + +.ov-row-body { + min-width: 0; +} + +.ov-row-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.ov-row-subject { + font-size: 13px; + font-weight: 500; + color: var(--ink); +} + +.ov-row-value { + white-space: nowrap; + font: 600 10.5px/1 var(--font-mono); + color: var(--ink); +} + +.ov-row-meta { + display: block; + margin-top: 4px; + font: 400 9px/1.2 var(--font-mono); + color: var(--ink-soft); +} + +/* before→now stacked bar */ +.ov-bar { + position: relative; + height: 13px; + margin-top: 5px; +} + +.ov-bar-track { + position: absolute; + inset: 0; + border-radius: 2px; + background: repeating-linear-gradient( + 90deg, + transparent, + transparent 62px, + var(--rule-soft) 62px, + var(--rule-soft) 63px + ); +} + +.ov-bar-fill { + position: absolute; + left: 0; + top: 0; + display: flex; + height: 13px; + min-width: 3px; + border-radius: 0 2px 2px 0; + overflow: hidden; +} + +.ov-bar-sign { + height: 100%; + background: var(--ink); +} + +.ov-bar-inc { + height: 100%; + background: var(--accent); +} + +/* scatter panel */ +.ov-scatter-panel { + padding: 13px 16px 8px; + min-height: 230px; +} + +.ov-scatter-head { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.ov-panel-title { + font: 600 16px/1.2 var(--font-serif); + color: var(--ink); +} + +.ov-panel-note { + font: 400 9.5px/1 var(--font-mono); + color: var(--ink-soft); + /* the note caption holds a label + the fullscreen button, laid out inline */ + display: inline-flex; + align-items: center; + gap: 10px; +} + +.ov-scatter-body { + flex: 1; + min-height: 340px; + margin-top: 6px; +} + +.ov-scatter-svg { + display: block; + overflow: visible; +} + +/* progressive-enhancement hover cue so the clickable bubbles feel interactive (mouse only — the + keyboard path is the leaderboard buttons). */ +.ov-scatter-dot { + transition: + r 0.12s ease, + fill-opacity 0.12s ease, + stroke-width 0.12s ease; +} + +.ov-scatter-dot:hover { + fill-opacity: 0.95 !important; + stroke-width: 1.75; +} + +/* inspector */ +.ov-insp-head { + padding: 13px 16px 12px; + border-bottom: 1px solid var(--rule); +} + +.ov-insp-title { + margin-top: 8px; + font-size: 12.5px; + font-weight: 600; + line-height: 1.32; + color: var(--ink); +} + +.ov-insp-parties { + margin-top: 6px; + font: 400 9.5px/1.3 var(--font-mono); + color: var(--ink-soft); +} + +.ov-insp-figures { + display: flex; + align-items: flex-end; + gap: 16px; + margin-top: 12px; + flex-wrap: wrap; +} + +.ov-insp-fig-label { + font: 400 8.5px/1 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.ov-insp-fig-val { + margin-top: 3px; + font: 400 15px/1 var(--font-mono); + color: var(--ink-mid); +} + +.ov-insp-fig-val.now { + font-weight: 600; + color: var(--ink); +} + +.ov-insp-arrow { + color: var(--accent); + font-size: 14px; + padding-bottom: 1px; +} + +.ov-insp-delta-wrap { + margin-left: auto; + text-align: right; +} + +.ov-insp-delta { + font: 600 16px/1 var(--font-mono); + color: var(--accent); +} + +.ov-insp-delta-meta { + margin-top: 2px; + font: 400 9px/1 var(--font-mono); + color: var(--ink-soft); +} + +.ov-insp-grid-wrap { + padding: 12px 16px 14px; +} + +.ov-insp-grid-heading { + margin-bottom: 6px; +} + +.ov-insp-grid { + margin: 0; +} + +.ov-insp-grid-row { + display: grid; + grid-template-columns: 118px 1fr; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--rule-soft); +} + +.ov-insp-grid-key { + font: 500 9px/1.35 var(--font-mono); + letter-spacing: 0.05em; + color: var(--ink-soft); +} + +.ov-insp-grid-val { + margin: 0; + font-size: 11.5px; + line-height: 1.35; + color: var(--ink); +} + +/* inspector head: kicker + status badge on one row */ +.ov-insp-head-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ov-status-badge { + flex: none; + padding: 2px 8px; + border: 1px solid var(--rule); + border-radius: 999px; + font: 500 8.5px/1.4 var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.ov-status-badge.active { + border-color: color-mix(in oklch, var(--accent) 45%, var(--rule)); + color: var(--accent); + background: color-mix(in oklch, var(--accent) 8%, transparent); +} + +.ov-status-badge.closed { + color: var(--ink-soft); + background: var(--paper-warm); +} + +/* annex history — REAL amendment rows for the selected contract */ +.ov-annex-wrap { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--rule); +} + +.ov-annex-heading { + margin-bottom: 8px; +} + +.ov-annex-list { + margin: 0; + padding: 0; + list-style: none; +} + +.ov-annex-row { + padding: 7px 0; + border-bottom: 1px solid var(--rule-soft); +} + +.ov-annex-main { + display: flex; + align-items: baseline; + gap: 10px; +} + +.ov-annex-seq { + flex: none; + font: 500 9px/1.3 var(--font-mono); + letter-spacing: 0.05em; + color: var(--ink-mid); +} + +.ov-annex-date { + flex: none; + font: 400 9px/1.3 var(--font-mono); + color: var(--ink-soft); +} + +.ov-annex-delta { + margin-left: auto; + flex: none; + font: 600 11px/1.3 var(--font-mono); + color: var(--accent); +} + +.ov-annex-reason { + margin-top: 3px; + font-size: 10.5px; + line-height: 1.4; + color: var(--ink-mid); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.ov-annex-empty { + margin: 0; + font: 400 10px/1.5 var(--font-mono); + color: var(--ink-soft); +} + +.ov-insp-source { + margin-top: 12px; + font: 400 9.5px/1.5 var(--font-mono); + color: var(--ink-soft); +} + +/* leaderboard-as-table disclosure + methodology note */ +.ov-table-details { + margin-top: var(--s-4); +} + +.ov-table-summary { + cursor: pointer; + font: 500 12px/1.4 var(--font-mono); + color: var(--ink-mid); +} + +.ov-table-body { + margin-top: var(--s-3); +} + +.ov-methodology { + margin-top: var(--s-3); +} + +/* ── SECTION 3 — overrun-by-sector table (CPV division, aggregate growth, € at risk) ── */ +.ov-sector-list-panel { + padding: 12px 16px 14px; +} + +/* bucket markers — works→accent, goods→slate, services→ochre, other→ink-soft. Each is paired with a + text label / legend so colour is never the sole carrier of the category (WCAG 1.4.1). */ +.ov-bucket-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin: 0 0 10px; + padding: 0; + list-style: none; + font: 400 9.5px/1 var(--font-mono); + color: var(--ink-mid); +} + +.ov-bucket-legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.ov-sector-dot { + width: 9px; + height: 9px; + border-radius: 999px; + display: inline-block; + flex: none; + background: var(--ink-soft); +} + +.ov-sector-dot.works { + background: var(--accent); +} + +.ov-sector-dot.goods { + background: var(--slate); +} + +.ov-sector-dot.services { + background: var(--ochre); +} + +.ov-sector-dot.other { + background: var(--ink-soft); +} + +/* horizontal scroll wrapper for the wide (6-column) tables so they don't crush on narrow screens */ +.ov-table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.ov-sector-table { + width: 100%; + border-collapse: collapse; +} + +.ov-sector-table thead tr { + font: 500 8.5px/1 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.ov-sector-table thead th { + padding: 6px 8px 7px; + text-align: right; + border-bottom: 1px solid var(--ink); +} + +.ov-sector-table thead th:nth-child(1), +.ov-sector-table thead th:nth-child(2) { + text-align: left; +} + +.ov-sector-table tbody tr { + border-bottom: 1px solid var(--rule-soft); +} + +.ov-sector-table td { + padding: 7px 8px; +} + +.ov-sector-code { + font: 600 11px/1 var(--font-mono); + color: var(--ink-mid); +} + +.ov-sector-name { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 11.5px; + color: var(--ink); +} + +.ov-sector-growth { + text-align: right; + font: 600 11px/1 var(--font-mono); + color: var(--ink-mid); +} + +.ov-sector-growth.is-top { + color: var(--accent); +} + +.ov-sector-risk { + text-align: right; + font: 500 11px/1 var(--font-mono); + color: var(--ink); +} + +/* ── SECTION 4 — institutions table ── */ +.ov-auth-panel { + padding: 12px 16px 14px; +} + +.ov-auth-table { + width: 100%; + border-collapse: collapse; +} + +.ov-auth-table thead tr { + font: 500 8.5px/1 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.ov-auth-table thead th { + padding: 6px 8px 7px; + text-align: right; + border-bottom: 1px solid var(--ink); +} + +.ov-auth-table thead th.c-rank, +.ov-auth-table thead th.c-name, +.ov-auth-table thead th.c-share { + text-align: left; +} + +.ov-auth-table tbody tr { + border-bottom: 1px solid var(--rule-soft); +} + +.ov-auth-table td { + padding: 8px; + font-size: 11.5px; + vertical-align: middle; +} + +.ov-auth-table td.c-rank { + font: 600 12px/1 var(--font-mono); + color: var(--ink-soft); + width: 28px; +} + +.ov-auth-table td.c-name { + color: var(--ink); +} + +.ov-auth-table td.c-num { + text-align: right; + font: 500 11px/1 var(--font-mono); + white-space: nowrap; +} + +.ov-auth-total { + color: var(--ink); + font-weight: 600 !important; +} + +.ov-auth-growth { + color: var(--ink-mid) !important; +} + +.ov-auth-growth.is-top { + color: var(--accent) !important; +} + +.ov-auth-table td.c-share { + width: 150px; +} + +.ov-auth-foot { + margin: 12px 0 0; + text-align: center; + font: 400 9.5px/1.4 var(--font-mono); + letter-spacing: 0.06em; + color: var(--ink-soft); +} + +/* the scale caption holds a label + the button (the matching .ov-panel-note layout lives with its + base rule above) */ +.ov-board-scale { + display: inline-flex; + align-items: center; + gap: 10px; +} + +/* native fullscreen — fill the viewport, let the chart grow to fill it */ +.trend-chart-panel:fullscreen, +.ov-board:fullscreen, +.ov-scatter-panel:fullscreen { + background: var(--paper); + padding: 20px 24px; + width: 100vw; + height: 100vh; + overflow: auto; +} + +.trend-chart-panel:fullscreen .trend-chart-body, +.ov-scatter-panel:fullscreen .ov-scatter-body { + flex: 1; + min-height: 0; +} + +.ov-board:fullscreen .ov-board-list { + max-height: none; + flex: 1; +} + +/* ===== end list-search ===== */ + +/* ===== Contracts overview (/trends): lenses, distribution rows, contract cards ===== + Translated from the „Договори — обзор" design mock into the site's token palette: ink line for + € volume, muted info bars for counts, accent red only for the selection/„над типичното" cues. */ + +.ov-controls { + display: flex; + align-items: center; + gap: var(--s-4); + flex-wrap: wrap; + margin: var(--s-5) 0 var(--s-4); + padding: var(--s-3) 0; + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); +} + +.ov-controls-label { + font: 500 10px/1 var(--font-mono); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-faint); +} + +.ov-chips { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--s-2); + flex-wrap: wrap; +} + +.ov-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font: 500 11px/1 var(--font-mono); + padding: 6px 9px; + border: 1px solid var(--accent); + border-radius: 3px; + background: var(--accent-bg); + color: var(--accent); + text-decoration: none; +} + +.ov-chip:visited { + color: var(--accent); +} + +.ov-chip span { + opacity: 0.6; +} + +.ov-hint { + font: 400 12px/1 var(--font-mono); + color: var(--text-muted); +} + +/* Segmented link controls (angle switcher, step, sorts) */ +.ovz-seg { + display: inline-flex; + border: 1px solid var(--rule); + border-radius: 4px; + overflow: hidden; +} + +.ovz-seg a { + font: 600 11px/1 var(--font-mono); + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 8px 13px; + color: var(--text-muted); + background: var(--surface); + text-decoration: none; + border-right: 1px solid var(--rule-soft); + white-space: nowrap; +} + +.ovz-seg a:last-child { + border-right: none; +} + +.ovz-seg a:hover { + color: var(--text); +} + +.ovz-seg a[aria-current] { + background: var(--ink); + color: var(--paper); +} + +.ovz-seg a:visited { + color: var(--text-muted); +} + +.ovz-seg a[aria-current]:visited { + color: var(--paper); +} + +/* Panels */ +.ovz-panel { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 5px; + padding: var(--s-4) var(--s-5); + margin-bottom: var(--s-5); +} + +.ov-panel-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--s-4); + flex-wrap: wrap; + margin-bottom: var(--s-3); +} + +.ovz-panel-title { + font: 600 20px/1.15 var(--font-serif); + margin: 0; +} + +.ovz-panel-title em { + color: var(--accent); +} + +.ov-panel-hint { + margin: 6px 0 0; + font: 400 12px/1.45 var(--font-mono); + color: var(--text-faint); + max-width: 62ch; +} + +.ov-panel-tools { + display: flex; + align-items: center; + gap: var(--s-3); + flex-wrap: wrap; +} + +.ovz-legend { + display: inline-flex; + align-items: center; + gap: 6px; + font: 400 11px/1 var(--font-mono); + color: var(--text-faint); +} + +.ov-legend-bar { + width: 9px; + height: 9px; + background: oklch(55% 0.03 240 / 0.55); + border-radius: 1px; +} + +.ov-legend-line { + width: 14px; + height: 2.4px; + background: var(--ink); + border-radius: 2px; + margin-left: 8px; +} + +/* Combo chart (bars = contracts, line = € volume) */ +.combo-chart { + position: relative; + margin-top: var(--s-2); +} + +.combo-grid { + stroke: var(--rule-soft); + stroke-width: 1; +} + +.combo-bar { + fill: oklch(55% 0.03 240 / 0.5); +} + +.combo-bar.is-hover { + fill: oklch(55% 0.03 240 / 0.9); +} + +.combo-bar.is-partial { + fill: oklch(55% 0.03 240 / 0.25); +} + +.combo-line { + fill: none; + stroke: var(--ink); + stroke-width: 2.2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.combo-line-partial { + fill: none; + stroke: var(--ink); + stroke-width: 2; + stroke-dasharray: 4 4; + opacity: 0.7; +} + +.combo-cursor { + stroke: var(--accent); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.combo-dot { + fill: var(--accent); + stroke: var(--paper); + stroke-width: 1.6; +} + +.combo-xlab { + display: flex; + justify-content: space-between; + margin-top: 5px; + padding: 0 2px; + font: 400 10px/1 var(--font-mono); + color: var(--text-faint); +} + +.combo-tip { + position: absolute; + pointer-events: none; + transform: translate(-50%, -108%); + background: var(--ink); + color: var(--paper); + padding: 7px 10px; + border-radius: 3px; + white-space: nowrap; + z-index: 5; +} + +.combo-tip-label { + font: 500 10px/1 var(--font-mono); + letter-spacing: 0.06em; + opacity: 0.75; +} + +.combo-tip-row { + display: flex; + gap: var(--s-3); + justify-content: space-between; + margin-top: 5px; + font: 400 10px/1 var(--font-mono); +} + +.combo-tip-row strong { + font: 600 11.5px/1 var(--font-mono); +} + +/* Year cards under the chart */ +.ov-years { + display: flex; + gap: 7px; + margin-top: var(--s-4); + flex-wrap: wrap; +} + +.ov-year { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + border: 1px solid var(--rule); + border-radius: 3px; + background: var(--surface); + min-width: 78px; + text-decoration: none; + color: var(--text); +} + +.ov-year:visited { + color: var(--text); +} + +.ov-year:hover { + border-color: var(--ink); +} + +.ov-year.is-active { + border-color: var(--accent); + background: var(--accent-bg); + color: var(--accent); +} + +.ov-year.is-active:visited { + color: var(--accent); +} + +.ov-year.is-slim { + min-width: 0; +} + +.ov-year-label { + font: 600 13px/1 var(--font-mono); +} + +.ov-year-partial { + font: 400 9px/1 var(--font-mono); + color: var(--text-faint); +} + +.ov-year-val { + font: 400 10px/1 var(--font-mono); + color: var(--text-faint); +} + +.ov-year.is-active .ov-year-val, +.ov-year.is-active .ov-year-partial { + color: var(--accent); +} + +/* CPV lens: header + clickable distribution rows */ +.ov-cpv { + padding-left: 0; + padding-right: 0; +} + +.ov-cpv .ov-panel-head, +.ov-cpv-head, +.ov-cpv-row, +.ov-cpv-foot { + padding-left: var(--s-5); + padding-right: var(--s-5); +} + +.ov-cpv-head, +.ov-cpv-row { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) 92px 56px minmax(180px, 320px); + gap: var(--s-3); + align-items: center; +} + +.ov-cpv[data-compact] .ov-cpv-row { + grid-template-columns: 18px 52px minmax(0, 1fr) 92px; +} + +.ov-cpv-head { + padding-top: 9px; + padding-bottom: 7px; + border-bottom: 1px solid var(--ink); + font: 500 9px/1.2 var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); +} + +.ov-cpv-head .num { + text-align: right; +} + +.ov-cpv-row { + padding-top: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--rule-soft); + border-left: 2px solid transparent; + text-decoration: none; + color: var(--text); +} + +.ov-cpv-row:visited { + color: var(--text); +} + +.ov-cpv-row:hover { + background: oklch(48% 0.18 28 / 0.05); +} + +.ov-cpv-row.is-active { + background: var(--accent-bg); + border-left-color: var(--accent); +} + +.ov-cpv-code { + font: 600 11px/1 var(--font-mono); + color: var(--text-faint); +} + +.ov-cpv-row.is-active .ov-cpv-code { + color: var(--accent); +} + +.ov-cpv-name { + min-width: 0; +} + +.ov-cpv-name .clamp { + display: block; + font-size: 13px; +} + +.ov-cpv-row.is-active .ov-cpv-name .clamp { + font-weight: 600; +} + +.ov-cpv-range { + display: block; + margin-top: 2px; + font: 400 10px/1 var(--font-mono); + color: var(--text-faint); +} + +.ov-cpv-med { + text-align: right; + white-space: nowrap; + font: 600 12px/1 var(--font-mono); +} + +.ov-cpv-n { + text-align: right; + white-space: nowrap; + font: 400 11.5px/1 var(--font-mono); + color: var(--text-muted); +} + +.ov-check { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1.5px solid var(--rule); + display: flex; + align-items: center; + justify-content: center; + font: 700 9px/1 var(--font-mono); + color: var(--paper); +} + +.ov-cpv-row.is-active .ov-check { + border-color: var(--accent); + background: var(--accent); +} + +.ov-dist { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.ov-dist-axis { + stroke: var(--rule-soft); + stroke-width: 1; +} + +.ov-dist-box { + fill: oklch(55% 0.03 240 / 0.16); +} + +.ov-dot { + fill: oklch(18% 0.012 70 / 0.4); +} + +.ov-dot.is-outlier { + fill: var(--accent); +} + +.ov-dist-median { + stroke: var(--accent); + stroke-width: 1.6; +} + +.ov-cpv-foot { + padding-top: 6px; + padding-bottom: var(--s-3); + display: grid; + grid-template-columns: 52px minmax(0, 1fr) 92px 56px minmax(180px, 320px); + gap: var(--s-3); +} + +.ov-cpv-foot .ov-dist-ticks { + grid-column: 5; +} + +.ov-dist-ticks line { + stroke: var(--rule); + stroke-width: 1; +} + +.ov-dist-ticks text { + font: 400 8px var(--font-mono); + fill: var(--text-faint); +} + +/* Cross lens: year picker + CPV picker side by side. The columns stretch to equal height and the + year panel is a flex column whose chart grows, so the combo chart fills the card instead of + floating in dead space above the tall CPV list (the SVG scales via viewBox + + preserveAspectRatio="none", so stretching it is safe). */ +.ov-cross { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: var(--s-5); + align-items: stretch; +} + +.ov-cross .ovz-panel { + margin-bottom: 0; +} + +.ov-cross + .ovz-panel, +.ov-cross { + margin-bottom: var(--s-5); +} + +.ov-cross-year { + display: flex; + flex-direction: column; +} + +.ov-cross-year .combo-chart { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* the inline height (260px) becomes the flex-basis floor; leftover panel height goes to the plot */ +.ov-cross-year .combo-chart svg { + flex: 1 1 auto; + min-height: 0; +} + +.ov-cross-year .ov-years { + margin-top: auto; + padding-top: var(--s-4); +} + +.ov-cross-chart-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; +} + +@media (max-width: 960px) { + .ov-cross { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + .ov-cross-year .combo-chart svg { + height: 190px !important; + flex: 0 0 auto; + } +} + +/* Shared contracts list: card grid */ +.ov-cards { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--s-3); +} + +.ov-card { + display: block; + border: 1px solid var(--rule-soft); + border-radius: 4px; + padding: 12px 14px; + background: var(--paper); + text-decoration: none; + color: var(--text); + transition: + box-shadow 0.15s, + border-color 0.15s; +} + +.ov-card:visited { + color: var(--text); +} + +.ov-card:hover { + border-color: var(--rule); + box-shadow: 0 2px 8px oklch(18% 0.012 70 / 0.06); +} + +.ov-card .clamp { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ov-card-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s-2); +} + +.ov-card-date { + font: 500 11px/1 var(--font-mono); + color: var(--text-faint); +} + +.ov-card-val { + font: 600 13px/1 var(--font-mono); + white-space: nowrap; +} + +.ov-card-buyer { + margin-top: 8px; + font-size: 13px; + font-weight: 600; +} + +.ov-card-seller { + margin-top: 2px; + font-size: 12.5px; + color: var(--text-muted); +} + +.ov-card-seller span { + color: var(--accent); +} + +.ov-card-foot { + display: flex; + align-items: center; + gap: var(--s-2); + margin-top: 9px; + padding-top: 9px; + border-top: 1px solid var(--rule-soft); + min-width: 0; +} + +.ov-card-cpv { + font: 600 9.5px/1 var(--font-mono); + letter-spacing: 0.04em; + color: var(--text-faint); + border: 1px solid var(--rule); + border-radius: 2px; + padding: 3px 5px; + white-space: nowrap; +} + +.ov-card-cohort { + flex: 1 1 auto; + min-width: 0; + font: 500 9.5px/1.2 var(--font-mono); + letter-spacing: 0.04em; + color: var(--text-faint); +} + +.ov-card-rel { + margin-left: auto; + font: 600 10.5px/1 var(--font-mono); + white-space: nowrap; +} + +.ov-rel-hi { + color: var(--accent); +} + +.ov-rel-lo { + color: oklch(50% 0.05 240); +} + +.ov-rel-mid { + color: var(--text-faint); +} + +.ov-empty { + padding: var(--s-5) 0; + text-align: center; + font: 400 12px/1.5 var(--font-mono); + color: var(--text-faint); +} + +.ov-note { + margin: var(--s-4) calc(-1 * var(--s-5)) calc(-1 * var(--s-4)); + padding: 11px var(--s-5) 14px; + background: oklch(55% 0.03 240 / 0.06); + border-top: 1px solid oklch(55% 0.03 240 / 0.16); + border-radius: 0 0 5px 5px; + font: 400 11.5px/1.45 var(--font-sans); + color: var(--text-muted); +} + +@media (max-width: 760px) { + .ov-cpv-head, + .ov-cpv-row { + grid-template-columns: 52px minmax(0, 1fr) 92px; + } + + .ov-cpv-head .num + .num, + .ov-cpv-head span:last-child, + .ov-cpv-row .ov-cpv-n, + .ov-cpv-row .ov-dist, + .ov-cpv-foot { + display: none; + } +} diff --git a/apps/web/app/styles/tokens.css b/apps/web/app/styles/tokens.css index ac6f4775..7dbb05fd 100644 --- a/apps/web/app/styles/tokens.css +++ b/apps/web/app/styles/tokens.css @@ -1,8 +1,7 @@ -/* Design tokens — OKLch colour palette, type stack, 8-pt spacing scale. - @theme exposes these to Tailwind (bg-paper, text-ink…) AND as CSS vars; - :root aliases let component CSS keep using var(--ink), var(--accent), etc. - The mock uses a system serif/mono stack — no webfont request. */ - +/* Editorial design tokens — OKLch. @theme exposes them to Tailwind (bg-paper, text-ink…) AND as CSS + vars (--color-ink…); the ported component CSS below and the @sigma/config procedure colours read + the same vars, so the palette lives in exactly one place. The mock uses a system serif/mono stack — + no webfont request (Inter dropped). */ @theme { --color-paper: oklch(98.5% 0.008 80); --color-paper-warm: oklch(96% 0.012 78); @@ -17,6 +16,16 @@ --color-accent: oklch(48% 0.18 28); /* red — links/warnings */ --color-accent-bg: oklch(94% 0.04 28); --color-pos: oklch(45% 0.1 165); /* teal — positive deltas */ + /* Decorative editorial accents (already the trend-dashboard chart palette: slate #5E7C8B count + series, tan #C4B79C € line). Promoted to shared tokens so the /analytics landing thumbnails and + the „парите" highlight read from the palette, not raw hexes. Never the sole carrier of meaning — + the slate word is also italic; the thumbnails are aria-hidden decoration. */ + --color-slate: oklch(55% 0.035 233); + --color-tan: oklch(77% 0.03 90); + /* Warm ochre — the „услуги" (services) bucket marker on the /overruns sector treemap + ranked list. + Pairs with --accent (works) and --slate (goods); each bucket also carries a text label + legend, + so colour is never the sole differentiator (WCAG 1.4.1). */ + --color-ochre: oklch(64% 0.12 70); --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; @@ -28,6 +37,9 @@ /* Short aliases → @theme tokens, so the ported component CSS keeps using var(--paper) etc. */ --paper: var(--color-paper); --paper-warm: var(--color-paper-warm); + /* Pure-white raised surface — form chips/toggles that must read as "above" the warm panels + (the design renders the trend filter chips and the step toggle in #fff on the #FBF8F1 bar). */ + --paper-raised: #ffffff; --paper-deep: var(--color-paper-deep); --ink: var(--color-ink); --ink-mid: var(--color-ink-mid); @@ -37,6 +49,9 @@ --accent: var(--color-accent); --accent-bg: var(--color-accent-bg); --pos: var(--color-pos); + --slate: var(--color-slate); + --tan: var(--color-tan); + --ochre: var(--color-ochre); /* Legacy token aliases — keep page-local inline styles working */ --bg: var(--paper); diff --git a/apps/web/workers/cache-key.test.ts b/apps/web/workers/cache-key.test.ts index e2fd5b4f..6d261a22 100644 --- a/apps/web/workers/cache-key.test.ts +++ b/apps/web/workers/cache-key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { cacheKey } from './cache-key'; +import { cacheKey, PLANNED_QUERY_PARAMS } from './cache-key'; import { CANONICAL_QUERY_PARAMS, INTENTIONALLY_UNKEYED } from '../app/lib/query-params'; function cacheUrl(input: string): URL { @@ -32,7 +32,7 @@ const APP_SOURCES: Record = import.meta.glob('../app/**/*.{ts,ts // - A new URLSearchParams binding name (other than sp/searchParams/base) needs a pattern added here. function consumedQueryParams(): Set { const patterns = [ - /(?:\bsp|\bsearchParams|\bbase|\.searchParams|URLSearchParams\([^)]*\))\.(?:get|getAll|has)\(\s*['"]([A-Za-z_]\w*)['"]/g, + /(?:\bsp|\bsearchParams|\bbase|\.searchParams|URLSearchParams\([^)]*\))\s*\.(?:get|getAll|has)\(\s*['"]([A-Za-z_]\w*)['"]/g, /\bgetMulti\(\s*\w+\s*,\s*['"]([A-Za-z_]\w*)['"]/g, // The `const sel = (k) => sp.get(k)` helper in the dashboard routes (map/competition/flows/trends). /\bsel\(\s*['"]([A-Za-z_]\w*)['"]/g, @@ -109,6 +109,32 @@ describe('cacheKey', () => { expect(cacheUrl('http://local/contracts/%').pathname).toBe('/contracts/%'); }); + it('keys the /trends „вкл. текущия месец" toggle so the with-current chart gets its own entry (CWE-349)', () => { + // ?cur=1 re-runs the trend server-side WITH the current partial period — a different chart, + // different totals and year cards. It must never share a cached SSR body with the default view. + const base = cacheUrl('http://local/trends'); + const withCurrent = cacheUrl('http://local/trends?cur=1'); + + expect(withCurrent.search).not.toBe(base.search); + expect(withCurrent.searchParams.get('cur')).toBe('1'); + }); + + it('keys the repeatable /trends CPV multi-select so faceted charts get their own entries (CWE-349)', () => { + // The обзор cross lens re-runs the year chart + contract list server-side per selected CPV set; + // distinct selections (including subsets) must never share one cached SSR body. + const base = cacheUrl('http://local/trends?angle=cross'); + const one = cacheUrl('http://local/trends?angle=cross&cpv=45233'); + const two = cacheUrl('http://local/trends?angle=cross&cpv=45233&cpv=33600'); + + expect(one.search).not.toBe(base.search); + expect(two.search).not.toBe(one.search); + expect(two.searchParams.getAll('cpv')).toEqual(['33600', '45233']); // sorted, both values keyed + // cacheKey() sorts `cpv` values by value (not just by URLSearchParams.sort()'s per-key + // stability), so a differently-ordered request for the same set collapses to one cache entry + // instead of fragmenting the edge cache. + expect(cacheUrl('http://local/trends?angle=cross&cpv=33600&cpv=45233').search).toBe(two.search); + }); + it('keys response-affecting params so they cannot collapse to one cache entry (CWE-349, #56)', () => { // ?bids=1 narrows /contracts to single-bid contracts — different rows and totals. expect(cacheUrl('http://local/contracts?bids=1').search).not.toBe( @@ -122,21 +148,32 @@ describe('cacheKey', () => { }); describe('CANONICAL_QUERY_PARAMS drift guard', () => { - it('covers every query param the app reads off the URL', () => { + it('covers every query param the app reads off the URL (CWE-349, #56)', () => { const consumed = consumedQueryParams(); // Sanity: the scanner must actually find params, else a regex/glob change silently disarms it. expect(consumed.size).toBeGreaterThan(10); expect(consumed.has('bids')).toBe(true); expect(consumed.has('page')).toBe(true); + // Security direction: every param a route loader / SSR render consumes must be keyed (in the + // allow-list) or explicitly declared response-neutral, or two distinct views collapse to one + // cache entry and the wrong data gets served. The reverse direction (allow-list entries nothing + // reads yet) is intentionally NOT asserted: params for stacked-later routes legitimately sit in + // the allow-list ahead of their route. const allowed = new Set([...CANONICAL_QUERY_PARAMS, ...INTENTIONALLY_UNKEYED]); const undeclared = [...consumed].filter((p) => !allowed.has(p)).sort(); expect(undeclared).toEqual([]); }); - it('does not retain allow-list entries that nothing reads', () => { + // Soft-fails (doesn't block unrelated PRs) rather than a hard failure, because allow-list entries + // legitimately sit ahead of their route for stacked-later work — but `expect.soft` still reports the + // stale entries as a visible failure in CI output, unlike a bare `console.info`, so a real drift + // (e.g. a typo like `bidz` instead of `bids`) is caught rather than silently going unnoticed forever. + it('flags (without hard-failing) allow-list entries nothing currently reads', () => { const consumed = consumedQueryParams(); - const stale = [...CANONICAL_QUERY_PARAMS].filter((p) => !consumed.has(p)).sort(); - expect(stale).toEqual([]); + const stale = [...CANONICAL_QUERY_PARAMS] + .filter((p) => !consumed.has(p) && !PLANNED_QUERY_PARAMS.has(p)) + .sort(); + expect.soft(stale).toEqual([]); }); }); diff --git a/apps/web/workers/cache-key.ts b/apps/web/workers/cache-key.ts index c5335a6b..fb5d9c17 100644 --- a/apps/web/workers/cache-key.ts +++ b/apps/web/workers/cache-key.ts @@ -2,6 +2,14 @@ // the shared source of truth in app/lib/query-params.ts (also used by withParams for links). import { CANONICAL_QUERY_PARAMS } from '../app/lib/query-params'; +// Allow-list entries added ahead of their route — stacked-later work for /compare (`a`, `b`, +// `metric`), /overruns (`by`), and /price-anomaly (`cohort`). The cache-key.test.ts drift guard's +// stale-entry check treats these as expected-not-yet-consumed rather than flagging them, so a real +// drift (e.g. a `bidz` typo instead of `bids`) still surfaces while these documented, planned +// entries don't block unrelated PRs. When one of these routes ships and reads its param, it simply +// becomes "consumed" and this listing becomes a no-op for it — safe to leave or prune then. +export const PLANNED_QUERY_PARAMS = new Set(['a', 'b', 'by', 'cohort', 'metric']); + export function cacheKey(request: Request, deployTag: string): Request { const url = new URL(request.url); const params = new URLSearchParams(); @@ -21,6 +29,16 @@ export function cacheKey(request: Request, deployTag: string): Request { if (CANONICAL_QUERY_PARAMS.has(key)) params.append(key, value); } + // The /trends CPV multi-select is a set, not a sequence — `cpv=A&cpv=B` and `cpv=B&cpv=A` select + // the same group and must render the same SSR body. Canonicalize value order here (not just rely + // on the UI writing pre-sorted hrefs) so distinct request orderings for an equal set never + // fragment the edge cache into duplicate entries. + const cpvValues = params.getAll('cpv').sort(); + if (cpvValues.length > 0) { + params.delete('cpv'); + for (const v of cpvValues) params.append('cpv', v); + } + params.sort(); params.set('_dt', deployTag); url.search = params.toString(); diff --git a/osv-scanner.toml b/osv-scanner.toml index dad96e31..cd5cf6c8 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -10,16 +10,17 @@ # outlive the vulnerability they cover. `pnpm why ` shows the resolved version and # what pulls it in. -# ── sharp 0.34.5 — GHSA-f88m-g3jw-g9cj (High, CVSS 7.0), fixed in 0.35.0 ────────────────── -# WHY IGNORED: sharp is a DEV-ONLY, TRANSITIVE dependency pulled in only by `miniflare` -# (Cloudflare's local Workers simulator, used by `wrangler dev` and the test suite). -# `miniflare@4.20260520.0` pins sharp to EXACTLY 0.34.5, so it cannot be bumped without -# upgrading miniflare/wrangler (which drags ~29 unrelated build packages). sharp is NEVER -# bundled into the deployed Worker — Cloudflare Workers have no native image runtime — so this -# vulnerability cannot reach production. -# REMOVE WHEN: wrangler/miniflare ships a release that pins sharp >= 0.35.0. Check with -# `pnpm why sharp`; if it resolves to >= 0.35.0, delete this block. +# ── react-router 7.18.0 — GHSA-qwww-vcr4-c8h2 (High, CVSS 7.1), fixed in 8.3.0 ────────────── +# WHY IGNORED: this CVE is a CSRF flaw in react-router's UNSTABLE RSC (React Server +# Components) code paths only — "this only affects your application if you are using the +# unstable RSC APIs" per the advisory. Verified via `git grep` across this repo for RSC +# usage (unstable_.*RSC, react-server, unstable_RSCPayload, unstable_routeRSCServerRequest): +# zero hits. This app does not use RSC. No fix exists in the 7.x line (introduced in 7.12.0, +# only patched in 8.3.0) — upgrading to react-router 8.x is a major, breaking version bump +# out of scope for a security patch to a code path this app never exercises. +# REMOVE WHEN: this app adopts react-router's RSC APIs (re-evaluate applicability first), or +# a deliberate, separately-planned major-version upgrade to react-router 8.x lands. [[IgnoredVulns]] -id = "GHSA-f88m-g3jw-g9cj" +id = "GHSA-qwww-vcr4-c8h2" ignoreUntil = 2026-10-01T00:00:00Z -reason = "sharp is a dev-only transitive of miniflare (local Workers simulator), pinned to 0.34.5 upstream and never bundled into the deployed Worker. Remove once miniflare/wrangler pins sharp >= 0.35.0 (pnpm why sharp)." +reason = "CSRF in react-router's unstable RSC code paths only (GHSA-qwww-vcr4-c8h2) - this app does not use RSC (verified via repo-wide grep for RSC APIs). No fix in the 7.x line; upgrading to 8.x is a major breaking change out of scope for a security patch to an unused code path." diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 4d840867..6d8cff7d 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -442,8 +442,10 @@ export interface NetworkData { // Procurement spend by period for the /trends chart. Contracts without a usable signing date are // excluded from the series and reported as coverage, never silently dropped. +export type TrendGranularity = 'month' | 'quarter' | 'year'; + export interface TrendPoint { - period: string; // 'YYYY-MM' (month granularity) or 'YYYY' (year) + period: string; // 'YYYY-MM' (month), 'YYYY-Qn' (quarter) or 'YYYY' (year) valueEur: number; contracts: number; partial: boolean; // the final period (the as_of period) is still being filled; rendered dashed @@ -458,7 +460,7 @@ export interface TrendYear { } export interface TrendData { - granularity: 'month' | 'year'; + granularity: TrendGranularity; points: TrendPoint[]; // continuous and zero-filled, sorted by period years: TrendYear[]; // per-year summary with year-over-year change sectors: SectorRef[]; // options for the sector select @@ -467,10 +469,42 @@ export interface TrendData { scope: { sector: string | null; funding: 'all' | 'eu' | 'national'; - granularity: 'month' | 'year'; + granularity: TrendGranularity; }; } +// ── Contracts overview (/trends lenses) ────────────────────────────────────────────────────────── +// Per-CPV-group price distribution and the shared filtered contract cards for the overview surface. +// A "group" is the 5-digit CPV class prefix — fine enough that contracts inside it are comparable, +// coarse enough that cohorts stay populated. + +export interface CpvGroupStat { + group: string; // 5-digit CPV prefix, e.g. '33600' + name: string | null; // representative cpv_description within the group (most common among the sample) + contracts: number; // contracts with a positive EUR value in the group + medianEur: number; + p10Eur: number; + p90Eur: number; + maxEur: number; + sampleEur: number[]; // real contract values: a quantile ladder plus the top outliers (dot cloud) +} + +export interface CpvGroupMedian { + group: string; + name: string | null; + contracts: number; + medianEur: number; +} + +export interface OverviewContract { + id: string; // contract slug for /contracts/:id + signedAt: string | null; + valueEur: number; + authorityName: string; + bidderName: string; // display name (consortiums folded to 'X и др.') + cpvGroup: string | null; // 5-digit CPV prefix, null when the tender has no usable CPV +} + // ── Regions (map) ───────────────────────────────────────────────────────────────────────────────── // Spend per Bulgarian region (NUTS3) for the /map choropleth. Region is known for ~half of // authorities, so the unattributed bucket and coverage are first-class, never hidden. diff --git a/packages/db/migrations/0003_contracts_overrun_index.sql b/packages/db/migrations/0003_contracts_overrun_index.sql new file mode 100644 index 00000000..72eeafe5 --- /dev/null +++ b/packages/db/migrations/0003_contracts_overrun_index.sql @@ -0,0 +1,7 @@ +-- Partial index for the overrun predicate (annex_count > 0 AND current_value_eur > signing_value_eur +-- AND signing_value_eur >= 1000), shared by /overruns + /analytics (OVERRUN_WHERE in +-- packages/db/src/queries/overruns.ts). Those pages run several aggregates over that predicate; with no +-- index each one full-scans ~190k contracts. The annex_count > 0 partial keeps the index to the small +-- minority of contracts that carry annexes (the only rows that can ever be overruns), so every overrun +-- aggregate starts from that narrow set instead of the whole table. +CREATE INDEX IF NOT EXISTS idx_contracts_overrun ON contracts(annex_count) WHERE annex_count > 0; diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts index 72e4e48b..e51a2ecf 100644 --- a/packages/db/src/migrations.test.ts +++ b/packages/db/src/migrations.test.ts @@ -10,6 +10,7 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql'); const migration1 = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql'); const migration2 = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql'); +const migration3 = resolve(root, 'packages/db/migrations/0003_contracts_overrun_index.sql'); const backfill = resolve(root, 'scripts/backfill-current-value-currency.sql'); const precompute = resolve(root, 'scripts/precompute.sql'); @@ -31,6 +32,7 @@ describe('served migrations', () => { readScript(dbPath, migration0); readScript(dbPath, migration1); readScript(dbPath, migration2); + readScript(dbPath, migration3); expect( sqlite( @@ -85,6 +87,14 @@ describe('served migrations', () => { ).trim(), ).toBe('1'); + // 0003 adds the partial overrun index used by /overruns + /analytics (OVERRUN_WHERE). + expect( + sqlite( + dbPath, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_contracts_overrun' AND tbl_name='contracts';", + ).trim(), + ).toBe('1'); + // The served schema must never carry raw_* staging tables. expect( sqlite(dbPath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'raw_%';").trim(), diff --git a/packages/db/src/queries/trend.test.ts b/packages/db/src/queries/trend.test.ts index 4a92e0b7..6ca54973 100644 --- a/packages/db/src/queries/trend.test.ts +++ b/packages/db/src/queries/trend.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { getSpendingTrend } from './trend'; +import { + getCpvGroupMedians, + getCpvGroupStats, + getSpendingTrend, + listOverviewContracts, +} from './trend'; // Fake D1 keyed by call type (same approach as competition.test.ts / regions.test.ts). Verifies the // JS-side shaping: zero-filling gaps in the period series, the per-year summary with year-over-year @@ -97,8 +102,97 @@ describe('getSpendingTrend', () => { ]); }); - it('marks the as_of period and year partial and suppresses the partial year YoY', async () => { + it('never computes YoY against a non-adjacent year when a whole year is missing', async () => { + // Data for 2020 and 2022 only — 2021 is a gap year. 2022's YoY must NOT be computed against + // 2020: the gap is zero-filled and the YoY lookup is strictly year-1, so 2022 yields null. + const db = { + prepare(sql: string) { + return { + bind() { + return this; + }, + async all() { + return { + results: [ + { period: '2020', value_eur: 4000, contracts: 40 }, + { period: '2022', value_eur: 5000, contracts: 50 }, + ] as T[], + }; + }, + async first() { + if (sql.includes('as_of')) return { as_of: null } as T; + return COVERAGE as T; + }, + }; + }, + } as unknown as D1Database; + + const { years } = await getSpendingTrend(db, { granularity: 'year' }); + expect(years).toEqual([ + { year: '2020', valueEur: 4000, contracts: 40, yoyPct: null, partial: false }, + { year: '2021', valueEur: 0, contracts: 0, yoyPct: -1, partial: false }, // real -100% vs 2020 + { year: '2022', valueEur: 5000, contracts: 50, yoyPct: null, partial: false }, // NOT (5000-4000)/4000 + ]); + }); + + it('excludes the current (as_of) month by default — the series ends on the last complete month', async () => { const { points, years } = await getSpendingTrend(fakeDb(undefined, '2023-01-15'), {}); + // 2023-01 is the as_of month → dropped; the zero-fill ends at the last remaining actual. + expect(points.map((p) => p.period)).toEqual(['2022-01', '2022-02', '2022-03']); + expect(points.every((p) => !p.partial)).toBe(true); + // The as_of year had only the current month → it disappears from the per-year fold too. + expect(years.map((y) => y.year)).toEqual(['2022']); + }); + + it('excludes the current quarter by default at quarter grain', async () => { + const { points } = await getSpendingTrend(fakeDb(undefined, '2023-01-15'), { + granularity: 'quarter', + }); + // Both 2022 months fold into 2022-Q1; the as_of quarter (2023-Q1) is dropped, so the series + // ends on the last complete quarter that has data — no zero-fill past it. + expect(points.map((p) => p.period)).toEqual(['2022-Q1']); + expect(points.every((p) => !p.partial)).toBe(true); + }); + + it('excludes the current year by default at year grain', async () => { + const db = { + prepare(sql: string) { + return { + bind() { + return this; + }, + async all() { + return { + results: [ + { period: '2022', value_eur: 4000, contracts: 40 }, + { period: '2023', value_eur: 1500, contracts: 15 }, + ] as T[], + }; + }, + async first() { + if (sql.includes('as_of')) return { as_of: '2023-06-15' } as T; + return COVERAGE as T; + }, + }; + }, + } as unknown as D1Database; + const { points, years } = await getSpendingTrend(db, { granularity: 'year' }); + expect(points.map((p) => p.period)).toEqual(['2022']); + expect(years.map((y) => y.year)).toEqual(['2022']); + + const included = await getSpendingTrend(db, { granularity: 'year', includeCurrent: true }); + expect(included.points.map((p) => p.period)).toEqual(['2022', '2023']); + expect(included.points.at(-1)).toMatchObject({ partial: true }); + expect(included.years.find((y) => y.year === '2023')).toMatchObject({ + partial: true, + yoyPct: null, + }); + }); + + it('with includeCurrent, marks the as_of period and year partial and suppresses the partial year YoY', async () => { + const { points, years } = await getSpendingTrend(fakeDb(undefined, '2023-01-15'), { + includeCurrent: true, + }); expect(points.at(-1)).toMatchObject({ period: '2023-01', partial: true }); expect(points.find((p) => p.period === '2022-03')).toMatchObject({ partial: false }); const y2023 = years.find((y) => y.year === '2023')!; @@ -153,6 +247,105 @@ describe('getSpendingTrend', () => { expect(series.args).toEqual(['2020-01-01', 'auth:111']); }); + it('facets the whole series by selected CPV groups with one OR-of-ranges scan (no per-group queries)', async () => { + // Distinct fixtures for the faceted vs unfaceted scan: selecting groups must narrow the rows. + const ALL = [ + { period: '2022', value_eur: 9000, eu_value_eur: 0, contracts: 90 }, + { period: '2023', value_eur: 6000, eu_value_eur: 0, contracts: 60 }, + ]; + const FACETED = [ + { period: '2022', value_eur: 2000, eu_value_eur: 0, contracts: 20 }, + { period: '2023', value_eur: 1000, eu_value_eur: 0, contracts: 10 }, + ]; + const calls: QueryCall[] = []; + const db = { + prepare(sql: string) { + return { + args: [] as unknown[], + bind(...args: unknown[]) { + this.args = args; + calls.push({ sql, args }); + return this; + }, + async all() { + return { results: (this.args.includes('45233') ? FACETED : ALL) as T[] }; + }, + async first() { + if (sql.includes('as_of')) return { as_of: null } as T; + return { dated: 10, total: 10 } as T; + }, + }; + }, + } as unknown as D1Database; + + const all = await getSpendingTrend(db, { granularity: 'year' }, { includeSectors: false }); + const faceted = await getSpendingTrend( + db, + { granularity: 'year', cpvGroups: ['45233', '33600'] }, + { includeSectors: false }, + ); + + // Selection narrows the chart rows — exact totals from the faceted fixture only. + expect(all.totalValueEur).toBe(15000); + expect(faceted.totalValueEur).toBe(3000); + expect(faceted.points.map((pt) => [pt.period, pt.valueEur, pt.contracts])).toEqual([ + ['2022', 2000, 20], + ['2023', 1000, 10], + ]); + + // One aggregate scan: tenders joined once, an OR of half-open index ranges, all params bound. + const series = calls.filter((c) => c.sql.includes('GROUP BY period')); + expect(series).toHaveLength(2); + const sql = series[1]!.sql; + expect(sql).toContain('JOIN tenders t ON t.id = c.tender_id'); + expect(sql).toContain( + '((t.cpv_code >= ? AND t.cpv_code < ?) OR (t.cpv_code >= ? AND t.cpv_code < ?))', + ); + expect(series[1]!.args).toEqual(['2020-01-01', '45233', '45234', '33600', '33601']); + // The unfaceted default is untouched: no join, no range params. + expect(series[0]!.sql).not.toContain('JOIN tenders'); + expect(series[0]!.args).toEqual(['2020-01-01']); + }); + + it('ignores malformed CPV groups instead of joining tenders on garbage', async () => { + const captured: string[] = []; + await getSpendingTrend(fakeDb(captured), { cpvGroups: ['4523', 'abcde', "45'--"] }); + const series = captured.find((q) => q.includes('GROUP BY period'))!; + expect(series).not.toContain('JOIN tenders'); + expect(series).not.toContain('t.cpv_code >= ?'); + }); + + it('folds monthly rows into a continuous quarterly series (queried at month grain)', async () => { + const sqls: string[] = []; + const { points, granularity } = await getSpendingTrend(fakeDb(sqls), { + granularity: 'quarter', + }); + // Quarters come from the monthly substr, not a SQL quarter expression. + expect(sqls.some((s) => s.includes('substr(c.signed_at, 1, 7)'))).toBe(true); + expect(granularity).toBe('quarter'); + expect(points.map((p) => p.period)).toEqual([ + '2022-Q1', + '2022-Q2', + '2022-Q3', + '2022-Q4', + '2023-Q1', + ]); + // 2022-01 + 2022-03 land in the same quarter; the gap quarters are zero-filled. + expect(points[0]).toMatchObject({ valueEur: 4000, contracts: 40 }); + expect(points[1]).toMatchObject({ valueEur: 0, contracts: 0 }); + expect(points.at(-1)).toMatchObject({ valueEur: 5000, contracts: 50 }); + }); + + it('with includeCurrent, marks the as_of quarter partial', async () => { + const { points, years } = await getSpendingTrend(fakeDb(undefined, '2023-01-15'), { + granularity: 'quarter', + includeCurrent: true, + }); + expect(points.at(-1)).toMatchObject({ period: '2023-Q1', partial: true }); + expect(points.find((p) => p.period === '2022-Q1')).toMatchObject({ partial: false }); + expect(years.find((y) => y.year === '2023')).toMatchObject({ partial: true, yoyPct: null }); + }); + it('scopes the trend by bidderId through the contract bidder', async () => { const national = await getSpendingTrend(scopedFakeDb([]), { granularity: 'year' }); const calls: QueryCall[] = []; @@ -174,3 +367,213 @@ describe('getSpendingTrend', () => { expect(series.args).toEqual(['2020-01-01', 'eik:222']); }); }); + +// ── Contracts overview queries ─────────────────────────────────────────────────────────────────── + +// Fake D1 that routes each prepared statement by SQL shape and records { sql, args } for assertions. +function overviewDb(handlers: { + all?: (sql: string, args: unknown[]) => unknown[]; + first?: (sql: string, args: unknown[]) => unknown; + calls?: QueryCall[]; +}): D1Database { + return { + prepare(sql: string) { + return { + args: [] as unknown[], + bind(...args: unknown[]) { + this.args = args; + handlers.calls?.push({ sql, args }); + return this; + }, + async all() { + return { results: (handlers.all?.(sql, this.args) ?? []) as T[] }; + }, + async first() { + return (handlers.first?.(sql, this.args) ?? null) as T; + }, + }; + }, + } as unknown as D1Database; +} + +describe('getCpvGroupStats', () => { + // cnt=101 → floor-rank percentiles: p10 at rn 11, median at rn 51, p90 at rn 91 (matches the SQL's + // integer division). The rows below stand in for the quantile ladder the query returns. + const DIST_33600 = [ + { v: 100, name: 'Фармацевтични продукти', rn: 1, cnt: 101 }, + { v: 1000, name: 'Фармацевтични продукти', rn: 11, cnt: 101 }, + { v: 38000, name: 'Фармацевтични продукти', rn: 51, cnt: 101 }, + { v: 200000, name: 'Медицински консумативи', rn: 91, cnt: 101 }, + { v: 900000, name: null, rn: 101, cnt: 101 }, + ]; + const DIST_45000 = [{ v: 5000, name: 'Строителни работи', rn: 1, cnt: 1 }]; + + function db(calls: QueryCall[]): D1Database { + return overviewDb({ + calls, + all(sql, args) { + if (sql.includes('GROUP BY grp')) { + return [ + { grp: '33600', contracts: 101 }, + { grp: '45000', contracts: 1 }, + ]; + } + if (args[0] === '33600') return DIST_33600; + if (args[0] === '45000') return DIST_45000; + return []; + }, + first(sql) { + if (sql.includes('COUNT(DISTINCT')) return { n: 2045 }; + return null; + }, + }); + } + + it('returns top groups with exact floor-rank percentiles from one bounded pass per group', async () => { + const { groups, totalGroups } = await getCpvGroupStats(db([]), 2); + expect(totalGroups).toBe(2045); + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ + group: '33600', + contracts: 101, + p10Eur: 1000, + medianEur: 38000, + p90Eur: 200000, + maxEur: 900000, + name: 'Фармацевтични продукти', // most common description among the sample + }); + expect(groups[0]!.sampleEur).toEqual([100, 1000, 38000, 200000, 900000]); + // A single-contract group degenerates to that one value everywhere. + expect(groups[1]).toMatchObject({ + group: '45000', + p10Eur: 5000, + medianEur: 5000, + p90Eur: 5000, + }); + }); + + it('scans each group through a half-open cpv_code prefix range (indexable)', async () => { + const calls: QueryCall[] = []; + await getCpvGroupStats(db(calls), 2); + const dist = calls.filter((c) => c.sql.includes('ROW_NUMBER() OVER')); + expect(dist.map((c) => c.args)).toEqual([ + ['33600', '33601'], + ['45000', '45001'], + ]); + expect(dist[0]!.sql).toContain('t.cpv_code >= ? AND t.cpv_code < ?'); + // The distribution query never sorts by anything unindexed and returns only picked ranks. + expect(dist[0]!.sql).toContain('rn = (cnt - 1) * 5 / 10 + 1'); + }); +}); + +describe('getCpvGroupMedians', () => { + it('returns the lower median per group, dedupes and drops malformed groups', async () => { + const calls: QueryCall[] = []; + const db = overviewDb({ + calls, + first(sql, args) { + if (!sql.includes('rn = (cnt - 1) * 5 / 10 + 1')) return null; + if (args[0] === '22112') return { v: 6300, name: ' Училищни учебници ', cnt: 10 }; + if (args[0] === '99999') return { v: 100, name: null, cnt: 3 }; + return null; + }, + }); + const medians = await getCpvGroupMedians(db, ['22112', '22112', 'bogus', '99999', '4500']); + expect(medians).toEqual([ + { group: '22112', name: 'Училищни учебници', contracts: 10, medianEur: 6300 }, + { group: '99999', name: null, contracts: 3, medianEur: 100 }, + ]); + // Two valid unique groups → exactly two median statements; '…9' prefix rolls to the next char. + const medianCalls = calls.filter((c) => c.sql.includes('rn = (cnt - 1)')); + expect(medianCalls).toHaveLength(2); + expect(medianCalls[1]!.args).toEqual(['99999', '9999:']); + }); + + it('is a no-op for an empty group list', async () => { + expect(await getCpvGroupMedians(overviewDb({}), [])).toEqual([]); + }); +}); + +describe('listOverviewContracts', () => { + const ROWS = [ + { + id: 'c:abc', + signed_at: '2025-06-01', + amount_eur: 125000, + cpv_code: '33600000', + authority_name: 'УМБАЛ Александровска ЕАД', + bidder_name: 'Апекс Инженеринг ООД', + bidder_kind: 'company', + }, + { + id: 'c:def', + signed_at: '2025-05-01', + amount_eur: 500, + cpv_code: null, + authority_name: 'Община Брегово', + bidder_name: 'Фирма А; Фирма Б', + bidder_kind: 'consortium', + }, + ]; + + it('maps rows to overview cards (slug, display names, 5-digit group)', async () => { + const db = overviewDb({ all: () => ROWS }); + const items = await listOverviewContracts(db, {}); + expect(items).toEqual([ + { + id: 'abc', + signedAt: '2025-06-01', + valueEur: 125000, + authorityName: 'УМБАЛ Александровска ЕАД', + bidderName: 'Апекс Инженеринг ООД', + cpvGroup: '33600', + }, + { + id: 'def', + signedAt: '2025-05-01', + valueEur: 500, + authorityName: 'Община Брегово', + bidderName: 'Фирма А и др.', // consortium folded like the rest of the site + cpvGroup: null, + }, + ]); + }); + + it('applies year and CPV-group cuts and the value sort, all bounded by LIMIT', async () => { + const calls: QueryCall[] = []; + const db = overviewDb({ calls, all: () => [] }); + await listOverviewContracts(db, { + year: '2024', + cpvGroups: ['45233'], + sort: 'value', + limit: 12, + }); + const call = calls[0]!; + expect(call.sql).toContain('substr(c.signed_at, 1, 4) = ?'); + expect(call.sql).toContain('t.cpv_code >= ? AND t.cpv_code < ?'); + expect(call.sql).toContain('ORDER BY c.amount_eur DESC'); + expect(call.args).toEqual(['2020-01-01', '2024', '45233', '45234', 12]); + }); + + it('facets on multiple CPV groups as one OR-of-ranges cut and drops malformed codes', async () => { + const calls: QueryCall[] = []; + const db = overviewDb({ calls, all: () => [] }); + await listOverviewContracts(db, { cpvGroups: ['45233', '33600', 'bogus'] }); + const call = calls[0]!; + expect(call.sql).toContain( + '((t.cpv_code >= ? AND t.cpv_code < ?) OR (t.cpv_code >= ? AND t.cpv_code < ?))', + ); + expect(call.args).toEqual(['2020-01-01', '45233', '45234', '33600', '33601', 24]); + }); + + it('defaults to newest-first within the trend window on the same value basis', async () => { + const calls: QueryCall[] = []; + const db = overviewDb({ calls, all: () => [] }); + await listOverviewContracts(db, {}); + const call = calls[0]!; + expect(call.sql).toContain('ORDER BY c.signed_at DESC'); + expect(call.sql).toContain('c.amount_eur > 0'); + expect(call.sql).toContain('substr(c.signed_at, 1, 4) GLOB'); + expect(call.args).toEqual(['2020-01-01', 24]); + }); +}); diff --git a/packages/db/src/queries/trend.ts b/packages/db/src/queries/trend.ts index 576a9043..0d521a1b 100644 --- a/packages/db/src/queries/trend.ts +++ b/packages/db/src/queries/trend.ts @@ -4,15 +4,45 @@ // usable signing date are excluded from the series and reported as coverage. Edge-cached at the route, // like getFlows; precompute is a possible follow-up. -import type { TrendData, TrendPoint, TrendYear } from '@sigma/api-contract'; +import type { + CpvGroupMedian, + CpvGroupStat, + OverviewContract, + SectorRef, + TrendData, + TrendGranularity, + TrendPoint, + TrendYear, +} from '@sigma/api-contract'; +import { CPV_SECTORS } from '@sigma/config'; +import { cleanName, entityName } from '@sigma/shared'; +import { contractSlug } from './identity'; import { sectorOptions } from './sectors'; export interface TrendParams { sector?: string | null; funding?: 'all' | 'eu' | 'national'; - granularity?: 'month' | 'year'; + granularity?: TrendGranularity; authorityId?: string | null; bidderId?: string | null; + // 5-digit CPV group prefixes (the /trends обзор multi-select). Faceting stays inside the one + // aggregate series scan: an OR of half-open cpv_code prefix ranges on the tenders join, never a + // per-group query. Malformed codes are dropped here (defense in depth behind the route parser). + cpvGroups?: string[] | null; + // The current (as_of) period is still filling, so by default it is EXCLUDED from the series and + // the per-year fold — a half-filled month/quarter/year reading as a real dip is worse than a + // shorter chart. Opt in (the /trends „вкл. текущия месец" toggle) to get it back, flagged `partial`. + includeCurrent?: boolean; +} + +/** Valid selected 5-digit CPV group codes, or [] — shared by the trend + overview-list scopes. */ +function validCpvGroups(groups: string[] | null | undefined): string[] { + return (groups ?? []).filter((g) => /^\d{5}$/.test(g)); +} + +/** `(range OR range …)` clause over idx_tenders_cpv for a validated group set (caller pushes params). */ +function cpvGroupsClause(groups: string[]): string { + return `(${groups.map(() => '(t.cpv_code >= ? AND t.cpv_code < ?)').join(' OR ')})`; } export interface TrendQueryOptions { @@ -41,11 +71,17 @@ function scope(p: TrendParams): { join: string; where: string[]; params: unknown // amount_eur IS NOT NULL, so the trend must too, or the same total differs between pages. const where = ['c.amount_eur IS NOT NULL']; const params: unknown[] = []; - const join = p.sector || p.authorityId ? 'JOIN tenders t ON t.id = c.tender_id' : ''; + const cpvGroups = validCpvGroups(p.cpvGroups); + const join = + p.sector || p.authorityId || cpvGroups.length ? 'JOIN tenders t ON t.id = c.tender_id' : ''; if (p.sector) { where.push('substr(t.cpv_code, 1, 2) = ?'); params.push(p.sector); } + if (cpvGroups.length) { + where.push(cpvGroupsClause(cpvGroups)); + for (const g of cpvGroups) params.push(...cpvGroupRange(g)); + } if (p.authorityId) { where.push('t.authority_id = ?'); params.push(p.authorityId); @@ -59,13 +95,28 @@ function scope(p: TrendParams): { join: string; where: string[]; params: unknown return { join, where, params }; } +// 'YYYY-MM' → 'YYYY-Qn'. Quarter series is queried monthly and folded here (no SQL date math). +function quarterOf(month: string): string { + const [y, m] = month.split('-') as [string, string]; + return `${y}-Q${Math.ceil(Number(m) / 3)}`; +} + // Continuous period keys (inclusive) for zero-filling gaps, so the chart has no holes. -function fillPeriods(first: string, last: string, granularity: 'month' | 'year'): string[] { +function fillPeriods(first: string, last: string, granularity: TrendGranularity): string[] { if (granularity === 'year') { const out: string[] = []; for (let y = Number(first); y <= Number(last); y += 1) out.push(String(y)); return out; } + if (granularity === 'quarter') { + const [fy, fq] = first.split('-Q').map(Number) as [number, number]; + const [ly, lq] = last.split('-Q').map(Number) as [number, number]; + const out: string[] = []; + for (let q = fy * 4 + (fq - 1); q <= ly * 4 + (lq - 1); q += 1) { + out.push(`${Math.floor(q / 4)}-Q${(q % 4) + 1}`); + } + return out; + } const [fy, fm] = first.split('-').map(Number) as [number, number]; const [ly, lm] = last.split('-').map(Number) as [number, number]; const out: string[] = []; @@ -81,7 +132,9 @@ export async function getSpendingTrend( options: TrendQueryOptions = {}, ): Promise { const includeSectors = options.includeSectors ?? true; - const granularity = p.granularity === 'year' ? 'year' : 'month'; + const granularity: TrendGranularity = + p.granularity === 'year' || p.granularity === 'quarter' ? p.granularity : 'month'; + // Quarters are queried at month grain (substr can't cut a quarter) and folded below. const periodLen = granularity === 'year' ? 4 : 7; // substr length: 'YYYY' vs 'YYYY-MM' const s = scope(p); @@ -112,10 +165,30 @@ export async function getSpendingTrend( // The final period (the as_of period) is still being filled; mark it so the chart and table do not // read its dip as a real decline, and so YoY is not computed against a partial year. const asOf = asOfRow?.as_of ?? null; - const partialPeriod = asOf ? asOf.slice(0, periodLen) : null; + const asOfPeriod = asOf ? asOf.slice(0, periodLen) : null; + const partialPeriod = + asOfPeriod && granularity === 'quarter' ? quarterOf(asOfPeriod) : asOfPeriod; const partialYear = asOf ? asOf.slice(0, 4) : null; - const rows = series.results; + let rows = series.results; + if (granularity === 'quarter' && rows.length) { + // Fold the monthly rows into quarters (input is sorted by period, so quarters stay in order). + const byQuarter = new Map(); + for (const r of rows) { + const period = quarterOf(r.period); + const acc = byQuarter.get(period) ?? { period, value_eur: 0, contracts: 0 }; + acc.value_eur += r.value_eur; + acc.contracts += r.contracts; + byQuarter.set(period, acc); + } + rows = [...byQuarter.values()]; + } + // Default: drop the current (as_of) period entirely — month, quarter AND year grain — before the + // zero-fill, so the series ends on the last COMPLETE period. With includeCurrent it stays in, + // flagged `partial` below (dashed tail / faded bar in the charts, YoY suppressed). + if (!(p.includeCurrent ?? false) && partialPeriod) { + rows = rows.filter((r) => r.period !== partialPeriod); + } let points: TrendPoint[] = []; if (rows.length) { const byPeriod = new Map(rows.map((r) => [r.period, r])); @@ -142,9 +215,11 @@ export async function getSpendingTrend( yearMap.set(y, acc); } const sortedYears = [...yearMap.keys()].sort(); - const years: TrendYear[] = sortedYears.map((year, i) => { + const years: TrendYear[] = sortedYears.map((year) => { const cur = yearMap.get(year)!; - const prev = i > 0 ? yearMap.get(sortedYears[i - 1]!)! : null; + // Strictly the adjacent prior year: if it is absent (a gap in the series), YoY stays null + // rather than silently comparing against a non-adjacent year. + const prev = yearMap.get(String(Number(year) - 1)) ?? null; const partial = year === partialYear; return { year, @@ -171,3 +246,233 @@ export async function getSpendingTrend( scope: { sector: p.sector ?? null, funding: p.funding ?? 'all', granularity }, }; } + +// ── Contracts overview: per-CPV-group price distributions + the filtered contract cards ────────── +// +// A CPV "group" is the 5-digit class prefix of tenders.cpv_code. There is no precomputed percentile +// rollup (sector_totals is per 2-digit division, count/sum only), so percentiles are computed live — +// but bounded: only the top-N groups by contract count get the full distribution, and every per-group +// scan rides idx_tenders_cpv via a half-open prefix range (cpv_code >= G AND cpv_code < succ(G)). +// The route is edge-cached, so these scans run once per cache window, not per request. + +// A usable CPV group is 5 leading digits. +const CPV_GROUP_GLOB = "t.cpv_code GLOB '[0-9][0-9][0-9][0-9][0-9]*'"; + +/** Half-open index range covering every cpv_code with the 5-digit prefix (works for '…9' too). */ +function cpvGroupRange(group: string): [string, string] { + const hi = group.slice(0, -1) + String.fromCharCode(group.charCodeAt(group.length - 1) + 1); + return [group, hi]; +} + +// One pass over a group's positive-EUR contracts (sorted by value, via the CPV index range) that +// returns only ~30 rows: the exact p10/p50/p90 ranks, a ~5%-step quantile ladder for the dot cloud, +// and the top outliers. Rank arithmetic is integer (SQLite '/' floors), mirrored in JS below. +const GROUP_DIST_SQL = ` + WITH s AS ( + SELECT c.amount_eur AS v, t.cpv_description AS name, + ROW_NUMBER() OVER (ORDER BY c.amount_eur) AS rn, + COUNT(*) OVER () AS cnt + FROM contracts c JOIN tenders t ON t.id = c.tender_id + WHERE t.cpv_code >= ? AND t.cpv_code < ? AND c.amount_eur > 0 + ) + SELECT v, name, rn, cnt FROM s + WHERE rn = 1 OR rn = cnt + OR rn = (cnt - 1) * 1 / 10 + 1 + OR rn = (cnt - 1) * 5 / 10 + 1 + OR rn = (cnt - 1) * 9 / 10 + 1 + OR (rn - 1) % (CASE WHEN cnt > 21 THEN (cnt - 1) / 20 ELSE 1 END) = 0 + OR rn > cnt - 5 + ORDER BY rn`; + +interface GroupDistRow { + v: number; + name: string | null; + rn: number; + cnt: number; +} + +/** floor-rank of quantile q among cnt sorted rows (1-based) — must match GROUP_DIST_SQL. */ +const rankOf = (cnt: number, q10: number) => Math.floor(((cnt - 1) * q10) / 10) + 1; + +// Most common non-empty description among the sampled rows — a representative human label for the +// group without a separate dictionary scan. +function sampleName(rows: GroupDistRow[]): string | null { + const freq = new Map(); + for (const r of rows) { + const name = r.name?.trim(); + if (name) freq.set(name, (freq.get(name) ?? 0) + 1); + } + let best: string | null = null; + let bestN = 0; + for (const [name, n] of freq) { + if (n > bestN) { + best = name; + bestN = n; + } + } + return best; +} + +function toGroupStat(group: string, rows: GroupDistRow[]): CpvGroupStat | null { + if (!rows.length) return null; + const cnt = rows[0]!.cnt; + const at = (rank: number) => rows.find((r) => r.rn === rank)?.v ?? rows[0]!.v; + return { + group, + name: sampleName(rows), + contracts: cnt, + medianEur: at(rankOf(cnt, 5)), + p10Eur: at(rankOf(cnt, 1)), + p90Eur: at(rankOf(cnt, 9)), + maxEur: rows[rows.length - 1]!.v, + sampleEur: rows.map((r) => r.v), + }; +} + +export interface CpvGroupStatsResult { + groups: CpvGroupStat[]; // top-N by contract count, in that order + totalGroups: number; // distinct 5-digit groups in the corpus (the headline KPI) +} + +/** + * Top-N CPV groups by contract count, each with median / p10–p90 / max and a real-value sample for + * the distribution row. One grouped scan for the ranking (same precedent as the live sector facet in + * queries/contracts.ts), then one bounded indexed pass per group. + * + * Perf note: the ranking scan is a full GROUP BY over all positive-EUR contracts (no rollup table + * backs 5-digit CPV groups today — sector_totals is 2-digit division only), so cost grows with + * corpus size. The route is edge-cached, so this runs once per cache window, not per request. + * TODO: if the corpus grows enough for this to matter, precompute a per-group rollup (mirroring + * sector_totals) instead of scanning contracts/tenders live here. + */ +export async function getCpvGroupStats(db: D1Database, limit = 10): Promise { + const [top, totalRow] = await Promise.all([ + db + .prepare( + `SELECT substr(t.cpv_code, 1, 5) AS grp, COUNT(*) AS contracts + FROM contracts c JOIN tenders t ON t.id = c.tender_id + WHERE c.amount_eur > 0 AND ${CPV_GROUP_GLOB} + GROUP BY grp ORDER BY contracts DESC, grp LIMIT ?`, + ) + .bind(limit) + .all<{ grp: string; contracts: number }>(), + db + .prepare( + `SELECT COUNT(DISTINCT substr(cpv_code, 1, 5)) AS n + FROM tenders t WHERE ${CPV_GROUP_GLOB}`, + ) + .first<{ n: number }>(), + ]); + + const dists = await Promise.all( + top.results.map((r) => + db + .prepare(GROUP_DIST_SQL) + .bind(...cpvGroupRange(r.grp)) + .all(), + ), + ); + + const groups = top.results + .map((r, i) => toGroupStat(r.grp, dists[i]!.results)) + .filter((g): g is CpvGroupStat => g !== null); + return { groups, totalGroups: totalRow?.n ?? 0 }; +} + +/** + * Median (plus count and a representative name) for arbitrary CPV groups — the „спрямо типичното" + * cohort baseline for contract cards whose group is outside the top-N stats. Bounded by the caller: + * one indexed pass per requested group, and the card page has at most a handful of distinct groups. + */ +export async function getCpvGroupMedians( + db: D1Database, + groups: string[], +): Promise { + const unique = [...new Set(groups)].filter((g) => /^\d{5}$/.test(g)); + if (!unique.length) return []; + const rows = await Promise.all( + unique.map((g) => + db + .prepare( + `WITH s AS ( + SELECT c.amount_eur AS v, t.cpv_description AS name, + ROW_NUMBER() OVER (ORDER BY c.amount_eur) AS rn, + COUNT(*) OVER () AS cnt + FROM contracts c JOIN tenders t ON t.id = c.tender_id + WHERE t.cpv_code >= ? AND t.cpv_code < ? AND c.amount_eur > 0 + ) + SELECT v, name, cnt FROM s WHERE rn = (cnt - 1) * 5 / 10 + 1`, + ) + .bind(...cpvGroupRange(g)) + .first<{ v: number; name: string | null; cnt: number }>(), + ), + ); + const out: CpvGroupMedian[] = []; + unique.forEach((group, i) => { + const r = rows[i]; + if (r) out.push({ group, name: r.name?.trim() || null, contracts: r.cnt, medianEur: r.v }); + }); + return out; +} + +export interface OverviewContractsParams { + year?: string | null; // 'YYYY' + cpvGroups?: string[] | null; // 5-digit prefixes (multi-select facet; OR of index ranges) + sort?: 'date' | 'value'; + limit?: number; +} + +interface OverviewRow { + id: string; + signed_at: string | null; + amount_eur: number; + cpv_code: string | null; + authority_name: string; + bidder_name: string; + bidder_kind: 'company' | 'consortium'; +} + +/** + * The shared contract cards under the overview lenses: same value/date basis as the trend series + * (positive EUR, real signing date inside the window), optionally cut by year and/or CPV group, + * newest-first or biggest-first. Bounded LIMIT; rides idx_contracts_signed / idx_contracts_amount_eur + * (and idx_tenders_cpv for the group cut). + */ +export async function listOverviewContracts( + db: D1Database, + p: OverviewContractsParams, +): Promise { + const where = ['c.amount_eur > 0', YEAR_KNOWN, 'c.signed_at >= ?', "c.signed_at <= date('now')"]; + const params: unknown[] = [START]; + if (p.year) { + where.push('substr(c.signed_at, 1, 4) = ?'); + params.push(p.year); + } + const groups = validCpvGroups(p.cpvGroups); + if (groups.length) { + where.push(cpvGroupsClause(groups)); + for (const g of groups) params.push(...cpvGroupRange(g)); + } + const order = + p.sort === 'value' ? 'ORDER BY c.amount_eur DESC, c.id' : 'ORDER BY c.signed_at DESC, c.id'; + const { results } = await db + .prepare( + `SELECT c.id, c.signed_at, c.amount_eur, t.cpv_code, + a.name AS authority_name, b.name AS bidder_name, b.kind AS bidder_kind + FROM contracts c + JOIN tenders t ON t.id = c.tender_id + JOIN authorities a ON a.id = t.authority_id + JOIN bidders b ON b.id = c.bidder_id + WHERE ${where.join(' AND ')} ${order} LIMIT ?`, + ) + .bind(...params, p.limit ?? 24) + .all(); + return results.map((r) => ({ + id: contractSlug(r.id), + signedAt: r.signed_at, + valueEur: r.amount_eur, + authorityName: cleanName(r.authority_name), + bidderName: entityName(cleanName(r.bidder_name), r.bidder_kind), + cpvGroup: r.cpv_code && /^\d{5}/.test(r.cpv_code) ? r.cpv_code.slice(0, 5) : null, + })); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 150a51d1..31fddee3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ overrides: vite@8: ^8.0.16 undici: ^7.28.0 '@babel/core': ^7.29.6 + sharp: ^0.35.0 + postcss: ^8.5.18 + valibot: ^1.4.2 importers: @@ -36,7 +39,7 @@ importers: version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) wrangler: specifier: ^4.93.1 - version: 4.93.1(@cloudflare/workers-types@4.20260521.1) + version: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@25.9.1) apps/etl: dependencies: @@ -82,10 +85,10 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.1 - version: 1.37.3(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)) + version: 1.37.3(@types/node@22.19.19)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)) '@react-router/dev': specifier: 7.18.0 - version: 7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)) + version: 7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.3.0(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)) @@ -112,7 +115,7 @@ importers: version: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0) wrangler: specifier: ^4.75.0 - version: 4.93.1(@cloudflare/workers-types@4.20260521.1) + version: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19) packages/api-contract: dependencies: @@ -412,6 +415,9 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -584,152 +590,161 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1555,8 +1570,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1597,8 +1612,8 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} prettier@3.8.3: @@ -1662,17 +1677,22 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1771,8 +1791,8 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - valibot@1.4.0: - resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -2219,15 +2239,16 @@ snapshots: optionalDependencies: workerd: 1.20260520.1 - '@cloudflare/vite-plugin@1.37.3(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))': + '@cloudflare/vite-plugin@1.37.3(@types/node@22.19.19)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1) - miniflare: 4.20260520.0 + miniflare: 4.20260520.0(@types/node@22.19.19) unenv: 2.0.0-rc.24 vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0) - wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1) + wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19) ws: 8.21.0 transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate - workerd @@ -2288,6 +2309,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -2375,98 +2401,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.2 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.2 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.2': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.2': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.2': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.2 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.2 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.2 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.2 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.2 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.2 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.3': dependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.2 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -2518,7 +2554,7 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@react-router/dev@7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))': + '@react-router/dev@7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))': dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 @@ -2545,14 +2581,14 @@ snapshots: prettier: 3.8.3 react-refresh: 0.14.2 react-router: 7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - semver: 7.8.0 + semver: 7.8.5 tinyglobby: 0.2.17 - valibot: 1.4.0(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0) vite-node: 3.2.4(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0) optionalDependencies: typescript: 5.9.3 - wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1) + wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -3131,21 +3167,35 @@ snapshots: mdn-data@2.27.1: {} - miniflare@4.20260520.0: + miniflare@4.20260520.0(@types/node@22.19.19): dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 + sharp: 0.35.3(@types/node@22.19.19) undici: 7.28.0 workerd: 1.20260520.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + miniflare@4.20260520.0(@types/node@25.9.1): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.3(@types/node@25.9.1) + undici: 7.28.0 + workerd: 1.20260520.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.16: {} node-releases@2.0.45: {} @@ -3178,9 +3228,9 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - postcss@8.5.15: + postcss@8.5.23: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -3269,40 +3319,75 @@ snapshots: semver@6.3.1: {} - semver@7.8.0: {} + semver@7.8.5: {} set-cookie-parser@2.7.2: {} - sharp@0.34.5: + sharp@0.35.3(@types/node@22.19.19): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.19.19 + + sharp@0.35.3(@types/node@25.9.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 25.9.1 siginfo@2.0.0: {} @@ -3382,7 +3467,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - valibot@1.4.0(typescript@5.9.3): + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -3412,7 +3497,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.23 rollup: 4.60.4 tinyglobby: 0.2.17 optionalDependencies: @@ -3425,7 +3510,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.23 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: @@ -3438,7 +3523,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.23 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: @@ -3505,13 +3590,31 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260520.1 '@cloudflare/workerd-windows-64': 1.20260520.1 - wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1): + wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260520.0(@types/node@22.19.19) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260520.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260521.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@25.9.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 4.20260520.0 + miniflare: 4.20260520.0(@types/node@25.9.1) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 workerd: 1.20260520.1 @@ -3519,6 +3622,7 @@ snapshots: '@cloudflare/workers-types': 4.20260521.1 fsevents: 2.3.3 transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 96815cea..bba29a73 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,15 @@ overrides: # @babel/core <7.29.6 — arbitrary file read via sourceMappingURL (GHSA-4x5r-pxfx-6jf8); # dev/build-time only (via @react-router/dev), never ships to the Worker. '@babel/core': '^7.29.6' + # sharp <0.35.0 — HIGH severity (CVSS 7.0) advisory GHSA-f88m-g3jw-g9cj, via + # wrangler→miniflare. Dev/build-time only; never ships to the Worker. + sharp: '^0.35.0' + # postcss <8.5.18 — path traversal via sourceMappingURL auto-load + # (GHSA-r28c-9q8g-f849); patch-level fix. + # valibot <1.4.2 — flatten() crashes on inherited-property keys + # (GHSA-5qjj-4xww-7phc); patch-level fix. + postcss: '^8.5.18' + valibot: '^1.4.2' onlyBuiltDependencies: - esbuild