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