Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
28 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
b7dfffe
fix(web): dedupe CPV medians, SSR-safe MetricInfo effect, CSS marker/…
StanislavBG Jul 10, 2026
e557107
fix(web): address ydimitrof review round on #170
StanislavBG Jul 11, 2026
afa20ce
Merge remote-tracking branch 'origin/main' into pr/trends
StanislavBG Jul 11, 2026
2c1ab8b
fix(web): address ydimitrof review round on PR #170 (trend lenses)
StanislavBG Jul 11, 2026
c973c14
fix(web): address round-4 ydimitrof review threads on PR #170
StanislavBG Jul 18, 2026
f63d73b
Merge remote-tracking branch 'origin/main' into pr/trends
StanislavBG Jul 18, 2026
47c210e
fix(web): prettier formatting on PR #170 merge-conflict resolution
StanislavBG Jul 18, 2026
2ee5c42
chore(web): remove stray internal draft-reply artifact from PR #170
StanislavBG Jul 18, 2026
aae0b5a
fix(web): canonicalize cpv selection order and restore metric-info po…
StanislavBG Jul 20, 2026
cd62f47
fix(web): warn on partial-period-at-index-0 invariant break, drop red…
StanislavBG Jul 22, 2026
9b2d054
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG Jul 22, 2026
f2c8d4d
fix(web): detect partial period at index 0 in ComboTrendChart
StanislavBG Jul 26, 2026
b91e864
style(web): standardize components.css on --font-mono-plex
StanislavBG Jul 27, 2026
7f900bd
build(deps): patch postcss/valibot CVEs, suppress unrelated react-rou…
StanislavBG Jul 27, 2026
6f82f50
build: merge origin/main into pr/trends, resolve conflicts
StanislavBG Jul 28, 2026
a822250
build(deps): drop stale sharp CVE suppression, now fixed by the merge
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
47 changes: 47 additions & 0 deletions apps/web/app/components/ComboTrendChart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
import { monthYear } from '@sigma/shared';
import { periodLabel, yearAxisTicks } from '../lib/trendAxis';

// Reference oracle: ComboTrendChart's x-axis tick logic before it was extracted into
// lib/trendAxis.ts (byte-identical to TrendChart's prior inline copy — that duplication is what
// review thread ComboTrendChart.tsx:62 / TrendChart.tsx:36 flagged).
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(({ i }) => yearStart == null || points[i]!.period.endsWith(yearStart));
}

// Reference oracle: ComboTrendChart's own periodLabel before the move to lib/trendAxis.ts.
function periodLabelBefore(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);
}

const points: TrendPoint[] = [
{ period: '2023-11', valueEur: 10, contracts: 2, partial: false },
{ period: '2023-12', valueEur: 20, contracts: 3, partial: false },
{ period: '2024-01', valueEur: 30, contracts: 4, partial: false },
{ period: '2024-02', valueEur: 5, contracts: 1, partial: true },
];

describe('yearAxisTicks (shared helper used by ComboTrendChart)', () => {
it('matches ComboTrendChart’s prior inline tick logic exactly across granularities', () => {
for (const granularity of ['month', 'quarter', 'year'] as const) {
expect(yearAxisTicks(points, granularity)).toEqual(ticksBefore(points, granularity));
}
});
});

describe('periodLabel (shared helper used by ComboTrendChart’s hover tooltip)', () => {
it('matches ComboTrendChart’s prior inline periodLabel exactly for every granularity', () => {
expect(periodLabel('2024-02', 'month')).toBe(periodLabelBefore('2024-02', 'month'));
expect(periodLabel('2024-Q1', 'quarter')).toBe(periodLabelBefore('2024-Q1', 'quarter'));
expect(periodLabel('2024', 'year')).toBe(periodLabelBefore('2024', 'year'));
});
});
150 changes: 150 additions & 0 deletions apps/web/app/components/ComboTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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) => (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);
// Invariant: the partial period is always the last point. `hasPartial` treats index 0 as
// "no partial period" — if a partial period ever ends up first, this silently renders the whole
// line solid instead of catching the regression.
const hasPartial = partialIdx > 0;
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
if (import.meta.env.DEV && partialIdx > 0 && partialIdx !== n - 1) {
console.warn(
`ComboTrendChart: partial period at index ${partialIdx} of ${n} is not last — the ` +
'partial-is-always-last invariant is broken upstream; the chart will render as if there is no partial period.',
);
}
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">
{ticks.map((t) => (
<span key={t.i}>{t.year}</span>
))}
</div>
{hp && hover != null && (
<div
className="combo-tip"
role="status"
Comment thread
StanislavBG marked this conversation as resolved.
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>() {
Comment thread
StanislavBG marked this conversation as resolved.
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>
);
}
91 changes: 91 additions & 0 deletions apps/web/app/components/MetricInfo.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>(null);
const popRef = useRef<HTMLSpanElement>(null);
// Horizontal shift (px) that keeps the click-opened popover inside the viewport on small screens
Comment thread
StanislavBG marked this conversation as resolved.
// (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 (
<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>
);
}
Loading