feat(tradein): house analytics section (chart + KPI + sold list) #546

Merged
lekss361 merged 1 commit from feat/tradein-house-analytics-frontend into main 2026-05-24 18:09:46 +00:00
Owner

Что

Новая секция Аналитика дома на trade-in странице — раскрывает уже собранные данные house_placement_history (15k+ rows от avito_imv + yandex_valuation backfill).

Backend

GET /api/v1/trade-in/estimate/{id}/house-analytics

  • Resolve target house_ids: нормализованный адрес → geo-fallback 100m → если <8 hist rows expand до 300m
  • Возвращает:
    • price_history — median ₽/м² по году (percentile_cont)
    • recent_sold — последние 12 мес снятые с фактической ценой + торгом (limit 20)
    • kpi — median exposure_days, median bargain %, sold_rate %

Frontend

HouseAnalyticsSection под PlacementHistoryCard с 3 блоками:

  • PriceHistoryChart — Recharts line chart
  • HouseAnalyticsKpiRow — 3 KPI cards (экспозиция · торг · sold-rate)
  • RecentSoldList — таблица снятых лотов 12 мес

New dep: recharts@^2.15.4.

Что НЕ делает

  • Не создаёт новых таблиц
  • Не подключает matching pipeline (это отдельный issue Phase 0 для live monitoring)
  • Не модифицирует existing cards

Test plan

  • backend: ruff/mypy/pre-commit pass
  • frontend: tsc --noEmit, next build pass
  • smoke: открыть /trade-in?id=d1570e00-f87f-4467-bbb8-161beb927be9 после deploy и убедиться что секция рендерится (target house 8731 имеет 16 hist rows; в 300m radius — 577)

Demo завтра

Этот PR — для demo, показывает реальную ценность от уже собранных backfill данных, без создания новых ETL.

## Что Новая секция **Аналитика дома** на trade-in странице — раскрывает уже собранные данные `house_placement_history` (15k+ rows от avito_imv + yandex_valuation backfill). ## Backend `GET /api/v1/trade-in/estimate/{id}/house-analytics` - Resolve target house_ids: нормализованный адрес → geo-fallback 100m → если <8 hist rows expand до 300m - Возвращает: - `price_history` — median ₽/м² по году (percentile_cont) - `recent_sold` — последние 12 мес снятые с фактической ценой + торгом (limit 20) - `kpi` — median exposure_days, median bargain %, sold_rate % ## Frontend `HouseAnalyticsSection` под `PlacementHistoryCard` с 3 блоками: - `PriceHistoryChart` — Recharts line chart - `HouseAnalyticsKpiRow` — 3 KPI cards (экспозиция · торг · sold-rate) - `RecentSoldList` — таблица снятых лотов 12 мес New dep: `recharts@^2.15.4`. ## Что НЕ делает - Не создаёт новых таблиц - Не подключает matching pipeline (это отдельный issue Phase 0 для live monitoring) - Не модифицирует existing cards ## Test plan - [x] backend: ruff/mypy/pre-commit pass - [x] frontend: tsc --noEmit, next build pass - [ ] smoke: открыть `/trade-in?id=d1570e00-f87f-4467-bbb8-161beb927be9` после deploy и убедиться что секция рендерится (target house 8731 имеет 16 hist rows; в 300m radius — 577) ## Demo завтра Этот PR — для demo, показывает реальную ценность от уже собранных backfill данных, без создания новых ETL.
lekss361 added 1 commit 2026-05-24 18:01:14 +00:00
Backend: GET /api/v1/trade-in/estimate/{id}/house-analytics — resolves target
house_ids через нормализованный адрес или geo-fallback 100m, expand до 300m
если в самом доме <8 hist rows. Возвращает:
- price_history: median ₽/м² по году (из house_placement_history)
- recent_sold: последние 12 мес снятые с фактической ценой + торгом
- kpi: median exposure_days, median bargain %, sold_rate %

Frontend: HouseAnalyticsSection (под PlacementHistoryCard) c 3 блоками:
- PriceHistoryChart (Recharts line, median ₽/м²)
- HouseAnalyticsKpiRow (3 KPI cards)
- RecentSoldList (таблица снятых лотов)

Использует уже собранные данные house_placement_history (15k+ rows от
backfill scripts: avito_imv + yandex_valuation). Новых таблиц нет.
Author
Owner

Deep Code Review — PR #546

Status: APPROVE — clean implementation, no blockers. 5 low-severity nits below.

Files: 11 (P0: 1 SQL endpoint + 1 schema · P1: 4 components + 1 hooks file · P2: types, page wiring, lockfile, package.json)
SHA: 7eeffe3 matched · Lines: +4250 / -1 (3705 lockfile, ~250 code)

