-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): договори — обзор (лещи време/CPV/кръстосано) #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
StanislavBG
wants to merge
28
commits into
midt-bg:main
Choose a base branch
from
StanislavBG:pr/trends
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all 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 24af313
fix(web): restore bids+page cache-key params (CWE-349 #56 guard)
StanislavBG 6ccae13
fix(web): restore CWE-349 cache-key drift guard + risk-box styles (re…
StanislavBG cf06f21
test(web): restore behavioral cache-key assert for keyed params
StanislavBG 957ad75
fix(web): metric-info popover text can never overflow the card
StanislavBG 400b73e
feat(web): обзор на договорите — лещи време/CPV/кръстосано
StanislavBG 0f8b574
fix(web): key edge cache over the trends lens params
StanislavBG 8d08acc
feat(web): cpv selection facets the cross-lens year chart
StanislavBG c69eabc
fix(db): yoy compares only adjacent years in the overview
StanislavBG c407e29
feat(db): exclude the current partial period from the trend by default
StanislavBG 03a01eb
feat(web): „вкл. текущия месец" toggle on the обзор time chart
StanislavBG 06a4543
refactor(web): drop leftover forecast-era combo-tooltip css
StanislavBG b7dfffe
fix(web): dedupe CPV medians, SSR-safe MetricInfo effect, CSS marker/…
StanislavBG e557107
fix(web): address ydimitrof review round on #170
StanislavBG afa20ce
Merge remote-tracking branch 'origin/main' into pr/trends
StanislavBG 2c1ab8b
fix(web): address ydimitrof review round on PR #170 (trend lenses)
StanislavBG c973c14
fix(web): address round-4 ydimitrof review threads on PR #170
StanislavBG f63d73b
Merge remote-tracking branch 'origin/main' into pr/trends
StanislavBG 47c210e
fix(web): prettier formatting on PR #170 merge-conflict resolution
StanislavBG 2ee5c42
chore(web): remove stray internal draft-reply artifact from PR #170
StanislavBG aae0b5a
fix(web): canonicalize cpv selection order and restore metric-info po…
StanislavBG cd62f47
fix(web): warn on partial-period-at-index-0 invariant break, drop red…
StanislavBG 9b2d054
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG f2c8d4d
fix(web): detect partial period at index 0 in ComboTrendChart
StanislavBG b91e864
style(web): standardize components.css on --font-mono-plex
StanislavBG 7f900bd
build(deps): patch postcss/valibot CVEs, suppress unrelated react-rou…
StanislavBG 6f82f50
build: merge origin/main into pr/trends, resolve conflicts
StanislavBG a822250
build(deps): drop stale sharp CVE suppression, now fixed by the merge
StanislavBG File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| // @vitest-environment jsdom | ||
| import { act } from 'react'; | ||
| import { createRoot, type Root } from 'react-dom/client'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import type { TrendPoint } from '@sigma/api-contract'; | ||
|
|
||
| import { ComboTrendChart } from './ComboTrendChart'; | ||
|
|
||
| describe('ComboTrendChart', () => { | ||
| let container: HTMLDivElement; | ||
| let root: Root; | ||
|
|
||
| beforeEach(() => { | ||
| container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| act(() => { | ||
| root.unmount(); | ||
| }); | ||
| container.remove(); | ||
| }); | ||
|
|
||
| function comboLinePartial() { | ||
| return container.querySelector('path.combo-line-partial'); | ||
| } | ||
|
|
||
| // Regression: the partial-is-always-last invariant can be violated upstream. When the | ||
| // partial period lands at index 0 instead of last, `hasPartial` must still detect it — | ||
| // `partialIdx > 0` treats index 0 as "no partial period" and silently renders the whole | ||
| // series as solid. | ||
| it('detects a partial period at index 0 and renders the dashed-partial path', () => { | ||
| const points: TrendPoint[] = [ | ||
| { period: '2024-01', valueEur: 5, contracts: 1, partial: true }, | ||
| { period: '2024-02', valueEur: 10, contracts: 2, partial: false }, | ||
| { period: '2024-03', valueEur: 20, contracts: 3, partial: false }, | ||
| ]; | ||
| act(() => { | ||
| root.render(<ComboTrendChart points={points} granularity="month" />); | ||
| }); | ||
| expect(comboLinePartial()).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('detects a partial period at the last index (the normal case)', () => { | ||
| const points: TrendPoint[] = [ | ||
| { period: '2024-01', valueEur: 5, contracts: 1, partial: false }, | ||
| { period: '2024-02', valueEur: 10, contracts: 2, partial: false }, | ||
| { period: '2024-03', valueEur: 20, contracts: 3, partial: true }, | ||
| ]; | ||
| act(() => { | ||
| root.render(<ComboTrendChart points={points} granularity="month" />); | ||
| }); | ||
| expect(comboLinePartial()).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('renders no dashed-partial path when nothing is partial', () => { | ||
| const points: TrendPoint[] = [ | ||
| { period: '2024-01', valueEur: 5, contracts: 1, partial: false }, | ||
| { period: '2024-02', valueEur: 10, contracts: 2, partial: false }, | ||
| ]; | ||
| act(() => { | ||
| root.render(<ComboTrendChart points={points} granularity="month" />); | ||
| }); | ||
| expect(comboLinePartial()).toBeNull(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| 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. | ||
| // findIndex returns -1 when no point is partial — that's the canonical "none" sentinel here, | ||
| // not 0, so `hasPartial` must not rely on index truthiness (a partial period at index 0 is a | ||
| // real partial period, not "no partial period"). | ||
| const partialIdx = points.findIndex((p) => p.partial); | ||
| const hasPartial = partialIdx !== -1; | ||
| if (import.meta.env.DEV && partialIdx === 0 && n > 1) { | ||
| console.warn( | ||
| `ComboTrendChart: partial period at index 0 of ${n} — the partial-is-always-last ` + | ||
| 'invariant is broken upstream; the chart will render with no solid segment before it.', | ||
| ); | ||
| } else 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 dashed tail will connect from the wrong point.', | ||
| ); | ||
| } | ||
| 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 && solidEnd >= 0 ? `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" | ||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>() { | ||
|
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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.