Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1c678bd
feat(web): dashboard design-system base (MetricInfo, fullscreen, toke…
StanislavBG Jun 28, 2026
24af313
fix(web): restore bids+page cache-key params (CWE-349 #56 guard)
StanislavBG Jun 28, 2026
6ccae13
fix(web): restore CWE-349 cache-key drift guard + risk-box styles (re…
StanislavBG Jun 29, 2026
cf06f21
test(web): restore behavioral cache-key assert for keyed params
StanislavBG Jul 2, 2026
957ad75
fix(web): metric-info popover text can never overflow the card
StanislavBG Jul 3, 2026
400b73e
feat(web): обзор на договорите — лещи време/CPV/кръстосано
StanislavBG Jul 2, 2026
0f8b574
fix(web): key edge cache over the trends lens params
StanislavBG Jul 2, 2026
8d08acc
feat(web): cpv selection facets the cross-lens year chart
StanislavBG Jul 2, 2026
c69eabc
fix(db): yoy compares only adjacent years in the overview
StanislavBG Jul 2, 2026
c407e29
feat(db): exclude the current partial period from the trend by default
StanislavBG Jul 3, 2026
03a01eb
feat(web): „вкл. текущия месец" toggle on the обзор time chart
StanislavBG Jul 3, 2026
06a4543
refactor(web): drop leftover forecast-era combo-tooltip css
StanislavBG Jul 3, 2026
c37ef61
feat(web): overruns dashboard
StanislavBG Jun 28, 2026
b6f8418
fix(db): overruns annex chronology + CPV normalization + dedup (review)
StanislavBG Jun 29, 2026
7865642
fix(db): normalize full cpv_code in overruns by-sector query
StanislavBG Jul 2, 2026
d9aefe9
style(web): format overruns route to satisfy blocking lint
StanislavBG Jul 2, 2026
13505bf
perf(db): group overruns by-sector on a SQL cpvDivision mirror, not t…
StanislavBG Jul 2, 2026
e28b715
feat(web): metric definitions on both overruns tables
StanislavBG Jul 3, 2026
759dd87
feat(web): analyze index — five equal analysis cards
StanislavBG Jun 28, 2026
c0521bd
fix(web): analyze opaque-share wording, growth-multiple dedup, drop /…
StanislavBG Jun 29, 2026
bf48670
style(web): format overruns and analytics routes to satisfy blocking …
StanislavBG Jul 2, 2026
5f64191
refactor(web): drop the retired .lens-* analytics styles with the pag…
StanislavBG Jul 3, 2026
b864779
test(web): restore the reverse cache-key drift guard with a documente…
StanislavBG Jul 3, 2026
eaeeb2c
fix(web): analytics trend card copy — no forecast promised
StanislavBG Jul 3, 2026
7413f87
docs(web): методологията описва точно таблата — формули, обхват, изкл…
StanislavBG Jul 3, 2026
318213a
docs(web): correct value_suspect handling in methodology section 7
StanislavBG Jul 5, 2026
aed9543
docs(web): plain-language rewrite of the analytical-views methodology…
StanislavBG Jul 12, 2026
f90f1b6
Merge remote-tracking branch 'origin/main' into docs/methodology-dash…
StanislavBG Jul 18, 2026
38847f9
fix(web): address PR #193 review — isomorphic layout-effect, partial-…
StanislavBG Jul 20, 2026
aeb950b
fix(db): clarify authorityEik derives the bare EIK (PR #193 review)
StanislavBG Jul 20, 2026
bd2e06f
fix(db): chunk overrun annex IN-list under D1's 100-bound-param cap
StanislavBG Jul 22, 2026
a0776be
fix(web): distinguish exactly-24 from truncated contract list on /trends
StanislavBG Jul 22, 2026
96ac0d6
fix(web): sample overrun scatter x-axis ticks evenly across the range
StanislavBG Jul 22, 2026
114580f
fix(web): correct stale /trends-parity claim on analytics YoY hint
StanislavBG Jul 22, 2026
c7a8615
fix(web): keep --paper-raised on the OKLch palette, not a raw hex
StanislavBG Jul 22, 2026
ec05d09
test(db): cover the exact D1 100-bound-param chunk boundary for getOv…
StanislavBG Jul 22, 2026
5f94c94
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG Jul 22, 2026
b94c6ae
build(deps): patch postcss/valibot CVEs, suppress react-router RSC-on…
StanislavBG Jul 27, 2026
e789f1f
build: merge origin/main into docs/methodology-dashboards, resolve co…
StanislavBG Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions apps/web/app/components/ComboTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useState } from 'react';
import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
import { count, money, monthYear } from '@sigma/shared';

// 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,
cssHeight = 240,
interactive = true,
ariaLabel = 'Брой договори и € обем във времето',
}: {
points: TrendPoint[];
granularity: TrendGranularity;
cssHeight?: number;
interactive?: boolean;
ariaLabel?: string;
}) {
const [hover, setHover] = useState<number | null>(null);
if (points.length < 2) return null;

const n = points.length;
const vMax = Math.max(1, ...points.map((p) => p.valueEur)) * 1.12;
const cMax = Math.max(1, ...points.map((p) => p.contracts));
const x = (i: number) => (n > 1 ? PAD + (i * (W - 2 * PAD)) / (n - 1) : W / 2);
const yV = (v: number) => BOT - (v / vMax) * (BOT - TOP);
const yC = (c: number) => BOT - (c / cMax) * (BOT - TOP) * 0.62;
const bw = Math.max(2, ((W - 2 * PAD) / n) * 0.66);

// Final period is partial (still filling): dashed line tail + faded bar, like TrendChart.
const partialIdx = points.findIndex((p) => p.partial);
const hasPartial = partialIdx > 0;
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
const solidEnd = hasPartial ? partialIdx - 1 : n - 1;
const xy = (i: number) => `${x(i).toFixed(1)} ${yV(points[i]!.valueEur).toFixed(1)}`;
const line = points
.slice(0, solidEnd + 1)
.map((_p, i) => `${i ? 'L' : 'M'}${xy(i)}`)
.join(' ');
const dashed = hasPartial ? `M${xy(solidEnd)} L${xy(partialIdx)}` : '';

// 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 = points
.map((p, i) => ({ i, year: p.period.slice(0, 4) }))
.filter(({ i }) => yearStart == null || points[i]!.period.endsWith(yearStart));

const hp = hover != null ? points[hover] : null;

return (
<div className="combo-chart" onMouseLeave={() => interactive && setHover(null)}>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ display: 'block', width: '100%', height: cssHeight }}
role="img"
aria-label={ariaLabel}
>
{[0, 1 / 3, 2 / 3, 1].map((f) => (
<line
key={f}
className="combo-grid"
x1={0}
y1={yV(vMax * f).toFixed(1)}
x2={W}
y2={yV(vMax * f).toFixed(1)}
vectorEffect="non-scaling-stroke"
/>
))}
{points.map((p, i) => (
<rect
key={p.period}
className={`combo-bar${hover === i ? ' is-hover' : ''}${p.partial ? ' is-partial' : ''}`}
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}
/>
))}
<path className="combo-line" d={line} vectorEffect="non-scaling-stroke" />
{hasPartial && (
<path className="combo-line-partial" d={dashed} vectorEffect="non-scaling-stroke" />
)}
{hp && hover != null && (
<>
<line
className="combo-cursor"
x1={x(hover).toFixed(1)}
y1={6}
x2={x(hover).toFixed(1)}
y2={BOT}
vectorEffect="non-scaling-stroke"
/>
<circle
className="combo-dot"
cx={x(hover).toFixed(1)}
cy={yV(hp.valueEur).toFixed(1)}
r={4}
vectorEffect="non-scaling-stroke"
/>
</>
)}
</svg>
<div className="combo-xlab" aria-hidden="true">
{ticks.map((t) => (
<span key={t.i}>{t.year}</span>
))}
</div>
{hp && hover != null && (
<div
className="combo-tip"
role="status"
style={{
left: `${((x(hover) / W) * 100).toFixed(1)}%`,
top: (yV(hp.valueEur) / H) * cssHeight - 4,
}}
>
<div className="combo-tip-label">
{periodLabel(hp.period, granularity)}
{hp.partial ? ' · частично' : ''}
</div>
<div className="combo-tip-row">
<span>€ обем</span>
<strong>{money(hp.valueEur)}</strong>
</div>
<div className="combo-tip-row">
<span>договори</span>
<strong>{count(hp.contracts)}</strong>
</div>
</div>
)}
</div>
);
}
71 changes: 71 additions & 0 deletions apps/web/app/components/FullscreenButton.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends HTMLElement>() {
const ref = useRef<T>(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 (
<button
type="button"
className="fs-btn"
onClick={onToggle}
aria-pressed={active}
aria-label={active ? 'Изход от цял екран' : 'Разгледай графиката на цял екран'}
title={active ? 'Изход от цял екран' : 'На цял екран'}
>
<svg
aria-hidden="true"
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
{active ? (
<>
<path d="M6 2v4H2" />
<path d="M10 2v4h4" />
<path d="M6 14v-4H2" />
<path d="M10 14v-4h4" />
</>
) : (
<>
<path d="M2 6V2h4" />
<path d="M14 6V2h-4" />
<path d="M2 10v4h4" />
<path d="M14 10v4h-4" />
</>
)}
</svg>
<span>{active ? 'Изход' : 'Цял екран'}</span>
</button>
);
}
89 changes: 89 additions & 0 deletions apps/web/app/components/MetricInfo.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>(null);
const popRef = useRef<HTMLSpanElement>(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);

Comment thread
StanislavBG marked this conversation as resolved.
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 (
<span className={`metric-info${open ? ' is-open' : ''}`} ref={ref}>
<button
type="button"
className="metric-info-btn"
aria-label={aria}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className="metric-info-glyph" aria-hidden="true">
</span>
</button>
<span
className={`metric-info-pop${align === 'end' ? ' is-end' : ''}`}
aria-hidden="true"
ref={popRef}
// `translate` composes with the CSS `transform` reveal transition instead of replacing it
style={shift !== 0 ? { translate: `${shift}px 0` } : undefined}
>
<span className="metric-info-title">{title}</span>
<span className="metric-info-summary">{summary}</span>
{readout ? <span className="metric-info-readout">{readout}</span> : null}
</span>
</span>
);
}
9 changes: 5 additions & 4 deletions apps/web/app/components/TrendChart.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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));
Expand All @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions apps/web/app/lib/analytics-lenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading