diff --git a/apps/web/app/components/ComboTrendChart.test.ts b/apps/web/app/components/ComboTrendChart.test.ts new file mode 100644 index 000000000..33dec985f --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import type { TrendPoint } from '@sigma/api-contract'; +import { yearAxisTicks } from '../lib/trendAxis'; + +// Mirrors ComboTrendChart's own x(i) and left% formulas exactly (W/PAD match the component's +// constants) so this test fails if the component's positioning drifts from x(t.i) again. +const W = 1000; +const PAD = 8; +function xOf(i: number, n: number) { + return PAD + (i * (W - 2 * PAD)) / (n - 1); +} +function leftPct(i: number, n: number) { + return (xOf(i, n) / W) * 100; +} + +describe('ComboTrendChart year-label positioning', () => { + it('places each label at its tick x(t.i) position, not evenly spaced, when years are unevenly distributed', () => { + // Years clustered at the start (2021, 2022 one point apart) with a long gap before 2026 — a + // flow-based `justify-content: space-between` layout would spread these four labels evenly + // across the width, landing the last two under the wrong bars. + const points: TrendPoint[] = [ + { period: '2021', valueEur: 1, contracts: 1, partial: false }, + { period: '2022', valueEur: 1, contracts: 1, partial: false }, + { period: '2023', valueEur: 1, contracts: 1, partial: false }, + { period: '2024', valueEur: 1, contracts: 1, partial: false }, + { period: '2025', valueEur: 1, contracts: 1, partial: false }, + { period: '2026', valueEur: 1, contracts: 1, partial: true }, + ]; + const n = points.length; + const ticks = yearAxisTicks(points, 'year'); + + const positions = ticks.map((t) => ({ year: t.year, leftPct: leftPct(t.i, n) })); + + expect(positions).toEqual([ + { year: '2021', leftPct: leftPct(0, n) }, + { year: '2022', leftPct: leftPct(1, n) }, + { year: '2023', leftPct: leftPct(2, n) }, + { year: '2024', leftPct: leftPct(3, n) }, + { year: '2025', leftPct: leftPct(4, n) }, + { year: '2026', leftPct: leftPct(5, n) }, + ]); + + // Evenly-spaced (flow-based) positions would be i / (ticks.length - 1) * 100 — assert the + // computed positions do NOT match that for the interior ticks, proving this is tick-driven. + const evenlySpaced = ticks.map((_t, idx) => (idx / (ticks.length - 1)) * 100); + expect(positions.map((p) => p.leftPct)).not.toEqual(evenlySpaced); + }); + + it('matches tick x-position exactly for a fixture with a long gap before the last year (month grain)', () => { + const points: TrendPoint[] = [ + { period: '2021-11', valueEur: 1, contracts: 1, partial: false }, + { period: '2021-12', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-01', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-02', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-03', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-04', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-05', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-06', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-07', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-08', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-09', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-10', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-11', valueEur: 1, contracts: 1, partial: false }, + { period: '2022-12', valueEur: 1, contracts: 1, partial: false }, + { period: '2023-01', valueEur: 1, contracts: 1, partial: true }, + ]; + const n = points.length; + const ticks = yearAxisTicks(points, 'month'); + + expect(ticks).toEqual([ + { i: 2, year: '2022' }, + { i: 14, year: '2023' }, + ]); + expect(ticks.map((t) => leftPct(t.i, n))).toEqual([leftPct(2, n), leftPct(14, n)]); + }); +}); diff --git a/apps/web/app/components/ComboTrendChart.tsx b/apps/web/app/components/ComboTrendChart.tsx new file mode 100644 index 000000000..49c63053f --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.tsx @@ -0,0 +1,146 @@ +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) => PAD + (i * (W - 2 * PAD)) / (n - 1); + 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. + // `partial` is only ever set on the as_of (final) period (see TrendPoint in api-contract), so a + // leading partial can't occur — `> 0` intentionally treats an all-partial single-point series (or + // the impossible partialIdx === 0 case) as fully solid, mirroring TrendChart. + const partialIdx = points.findIndex((p) => p.partial); + const hasPartial = partialIdx > 0; + 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 ? `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 && ( + + )} +
+ ); +} diff --git a/apps/web/app/components/FullscreenButton.tsx b/apps/web/app/components/FullscreenButton.tsx new file mode 100644 index 000000000..efb2a85b6 --- /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 000000000..20100c123 --- /dev/null +++ b/apps/web/app/components/MetricInfo.tsx @@ -0,0 +1,118 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +// useLayoutEffect warns when it runs during SSR ("does nothing on the server"). Swap in useEffect +// for the server render so the console stays clean; the client still gets the synchronous layout +// measurement it needs before paint. +const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect; + +// 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); + // Pointer users reveal the popover via CSS `:hover`/`:focus-within` without ever raising `open` + // (that state only drives the click/touch path). Track hover and focus separately so + // `aria-expanded` reflects the actually-visible state, not just the click toggle. + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const visible = open || hovered || focused; + 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); + + useIsomorphicLayoutEffect(() => { + if (!open) { + setShift(0); + return; + } + const pop = popRef.current; + if (!pop) return; + const recalc = () => { + 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)); + }; + recalc(); + // Viewport can change while the popover is open (rotation, browser-chrome resize, scroll on + // small screens) — recompute so the popover doesn't drift outside the viewport. + window.addEventListener('resize', recalc); + window.addEventListener('scroll', recalc, true); + return () => { + window.removeEventListener('resize', recalc); + window.removeEventListener('scroll', recalc, true); + }; + }, [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 ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + ); +} diff --git a/apps/web/app/components/TrendChart.tsx b/apps/web/app/components/TrendChart.tsx index 9248669de..520df384f 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 8e14b0317..547ce5dda 100644 --- a/apps/web/app/lib/analytics-lenses.ts +++ b/apps/web/app/lib/analytics-lenses.ts @@ -11,14 +11,19 @@ export const ANALYTICS_LENSES = [ }, { href: '/trends', - title: 'Тренд', - desc: 'Как се движат разходите във времето по месеци и години.', + title: 'Договори — обзор', + desc: 'Договорите във времето, по CPV код, или двете наведнъж — с типичните цени по група.', }, { href: '/competition', title: 'Конкуренция', desc: 'Къде има висок дял „една оферта“ и концентрация на доставчици.', }, + { + href: '/overruns', + title: 'Раздуване', + desc: 'Кои договори, институции и сектори се раздуват най-много след сключване — и тенденцията във времето.', + }, ] as const; export const ANALYTICS_NAV_PATHS = [ diff --git a/apps/web/app/lib/analytics-stats.test.ts b/apps/web/app/lib/analytics-stats.test.ts new file mode 100644 index 000000000..8ff0cf981 --- /dev/null +++ b/apps/web/app/lib/analytics-stats.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import type { TrendPoint } from '@sigma/api-contract'; +import { + estimateYoyGrowth, + formatPeakMonth, + formatPpChange, + formatYearlyGrowth, + growthMultiple, + opaqueHeadline, + peakPoint, + type OpaqueShareYear, + type PeakablePoint, +} from './analytics-stats'; +import { formatGrowthFactor } from './overruns-chart'; + +describe('growthMultiple', () => { + it('turns a median overrun pct into 1 + pct as a ×-multiple', () => { + expect(growthMultiple(2.1)).toBe('3,1× (+210%)'); + expect(growthMultiple(0.5)).toBe('1,5× (+50%)'); + // Delegates to formatGrowthFactor, which strips a trailing „,0" — so a whole multiple reads „1×". + expect(growthMultiple(0)).toBe('1× (0%)'); + }); + it('matches /overruns formatGrowthFactor exactly (single source of truth)', () => { + for (const pct of [2.1, 0.5, 0, 1, 3.04]) { + expect(growthMultiple(pct)).toBe(formatGrowthFactor(pct)); + } + }); + it('returns an em-dash for absent / non-finite input', () => { + expect(growthMultiple(null)).toBe('—'); + expect(growthMultiple(undefined)).toBe('—'); + expect(growthMultiple(NaN)).toBe('—'); + }); +}); + +describe('formatYearlyGrowth', () => { + it('formats a ratio as a signed integer percent per year', () => { + expect(formatYearlyGrowth(0.18)).toBe('+18%/год'); + expect(formatYearlyGrowth(-0.04)).toBe('−4%/год'); + }); + it('returns an em-dash for null', () => { + expect(formatYearlyGrowth(null)).toBe('—'); + }); +}); + +describe('peakPoint / formatPeakMonth', () => { + const points: PeakablePoint[] = [ + { period: '2025-01', valueEur: 100 }, + { period: '2025-12', valueEur: 900 }, + { period: '2026-06', valueEur: 999, partial: true }, // partial — skipped + ]; + it('finds the highest-value complete period', () => { + expect(peakPoint(points)?.period).toBe('2025-12'); + }); + it('returns null for an empty / all-partial series', () => { + expect(peakPoint([])).toBeNull(); + expect(peakPoint([{ period: '2026-06', valueEur: 5, partial: true }])).toBeNull(); + }); + it('abbreviates the month for the peak label', () => { + expect(formatPeakMonth('2025-12')).toBe('дек 2025'); + expect(formatPeakMonth('2024-01')).toBe('яну 2024'); + expect(formatPeakMonth(null)).toBe('—'); + }); +}); + +describe('opaqueHeadline / formatPpChange', () => { + const rows: OpaqueShareYear[] = [ + { year: '2020', valueEur: 1000, singleOfferValueEur: 200 }, // 20% + { year: '2021', valueEur: 0, singleOfferValueEur: 0 }, // no value — dropped + { year: '2025', valueEur: 1000, singleOfferValueEur: 350 }, // 35% + ]; + it('reads first vs latest single-offer value share and the pp swing', () => { + const h = opaqueHeadline(rows)!; + expect(h.firstYear).toBe('2020'); + expect(h.latestYear).toBe('2025'); + expect(h.firstShare).toBeCloseTo(0.2); + expect(h.latestShare).toBeCloseTo(0.35); + expect(h.ppChange).toBeCloseTo(0.15); + }); + it('returns null when no year has value', () => { + expect(opaqueHeadline([{ year: '2020', valueEur: 0, singleOfferValueEur: 0 }])).toBeNull(); + expect(opaqueHeadline([])).toBeNull(); + }); + it('formats a percentage-point swing', () => { + expect(formatPpChange(0.15)).toBe('+15 пр.п.'); + expect(formatPpChange(-0.03)).toBe('−3 пр.п.'); + expect(formatPpChange(0)).toBe('0 пр.п.'); + expect(formatPpChange(null)).toBe('—'); + }); +}); + +// Build a full calendar year of monthly points with a flat per-month value/count. +function year( + y: number, + monthlyValue: number, + monthlyCount: number, + partial = false, +): TrendPoint[] { + return Array.from({ length: 12 }, (_, i) => ({ + period: `${y}-${String(i + 1).padStart(2, '0')}`, + valueEur: monthlyValue, + contracts: monthlyCount, + // mark the final month of the year partial when requested + partial: partial && i === 11, + })); +} + +describe('estimateYoyGrowth', () => { + it('recovers the YoY growth factor from complete years', () => { + const points = [...year(2021, 100, 50), ...year(2022, 120, 55), ...year(2023, 144, 60.5)]; + const g = estimateYoyGrowth(points); + expect(g.value).toBeCloseTo(1.2, 5); // 100 → 120 → 144 + expect(g.count).toBeCloseTo(1.1, 5); // 50 → 55 → 60.5 + }); + + it('ignores the partial final year', () => { + const points = [ + ...year(2021, 100, 50), + ...year(2022, 120, 55), + ...year(2023, 999, 999, true), // partial → excluded from the estimate + ]; + const g = estimateYoyGrowth(points); + expect(g.value).toBeCloseTo(1.2, 5); + }); + + it('returns a flat factor with fewer than two complete years', () => { + expect(estimateYoyGrowth(year(2023, 100, 50, true))).toEqual({ value: 1, count: 1 }); + }); + + it('clamps an absurd ratio into the sane band', () => { + const points = [...year(2021, 1, 1), ...year(2022, 1000, 1000)]; + const g = estimateYoyGrowth(points); + expect(g.value).toBeLessThanOrEqual(2); + expect(g.value).toBeGreaterThanOrEqual(0.5); + }); + + it('uses the median so an early corpus ramp-up year does not dominate the figure', () => { + // 2020 is the artificially-low open-data ramp-up year: a one-off +260% spike, then a steady ~+15%. + const points = [ + ...year(2020, 10, 5), + ...year(2021, 36, 18), // +260% — the backfill artifact + ...year(2022, 41, 20), // +14% + ...year(2023, 47, 23), // +15% + ...year(2024, 54, 26), // +15% + ]; + const g = estimateYoyGrowth(points); + // Endpoint CAGR / geometric mean would carry the spike forward at ~+52%/yr ((54/10)^(1/4)≈1.52); + // the median of the four ratios lands on the genuine sustainable ~+15%. + expect(g.value).toBeGreaterThan(1.1); + expect(g.value).toBeLessThan(1.25); + }); + + it('throws on a non-monthly (quarter/year granularity) series instead of silently returning flat', () => { + const quarterly: TrendPoint[] = [ + { period: '2022-Q1', valueEur: 100, contracts: 50, partial: false }, + { period: '2022-Q2', valueEur: 100, contracts: 50, partial: false }, + ]; + expect(() => estimateYoyGrowth(quarterly)).toThrow(/monthly/i); + + const yearly: TrendPoint[] = [ + { period: '2022', valueEur: 100, contracts: 50, partial: false }, + { period: '2023', valueEur: 120, contracts: 55, partial: false }, + ]; + expect(() => estimateYoyGrowth(yearly)).toThrow(/monthly/i); + }); + + it('groups monthly points into full calendar years before computing ratios (/analytics granularity)', () => { + // /analytics feeds this with `granularity: 'month'` series (365-ish rows/yr, not one row/yr). A + // naive "recent N points" implementation would treat 24 monthly points as 2 short years; the + // real grouping keys by period.slice(0, 4) so the ratio is still computed year-over-year. + const points = [...year(2022, 100, 50), ...year(2023, 130, 55)]; + expect(points).toHaveLength(24); // sanity: this is monthly input, not 2 yearly rows + const g = estimateYoyGrowth(points); + expect(g.value).toBeCloseTo(1.3, 5); + expect(g.count).toBeCloseTo(1.1, 5); + }); +}); diff --git a/apps/web/app/lib/analytics-stats.ts b/apps/web/app/lib/analytics-stats.ts new file mode 100644 index 000000000..5b68c3714 --- /dev/null +++ b/apps/web/app/lib/analytics-stats.ts @@ -0,0 +1,203 @@ +// Pure, unit-tested formatters + derivations for the /analytics landing cards. Each card shows two +// real KPI figures sourced from the loader's rollup queries; these helpers turn the raw numbers the +// DB returns into the exact card strings (a growth multiple, a yearly-growth tag, an abbreviated peak +// month, a percentage-point swing). No DB, no rendering — just arithmetic + formatting, so they can +// be tested in isolation (repo convention: no render tests). Every helper is honest about thin data: +// a missing / non-finite input returns the em-dash, never a fabricated figure. + +import type { TrendPoint } from '@sigma/api-contract'; +import { signedPct } from '@sigma/shared'; + +import { formatGrowthFactor } from './overruns-chart'; + +const EM_DASH = '—'; + +// Abbreviated Bulgarian month names for the trend „ПИК" stat (e.g. '2025-12' → „дек 2025"). +const MONTHS_SHORT_BG = [ + 'яну', + 'фев', + 'мар', + 'апр', + 'май', + 'юни', + 'юли', + 'авг', + 'сеп', + 'окт', + 'ное', + 'дек', +]; + +// Median post-annex growth as a multiple of the signing value: a median overrun of +210% (pct 2.1) +// reads „3,1×". Delegates to /overruns' formatGrowthFactor so /analytics and /overruns render the +// identical string (single source of truth for the „×" formatting); null/non-finite → em-dash. +export function growthMultiple(medianPct: number | null | undefined): string { + if (medianPct == null || !Number.isFinite(medianPct)) return EM_DASH; + return formatGrowthFactor(medianPct); +} + +// „+18%/год" — yearly growth as a signed integer percentage with the per-year suffix. The input ratio +// is the canonical /trends growth estimate (3-year trailing median, clamped) so the landing card and +// the /trends header always read the same figure. +export function formatYearlyGrowth(ratio: number | null | undefined): string { + if (ratio == null || !Number.isFinite(ratio)) return EM_DASH; + return `${signedPct(ratio, 0)}/год`; +} + +export interface PeakablePoint { + period: string; // 'YYYY-MM' + valueEur: number; + partial?: boolean; +} + +// The highest-value complete period in the series (the partial final period is skipped so a half-month +// dip never reads as the peak). Returns null for an empty / all-partial series. +export function peakPoint(points: T[]): T | null { + let best: T | null = null; + for (const p of points) { + if (p.partial) continue; + if (best == null || p.valueEur > best.valueEur) best = p; + } + return best; +} + +// 'YYYY-MM' → „дек 2025" (abbreviated month + year). +export function formatPeakMonth(period: string | null | undefined): string { + if (!period) return EM_DASH; + const m = /^(\d{4})-(\d{2})$/.exec(period); + if (!m) return period; + const month = MONTHS_SHORT_BG[Number(m[2]) - 1] ?? m[2]; + return `${month} ${m[1]}`; +} + +export interface OpaqueShareYear { + year: string; + valueEur: number; + singleOfferValueEur: number; +} + +export interface OpaqueHeadline { + latestYear: string; + latestShare: number; // ratio + firstYear: string; + firstShare: number; // ratio + ppChange: number; // latestShare − firstShare, in ratio units (multiply by 100 for пр.п.) +} + +// Single-offer value share for the latest and first years on record, plus the percentage-point swing +// between them. Years with no value are dropped (their share is undefined); null when nothing remains. +export function opaqueHeadline(rows: OpaqueShareYear[]): OpaqueHeadline | null { + const usable = rows.filter((r) => r.valueEur > 0).sort((a, b) => a.year.localeCompare(b.year)); + if (usable.length === 0) return null; + const first = usable[0]!; + const last = usable[usable.length - 1]!; + // singleOfferValueEur can exceed valueEur (or dip below 0) on dirty source rows — clamp so the + // share stays a valid ratio. + const clampRatio = (v: number) => Math.min(1, Math.max(0, v)); + const firstShare = clampRatio(first.singleOfferValueEur / first.valueEur); + const latestShare = clampRatio(last.singleOfferValueEur / last.valueEur); + return { + latestYear: last.year, + latestShare, + firstYear: first.year, + firstShare, + ppChange: latestShare - firstShare, + }; +} + +// A percentage-point swing as „+7 пр.п." / „−3 пр.п." (rounded to a whole point). Input is a ratio +// difference (0.07 → „+7 пр.п."); a flat or non-finite delta drops the sign. +export function formatPpChange(deltaRatio: number | null | undefined): string { + if (deltaRatio == null || !Number.isFinite(deltaRatio)) return EM_DASH; + const points = Math.round(deltaRatio * 100); + const sign = points > 0 ? '+' : points < 0 ? '−' : ''; + return `${sign}${Math.abs(points)} пр.п.`; +} + +// ===== YoY growth estimate for the „Тренд" card ===== +// Ported from the retired /trends seasonal forecast so /analytics owns the derivation it renders. + +export interface GrowthFactors { + value: number; // YoY multiplier for spend (1.0 = flat) + count: number; // YoY multiplier for contract count +} + +// Guard against a single freak year producing an absurd growth figure. A real YoY ratio for national +// procurement sits well inside this band; anything outside is treated as data noise and clamped. +const MIN_GROWTH = 0.5; +const MAX_GROWTH = 2; + +function clampGrowth(ratio: number): number { + if (!Number.isFinite(ratio) || ratio <= 0) return 1; + return Math.min(MAX_GROWTH, Math.max(MIN_GROWTH, ratio)); +} + +function median(xs: number[]): number { + if (xs.length === 0) return 1; + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2; +} + +// The growth rate is estimated from a TRAILING window of the most recent complete years, not the +// whole history. The early years of this corpus are the open-data feed's ramp-up (2020 → 2021 was +// +258% as the backfill filled in) — carrying that one-off spike forward gives an absurdly aggressive +// figure (~+57%/yr). A 3-year trailing window captures the genuine, sustainable recent rate. +const GROWTH_TRAILING_YEARS = 3; + +/** + * Estimate the YoY growth multiplier (spend + contract count) from the actual monthly series. + * Only complete years (12 non-partial months with a positive total) feed the estimate; the partial + * final year and a partial first year are ignored so a half-year never skews the ratio. Of those, + * only the last {@link GROWTH_TRAILING_YEARS} are used (the trailing window above) — and this + * trailing window is what protects the figure from the early ramp-up years, not the median per se. + * The factor is the median of the consecutive year ratios within the window; at the default 3-year + * window there are only two ratios, so the median coincides with their mean. Fewer than two complete + * years → flat. + */ +export function estimateYoyGrowth(points: TrendPoint[]): GrowthFactors { + // Precondition: a monthly (`YYYY-MM`) series — completeness below is judged by "12 months seen + // per year", which is only meaningful at month granularity. `TrendGranularity` also allows + // 'quarter' ('YYYY-Qn') and 'year' ('YYYY'); feeding either here would silently fail the + // months===12 check for every year and fall through to a flat {value:1,count:1} that reads as + // "no growth" instead of "wrong input" — assert instead so a future non-month caller fails loud. + for (const p of points) { + if (!/^\d{4}-\d{2}$/.test(p.period)) { + throw new Error( + `estimateYoyGrowth: expected a monthly (YYYY-MM) series, got period "${p.period}" — quarter/year granularity is not supported`, + ); + } + } + const byYear = new Map< + number, + { value: number; count: number; months: number; partial: boolean } + >(); + for (const p of points) { + const y = Number(p.period.slice(0, 4)); + const acc = byYear.get(y) ?? { value: 0, count: 0, months: 0, partial: false }; + acc.value += p.valueEur; + acc.count += p.contracts; + acc.months += 1; + if (p.partial) acc.partial = true; + byYear.set(y, acc); + } + const complete = [...byYear.entries()] + .filter(([, v]) => v.months === 12 && !v.partial && v.value > 0) + .sort((a, b) => a[0] - b[0]); + if (complete.length < 2) return { value: 1, count: 1 }; + // Only the last N complete years (the trailing window) drive the rate. + const recent = complete.slice(-GROWTH_TRAILING_YEARS); + + const valueRatios: number[] = []; + const countRatios: number[] = []; + for (let i = 1; i < recent.length; i += 1) { + const prev = recent[i - 1]![1]; + const cur = recent[i]![1]; + if (prev.value > 0) valueRatios.push(cur.value / prev.value); + if (prev.count > 0) countRatios.push(cur.count / prev.count); + } + return { + value: clampGrowth(valueRatios.length ? median(valueRatios) : 1), + count: clampGrowth(countRatios.length ? median(countRatios) : 1), + }; +} diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts index 158f16093..e9cb9ef16 100644 --- a/apps/web/app/lib/filters.test.ts +++ b/apps/web/app/lib/filters.test.ts @@ -4,8 +4,10 @@ import { authorityListFilters, companyListFilters, contractListFilters, + cpvGroupSelection, getMulti, leaderboardRankOffset, + MAX_CPV_GROUP_SELECTION, MAX_MULTI_VALUES, pageNav, PARAM_ORDER, @@ -118,6 +120,29 @@ describe('getMulti', () => { }); }); +describe('cpvGroupSelection', () => { + it('parses repeatable and CSV ?cpv values into a deduped, order-preserving set', () => { + expect(cpvGroupSelection(sp('cpv=45233&cpv=33600'))).toEqual(['45233', '33600']); + expect(cpvGroupSelection(sp('cpv=45233,33600'))).toEqual(['45233', '33600']); + expect(cpvGroupSelection(sp('cpv=45233&cpv=45233&cpv=33600'))).toEqual(['45233', '33600']); + expect(cpvGroupSelection(sp(''))).toEqual([]); + }); + + 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('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 6e5d621bb..6b8bad9ee 100644 --- a/apps/web/app/lib/filters.ts +++ b/apps/web/app/lib/filters.ts @@ -31,6 +31,26 @@ 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, order-preserving, capped at MAX_CPV_GROUP_SELECTION. + * Malformed or excess codes are dropped before they reach a filter or mint an edge-cache key + * variant (CWE-349). + */ +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)) + .slice(0, MAX_CPV_GROUP_SELECTION); +} + /** * 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 @@ -178,13 +198,16 @@ export function buildSectorGroup( // Canonical serialization order so the same logical state always yields the same URL string — // good for history/bookmarks/caching. Filter facets first, then search/sort, then the paging cursor // markers. Link param order (cosmetic). Every entry must be in CANONICAL_QUERY_PARAMS (asserted in -// filters.test.ts); withParams drops unknown params entirely, and a known param not listed here sorts last. +// filters.test.ts); withParams drops unknown params entirely, and a known param not listed here sorts +// last — it is NOT dropped. PARAM_ORDER only covers /contracts (its filter rail); the /trends and +// /overruns params (`step`, `angle`, `by`, `cpvSort`, `cur`) are in CANONICAL_QUERY_PARAMS so withParams +// keeps them, they just fall after this list in the generated URL rather than at a curated position. export const PARAM_ORDER = [ 'q', 'type', 'kind', 'sector', - 'g', // trends granularity (month/year) + 'g', // RESERVED for #144 (still open): /network graph-only re-centre fetch 'year', 'procedure', 'funding', diff --git a/apps/web/app/lib/overruns-chart.test.ts b/apps/web/app/lib/overruns-chart.test.ts new file mode 100644 index 000000000..5ddae4290 --- /dev/null +++ b/apps/web/app/lib/overruns-chart.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + formatGrowthFactor, + overrunBarGeometry, + scatterGeometry, + type ScatterDatum, +} from './overruns-chart'; + +describe('overrunBarGeometry', () => { + it('splits the current value into signing + overrun shares that sum to 100', () => { + const g = overrunBarGeometry(1_000_000, 1_500_000, 3_000_000); + expect(g.signPct).toBeCloseTo(66.7, 1); + expect(g.incPct).toBeCloseTo(33.3, 1); + expect(g.signPct + g.incPct).toBeCloseTo(100, 1); + }); + + it('scales the bar length against the corpus max (longest contract fills the track)', () => { + expect(overrunBarGeometry(1, 3_000_000, 3_000_000).nowScalePct).toBe(100); + expect(overrunBarGeometry(1, 1_500_000, 3_000_000).nowScalePct).toBe(50); + }); + + it('clamps an over-large signing so the overrun share can never go negative', () => { + const g = overrunBarGeometry(5_000_000, 1_000_000, 1_000_000); + expect(g.signPct).toBe(100); + expect(g.incPct).toBe(0); + }); + + it('collapses to an empty bar for a non-positive current value (honest, no NaN)', () => { + const g = overrunBarGeometry(0, 0, 1_000_000); + expect(g).toEqual({ signPct: 0, incPct: 0, nowScalePct: 0 }); + }); +}); + +describe('formatGrowthFactor', () => { + it('renders a pct ratio as a Bulgarian-formatted multiple of the signed value', () => { + expect(formatGrowthFactor(2.1)).toBe('3,1× (+210%)'); + expect(formatGrowthFactor(1)).toBe('2× (+100%)'); + expect(formatGrowthFactor(0)).toBe('1× (0%)'); + }); + + it('returns an em-dash for a non-finite input', () => { + expect(formatGrowthFactor(Number.NaN)).toBe('—'); + }); +}); + +describe('scatterGeometry', () => { + const rows: ScatterDatum[] = [ + { id: 'a', pct: 0.25, deltaEur: 65_000_000, annexCount: 1, rank: 1 }, + { id: 'b', pct: 0.5, deltaEur: 56_000_000, annexCount: 9, rank: 2 }, + { id: 'c', pct: 48.18, deltaEur: 52_000_000, annexCount: 3, rank: 3 }, + { id: 'd', pct: 36.19, deltaEur: 24_000_000, annexCount: 2, rank: 4 }, + ]; + + it('returns an honest empty plot (valid frame, no points) for no rows', () => { + const g = scatterGeometry([]); + expect(g.points).toHaveLength(0); + expect(g.grid).toHaveLength(0); + expect(g.xticks).toHaveLength(0); + expect(g.axis.right).toBeGreaterThan(g.axis.left); + }); + + it('maps higher growth % further right on the log x-axis', () => { + const g = scatterGeometry(rows); + const byId = Object.fromEntries(g.points.map((p) => [p.id, p])); + expect(byId.c!.x).toBeGreaterThan(byId.b!.x); + expect(byId.b!.x).toBeGreaterThan(byId.a!.x); + }); + + it('maps larger overrun € higher (smaller y) on the linear y-axis', () => { + const g = scatterGeometry(rows); + const byId = Object.fromEntries(g.points.map((p) => [p.id, p])); + expect(byId.a!.y).toBeLessThan(byId.d!.y); // 65M sits above 24M + }); + + it('grows the bubble radius with the annex count', () => { + const g = scatterGeometry(rows); + const byId = Object.fromEntries(g.points.map((p) => [p.id, p])); + expect(byId.b!.r).toBeGreaterThan(byId.a!.r); // 9 annexes vs 1 + }); + + it('flags the heaviest overruns (top half by €) as big', () => { + const g = scatterGeometry(rows); + const byId = Object.fromEntries(g.points.map((p) => [p.id, p])); + expect(byId.a!.big).toBe(true); + expect(byId.d!.big).toBe(false); + }); + + it('keeps every point inside the plot frame', () => { + const g = scatterGeometry(rows); + for (const p of g.points) { + expect(p.x).toBeGreaterThanOrEqual(g.axis.left); + expect(p.x).toBeLessThanOrEqual(g.axis.right); + expect(p.y).toBeGreaterThanOrEqual(g.axis.top); + expect(p.y).toBeLessThanOrEqual(g.axis.bottom); + } + }); + + it('emits nice round growth-% ticks within the data range', () => { + const g = scatterGeometry(rows); + expect(g.xticks.length).toBeGreaterThanOrEqual(2); + for (const t of g.xticks) { + expect(t.x).toBeGreaterThanOrEqual(g.axis.left - 0.1); + expect(t.x).toBeLessThanOrEqual(g.axis.right + 0.1); + } + }); +}); diff --git a/apps/web/app/lib/overruns-chart.ts b/apps/web/app/lib/overruns-chart.ts new file mode 100644 index 000000000..4702b0162 --- /dev/null +++ b/apps/web/app/lib/overruns-chart.ts @@ -0,0 +1,187 @@ +// Pure, SSR-safe geometry for the Overruns („Раздуване") dashboard. No DOM, no React — just maths, +// so it is unit-testable and the route stays a thin renderer. Two visual primitives: +// +// 1. overrunBarGeometry — the before→now stacked bar (ink = value at signing, accent = the overrun), +// sized against a shared corpus scale so bars are comparable across rows. +// 2. scatterGeometry — the „Облак на раздуването" cloud: x = % growth on a log axis, y = € overrun +// on a linear axis, bubble radius = annex count. Data-driven bounds (no hard-coded mock maxima), +// with honest empty handling so a corpus with no overruns yields an empty (not NaN) chart. +// +// Both mirror the Claude-Design mock's proportions but read their extents from the real rows. + +import { signedPct } from '@sigma/shared'; + +const round1 = (n: number): number => Math.round(n * 10) / 10; + +export interface OverrunBarGeometry { + /** Width of the ink (value-at-signing) segment, as a % of the bar's own length. */ + signPct: number; + /** Width of the accent (overrun) segment, as a % of the bar's own length. */ + incPct: number; + /** Length of the whole bar as a % of the shared corpus scale (0–100, clamped). */ + nowScalePct: number; +} + +// One stacked bar. The two inner segments split the CURRENT value into „paid at signing" vs „ballooned +// after"; the whole bar's length is the current value against the corpus scale max (so the longest +// contract fills the track). Guards: a non-positive current collapses to an empty bar; signing is +// clamped into [0, current] so a stray over-large signing can never push incPct negative. +export function overrunBarGeometry( + signingEur: number, + currentEur: number, + scaleMaxEur: number, +): OverrunBarGeometry { + const current = Math.max(currentEur, 0); + if (current <= 0) return { signPct: 0, incPct: 0, nowScalePct: 0 }; + const signing = Math.min(Math.max(signingEur, 0), current); + const signShare = signing / current; + const scale = scaleMaxEur > 0 ? Math.min(1, current / scaleMaxEur) : 0; + const signPct = round1(signShare * 100); + return { signPct, incPct: round1(100 - signPct), nowScalePct: round1(scale * 100) }; +} + +/** + * Median-growth KPI: a pct ratio (0.5 = +50%) shown as a multiple of the signed value with the + * percentage spelled out so „1,4×" cannot be misread as „40% of", e.g. „1,4× (+40%)". + */ +export function formatGrowthFactor(pctRatio: number): string { + if (!Number.isFinite(pctRatio)) return '—'; + const factor = Math.max(0, 1 + pctRatio); + const s = (Math.round(factor * 10) / 10).toFixed(1).replace(/\.0$/, '').replace('.', ','); + return `${s}× (${signedPct(pctRatio)})`; +} + +export interface ScatterDatum { + /** Stable key (contract id) — echoed back so the renderer can wire selection/hover. */ + id: string; + /** Growth ratio (0.5 = +50%); mapped onto the log x-axis as a percentage. */ + pct: number; + /** Absolute overrun € — the linear y-axis. */ + deltaEur: number; + /** Annex count — drives bubble radius. */ + annexCount: number; + /** 1-based rank in the active ordering, used as the bubble label. */ + rank: number; +} + +export interface ScatterPoint { + id: string; + rank: number; + x: number; + y: number; + r: number; + /** True for the heaviest overruns (top half by €) — the renderer paints these in the accent. */ + big: boolean; +} + +export interface ScatterGridLine { + y: number; + /** € value this gridline marks (renderer formats it). */ + value: number; +} + +export interface ScatterTick { + x: number; + /** Growth percentage (whole-number, e.g. 100 = +100%) this tick marks. */ + pctPercent: number; +} + +export interface ScatterGeometry { + width: number; + height: number; + /** Plot frame in viewBox units. */ + axis: { left: number; right: number; top: number; bottom: number }; + points: ScatterPoint[]; + grid: ScatterGridLine[]; + xticks: ScatterTick[]; +} + +export interface ScatterOptions { + width?: number; + height?: number; + left?: number; + right?: number; + top?: number; + bottom?: number; + /** Floor for the log x-axis, in percent (avoids log(0) and keeps tiny growths on-scale). */ + minPctFloor?: number; +} + +const DEFAULTS = { + width: 380, + height: 250, + left: 40, + right: 372, + top: 16, + bottom: 212, + minPctFloor: 5, +}; + +// „Nice" round growth-% ticks spanning the data's log range. Picks from a fixed ladder so labels read +// as +10% / +100% / +1000% rather than arbitrary 10^x values. +const TICK_LADDER = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000]; + +function chooseXTicks(loPct: number, hiPct: number): number[] { + const inRange = TICK_LADDER.filter((t) => t >= loPct && t <= hiPct); + if (inRange.length >= 2) return inRange.slice(0, 5); + // Degenerate/narrow range: bracket it with the nearest ladder stops so the axis still has marks. + const below = [...TICK_LADDER].reverse().find((t) => t <= loPct) ?? TICK_LADDER[0]!; + const above = TICK_LADDER.find((t) => t >= hiPct) ?? TICK_LADDER[TICK_LADDER.length - 1]!; + return Array.from(new Set([below, above])); +} + +// Build the cloud. Returns empty `points`/`grid`/`xticks` (but a valid frame) when there are no rows, +// so the SVG renders an honest empty plot rather than dividing by zero. +export function scatterGeometry( + data: ScatterDatum[], + options: ScatterOptions = {}, +): ScatterGeometry { + const o = { ...DEFAULTS, ...options }; + const axis = { left: o.left, right: o.right, top: o.top, bottom: o.bottom }; + const base: ScatterGeometry = { + width: o.width, + height: o.height, + axis, + points: [], + grid: [], + xticks: [], + }; + if (data.length === 0) return base; + + const pctPercents = data.map((d) => Math.max(o.minPctFloor, d.pct * 100)); + const loPct = Math.min(...pctPercents); + const hiPct = Math.max(...pctPercents); + const logLo = Math.log10(loPct); + const logHi = Math.log10(hiPct); + const logSpan = logHi - logLo || 1; // identical pcts → flat range, avoid /0 + const xOf = (pctPercent: number): number => { + const clamped = Math.max(loPct, Math.min(hiPct, pctPercent)); + return axis.left + ((Math.log10(clamped) - logLo) / logSpan) * (axis.right - axis.left); + }; + + const deltaMax = Math.max(...data.map((d) => d.deltaEur), 1); + const yOf = (delta: number): number => + axis.bottom - (Math.max(0, delta) / deltaMax) * (axis.bottom - axis.top); + + const bigThreshold = deltaMax / 2; + const points: ScatterPoint[] = data.map((d) => ({ + id: d.id, + rank: d.rank, + x: round1(xOf(Math.max(o.minPctFloor, d.pct * 100))), + y: round1(yOf(d.deltaEur)), + r: round1(4 + Math.min(Math.max(d.annexCount, 0), 12) * 1.25), + big: d.deltaEur >= bigThreshold, + })); + + const grid: ScatterGridLine[] = [0, 1 / 3, 2 / 3, 1].map((f) => { + const value = deltaMax * f; + return { y: round1(yOf(value)), value }; + }); + + const xticks: ScatterTick[] = chooseXTicks(loPct, hiPct).map((pctPercent) => ({ + x: round1(xOf(pctPercent)), + pctPercent, + })); + + return { ...base, points, grid, xticks }; +} diff --git a/apps/web/app/lib/overruns-inspector.test.ts b/apps/web/app/lib/overruns-inspector.test.ts new file mode 100644 index 000000000..1243f7389 --- /dev/null +++ b/apps/web/app/lib/overruns-inspector.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { OverrunAnnex } from '@sigma/db'; +import { contractStatus, groupAnnexes, STATUS_LABEL } from './overruns-inspector'; + +const NOW = new Date('2026-06-27T12:00:00Z'); + +describe('contractStatus', () => { + it('returns „closed" when the term date is before today', () => { + expect(contractStatus('2024-12-31', NOW)).toBe('closed'); + expect(STATUS_LABEL.closed).toBe('Приключен'); + }); + + it('returns „active" when the term date is today or in the future', () => { + expect(contractStatus('2026-06-27', NOW)).toBe('active'); // today is not yet past + expect(contractStatus('2027-01-01', NOW)).toBe('active'); + expect(STATUS_LABEL.active).toBe('В изпълнение'); + }); + + it('reads only the date prefix of a datetime', () => { + expect(contractStatus('2024-01-15T08:30:00Z', NOW)).toBe('closed'); + }); + + it('omits the badge (null) when there is no reliable date', () => { + expect(contractStatus(null, NOW)).toBeNull(); + expect(contractStatus(undefined, NOW)).toBeNull(); + expect(contractStatus('', NOW)).toBeNull(); + expect(contractStatus('не е посочено', NOW)).toBeNull(); + expect(contractStatus('2024', NOW)).toBeNull(); // not a full YYYY-MM-DD + }); +}); + +describe('groupAnnexes', () => { + const row = (over: Partial = {}): OverrunAnnex => ({ + contractId: 'c:1', + date: '2023-01-01', + reason: 'причина', + valueBeforeEur: 100, + valueAfterEur: 150, + deltaEur: 50, + ...over, + }); + + it('groups by contract and assigns a 1-based „Анекс N" sequence in order', () => { + const grouped = groupAnnexes([ + row({ contractId: 'c:1', date: '2023-01-01' }), + row({ contractId: 'c:1', date: '2023-06-01' }), + row({ contractId: 'c:2', date: '2024-01-01' }), + ]); + + expect(Object.keys(grouped)).toEqual(['c:1', 'c:2']); + expect(grouped['c:1']!.map((a) => a.seq)).toEqual([1, 2]); + expect(grouped['c:1']![1]!.date).toBe('2023-06-01'); + expect(grouped['c:2']!.map((a) => a.seq)).toEqual([1]); + }); + + it('carries the real delta and reason through, including nulls', () => { + const grouped = groupAnnexes([row({ deltaEur: null, reason: null })]); + + expect(grouped['c:1']![0]!.deltaEur).toBeNull(); + expect(grouped['c:1']![0]!.reason).toBeNull(); + }); + + it('returns an empty object for no rows', () => { + expect(groupAnnexes([])).toEqual({}); + }); +}); diff --git a/apps/web/app/lib/overruns-inspector.ts b/apps/web/app/lib/overruns-inspector.ts new file mode 100644 index 000000000..8ac0b0604 --- /dev/null +++ b/apps/web/app/lib/overruns-inspector.ts @@ -0,0 +1,54 @@ +// Pure inspector logic for /overruns — kept out of the route component so it is unit-testable and never +// fabricates. Two concerns: deriving a contract's status badge from a REAL term date, and grouping the +// pre-fetched annex rows per contract (assigning the „Анекс N" sequence) for O(1) lookup on selection. + +import type { OverrunAnnex } from '@sigma/db'; + +export type ContractStatus = 'active' | 'closed'; + +/** Human label for the status badge (Bulgarian). */ +export const STATUS_LABEL: Record = { + active: 'В изпълнение', + closed: 'Приключен', +}; + +// Derive the status badge ONLY from a real term/end date. A valid end date in the past → „Приключен"; +// a valid end date today or in the future → „В изпълнение". No reliable date → null, and the caller +// omits the badge entirely (we never invent a status). `now` is injectable for deterministic tests. +export function contractStatus( + endDate: string | null | undefined, + now: Date = new Date(), +): ContractStatus | null { + if (!endDate) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(endDate); + if (!m) return null; + const end = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + if (Number.isNaN(end)) return null; + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + return end < today ? 'closed' : 'active'; +} + +export interface AnnexEntry { + /** 1-based position within the contract's history, by date — the „Анекс N" label. */ + seq: number; + date: string | null; + reason: string | null; + deltaEur: number | null; +} + +// Group flat annex rows (already date-ordered per contract by the SQL) into per-contract lists, assigning +// the 1-based „Анекс N" sequence in arrival order. Returns a plain object so it serialises across the +// loader boundary; the inspector reads `grouped[contractId]` for the selected row. +export function groupAnnexes(rows: OverrunAnnex[]): Record { + const out: Record = {}; + for (const r of rows) { + const list = (out[r.contractId] ??= []); + list.push({ + seq: list.length + 1, + date: r.date, + reason: r.reason, + deltaEur: r.deltaEur, + }); + } + return out; +} diff --git a/apps/web/app/lib/query-params.ts b/apps/web/app/lib/query-params.ts index e7b603a30..fed6ce6c2 100644 --- a/apps/web/app/lib/query-params.ts +++ b/apps/web/app/lib/query-params.ts @@ -2,11 +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 + 'by', // /overruns — sort dimension (absolute | percent) 'center', 'count', + 'cpv', // /trends: repeatable CPV group multi-select faceting the обзор chart + list, validated + // 5-digit by cpvGroupSelection (filters.ts). /contracts does not read this param yet — no reader + // to validate there until that filter lands. + 'cpvSort', // /trends: CPV list ordering + 'cur', // /trends: include the current (partial) period — changes the chart, totals and year cards 'cursor', 'eu', 'funding', @@ -18,6 +25,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', @@ -27,3 +35,12 @@ export const CANONICAL_QUERY_PARAMS = new Set([ // Read but deliberately not response-affecting: excluded from the cache key, still kept in links. None // today; declared so a future one isn't silently absent. export const INTENTIONALLY_UNKEYED = new Set([]); + +// Allow-list entries keyed AHEAD of their reader: params owned by another OPEN stacked/parallel PR +// whose route lands separately. The reverse drift guard ("no stale allow-list entries") skips +// exactly these, so a key nothing will ever read cannot hide here indefinitely — every entry must +// name its owning PR and is removed (from this set) the moment that PR's reader merges. Keep this +// set minimal. +export const RESERVED_CACHE_PARAMS = new Set([ + 'g', // #144 (feat/network-force-layout, still open): /network reads ?g=1 for the graph-only re-centre fetch +]); diff --git a/apps/web/app/lib/trendAxis.ts b/apps/web/app/lib/trendAxis.ts new file mode 100644 index 000000000..8da8839df --- /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.ts b/apps/web/app/routes.ts index 70909b7d1..f45816beb 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -10,6 +10,7 @@ export default [ route('trends', 'routes/trends.tsx'), route('map', 'routes/map.tsx'), route('competition', 'routes/competition.tsx'), + route('overruns', 'routes/overruns.tsx'), route('analytics', 'routes/analytics.tsx'), route('companies', 'routes/companies.tsx'), route('companies.csv', 'routes/companies.csv.tsx'), diff --git a/apps/web/app/routes/analytics.tsx b/apps/web/app/routes/analytics.tsx index bb2d9c9ee..2fd309440 100644 --- a/apps/web/app/routes/analytics.tsx +++ b/apps/web/app/routes/analytics.tsx @@ -1,22 +1,27 @@ +import type { ReactNode } from 'react'; import { Link } from 'react-router'; import { - getCompetitionSummary, - getFlows, - getRegionalSpending, - getSpendingTrend, getDb, + getFlowsHeadline, + getOpaqueShareByYear, + getOverrunsHeadline, + getRegionHeadline, + getSpendingTrend, } from '@sigma/db'; import { count, money, pct } from '@sigma/shared'; -import type { ReactNode } from 'react'; import type { Route } from './+types/analytics'; import { Breadcrumbs } from '../components/Breadcrumbs'; -import { PageHeader } from '../components/PageHeader'; -import { Choropleth } from '../components/Choropleth'; -import { TrendChart } from '../components/TrendChart'; -import { SingleOfferPortion } from '../components/SingleOfferPortion'; -import { Section, ShareBar } from '../components/ui'; +import { MetricInfo } from '../components/MetricInfo'; import { publicCache } from '../lib/cache'; -import { ANALYTICS_LENSES } from '../lib/analytics-lenses'; +import { + estimateYoyGrowth, + formatPeakMonth, + formatPpChange, + formatYearlyGrowth, + growthMultiple, + opaqueHeadline, + peakPoint, +} from '../lib/analytics-stats'; import { seoMeta } from '../lib/meta'; export function meta({ matches }: Route.MetaArgs) { @@ -25,7 +30,7 @@ export function meta({ matches }: Route.MetaArgs) { path: '/analytics', title: 'Анализи — СИГМА', description: - 'Четири аналитични изгледа към обществените поръчки: потоци, карта, тренд и конкуренция.', + 'Пет аналитични изгледа към едни и същи обществени поръчки: раздуване след анекси, потоци на парите, карта по области, тренд във времето и конкуренция на процедурите — всеки води обратно към конкретните договори.', }); } @@ -33,172 +38,389 @@ export function headers() { return { 'Cache-Control': publicCache(1800) }; } +// Five lean, bounded rollup reads — one per landing card — in a single Promise.all (edge-cached +// 1800s). Query budget: getOverrunsHeadline (1) + getFlowsHeadline (1) + getRegionHeadline (1) + +// getSpendingTrend month series (3: series + coverage + as_of) + getOpaqueShareByYear (1) = 7 +// statements per cold load. The derivations (avg YoY, peak month, opaque headline) are pure helpers. export async function loader({ context }: Route.LoaderArgs) { const db = getDb(context.cloudflare.env); - const [flows, regional, trend, competition] = await Promise.all([ - getFlows(db, { top: 3 }), - getRegionalSpending(db, { funding: 'all' }), - getSpendingTrend(db, { funding: 'all', granularity: 'year' }, { includeSectors: false }), - getCompetitionSummary(db), + const [overruns, flows, region, trend, opaque] = await Promise.all([ + getOverrunsHeadline(db), + getFlowsHeadline(db), + getRegionHeadline(db), + getSpendingTrend(db, { funding: 'all', granularity: 'month' }, { includeSectors: false }), + getOpaqueShareByYear(db), ]); + const peak = peakPoint(trend.points); + // Canonical YoY growth (3-year trailing median of complete-year ratios, clamped — ported from the + // retired /trends forecast) over the SAME trend points the loader already fetched. The + // multiplier (1.15) is reported as a ratio (0.15 → „+15%/год") via formatYearlyGrowth. + const growth = estimateYoyGrowth(trend.points); return { - flows: flows.pairs.slice(0, 3), - regions: regional.regions.filter((region) => region.valueEur > 0).slice(0, 3), - allRegions: regional.regions, - regionTotal: regional.totalValueEur, - trend: { - points: trend.points, - latest: trend.years.at(-1) ?? null, - peak: trend.years.reduce( - (best, year) => (best == null || year.valueEur > best.valueEur ? year : best), - null as (typeof trend.years)[number] | null, - ), - }, - competition: { - totals: competition.totals, - topConcentration: competition.topConcentration, - }, + overruns, + flows, + region, + trend: { avgYoy: growth.value - 1, peakPeriod: peak?.period ?? null }, + opaque: opaqueHeadline(opaque), }; } -function LensLink({ to, children }: { to: string; children: ReactNode }) { +interface Stat { + value: string; + label: string; + accent?: boolean; + // Plain-language description shown in the ⓘ popover for this stat (the card uses a stretched link, so + // the MetricInfo button is valid — it sits above the link, not inside an anchor). + summary?: string; + // Optional analytical readout / extra gloss line in the popover (e.g. what the „×" factor means). + hint?: string; +} + +interface CardProps { + index: string; + category: string; + to: string; + titlePre: string; + titleEm: string; + emClass?: string; + desc: string; + cta: string; + stats: Stat[]; + thumb: ReactNode; +} + +// One full-width hero card: editorial left pane (eyebrow → serif title → 2-line description → KPI +// footer + CTA) and a decorative right pane carrying a static thumbnail. The whole card is a single +// real anchor (keyboard-focusable, visible focus ring in CSS); the thumbnail makes no data claim and +// is hidden from assistive tech, while the two KPI figures are plain text. +function AnalyzeCard({ + index, + category, + to, + titlePre, + titleEm, + emClass, + desc, + cta, + stats, + thumb, +}: CardProps) { + return ( +
+
+

+ ИЗГЛЕД · {index} + {category} +

+

+ {/* stretched link: makes the whole card clickable while the stat ⓘ buttons stay above it */} + + {titlePre} + {titleEm} + +

+

{desc}

+
+
+ {stats.map((s) => ( +
+
+ {s.value} +
+
+ {s.label} + {s.summary ? ( + + ) : null} +
+
+ ))} +
+ {cta} → +
+
+ +
+ ); +} + +// ── Decorative thumbnails (aria-hidden, static geometry; colours come from the CSS block) ────────── + +function ThumbOverruns() { + // before → now stacked bars, 4 rows: an ink base segment extended by an accent overrun segment. + const rows = [ + { base: 90, grow: 60 }, + { base: 70, grow: 95 }, + { base: 120, grow: 40 }, + { base: 55, grow: 70 }, + ]; + return ( + + {rows.map((r, i) => { + const y = 22 + i * 34; + return ( + + + + + ); + })} + + ); +} + +function ThumbFlows() { + // Two ribbons weaving from a left authority bar to right company bars (slate + accent). return ( -

- {children} -

+ + + + + + + + ); +} + +function ThumbMap() { + // Stylised choropleth grid — warm tiles of varying weight, the capital tile picked out in accent. + const tiles: (number | 'sofia')[] = [1, 2, 1, 3, 2, 'sofia', 1, 2, 1, 3, 2, 1]; + const cls = (v: number | 'sofia') => + v === 'sofia' + ? 'az-fill-accent' + : v === 3 + ? 'az-fill-ink' + : v === 2 + ? 'az-fill-slate az-thumb-soft' + : 'az-fill-rule'; + return ( + + {tiles.map((v, i) => { + const col = i % 4; + const row = Math.floor(i / 4); + return ( + + ); + })} + + ); +} + +function ThumbTrends() { + // Area under an actual line, a dashed forecast tail, and an accent peak dot. + return ( + + + + + + + ); +} + +function ThumbCompetition() { + // A single line trending up under a faint area — the rising opaque-spend share. + return ( + + + + + ); } export default function Analytics({ loaderData }: Route.ComponentProps) { - const { flows, regions, allRegions, regionTotal, trend, competition } = loaderData; + const { overruns, flows, region, trend, opaque } = loaderData; return ( <>
- - -
-
- {ANALYTICS_LENSES.map((lens) => ( -
-

Изглед

-

- {lens.title} -

-

{lens.desc}

- {lens.href === '/flows' && ( -
-

Най-големи национални потоци

- {flows.length ? ( -
    - {flows.map((flow) => ( -
  • - - {flow.authorityName} → {flow.bidderDisplayName} - - {money(flow.wonEur)} - {count(flow.contracts)} договора -
  • - ))} -
- ) : ( -

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

- )} -
- )} - {lens.href === '/map' && ( -
-
- -
-

Водещи области по стойност

- {regions.length ? ( -
    - {regions.map((region) => ( -
  • - {region.name} - {money(region.valueEur)} - - 0 ? region.valueEur / regionTotal : 0} - /> - -
  • - ))} -
- ) : ( -

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

- )} -
- )} - {lens.href === '/trends' && ( -
-

Годишен национален тренд

- {trend.points.length >= 2 ? ( - <> -
- -
-
- {trend.latest && ( -
-
{trend.latest.partial ? 'Текуща година' : 'Последна година'}
-
- {trend.latest.year} · {money(trend.latest.valueEur)} - {trend.latest.partial && · частично} -
-
- )} - {trend.peak && ( -
-
Пик
-
- {trend.peak.year} · {money(trend.peak.valueEur)} -
-
- )} -
- - ) : ( -

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

- )} -
- )} - {lens.href === '/competition' && ( -
-

Национален дял с една оферта

- - {competition.topConcentration && ( -

- Най-концентриран възложител:{' '} - - {competition.topConcentration.name} - {' '} - (индекс {pct(competition.topConcentration.hhi)}) -

- )} -
- )} - Виж {lens.title.toLowerCase()} → -
- ))} +
+
+

