Skip to content

feat(web): analyze index — five equal analysis cards#172

Open
StanislavBG wants to merge 25 commits into
midt-bg:mainfrom
StanislavBG:pr/analyze
Open

feat(web): analyze index — five equal analysis cards#172
StanislavBG wants to merge 25 commits into
midt-bg:mainfrom
StanislavBG:pr/analyze

Conversation

@StanislavBG

@StanislavBG StanislavBG commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Analyze index — five equal analysis cards

The /analytics landing page: five equal-weight analysis cards (Flows, Map/Regions, Trends, Competition, Overruns), each with a lean headline metric served by a dedicated bounded query. Flows and Map read precomputed rollups only (flow_pairs, authority_totals); the Trends, Competition, and Overruns headlines aggregate over contracts (Overruns via the idx_contracts_overrun partial index, Trends/Competition bounded by idx_contracts_signed) — no rollup carries the bids_received split or the month series, so those cannot be rollup reads today. A per-year competition rollup (value + single-bid value per year) is a candidate follow-up to take the last contracts scans off this page. Builds on Overruns (PR 3) — completes the stack.

Key changes

  • Route: routes/analytics.tsx.
  • Libs: analytics-stats.ts (+ test), analytics-lenses.ts.
  • Queries (+ tests): headline fns added to flows.ts (getFlowsHeadline), regions.ts (getRegionHeadline), competition.ts (getOpaqueShareByYear); consumes getOverrunsHeadline/getSpendingTrend from earlier layers.

Query budget (cold load, per 1800s edge-cache window)

7 statements: getOverrunsHeadline (1, contracts via partial index) + getFlowsHeadline (1, rollups) + getRegionHeadline (1, rollup) + getSpendingTrend month series with includeSectors:false, includeInsights:false (3: series + coverage + as_of) + getOpaqueShareByYear (1, contracts since 2020, full years only).

Stack

PR 4 of 4. Depends on pr/overruns (PR 3). Merge order: dash-base → trends → overruns → analyze (last). Targets main due to cross-fork stacking; review its diff against pr/overruns.

Reviewed: 2 swarm rounds + adversarial verification + data-layer + UX passes; 287 tests, production build green.

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах PR-а (пет равностойни аналитични карти на /analytics + специализирани headline заявки). Кодът е стегнат и добре тестван; по-долу са находките, подредени по тежест. Две са с потребителски ефект (счупена връзка и премахната CI защита), останалите са за съответствие на текст с данни и за дублиране.

Прегледът е автоматизиран; преценете всяка бележка по същество.

Comment thread apps/web/workers/cache-key.ts Outdated
Comment thread apps/web/app/lib/analytics-lenses.ts Outdated
Comment thread apps/web/app/routes/analytics.tsx Outdated
Comment thread apps/web/app/lib/analytics-stats.ts
Comment thread packages/db/src/queries/regions.ts Outdated
Comment thread packages/db/src/queries/regions.ts
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Благодаря — адресирано (force-push 83083e8):

drift guard: възстановен (в базовия #169).
/compare връзка: махнах /compare lens-а от този PR — маршрутът е извън stack-а тук (умишлено изключен), та плочката 404-ваше. На пълния branch /compare съществува.
opaque-share текст: изравнен с метриката → „процедури само с една оферта" (заявката мери само bids_received = 1).
growthMultiple: делегира на formatGrowthFactor — еднакъв формат на /analytics и /overruns.
sectorOptions: консолидиран в споделения sectors.ts (махнати 3-те копия).
мъртви полета: sofiaEur/totalEur махнати от RegionHeadline.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Проверих срещу 83083e8: всичките 6 находки на Неда са затворени — CWE-349 drift guard-ът е възстановен (consumed ⊆ allowed, expect(undeclared).toEqual([])), няма линк към несъществуващ /compare, картовият текст за единствена оферта е прецизиран, growthMultiple/sectorOptions са унифицирани. Share-математиката е guard-ната (opaqueHeadline филтрира valueEur > 0, value-weighted, текущата непълна година е изключена). „Конкуренция" картата показва дела за последната пълна година (етикетиран с годината, ред 409) — различен обхват от общата цифра на /competition, не противоречие. Една дребна бележка (не блокер): тази карта всъщност сканира contracts (не rollup, за разлика от описанието в PR-а) — чисто perf. Одобрявам.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Благодаря за прегледа — по бележката за „Конкуренция" (contracts vs rollup):

Проверка: нито един rollup не носи bids_received (нито разбивка „една оферта") — facet_counts има само година/стойност без split, home_totals/sector_totals също. Т.е. headline-ът не може да се пренасочи към rollup без нова таблица.
Семантика: картата ползва първата и последната пълна година (дял + промяна в пр.п.), така че многогодишният scan е нужен по същество; ограничен е с signed_at >= '2020-01-01' върху idx_contracts_signed.
Действие: без схема-промени в този PR — коригирах описанието на PR-а да казва точно кои headline-и четат rollup-и (Flows, Map) и кои агрегират contracts (Trends, Competition, Overruns), и вписах per-year competition rollup (value + single-bid value) като follow-up.
Loader trace: 7 statements на cold load, без дублирани scan-ове (trend е с includeSectors:false, includeInsights:false, така че тежките insights заявки не се изпълняват).
• Без нов commit — само описанието е обновено. Тестове/typecheck зелени (@sigma/db 170 pass, web 362 pass; 32-те db fail-а са само sqlite3 ENOENT на машината, идентични и на main).

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Рестакнат върху новия #171 — старите trends/overruns къмити отпадат от историята. Една реална адаптация към обзора: estimateYoyGrowth е пренесен в lib/analytics-stats (старият lib/trends-forecast вече не съществува) и отпадна includeInsights от getSpendingTrend; иначе diff-ът спрямо pr/overruns показва само analyze файловете. Нов head: 5ee4d0d; typecheck 7/7, тестовете зелени.

todorkolev pushed a commit that referenced this pull request Jul 3, 2026
Splits the 3353-line app.css into app.css (import manifest) + 9 focused files under styles/ (tokens, base, chrome, layout, components, tables, home, flow, pages). Verified rule-for-rule equivalent to the previous monolith (525 selectors / 1926 declarations, 0 dropped or altered); app.css is now @import-only. typecheck + test + lint green.

Open css-touching PRs (#126, #131, #170-#172) now rebase onto the split (target the styles/ files, not app.css).
@ydimitrof

Copy link
Copy Markdown

Прегледах PR #172 стриктно, с акцент върху сигурност и цялост на данните. По-долу е обобщението; прегледът е локален и адверсариален (проверих всяка заявка за инжекции, XSS, CWE-349 дрейф и деление на нула).

Сигурност (OWASP)

SQL инжекции — няма. Проверих всяка нова/променена заявка в overruns.ts, competition.ts, regions.ts, flows.ts, trend.ts. Всички SQL-фрагменти, които се интерполират в стринга (OVERRUN_WHERE, SECTOR_KEY_SQL, CPV_CLEAN, PCT, DELTA, OVERRUN_MIN_SIGNING_EUR, cpvGroupsClause), са изцяло константи или структурни ?-плейсхолдъри — никаква потребителска стойност не влиза в текста на заявката. Целият вход от URL минава през bind-параметри:

  • getOpaqueShareByYear / getFlowsHeadline / getRegionHeadline — статичен SQL без параметри.
  • CPV multi-select (?cpv) минава през cpvGroupSelectionvalidCpvGroups (^\d{5}$, дедуп, cap 10), а cpvGroupRange подава двете граници като bind-параметри в (t.cpv_code >= ? AND t.cpv_code < ?). Half-open диапазонът е коректен и за код, завършващ на 9 (следващ ASCII символ : > 9).

XSS — няма. Няма dangerouslySetInnerHTML, innerHTML, eval или new Function в новите маршрути/компоненти; React екранира всичко.

CSRF / достъп — n/a. Маршрутите са read-only, edge-кеширани GET loaders над публични данни за обществени поръчки; няма мутации, няма секрети, няма външни fetch-ове, няма четене на process.env. analytics.tsx loader-ът не приема потребителски параметри изобщо.

CWE-349 (кеш дрейф). CACHE_QUERY_PARAMS е разширен коректно (cpv, by, step, angle, cohort, metric, a/b …), а дрейф-гардът (consumed ⊆ allowed) е възстановен в базовия PR и покрит от cache-key.test.ts (изпълних го локално — 10/10 зелени).

Цялост на данните

  • Всяко деление е защитено: sofiaShare (totalEur > 0), opaqueShare (filter(valueEur > 0)), PCT (WHERE изисква signing_value_eur >= 1000 + двоен JS guard срещу Infinity/NaN), estimateYoyGrowth (clampGrowth + Number.isFinite, clamp 0.5–2.0). Всеки non-finite вход връща em-dash, не измислена цифра.
  • Миграция 0002_contracts_overrun_index.sql е адитивна (CREATE INDEX IF NOT EXISTS, partial index), без колизия в номерацията (0000/0001/0002 подредени), и е покрита с регресионен тест в migrations.test.ts.

Съответствие с описанието

Описанието вече е приведено в съответствие с реалността след прегледа на @nedda76 и @lyubomir-bozhinov: „Конкуренция" картата коректно е обозначена като contracts-скан (не rollup), опейк-текстът е изравнен с метриката (bids_received = 1), growthMultiple/sectorOptions са унифицирани, мъртвите полета са премахнати. Всичките 6 предходни находки са затворени.

Незадължителни бележки (не-блокери)

  1. CI не се изпълнява за клонаgh pr checks 172 връща „no checks reported on the 'pr/analyze' branch". Зелените тестове са само по думите на автора (170 db / 362 web); препоръчвам да се потвърди, че workflow-ът се закача за този клон преди merge, за да има обективна следа.
  2. Обем/обхват — суровият diff срещу main е 10 091/1 800 по 37 файла, защото това е върхът на 4-PR cross-fork stack. Ревюто по същество трябва да е спрямо pr/overruns; спазвайте реда на merge (dash-base → trends → overruns → analyze), за да не влезе непълен стек в main.
  3. Локално не можах да изпълня analytics-stats/filters тестовете (@sigma/* workspace пакетите не са билднати в worktree-а — среда, не код); cache-key тестовете минаха.

Кодът е чист, добре тестван и без открити уязвимости. Отлична дисциплина по параметризацията и guard-овете.

Вердикт: ОДОБРЯВАМ — няма блокери по сигурност или цялост на данните; адресирайте само CI видимостта преди merge.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Rebase-нат върху main с css split (az-* → styles/pages.css), prettier-чист, линеен. Старите нишки резолвнати — шестте находки бяха затворени още на 83083e8 и препотвърдени.

@StanislavBG StanislavBG force-pushed the pr/analyze branch 3 times, most recently from 87546d3 to ab9c6a2 Compare July 3, 2026 17:36
@ydimitrof

Copy link
Copy Markdown

Прегледах PR #172 стриктно и локално, с приоритет върху сигурността и целостта на данните. За разлика от предходните прегледи този път инсталирах зависимостите в worktree-а и изпълних тестовете и typecheck-а сам — по-долу са резултатите, а не думи на автора.

Сигурност (OWASP)

A03 Injection — няма. Прочетох ред по ред всяка нова/променена заявка в overruns.ts, competition.ts, regions.ts, flows.ts, trend.ts. Всички SQL-фрагменти, които се интерполират в текста на заявката, са константи или чисто структурни ?-плейсхолдъри; никаква потребителска стойност не влиза в стринга:

  • OVERRUN_WHERE, DELTA, PCT, SECTOR_KEY_SQL, CPV_CLEAN, CPV_GROUP_GLOB, MEDIAN_PCT_SQL — статични константи.
  • cpvGroupsClause генерира само броя (t.cpv_code >= ? AND t.cpv_code < ?) групи; самите кодове идват през bind. Границите се смятат от cpvGroupRange, а не се вграждат.
  • Целият URL-вход е валидиран преди заявката: angle/step/sort/cpvSort минават през pick() allow-list, year през /^20\d\d$/, а CPV multi-select през cpvGroupSelection (^\d{5}$, дедуп, cap 10). Заявките listOverviewContracts и getCpvGroupMedians при това ре-валидират ^\d{5}$ — защита в дълбочина. getOpaqueShareByYear/getFlowsHeadline/getRegionHeadline са без параметри.
  • Проверих граничния случай на half-open диапазона: за код, завършващ на 9, String.fromCharCode('9'+1) дава : (ASCII 0x3A, точно след 9), така че [45239, 4523:) покрива коректно всички под-кодове без препокриване. Пинато и от overruns-sql.test.ts, който изпълних срещу реалния sqlite3cpvDivision(SECTOR_KEY_SQL(code)) ≡ cpvDivision(code) върху „мръсния" корпус: 2/2 зелени.

A03 XSS — няма. Нула dangerouslySetInnerHTML, innerHTML, eval, new Function в новите маршрути/компоненти; React екранира. SVG-графиките (ComboTrendChart, overruns) рендерират само числови геометрии, не сурови стрингове.

CWE-349 (кеш дрейф) — чисто. CACHE_QUERY_PARAMS е разширен коректно (angle, step, cpv, by, cohort, metric, cpvSort, a/b…), старият g е заменен от step, а дрейф-гардът consumed ⊆ allowed е запазен. cache-key.test.ts мина при мен.

Достъп/секрети — n/a. Read-only, edge-кеширани GET loader-и над публични данни; няма мутации, няма process.env, няма външни fetch, няма localStorage. analytics.tsx loader-ът не приема потребителски вход.

Цялост на данните

  • Всяко деление е защитено: opaqueHeadline филтрира valueEur > 0; PCT изисква signing_value_eur >= 1000 в OVERRUN_WHERE; clampGrowth отхвърля non-finite и ≤0 (band 0.5–2.0); overrunBarGeometry колабира до празен бар при непозитивна стойност. Непълни/невалидни входове връщат em-dash, не измислена цифра.
  • „Конкуренция" изключва текущата непълна година (substr(signed_at,1,4) < strftime('%Y','now')) — симетрично с изхвърлянето на частичния as_of период в trend.ts. Тримесечното сгъване (quarterOf/fillPeriods) е коректно и запазва подредбата.
  • Добавените вторични ключове за подредба (…, authority_id, bidder_id / …, c.id) правят LIMIT-натите резултати детерминистични — добра корекция.
  • Миграция 0002_contracts_overrun_index.sql е адитивна и идемпотентна (CREATE INDEX IF NOT EXISTS, partial WHERE annex_count > 0), номерацията 0000/0001/0002 е последователна, покрита с регресионен тест.

Съответствие с описанието

Описанието е приведено в съответствие след предходните прегледи (@nedda76, @lyubomir-bozhinov): „Конкуренция" е коректно обозначена като contracts-скан, опейк-текстът съответства на bids_received = 1, growthMultiple/sectorOptions са унифицирани, мъртвите полета — премахнати, линкът към несъществуващия /compare — махнат (потвърдих, че в diff-а няма /compare href). Няма свързан тикет за съпоставка; описанието на PR-а служи като спецификация и имплементацията му отговаря.

Локална проверка (резултати)

  • @sigma/web: filters + analytics-stats + overruns-chart + overruns-inspector + cache-key71/71 зелени.
  • @sigma/db: overruns + competition + trend + flows + regions + migrations80/80 зелени; overruns-sql (реален sqlite3) → 2/2.
  • pnpm turbo run typecheck7/7 успешни.
  • gh pr checks 172 вече връща зелен check (CI е закачен) — предходната ми бележка за липсваща CI следа отпада.

Незадължителни бележки (не-блокери)

  1. getCpvGroupMedians ползва целочислен ранг (cnt-1)*5/10 + 1, т.е. long-median при четен брой (за cnt=2 взима по-ниската стойност). За baseline-„спрямо типичното" е приемливо и документирано; само отбелязвам, че не е интерполирана медиана.
  2. Обхватът е голям, защото това е върхът на 4-PR cross-fork stack — ревюто по същество е спрямо pr/overruns. Спазвайте реда на merge (dash-base → trends → overruns → analyze), за да не влезе непълен стек в main.

Кодът е чист, дисциплиниран по параметризация и guard-ове, и напълно покрит с тестове, които този път изпълних сам. Не откривам блокери по сигурност или цялост на данните.

Вердикт: ОДОБРЯВАМ — няма блокери; тестовете, typecheck-ът и CI са зелени, съответствието с описанието е потвърдено.

@ydimitrof

Copy link
Copy Markdown

Всички проверки минаха. db 238/238, web 375/375, typecheck 7/7. Пиша финалния коментар.


Прегледах PR #172 стриктно и локално — с приоритет върху сигурността и целостта на данните. Този път инсталирах зависимостите в worktree-а и изпълних целия тестов пакет, typecheck-а и SQL-инжекционния анализ сам; по-долу са резултатите, а не думи на автора. Прегледът е адверсариален: третирах всяка нова заявка като потенциален вектор за инжекция и всяко деление като потенциален източник на измислена цифра.

Сигурност (OWASP)

A03 Injection — няма. Прочетох ред по ред всеки интерполиран SQL-фрагмент в overruns.ts, competition.ts, regions.ts, flows.ts, trend.ts. Всичко, което влиза в текста на заявката чрез ${…}, е константа или чисто структурен ?-плейсхолдър — никаква потребителска стойност не се вгражда в стринга:

  • OVERRUN_WHERE, DELTA, PCT, SIGNING, SECTOR_KEY_SQL, CPV_CLEAN, MEDIAN_PCT_SQL, CPV_GROUP_GLOB, YEAR_KNOWN, OVERRUN_MIN_SIGNING_EUR — статични константи, дефинирани веднъж.
  • Всеки where.join(' AND ') / s.join се сглобява само от литерални фрагменти; всяка стойност (sector, year, authorityId, bidderId) отива през .bind(...params).
  • cpvGroupsClause генерира само броя (t.cpv_code >= ? AND t.cpv_code < ?) групи; границите идват от cpvGroupRange и се подават като bind-параметри, не се вграждат.
  • periodLen в getSpendingTrend е 4 или 7, изведени от whitelist-нат granularity (year|quarter|month) — никога от суров стринг.

Валидация на входа (защита в дълбочина). Целият URL-вход е валидиран преди заявката: angle/step/sort/cpvSort/by минават през pick()/allow-list, cpv multi-select през cpvGroupSelection (^\d{5}$, дедуп, cap 10), а getMulti cap-ва на 50 и филтрира sector срещу KNOWN_SECTORS. getOpaqueShareByYear/getFlowsHeadline/getRegionHeadline са без параметри. Проверих граничния случай на half-open диапазона: за код, завършващ на 9 (напр. 45239), горната граница е 4523 + String.fromCharCode(':'/0x3A) = 4523:, което коректно покрива 45239… и изключва 45240 (45240 > 4523:) — без препокриване. Пинато от overruns-sql.test.ts, който изпълних срещу реалния sqlite3 и мина.

A03 XSS — няма. Нула dangerouslySetInnerHTML, innerHTML, eval, new Function в новите маршрути/компоненти (grep потвърди). React екранира; SVG-графиките (ComboTrendChart, overruns scatter) рендерират само числови геометрии, не сурови стрингове.

CWE-349 (кеш дрейф) — чисто. CACHE_QUERY_PARAMS е разширен коректно (angle, step, cur, cpv, by, cohort, metric, cpvSort, a/b…); старият g е заменен от step. Дрейф-гардът consumed ⊆ CACHE_QUERY_PARAMS ∪ INTENTIONALLY_UNKEYED е запазен и cache-key.test.ts мина при мен. Потвърдих, че trends.tsx наистина чете step/cur/cpv и че всичките са в allow-list-а.

Достъп/секрети — n/a. Read-only, edge-кеширани GET loader-и над публични данни за обществени поръчки; няма мутации, няма process.env, няма външни fetch, няма localStorage/document.cookie. analytics.tsx loader-ът не приема потребителски вход. Няма нови зависимости в package.json/lock-файла (проверено) — нулев supply-chain риск. FullscreenButton/MetricInfo feature-детектват requestFullscreen и разкачват всичките си listener-и в useEffect cleanup — без ресурсни течове.

Цялост на данните

  • Всяко деление е защитено: sofiaShare (totalEur > 0), opaqueHeadline (filter(valueEur > 0)), PCT (WHERE изисква signing_value_eur >= 1000 + двоен JS guard срещу Infinity/NaN), clampGrowth (отхвърля non-finite и ≤0, band 0.5–2.0), overrunBarGeometry (колабира при непозитивна стойност). Непълни входове връщат em-dash, не измислена цифра.
  • „Конкуренция" изключва текущата непълна година — симетрично с изхвърлянето на частичния as_of период в trend.ts. Тримесечното сгъване (quarterOf/fillPeriods) запазва подредбата.
  • Вторичните ключове за подредба (…, authority_id, bidder_id / …, c.id) правят LIMIT-натите резултати детерминистични.
  • Миграция 0002_contracts_overrun_index.sql е адитивна и идемпотентна (CREATE INDEX IF NOT EXISTS, partial WHERE annex_count > 0), номерацията 0000/0001/0002 е последователна, покрита с регресионен тест.
  • CSS регресия — няма. Изтритите в layout.css .lens-* правила вече не се ползват никъде — analytics.tsx е пренаписан към новия „пет карти" дизайн (az-* класове), които са дефинирани в pages.css (потвърдено). За разлика от други PR-и в stack-а, тук няма orphan-нати класове.

Съответствие с описанието

Няма свързан тикет за съпоставка; описанието на PR-а служи като спецификация и имплементацията му отговаря (пет равностойни карти, 7 bounded statements на cold load). Описанието е приведено в съответствие след предходните прегледи (@nedda76, @lyubomir-bozhinov): „Конкуренция" е коректно обозначена като contracts-скан, опейк-текстът съответства на bids_received = 1, growthMultiple/sectorOptions са унифицирани, мъртвите полета и линкът към несъществуващия /compare — премахнати.

Локална проверка (резултати)

  • @sigma/db: 238/238 зелени (вкл. overruns, competition, trend, flows, regions, migrations, overruns-sql срещу реален sqlite3).
  • @sigma/web: 375/375 зелени (вкл. analytics-stats, filters, overruns-chart, overruns-inspector, cache-key).
  • pnpm turbo run typecheck7/7 успешни.

Незадължителни бележки (не-блокери)

  1. Header-коментарът на cache-key.ts е съкратен и вече не описва механиката на дрейф-гарда (кои източници сканира, кои са слепите му петна). Самият guard и INTENTIONALLY_UNKEYED са запазени, така че защитата е налична — само документацията олекна. Струва си да се върнат двата реда контекст.
  2. getCpvGroupMedians ползва целочислен ранг (cnt-1)*5/10 + 1, т.е. long-median при четен брой. За baseline „спрямо типичното" е приемливо и документирано; само отбелязвам, че не е интерполирана медиана.
  3. Обхватът е голям, защото това е върхът на 4-PR cross-fork stack — ревюто по същество е спрямо pr/overruns. Спазвайте реда на merge (dash-base → trends → overruns → analyze), за да не влезе непълен стек в main.

Кодът е чист, дисциплиниран по параметризация и guard-ове, и напълно покрит с тестове, които този път изпълних сам. Не откривам блокери по сигурност или цялост на данните, нито следа от зловреден код.

Вердикт: ОДОБРЯВАМ — няма блокери по сигурност или цялост на данните; тестовете, typecheck-ът и SQL-инжекционният анализ са зелени.

The popover often renders inside a thead th whose white-space: nowrap is
inherited by every line of the card, so long summaries and the mono
readout ran past the right edge. Reset wrapping on .metric-info-pop
(white-space: normal + overflow-wrap: anywhere), widen the card to
320px clamped to the viewport (min(320px, 100vw - 16px)), and add the
JS shift-into-viewport + coarse-pointer 44px hit area so all copies of
the component behave identically.
The обзор cross lens now re-runs the combo chart, year cards and totals
server-side for the ticked CPV groups (repeatable ?cpv, validated
5-digit codes, deduped, capped at 10), and the chart stretches to fill
its card against the CPV list. One aggregate scan via an OR of half-open
cpv_code index ranges; ?cpv stays keyed in the edge cache (CWE-349) with
UI-canonicalized ordering, and selection changes are announced via an
sr-only status line.
The by-sector aggregate grouped on substr(t.cpv_code, 1, 2) and then ran
cpvDivision() over the already-truncated prefix, so a dirty code like
' 45000000' resolved to division '4' there while the leaderboard
(cpvDivision over the full code) resolved it to 45 — the same contract
landed in different CPV sectors on the two surfaces.

Select the full t.cpv_code, GROUP BY it in SQL, and run cpvDivision()
on the full code in JS exactly like the leaderboard mapping; the
existing merge folds all codes onto their canonical division and
re-applies secLimit post-merge. Test covers a dirty leading char
flowing through both surfaces to the same division.
…he full cpv_code

Grouping by the full cpv_code fixed the dirty-code divergence but returned
one row per distinct 8-digit code — 582 rows on the local corpus (thousands
at scale) shipped out of D1 for a 15-row table. SECTOR_KEY_SQL strips the
separator characters real codes carry and takes substr(clean, 1, 2) only
when the cleaned code provably starts with two digits (in which case it IS
cpvDivision(code)); anything else falls through as the full raw code for
the JS re-key to fold in. Exact cpvDivision semantics, division-sized
result set, still no pre-merge LIMIT truncation.

Adds a sqlite3-CLI integration test pinning SECTOR_KEY_SQL ≡ cpvDivision
across the dirt corpus, and a unit test pinning the GROUP BY key against
both traps (naive substr, full-code blowup).
Every column header in „Кои институции раздуват най-много" and „Раздуване
по сектори" now carries the same ⓘ MetricInfo popover the headline KPIs
use: what the metric is, how it is computed (grounded in the SQL — growth
is SUM(delta)/SUM(signing), €-weighted, not an average of percents), and
the honest inclusion caveat (annex_count > 0, current > signing,
signing ≥ €1 000). Table header cells get white-space: nowrap so the ⓘ
never wraps the label.

Also: the „Договори по мащаб" leaderboard rows gain right-side breathing
room (row padding-right var(--s-3)) so value text and truncated titles no
longer touch the card edge; the inset lives on the row, so the selected
highlight still reads full-width and the proportional bar-track math is
untouched in normal and fullscreen mode.
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Дребна корекция на копито след изваждането на прогнозата: описанието на тренд-картата вече не обещава „прогноза напред" — eaeeb2c. #192 и #193 са ре-базирани върху новия връх.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

@todorkolev готов за ревю 🙏 — rebase-нат на main, CI зелен, prettier-чист, CSS промените в styles/* (app.css само @import). Резолвнати нишки. Approve-ни когато ти е удобно.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Одобрявам на връх eaeeb2c. Ре-проверих новия accuracy-слой ред по ред:

  • analytics-stats.ts — всяка деривация гардира тънки данни: em-dash при non-finite вход; valueEur > 0 филтър преди share-дивизията (opaqueHeadline); prev.value/prev.count > 0 преди всяко ratio; clampGrowth [0.5, 2]. Честно, без фабрикувани числа.
  • cache-key drift — чисто (guard покрива цялото дърво).

Една бележка (не блокер): estimateYoyGrowth смята съотношения между съседни записи, не между съседни календарни години — при пропусната пълна година в 3-годишния прозорец един 2-годишен скок се брои като едногодишен растеж и надува „%/год" (омекотено от clamp+median; на практика недостижимо за скорошните плътни години). 1 ред гард year[i] − year[i−1] === 1 го затваря напълно.

Останалото — чисто.

…ontainer

It was the only wide table on /overruns missing .ov-table-scroll (the institutions table right
below it already has it), so on narrow screens it broke out of the panel and spilled past the
right edge of the viewport instead of scrolling contained.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants