Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
4dd28ce
feat(etl): add contract health index foundation columns
StanislavBG Jun 29, 2026
e3fc6b3
feat(etl): health index phase-4 rollups (derive-health.sql)
StanislavBG Jul 2, 2026
885775d
feat(etl): contract_features leaves, peer keys, coverage
StanislavBG Jul 2, 2026
9e30d46
fix(etl): guard first_amend_shock against NULL/mismatched-currency si…
StanislavBG Jul 2, 2026
dcd4e30
feat(etl): contract health scoring 0-1, quality rollups, and pipeline…
StanislavBG Jul 2, 2026
ffb7b6e
feat(web): обзор на договорите — лещи време/CPV/кръстосано
StanislavBG Jul 2, 2026
f2ea743
feat(web): страница „Индекс на качеството" (contract quality index)
StanislavBG Jul 2, 2026
c05a5d2
fix(etl): code-review fixes for contract health scoring
StanislavBG Jul 2, 2026
b88c0db
fix(web): key edge cache over the new trends/quality query params
StanislavBG Jul 2, 2026
677ce47
fix(db): fresh migration chain — health columns only in 0003
StanislavBG Jul 2, 2026
b863807
fix(etl): guard zero-sum CPV division in health derive
StanislavBG Jul 2, 2026
781b42c
fix(etl): make derive-contract-features safe under local D1 batch limits
StanislavBG Jul 2, 2026
ff47440
fix(web): breadcrumb 'to' prop on the quality empty state
StanislavBG Jul 2, 2026
7b97d6b
style: prettier pass after rebase onto the css split
StanislavBG Jul 3, 2026
2fff429
feat(web): quality histogram click-to-filter + hardened metric info p…
StanislavBG Jul 3, 2026
e2da348
feat(web): разбивка faceting — sort direction toggle + avg-index rang…
StanislavBG Jul 3, 2026
7437a57
docs(web): методологията описва индекса за здраве на договора
StanislavBG Jul 3, 2026
5bc55c8
fix(web): quality page — pillar strip carousel + non-wrapping grain s…
StanislavBG Jul 5, 2026
f1e32bf
fix(docs): stop committed code referencing the uncommitted quality spec
StanislavBG Jul 5, 2026
78f176d
fix(web): address ydimitrof review round on #188
StanislavBG Jul 10, 2026
4ac5236
fix(web): address ydimitrof review round 2 on #188
StanislavBG Jul 11, 2026
3b8a173
fix(web): run prettier on files flagged by CI lint check
StanislavBG Jul 11, 2026
020e755
Merge remote-tracking branch 'origin/main' into feat/contract-health-…
StanislavBG Jul 11, 2026
a8ba6e5
test(web,db): add regression coverage for #188 review threads, docume…
StanislavBG Jul 11, 2026
a707262
fix(web,db): address remaining ydimitrof review threads on PR #188
StanislavBG Jul 12, 2026
1c5133d
fix(db): gate derive-contract-features.sql invariants as a hard assert
StanislavBG Jul 12, 2026
424ff74
fix(db,web): address ydimitrof review round on PR #188
StanislavBG Jul 18, 2026
c9055b3
Merge remote-tracking branch 'origin/main' into feat/contract-health-…
StanislavBG Jul 18, 2026
121c5f2
fix(web): include quality/trends params dropped from merge of CANONIC…
StanislavBG Jul 18, 2026
55aad22
fix(db): close the contract_features non-atomic rebuild window and dr…
StanislavBG Jul 21, 2026
4cd597a
fix(db): surface a diagnostic counter for unresolved foreign-currency…
StanislavBG Jul 21, 2026
7a35c8c
fix(scripts): make the year-coverage check schema-robust to TEXT vs I…
StanislavBG Jul 21, 2026
c516f08
fix(db): add pipeline_diag to the canonical schema migration
StanislavBG Jul 21, 2026
2f00324
docs(etl): note validate-health year-coverage String() normalization …
StanislavBG Jul 21, 2026
a6b5c16
fix(db): reconcile contract_features column-extraction test with stag…
StanislavBG Jul 22, 2026
158bc81
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG Jul 22, 2026
21df242
build(deps): patch postcss/valibot CVEs, bump react-router within 7.x…
StanislavBG Jul 27, 2026
1e4b069
docs(security): record postcss/valibot/react-router CVE rollout for P…
StanislavBG Jul 27, 2026
a311b0e
build: merge origin/main into feat/contract-health-index, resolve con…
StanislavBG Jul 28, 2026
7958cb5
fix(ci): reformat integrity-checks with prettier and index security-a…
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
158 changes: 158 additions & 0 deletions apps/web/app/components/ComboTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useState } from 'react';
import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
import { count, money, monthYear } from '@sigma/shared';
import { yearAxisTicks } from '../lib/trendAxis';

