diff --git a/apps/web/app/components/ComboTrendChart.tsx b/apps/web/app/components/ComboTrendChart.tsx new file mode 100644 index 000000000..3824686b8 --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { count, money, monthYear } from '@sigma/shared'; +import type { ForecastPoint } from '../lib/trends-forecast'; + +// 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; + +/** '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 function ComboTrendChart({ + points, + granularity, + forecast = [], + cssHeight = 240, + interactive = true, + ariaLabel = 'Брой договори и € обем във времето', +}: { + points: TrendPoint[]; + granularity: TrendGranularity; + // Projected months appended after the actuals — rendered as a dashed accent line under a + // „ПРОГНОЗА" region, never in the actuals' style (see lib/trends-forecast.ts for the method). + forecast?: ForecastPoint[]; + cssHeight?: number; + interactive?: boolean; + ariaLabel?: string; +}) { + const [hover, setHover] = useState(null); + if (points.length < 2) return null; + + // Actuals first, projection after; index >= fcStart ⇒ forecast point. + const all: { period: string; valueEur: number; contracts: number; partial: boolean }[] = [ + ...points, + ...forecast.map((f) => ({ + period: f.period, + valueEur: f.valueEur, + contracts: f.contracts, + partial: false, + })), + ]; + const fcStart = points.length; + const hasForecast = forecast.length > 0; + + const n = all.length; + const vMax = Math.max(1, ...all.map((p) => p.valueEur)) * 1.12; + const cMax = Math.max(1, ...all.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. + const partialIdx = points.findIndex((p) => p.partial); + const hasPartial = partialIdx > 0; + const solidEnd = hasPartial ? partialIdx - 1 : fcStart - 1; + const xy = (i: number) => `${x(i).toFixed(1)} ${yV(all[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)}` : ''; + // The projection opens from the last drawn actual point (partial tail if shown, else the last + // complete month) and runs through every forecast month, dashed in the accent color. + const fcLine = hasForecast + ? [fcStart - 1, ...forecast.map((_f, i) => fcStart + i)] + .map((idx, i) => `${i ? 'L' : 'M'}${xy(idx)}`) + .join(' ') + : ''; + // Region boundary: halfway between the last actual and the first projected month. + const fcEdge = hasForecast ? (x(fcStart - 1) + x(fcStart)) / 2 : 0; + + // x-axis year labels at the first period of each year (or every point at year grain). + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; + const ticks = all + .map((p, i) => ({ i, year: p.period.slice(0, 4) })) + .filter(({ i }) => yearStart == null || all[i]!.period.endsWith(yearStart)); + + const hp = hover != null ? all[hover] : null; + const hoverIsForecast = hover != null && hover >= fcStart; + + return ( +
interactive && setHover(null)}> + + {[0, 1 / 3, 2 / 3, 1].map((f) => ( + + ))} + {hasForecast && ( + <> + + + + ПРОГНОЗА + + + )} + {all.map((p, i) => ( + = fcStart ? ' is-forecast' : ''}`} + x={(x(i) - bw / 2).toFixed(1)} + y={yC(p.contracts).toFixed(1)} + width={bw.toFixed(1)} + height={(BOT - yC(p.contracts)).toFixed(1)} + onMouseEnter={interactive ? () => setHover(i) : undefined} + /> + ))} + + {hasPartial && ( + + )} + {hasForecast && ( + + )} + {hp && hover != null && ( + <> + + + + )} + + + {hp && hover != null && ( +
+
+ {periodLabel(hp.period, granularity)} + {hp.partial ? ' · частично' : ''} + {hoverIsForecast && ПРОГНОЗА} +
+
+ € обем + {money(hp.valueEur)} +
+
+ договори + {count(Math.round(hp.contracts))} +
+
+ )} +
+ ); +} 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..3a5ac3aa2 --- /dev/null +++ b/apps/web/app/components/MetricInfo.tsx @@ -0,0 +1,89 @@ +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); + + useLayoutEffect(() => { + 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.tsx b/apps/web/app/components/TrendChart.tsx index 9248669de..84450aebd 100644 --- a/apps/web/app/components/TrendChart.tsx +++ b/apps/web/app/components/TrendChart.tsx @@ -1,4 +1,4 @@ -import type { TrendPoint } from '@sigma/api-contract'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; // 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 +13,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 +32,11 @@ 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). + // x-axis ticks at the first month/quarter of each year, or at every point (year granularity). + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; const ticks = points .map((p, i) => ({ i, year: p.period.slice(0, 4) })) - .filter((t, idx) => granularity === 'year' || points[idx]!.period.endsWith('-01')); + .filter((_t, idx) => yearStart == null || points[idx]!.period.endsWith(yearStart)); // 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..003addf0e --- /dev/null +++ b/apps/web/app/lib/analytics-stats.test.ts @@ -0,0 +1,151 @@ +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); + }); +}); diff --git a/apps/web/app/lib/analytics-stats.ts b/apps/web/app/lib/analytics-stats.ts new file mode 100644 index 000000000..1b45e7cfc --- /dev/null +++ b/apps/web/app/lib/analytics-stats.ts @@ -0,0 +1,188 @@ +// 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]!; + const firstShare = first.singleOfferValueEur / first.valueEur; + const latestShare = 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 { + 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 5a9479efb..6856c5eee 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, } from './filters'; @@ -114,6 +116,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('leaderboardRankOffset', () => { it('continues rank numbering across paged keyset results', () => { expect(leaderboardRankOffset(1, 25)).toBe(0); diff --git a/apps/web/app/lib/filters.ts b/apps/web/app/lib/filters.ts index 113d6df3b..f4eb218a9 100644 --- a/apps/web/app/lib/filters.ts +++ b/apps/web/app/lib/filters.ts @@ -30,6 +30,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 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/trends-forecast.test.ts b/apps/web/app/lib/trends-forecast.test.ts new file mode 100644 index 000000000..1505e85cd --- /dev/null +++ b/apps/web/app/lib/trends-forecast.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { TrendPoint } from '@sigma/api-contract'; +import { buildForecast } from './trends-forecast'; + +// buildForecast only — the growth estimate it defaults to (estimateYoyGrowth) is covered by +// analytics-stats.test.ts, its home. + +// 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('buildForecast', () => { + it('projects each month from the same month last year × growth, flagged forecast', () => { + const points = [...year(2022, 100, 50), ...year(2023, 120, 55)]; + const fc = buildForecast(points, { value: 1.2, count: 1.1 }); + // last complete = 2023-12, so forecast runs 2024-01 .. 2024-12 (end of next year) + expect(fc).toHaveLength(12); + expect(fc[0]!.period).toBe('2024-01'); + expect(fc[0]!.forecast).toBe(true); + expect(fc[0]!.valueEur).toBeCloseTo(120 * 1.2, 5); // 2023-01 × 1.2 + expect(fc[0]!.contracts).toBeCloseTo(55 * 1.1, 5); + expect(fc.at(-1)!.period).toBe('2024-12'); + }); + + it('starts the month after the last COMPLETE month when the tail is partial', () => { + const points = [...year(2023, 100, 50), ...year(2024, 110, 52, true)]; + // 2024-12 is partial → last complete is 2024-11 → first forecast month is 2024-12 + const fc = buildForecast(points, { value: 1, count: 1 }); + expect(fc[0]!.period).toBe('2024-12'); + expect(fc.every((p) => p.forecast)).toBe(true); + }); + + it('returns nothing for an empty series', () => { + expect(buildForecast([])).toEqual([]); + }); + + it('defaults the growth factor to the canonical actuals-based estimate', () => { + const points = [...year(2021, 100, 50), ...year(2022, 120, 55), ...year(2023, 144, 60.5)]; + const fc = buildForecast(points); // estimateYoyGrowth → value ≈ 1.2 + expect(fc[0]!.valueEur).toBeCloseTo(144 * 1.2, 5); + }); + + it('suppresses the forecast when there is no prior-year seasonal base (no zero cliff)', () => { + // Only a partial first half-year — there is no month one year earlier to seed any projection, + // so every projected month would be 0. The forecast must be dropped, not rendered as a collapse. + const points: TrendPoint[] = Array.from({ length: 6 }, (_, i) => ({ + period: `2024-${String(i + 1).padStart(2, '0')}`, + valueEur: 100, + contracts: 10, + partial: false, + })); + expect(buildForecast(points, { value: 1, count: 1 })).toEqual([]); + }); +}); diff --git a/apps/web/app/lib/trends-forecast.ts b/apps/web/app/lib/trends-forecast.ts new file mode 100644 index 000000000..4be7eae74 --- /dev/null +++ b/apps/web/app/lib/trends-forecast.ts @@ -0,0 +1,70 @@ +// Seasonal forecast for the /trends combo chart. The projection is derived from the REAL monthly +// actuals: each future month = the same calendar month one year earlier × a year-over-year growth +// factor, so the full seasonal shape (year-end peaks, summer dips) is carried forward rather than a +// flat line. The growth factor is the canonical /analytics estimate (estimateYoyGrowth: 3-year +// trailing median of complete-year ratios, clamped to [0.5, 2]) — nothing here is fabricated. +// Forecast points are always flagged `forecast: true` so the UI labels them „ПРОГНОЗА" and never +// renders them as actuals. + +import type { TrendPoint } from '@sigma/api-contract'; + +import { estimateYoyGrowth, type GrowthFactors } from './analytics-stats'; + +export interface ForecastPoint { + period: string; // 'YYYY-MM' + valueEur: number; + contracts: number; + forecast: true; +} + +// Project at most this many months forward (and never past the end of the year after the last actual). +const MAX_HORIZON = 18; + +function addMonth(year: number, month: number): [number, number] { + return month === 12 ? [year + 1, 1] : [year, month + 1]; +} + +function monthKey(year: number, month: number): string { + return `${year}-${String(month).padStart(2, '0')}`; +} + +/** + * Project future months from the actual monthly series. The forecast starts the month after the last + * COMPLETE month — a partial current month never seeds the projection — and runs to the end of the + * year after the last actual (capped at MAX_HORIZON months). Each month is seeded from the same + * calendar month one year earlier (actual, or an already-projected month) × the growth factor, so + * seasonality dominates. A missing seasonal base yields 0 (honest: we never invent a level we have + * no basis for). + */ +export function buildForecast( + points: TrendPoint[], + growth?: GrowthFactors, + horizon = MAX_HORIZON, +): ForecastPoint[] { + if (points.length === 0) return []; + const g = growth ?? estimateYoyGrowth(points); + + const level = new Map(); + for (const p of points) level.set(p.period, { value: p.valueEur, count: p.contracts }); + + const lastComplete = [...points].reverse().find((p) => !p.partial) ?? points[points.length - 1]!; + let [y, m] = lastComplete.period.split('-').map(Number) as [number, number]; + const endYear = Number(lastComplete.period.slice(0, 4)) + 1; + + const out: ForecastPoint[] = []; + for (let i = 0; i < horizon; i += 1) { + [y, m] = addMonth(y, m); + if (y > endYear) break; + const key = monthKey(y, m); + const base = level.get(monthKey(y - 1, m)) ?? { value: 0, count: 0 }; + const value = base.value * g.value; + const count = base.count * g.count; + level.set(key, { value, count }); + out.push({ period: key, valueEur: value, contracts: count, forecast: true }); + } + // No prior-year seasonal base for the first projected month (the actuals don't yet span a full year + // before the forecast start) → the projection opens at 0, a „ПРОГНОЗА" wedge crashing to zero that + // reads as a predicted collapse. Suppress the forecast entirely rather than render that false cliff. + if (out.length === 0 || out[0]!.valueEur === 0) return []; + return out; +} 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 9565a8caf..4c4be3dea 100644 --- a/apps/web/app/routes/analytics.tsx +++ b/apps/web/app/routes/analytics.tsx @@ -1,16 +1,26 @@ +import type { ReactNode } from 'react'; import { Link } from 'react-router'; -import { getCompetitionSummary, getFlows, getRegionalSpending, getSpendingTrend } from '@sigma/db'; +import { + 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) { @@ -19,7 +29,7 @@ export function meta({ matches }: Route.MetaArgs) { path: '/analytics', title: 'Анализи — СИГМА', description: - 'Четири аналитични изгледа към обществените поръчки: потоци, карта, тренд и конкуренция.', + 'Пет аналитични изгледа към едни и същи обществени поръчки: раздуване след анекси, потоци на парите, карта по области, тренд във времето и конкуренция на процедурите — всеки води обратно към конкретните договори.', }); } @@ -27,172 +37,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 = context.cloudflare.env.DB; - 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 ( -

- {children} -

+ + {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 ( + + + + + + + + ); +} + +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..356bb5496 --- /dev/null +++ b/apps/web/app/routes/overruns.tsx @@ -0,0 +1,908 @@ +import { type ReactNode, useState } from 'react'; +import { Link, useNavigation, useSearchParams } from 'react-router'; +import { count, date, money, moneyBare, pct, signedPct } from '@sigma/shared'; +import { + 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 { env } = context.cloudflare; + 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(env.DB, { by }); + const annexes = await getOverrunAnnexes( + env.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 ( +