-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): analyze index — five equal analysis cards #172
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
41
commits into
midt-bg:main
Choose a base branch
from
StanislavBG:pr/analyze
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 27 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 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 c37ef61
feat(web): overruns dashboard
StanislavBG b6f8418
fix(db): overruns annex chronology + CPV normalization + dedup (review)
StanislavBG 7865642
fix(db): normalize full cpv_code in overruns by-sector query
StanislavBG d9aefe9
style(web): format overruns route to satisfy blocking lint
StanislavBG 13505bf
perf(db): group overruns by-sector on a SQL cpvDivision mirror, not t…
StanislavBG e28b715
feat(web): metric definitions on both overruns tables
StanislavBG 759dd87
feat(web): analyze index — five equal analysis cards
StanislavBG c0521bd
fix(web): analyze opaque-share wording, growth-multiple dedup, drop /…
StanislavBG bf48670
style(web): format overruns and analytics routes to satisfy blocking …
StanislavBG 5f64191
refactor(web): drop the retired .lens-* analytics styles with the pag…
StanislavBG b864779
test(web): restore the reverse cache-key drift guard with a documente…
StanislavBG eaeeb2c
fix(web): analytics trend card copy — no forecast promised
StanislavBG a9b69a3
fix(web): wrap the overrun-by-sector table in the horizontal scroll c…
StanislavBG fdb370f
fix(web): address ydimitrof review round on #172 (analyze)
StanislavBG 4755c1b
fix(web): address ydimitrof review round 2 on #172
StanislavBG 9956f63
Merge remote-tracking branch 'origin/main' into pr/analyze
StanislavBG cca8f3e
fix(web): address ydimitrof review round on PR #172 (analyze cards)
StanislavBG 015c935
test(db): verify competition ORDER BY tie-breaker aliases against rea…
StanislavBG 93af83d
fix(web): address ydimitrof review round on PR #172 (11 residual thre…
StanislavBG e372a1d
Merge branch 'main' into pr/analyze
StanislavBG b8d7356
fix(web): run prettier on files flagged by CI lint after PR #172 merge
StanislavBG c47366d
fix(web,db): address ydimitrof review round on pr/analyze (5 threads)
StanislavBG 9a8a6f3
style(web): run prettier on components.css after review-round fix
StanislavBG 47dc0a0
fix(web): address ydimitrof review round on pr/analyze (6 threads)
StanislavBG 035e5b1
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG d102938
fix(web): extract shared year-axis tick helper for TrendChart/ComboTr…
StanislavBG 9666ab1
build(deps): patch postcss/valibot CVEs, bump react-router within 7.x…
StanislavBG 9b3e26f
build: merge origin/main into pr/analyze, resolve conflicts
StanislavBG fed9ef8
build: regenerate pnpm-lock.yaml with pinned pnpm 10.33.0
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,157 @@ | ||
| 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. | ||
| // `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; | ||
|
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)}` : ''; | ||
|
|
||
| // 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"> | ||
|
StanislavBG marked this conversation as resolved.
|
||
| {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"> | ||
|
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> | ||
| ); | ||
| } | ||
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>() { | ||
| 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> | ||
| ); | ||
| } |
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,105 @@ | ||
| import { useEffect, useLayoutEffect, useRef, useState } from 'react'; | ||
|
|
||
| // useLayoutEffect warns when it runs during SSR ("does nothing on the server"). Swap in useEffect | ||
| // for the server render so the console stays clean; the client still gets the synchronous layout | ||
| // measurement it needs before paint. | ||
| const useIsomorphicLayoutEffect = typeof document !== '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); | ||
|
|
||
| useIsomorphicLayoutEffect(() => { | ||
| if (!open) { | ||
| setShift(0); | ||
| return; | ||
| } | ||
| const pop = popRef.current; | ||
| if (!pop) return; | ||
| const recalc = () => { | ||
| 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)); | ||
| }; | ||
| recalc(); | ||
| // Viewport can change while the popover is open (rotation, browser-chrome resize, scroll on | ||
| // small screens) — recompute so the popover doesn't drift outside the viewport. | ||
| window.addEventListener('resize', recalc); | ||
| window.addEventListener('scroll', recalc, true); | ||
| return () => { | ||
| window.removeEventListener('resize', recalc); | ||
| window.removeEventListener('scroll', recalc, true); | ||
| }; | ||
| }, [open]); | ||
|
StanislavBG marked this conversation as resolved.
|
||
|
|
||
| // 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> | ||
|
StanislavBG marked this conversation as resolved.
|
||
| <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> | ||
| ); | ||
| } | ||
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
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
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.