// Bar + line combo for the contracts overview (/trends): bars carry the contract count, the ink line
// the € volume. Server-rendered SVG like TrendChart; the only client behavior is the hover tooltip
// (React state after hydration — SSR renders the chart without it, so no-JS still gets the picture).
// The accessible data lives in the year cards next to the chart, matching the TrendChart pattern.

const W = 1000;
const H = 300;
const TOP = 10;
const BOT = 272;
const PAD = 8;

/** '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);
Comment thread
StanislavBG marked this conversation as resolved.
// Bars are centred on x(i), and x(0)/x(n-1) sit on the plot edges — so the first/last bar would
// overflow the viewBox by bw/2 (severe at n=2, bw≈324). Inset just the bar x-position at the ends;
// the line/cursor/dot keep using x(i) so the value series stays anchored to the true period edges.
const barX = (i: number) => Math.min(W - PAD - bw / 2, Math.max(PAD + bw / 2, x(i)));

// Final period is partial (still filling): dashed line tail + faded bar, like TrendChart.
const partialIdx = points.findIndex((p) => p.partial);
// partialIdx > 0 also treats "no partial point" (findIndex returns -1) as non-partial, and a
// partial flag on the very first point (index 0) as non-partial too — the latter never happens
// in practice (the first period is never still-filling), matching TrendChart's same assumption.
const hasPartial = partialIdx > 0;
Comment thread
StanislavBG marked this conversation as resolved.
const solidEnd = hasPartial ? partialIdx - 1 : n - 1;
const xy = (i: number) => `${x(i).toFixed(1)} ${yV(points[i]!.valueEur).toFixed(1)}`;
const line = points
.slice(0, solidEnd + 1)
.map((_p, i) => `${i ? 'L' : 'M'}${xy(i)}`)
.join(' ');
const dashed = hasPartial ? `M${xy(solidEnd)} L${xy(partialIdx)}` : '';

const ticks = yearAxisTicks(points, granularity);

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

return (
<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={(barX(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>
);
}
94 changes: 94 additions & 0 deletions apps/web/app/components/MetricInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';

// SSR has no DOM, so useLayoutEffect on the server both does nothing useful and logs React's
// "useLayoutEffect does nothing on the server" warning. Swap to useEffect during SSR (typeof
// window guards it) while keeping the client on useLayoutEffect for its pre-paint clamp.
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

// A small ⓘ affordance next to a metric label. For pointer users it reveals an elegant popover on
// hover or keyboard focus (pure CSS `:hover` / `:focus-within`). Because hover does not exist on
// touch, a click also toggles the popover open via an `is-open` class — and an outside-click or Esc
// closes it again. The button carries the full text as its aria-label, so screen-reader users get the
// same information without the visual popover (which is aria-hidden). SSR-safe: the initial render is
// closed and the toggle/effects only run on the client.
export function MetricInfo({
title,
summary,
readout,
align = 'start',
}: {
title: string;
summary: string;
// Plain string so the readout is always reflected verbatim into the aria-label (all callers pass a
// string — the screen-reader text must never silently drop a non-string interpretation).
readout?: string;
// Which edge the popover anchors to — use 'end' for right-most metrics so it doesn't clip.
align?: 'start' | 'end';
}) {
const aria = readout ? `${title}. ${summary} ${readout}`.trim() : `${title}. ${summary}`;
const [open, setOpen] = useState(false);
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.
useIsomorphicLayoutEffect(() => {
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>
);
}
10 changes: 4 additions & 6 deletions apps/web/app/components/TrendChart.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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));
Expand All @@ -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.
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: '/quality',
title: 'Индекс на качеството',
desc: 'Колко здрав е процесът по всеки договор: пет измерения, една оценка 0–100.',
},
] as const;

export const ANALYTICS_NAV_PATHS = [
Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/lib/etl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** True for the expected "table doesn't exist yet" error the daily ETL derive can leave behind
* (before the first derive, or mid-rebuild since ship-domain drops+recreates contract_features). */
export function isMissingDerivedTableError(err: unknown): boolean {
return /no such table/i.test(err instanceof Error ? err.message : String(err));
}
48 changes: 48 additions & 0 deletions apps/web/app/lib/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MAX_MULTI_VALUES,
pageNav,
PARAM_ORDER,
qualityRankingControls,
searchHref,
withParams,
} from './filters';
Expand Down Expand Up @@ -344,6 +345,53 @@ describe('pageNav', () => {
});
});