— Анализи

+

+ Едни и същи пари, видени иначе +

+

+ Всеки изглед отговаря на различен въпрос за обществените поръчки, но всички водят + обратно към конкретните договори. Избери ъгъл. +

+
+ +
+ } + /> + + } + /> + + } + /> + + } + /> + + } + />
-
+ +

Данни: Регистър на обществените поръчки (АОП)

+
); diff --git a/apps/web/app/routes/overruns.tsx b/apps/web/app/routes/overruns.tsx new file mode 100644 index 000000000..6256a2164 --- /dev/null +++ b/apps/web/app/routes/overruns.tsx @@ -0,0 +1,911 @@ +import { type ReactNode, useState } from 'react'; +import { Link, useNavigation, useSearchParams } from 'react-router'; +import { count, date, money, moneyBare, pct, signedPct } from '@sigma/shared'; +import { + getDb, + getOverrunAnnexes, + getOverrunsAnalytics, + type OverrunAuthorityRow, + type OverrunRow, + type OverrunSectorRow, +} from '@sigma/db'; +import type { Route } from './+types/overruns'; +import { Breadcrumbs } from '../components/Breadcrumbs'; +import { DataTable, type Column } from '../components/DataTable'; +import { FullscreenButton, useFullscreen } from '../components/FullscreenButton'; +import { MetricInfo } from '../components/MetricInfo'; +import { Callout, ShareBar } from '../components/ui'; +import { publicCache } from '../lib/cache'; +import { withDbRetry } from '../lib/retry'; +import { seoMeta } from '../lib/meta'; +import { + formatGrowthFactor, + overrunBarGeometry, + scatterGeometry, + type ScatterDatum, +} from '../lib/overruns-chart'; +import { + contractStatus, + groupAnnexes, + STATUS_LABEL, + type AnnexEntry, +} from '../lib/overruns-inspector'; +import { withParams } from '../lib/filters'; + +export function meta({ matches }: Route.MetaArgs) { + return seoMeta({ + matches, + path: '/overruns', + title: 'Раздуване — СИГМА', + description: + 'Кои договори се раздуха най-много след подписването чрез анекси. Класация по абсолютно и процентно нарастване, облак на раздуването, по сектори (CPV) и по институции — всеки лев проследим до конкретния договор.', + }); +} + +export function headers() { + return { 'Cache-Control': publicCache(1800) }; +} + +export async function loader({ request, context }: Route.LoaderArgs) { + const by = new URL(request.url).searchParams.get('by') === 'percent' ? 'percent' : 'absolute'; + const db = getDb(context.cloudflare.env); + return withDbRetry(async () => { + // Five bounded queries (see getOverrunsAnalytics): leaderboard, corpus aggregate, median, by- + // authority, by-sector. Then ONE more bounded query for the shown contracts' annex history — the + // inspector is client-selected, so everything it needs is fetched here and rendered from memory. + const data = await getOverrunsAnalytics(db, { by }); + const annexes = await getOverrunAnnexes( + db, + data.rows.map((r) => r.contractId), + ); + return { data, by, annexesByContract: groupAnnexes(annexes) }; + }); +} + +// ── design tokens (mock hexes → app CSS variables) ─────────────────────────────────── +// Static layout/typography styles live in app.css (block „overruns-dashboard"). These constants are +// kept ONLY for the SVG scatter's presentation attributes (fill/stroke) — the few places where a value +// is data-driven and cannot be a static class. +const INK = 'var(--ink)'; +const INK_SOFT = 'var(--ink-soft)'; +const ACCENT = 'var(--accent)'; +const RULE = 'var(--rule)'; +const RULE_SOFT = 'var(--rule-soft)'; +const PAPER = 'var(--paper)'; + +// ── leaderboard table (the accessible figures, every row linked) ────────────────────── +const contractColumns: Column[] = [ + { key: 'rank', header: '#', isRank: true, cell: (_r, i) => i + 1 }, + { + key: 'subject', + header: 'Договор', + isTitle: true, + cell: (r) => {r.subject}, + }, + { + key: 'parties', + header: 'Възложител · Изпълнител', + secondary: true, + cell: (r) => ( + <> + {r.authorityName} + {' → '} + {r.bidderName} + + ), + }, + { key: 'signing', header: 'При сключване', align: 'money', cell: (r) => money(r.signingEur) }, + { key: 'current', header: 'Сега', align: 'money', cell: (r) => money(r.currentEur) }, + { + key: 'delta', + header: 'Нарастване', + align: 'money', + cell: (r) => ( + <> + +{money(r.deltaEur)} ({signedPct(r.pct)}) + + ), + }, + { + key: 'annex', + header: 'Анекси', + align: 'num', + secondary: true, + cell: (r) => count(r.annexCount), + }, +]; + +// ── inspector field helpers (REAL contract metadata, mock-faithful formatting) ──────── +// „Финансиране": EU-funded → „Европейско [· programme]", national → „Национално", unknown → „—". +function financingText(row: OverrunRow): string { + if (row.euFunded == null) return '—'; + if (!row.euFunded) return 'Национално'; + return row.euProgramme ? `Европейско · ${row.euProgramme}` : 'Европейско'; +} + +// „CPV код": „45233110 — Строеж на магистрали" when both present; code alone, or „—" when absent. +function cpvText(row: OverrunRow): string { + if (!row.cpvCode) return '—'; + return row.cpvDescription ? `${row.cpvCode} — ${row.cpvDescription}` : row.cpvCode; +} + +// „Срок": the contract term — the tender's „Очакван край" date when present, else the contract's +// duration in days. Returns null when neither is on record so the row is omitted (never fabricated). +function termText(row: OverrunRow): string | null { + if (row.endDate) return date(row.endDate); + if (row.durationDays != null) return `${count(row.durationDays)} дни`; + return null; +} + +// The structured „ДЕТАЙЛИ ПО ДОГОВОРА" grid — every value is a real contracts/tenders column. The +// „Срок" row is only included when a real term value exists. +function inspectorFields(row: OverrunRow): { k: string; v: string }[] { + const term = termText(row); + return [ + { k: 'Сектор', v: row.sectorLabel }, + { k: 'Процедура', v: row.procedureType ?? '—' }, + { k: 'CPV код', v: cpvText(row) }, + { k: 'Финансиране', v: financingText(row) }, + { k: 'Сключен', v: date(row.signedAt) }, + ...(term ? [{ k: 'Срок', v: term }] : []), + { k: 'Възложител · ЕИК', v: `${row.authorityName} · ${row.authorityEik || '—'}` }, + { k: 'Изпълнител · ЕИК', v: `${row.bidderName} · ${row.bidderEik || 'непотвърден'}` }, + ]; +} + +// ── section header (serif title + mono note, the design's per-section caption row) ──── +function SectionHead({ id, title, note }: { id: string; title: ReactNode; note?: string }) { + return ( +
+

+ {title} +

+ {note ? {note} : null} +
+ ); +} + +// ── before→now stacked bar (decorative; the figures sit beside it as text) ──────────── +// Only the geometry (segment widths, overall length) is inline — it is data-driven. Colours and the +// dashed track live in app.css. +function OverrunBar({ + signingEur, + currentEur, + scaleMaxEur, +}: { + signingEur: number; + currentEur: number; + scaleMaxEur: number; +}) { + const g = overrunBarGeometry(signingEur, currentEur, scaleMaxEur); + return ( +