Strengths

  • SQL correctness: CAST(:id AS uuid) everywhere (psycopg v3 compliant), explicit EXTRACT(YEAR ...)::int, NULLIF(area_m2, 0) / NULLIF(start_price, 0) guards, sensible row filters (last_price > 100000, area_m2 > 10), correct percentile_cont(...) FILTER for bargain median.
  • Indexes already cover the workload: hph_house_idx (house_id, last_price_date DESC) for price_history + recent_sold ordering, hph_removed_idx WHERE removed_date IS NOT NULL for the recent-sold 12mo filter, hph_house_source_extid_uniq_idx for dedup. No new indexes needed.
  • Resolve→expand fallback (8 hist rows threshold → 300m radius) is a sound pattern, matches placement-history endpoint shape.
  • Security clean: no dangerouslySetInnerHTML, no user-supplied URLs/anchors, no SVG injection — recharts renders only numeric arrays.
  • TanStack Query integration mirrors existing hooks (staleTime: 10min, enabled guard on null id, structured queryKey).
  • Schema parity: Pydantic ↔ TS interface field-by-field match (PriceHistoryYearPoint, RecentSoldEntry, HouseAnalyticsKpi, HouseAnalyticsResponse).
  • Graceful empty state: section returns null when total_lots === 0 instead of rendering empty cards.

Low-severity findings (non-blocking)

L1 — perf, PriceHistoryChart.tsx:2-10 Static import { LineChart, Line, ... } from "recharts" ships ~200KB+ to every /trade-in page load, even when the section short-circuits to null (no house data). Wrap with next/dynamic:

const PriceHistoryChart = dynamic(
  () => import("./PriceHistoryChart").then(m => m.PriceHistoryChart),
  { ssr: false, loading: () => null }
);

L2 — display bug, HouseAnalyticsKpiRow.tsx:46-49 Always prefixes for median_bargain_pct. If price rose (negative value), UI shows −−5.0%. Mirror the RecentSoldList +/− logic:

value={kpi.median_bargain_pct != null
  ? `${kpi.median_bargain_pct > 0 ? "−" : "+"}${Math.abs(kpi.median_bargain_pct).toFixed(1)}%`
  : "—"}

Low risk in practice (most rows are bargains), but inconsistent with sibling component.

L3 — a11y, HouseAnalyticsSection.tsx:18 + chart/list articles No aria-label on the section / chart <article> / list <article>. Peers (HouseInfoCard, CianValuationCard, IMVBenchmark, DealsCard) all carry aria-label="...". Add:

  • <section aria-label="Аналитика дома">
  • <article aria-label="Динамика цен в доме"> on chart
  • <article aria-label="Недавно снятые лоты"> on RecentSoldList

L4 — copy precision, PriceHistoryChart.tsx:21 Title "Динамика цен в доме" is shown even when data comes from radius_m=300 neighbors (header in HouseAnalyticsSection correctly disambiguates with "в радиусе 300м", but chart subtitle doesn't). Optionally pass radiusM prop and append (включая соседние дома) when > 0.

L5 — dep lockfile mismatch, pnpm-lock.yaml PR adds pnpm-lock.yaml (3705 lines) but tradein-mvp/frontend/Dockerfile uses npm install with package-lock.json* glob. The pnpm lockfile is unused at build time (npm resolves deps fresh from package.json). Won't break deploy (uses npm install, not npm ci), but signals pkg-manager confusion. Either:

  • (a) commit package-lock.json instead and drop pnpm-lock, or
  • (b) switch Dockerfile to corepack enable && pnpm install --frozen-lockfile.

Cross-PR check

  • #530 CianChartPoint pattern: PR description claims chart reuses #530 pattern, but #530 uses inline <svg> sparkline (CianValuationCard.tsx:15-32). This PR introduces recharts as a new dep. Trade-off accepted — recharts gives axis/tooltip out of box vs hand-rolled sparkline.
  • #527 houses bootstrap + #528/532 placement history: endpoint reuses the established tradein_normalize_short_addr + ST_DWithin 100m resolve pattern. No regression on placement-history endpoint (same address-resolve block).
  • #512 safeUrl: N/A — no user-supplied URLs in this surface.
  • #517 role="status": N/A — section returns null on loading/error rather than rendering a status; acceptable for non-blocking enrichment card.

Smoke test

PR description target: estimate d1570e00-f87f-4467-bbb8-161beb927be9 (house 8731, 16 hist rows direct, 577 in 300m). Validate post-deploy that radius_m=0 and price_history.length >= 2.

— deep-code-reviewer

## Deep Code Review — PR #546 <!-- gendesign-review-bot: sha=7eeffe3 verdict=approve --> **Status:** APPROVE — clean implementation, no blockers. 5 low-severity nits below. **Files:** 11 (P0: 1 SQL endpoint + 1 schema · P1: 4 components + 1 hooks file · P2: types, page wiring, lockfile, package.json) **SHA:** 7eeffe3 matched · **Lines:** +4250 / -1 (3705 lockfile, ~250 code) ### Strengths - **SQL correctness**: `CAST(:id AS uuid)` everywhere (psycopg v3 compliant), explicit `EXTRACT(YEAR ...)::int`, `NULLIF(area_m2, 0)` / `NULLIF(start_price, 0)` guards, sensible row filters (`last_price > 100000`, `area_m2 > 10`), correct `percentile_cont(...) FILTER` for bargain median. - **Indexes already cover** the workload: `hph_house_idx (house_id, last_price_date DESC)` for price_history + recent_sold ordering, `hph_removed_idx WHERE removed_date IS NOT NULL` for the recent-sold 12mo filter, `hph_house_source_extid_uniq_idx` for dedup. No new indexes needed. - **Resolve→expand fallback** (8 hist rows threshold → 300m radius) is a sound pattern, matches `placement-history` endpoint shape. - **Security clean**: no `dangerouslySetInnerHTML`, no user-supplied URLs/anchors, no SVG injection — recharts renders only numeric arrays. - **TanStack Query integration** mirrors existing hooks (`staleTime: 10min`, `enabled` guard on null id, structured queryKey). - **Schema parity**: Pydantic ↔ TS `interface` field-by-field match (PriceHistoryYearPoint, RecentSoldEntry, HouseAnalyticsKpi, HouseAnalyticsResponse). - **Graceful empty state**: section returns `null` when `total_lots === 0` instead of rendering empty cards. ### Low-severity findings (non-blocking) **L1 — perf, `PriceHistoryChart.tsx:2-10`** Static `import { LineChart, Line, ... } from "recharts"` ships ~200KB+ to every `/trade-in` page load, even when the section short-circuits to `null` (no house data). Wrap with `next/dynamic`: ```tsx const PriceHistoryChart = dynamic( () => import("./PriceHistoryChart").then(m => m.PriceHistoryChart), { ssr: false, loading: () => null } ); ``` **L2 — display bug, `HouseAnalyticsKpiRow.tsx:46-49`** Always prefixes `−` for `median_bargain_pct`. If price rose (negative value), UI shows `−−5.0%`. Mirror the `RecentSoldList` `+/−` logic: ```tsx value={kpi.median_bargain_pct != null ? `${kpi.median_bargain_pct > 0 ? "−" : "+"}${Math.abs(kpi.median_bargain_pct).toFixed(1)}%` : "—"} ``` Low risk in practice (most rows are bargains), but inconsistent with sibling component. **L3 — a11y, `HouseAnalyticsSection.tsx:18` + chart/list articles** No `aria-label` on the section / chart `<article>` / list `<article>`. Peers (`HouseInfoCard`, `CianValuationCard`, `IMVBenchmark`, `DealsCard`) all carry `aria-label="..."`. Add: - `<section aria-label="Аналитика дома">` - `<article aria-label="Динамика цен в доме">` on chart - `<article aria-label="Недавно снятые лоты">` on RecentSoldList **L4 — copy precision, `PriceHistoryChart.tsx:21`** Title `"Динамика цен в доме"` is shown even when data comes from `radius_m=300` neighbors (header in `HouseAnalyticsSection` correctly disambiguates with "в радиусе 300м", but chart subtitle doesn't). Optionally pass `radiusM` prop and append `(включая соседние дома)` when > 0. **L5 — dep lockfile mismatch, `pnpm-lock.yaml`** PR adds `pnpm-lock.yaml` (3705 lines) but `tradein-mvp/frontend/Dockerfile` uses `npm install` with `package-lock.json*` glob. The pnpm lockfile is unused at build time (npm resolves deps fresh from `package.json`). Won't break deploy (uses `npm install`, not `npm ci`), but signals pkg-manager confusion. Either: - (a) commit `package-lock.json` instead and drop pnpm-lock, or - (b) switch Dockerfile to `corepack enable && pnpm install --frozen-lockfile`. ### Cross-PR check - **#530 CianChartPoint pattern**: PR description claims chart reuses #530 pattern, but #530 uses inline `<svg>` sparkline (`CianValuationCard.tsx:15-32`). This PR introduces recharts as a new dep. Trade-off accepted — recharts gives axis/tooltip out of box vs hand-rolled sparkline. - **#527 houses bootstrap + #528/532 placement history**: endpoint reuses the established `tradein_normalize_short_addr` + ST_DWithin 100m resolve pattern. No regression on placement-history endpoint (same address-resolve block). - **#512 safeUrl**: N/A — no user-supplied URLs in this surface. - **#517 role="status"**: N/A — section returns null on loading/error rather than rendering a status; acceptable for non-blocking enrichment card. ### Smoke test PR description target: estimate `d1570e00-f87f-4467-bbb8-161beb927be9` (house 8731, 16 hist rows direct, 577 in 300m). Validate post-deploy that `radius_m=0` and `price_history.length >= 2`. — deep-code-reviewer
lekss361 merged commit 80850a591e into main 2026-05-24 18:09:46 +00:00
lekss361 deleted branch feat/tradein-house-analytics-frontend 2026-05-24 18:09:46 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#546
No description provided.