describe('qualityRankingControls', () => {
it('parses the /quality „Разбивка" controls the loader consumes', () => {
expect(qualityRankingControls(sp('rdir=desc&rfrom=10&rto=60'))).toEqual({
rankDir: 'desc',
rankFrom: 10,
rankTo: 60,
});
expect(qualityRankingControls(sp(''))).toEqual({
rankDir: null,
rankFrom: null,
rankTo: null,
});
});

it('accepts only asc|desc for ?rdir — anything else falls back to the default order', () => {
expect(qualityRankingControls(sp('rdir=asc')).rankDir).toBe('asc');
expect(qualityRankingControls(sp('rdir=down')).rankDir).toBeNull();
expect(qualityRankingControls(sp('rdir=DESC')).rankDir).toBeNull();
expect(qualityRankingControls(sp("rdir=asc'--")).rankDir).toBeNull();
});

it('validates ?rfrom/?rto as ints in [0, 100] and drops malformed bounds (CWE-349)', () => {
expect(qualityRankingControls(sp('rfrom=0&rto=100'))).toMatchObject({
rankFrom: 0,
rankTo: 100,
});
expect(qualityRankingControls(sp('rfrom=35')).rankFrom).toBe(35); // one-sided range is fine
expect(qualityRankingControls(sp('rto=35')).rankTo).toBe(35);
expect(qualityRankingControls(sp('rfrom=101')).rankFrom).toBeNull();
expect(qualityRankingControls(sp('rfrom=-1')).rankFrom).toBeNull();
expect(qualityRankingControls(sp('rfrom=1.5')).rankFrom).toBeNull();
expect(qualityRankingControls(sp('rfrom=abc')).rankFrom).toBeNull();
expect(qualityRankingControls(sp('rfrom=5 OR 1=1')).rankFrom).toBeNull();
expect(qualityRankingControls(sp('rfrom=1000')).rankFrom).toBeNull();
});

it('swaps an inverted ?rfrom/?rto pair so the range is always from ≤ to', () => {
const f = qualityRankingControls(sp('rfrom=60&rto=10'));
expect(f.rankFrom).toBe(10);
expect(f.rankTo).toBe(60);
// from = to pins a single display value — kept, not dropped
const pin = qualityRankingControls(sp('rfrom=69&rto=69'));
expect(pin.rankFrom).toBe(69);
expect(pin.rankTo).toBe(69);
});
});

describe('withParams', () => {
it('drops unknown params — including repeated ones — so none can ride a link into the edge cache (#197)', () => {
expect(withParams(sp('sort=value-desc&x=poison'), {})).toBe('?sort=value-desc');
Expand Down
Loading