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