Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
a9b69a3
fix(web): wrap the overrun-by-sector table in the horizontal scroll c…
StanislavBG Jul 5, 2026
fdb370f
fix(web): address ydimitrof review round on #172 (analyze)
StanislavBG Jul 10, 2026
4755c1b
fix(web): address ydimitrof review round 2 on #172
StanislavBG Jul 11, 2026
9956f63
Merge remote-tracking branch 'origin/main' into pr/analyze
StanislavBG Jul 11, 2026
cca8f3e
fix(web): address ydimitrof review round on PR #172 (analyze cards)
StanislavBG Jul 11, 2026
015c935
test(db): verify competition ORDER BY tie-breaker aliases against rea…
StanislavBG Jul 12, 2026
93af83d
fix(web): address ydimitrof review round on PR #172 (11 residual thre…
StanislavBG Jul 18, 2026
e372a1d
Merge branch 'main' into pr/analyze
StanislavBG Jul 18, 2026
b8d7356
fix(web): run prettier on files flagged by CI lint after PR #172 merge
StanislavBG Jul 18, 2026
c47366d
fix(web,db): address ydimitrof review round on pr/analyze (5 threads)
StanislavBG Jul 20, 2026
9a8a6f3
style(web): run prettier on components.css after review-round fix
StanislavBG Jul 20, 2026
47dc0a0
fix(web): address ydimitrof review round on pr/analyze (6 threads)
StanislavBG Jul 21, 2026
035e5b1
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG Jul 22, 2026
d102938
fix(web): extract shared year-axis tick helper for TrendChart/ComboTr…
StanislavBG Jul 26, 2026
9666ab1
build(deps): patch postcss/valibot CVEs, bump react-router within 7.x…
StanislavBG Jul 27, 2026
9b3e26f
build: merge origin/main into pr/analyze, resolve conflicts
StanislavBG Jul 28, 2026
fed9ef8
build: regenerate pnpm-lock.yaml with pinned pnpm 10.33.0
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
76 changes: 76 additions & 0 deletions apps/web/app/components/ComboTrendChart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import type { TrendPoint } from '@sigma/api-contract';
import { yearAxisTicks } from '../lib/trendAxis';

// Mirrors ComboTrendChart's own x(i) and left% formulas exactly (W/PAD match the component's
// constants) so this test fails if the component's positioning drifts from x(t.i) again.
const W = 1000;
const PAD = 8;
function xOf(i: number, n: number) {
return PAD + (i * (W - 2 * PAD)) / (n - 1);
}
function leftPct(i: number, n: number) {
return (xOf(i, n) / W) * 100;
}

describe('ComboTrendChart year-label positioning', () => {
it('places each label at its tick x(t.i) position, not evenly spaced, when years are unevenly distributed', () => {
// Years clustered at the start (2021, 2022 one point apart) with a long gap before 2026 — a
// flow-based `justify-content: space-between` layout would spread these four labels evenly
// across the width, landing the last two under the wrong bars.
const points: TrendPoint[] = [
{ period: '2021', valueEur: 1, contracts: 1, partial: false },
{ period: '2022', valueEur: 1, contracts: 1, partial: false },
{ period: '2023', valueEur: 1, contracts: 1, partial: false },
{ period: '2024', valueEur: 1, contracts: 1, partial: false },
{ period: '2025', valueEur: 1, contracts: 1, partial: false },
{ period: '2026', valueEur: 1, contracts: 1, partial: true },
];
const n = points.length;
const ticks = yearAxisTicks(points, 'year');

const positions = ticks.map((t) => ({ year: t.year, leftPct: leftPct(t.i, n) }));

expect(positions).toEqual([
{ year: '2021', leftPct: leftPct(0, n) },
{ year: '2022', leftPct: leftPct(1, n) },
{ year: '2023', leftPct: leftPct(2, n) },
{ year: '2024', leftPct: leftPct(3, n) },
{ year: '2025', leftPct: leftPct(4, n) },
{ year: '2026', leftPct: leftPct(5, n) },
]);

// Evenly-spaced (flow-based) positions would be i / (ticks.length - 1) * 100 — assert the
// computed positions do NOT match that for the interior ticks, proving this is tick-driven.
const evenlySpaced = ticks.map((_t, idx) => (idx / (ticks.length - 1)) * 100);
expect(positions.map((p) => p.leftPct)).not.toEqual(evenlySpaced);
});

it('matches tick x-position exactly for a fixture with a long gap before the last year (month grain)', () => {
const points: TrendPoint[] = [
{ period: '2021-11', valueEur: 1, contracts: 1, partial: false },
{ period: '2021-12', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-01', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-02', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-03', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-04', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-05', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-06', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-07', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-08', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-09', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-10', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-11', valueEur: 1, contracts: 1, partial: false },
{ period: '2022-12', valueEur: 1, contracts: 1, partial: false },
{ period: '2023-01', valueEur: 1, contracts: 1, partial: true },
];
const n = points.length;
const ticks = yearAxisTicks(points, 'month');

expect(ticks).toEqual([
{ i: 2, year: '2022' },
{ i: 14, year: '2023' },
]);
expect(ticks.map((t) => leftPct(t.i, n))).toEqual([leftPct(2, n), leftPct(14, n)]);
});
});
146 changes: 146 additions & 0 deletions apps/web/app/components/ComboTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { useState } from 'react';
import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
import { count, money } from '@sigma/shared';
import { periodLabel, yearAxisTicks } from '../lib/trendAxis';

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

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

export function ComboTrendChart({
points,
granularity,
cssHeight = 240,
interactive = true,
ariaLabel = 'Брой договори и € обем във времето',
}: {
points: TrendPoint[];
granularity: TrendGranularity;
cssHeight?: number;
interactive?: boolean;
ariaLabel?: string;
}) {
const [hover, setHover] = useState<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) => PAD + (i * (W - 2 * PAD)) / (n - 1);
const yV = (v: number) => BOT - (v / vMax) * (BOT - TOP);
const yC = (c: number) => BOT - (c / cMax) * (BOT - TOP) * 0.62;
Comment thread
StanislavBG marked this conversation as resolved.
const bw = Math.max(2, ((W - 2 * PAD) / n) * 0.66);

// Final period is partial (still filling): dashed line tail + faded bar, like TrendChart.
// `partial` is only ever set on the as_of (final) period (see TrendPoint in api-contract), so a
// leading partial can't occur — `> 0` intentionally treats an all-partial single-point series (or
// the impossible partialIdx === 0 case) as fully solid, mirroring TrendChart.
const partialIdx = points.findIndex((p) => p.partial);
const hasPartial = partialIdx > 0;
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={(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">
Comment thread
StanislavBG marked this conversation as resolved.
{ticks.map((t) => (
<span key={t.i} style={{ left: `${((x(t.i) / W) * 100).toFixed(1)}%` }}>
{t.year}
</span>
))}
</div>
{hp && hover != null && (
<div
className="combo-tip"
aria-hidden="true"
style={{
left: `${((x(hover) / W) * 100).toFixed(1)}%`,
top: (yV(hp.valueEur) / H) * cssHeight - 4,
}}
>
<div className="combo-tip-label">
Comment thread
StanislavBG marked this conversation as resolved.
{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>
);
}
Loading