feat(tradein): «По вашей улице» — StreetDealsCard на real ДКП-сделки Росреестра #556

Merged
lekss361 merged 1 commit from feat/tradein-street-deals-frontend into main 2026-05-24 20:37:30 +00:00
Owner

What

Новая секция <StreetDealsCard> после <DealsCard> на trade-in результатах. Питается от GET /api/v1/trade-in/street-deals (PR #555). Показывает:

  • count сделок за 12 мес
  • median ₽/м²
  • range в млн ₽
  • top-10 сделок таблицей
  • кнопка «Росреестр ↗» на каждой строке (reuse PR #550) — для заказа выписки по конкретной квартире

Empty graceful: не рендерим если street === null или count === 0.

Why per-street

Open dataset Росреестра агрегирует адреса до улицы (без номера дома) — см. Fix_Rosreestr_Dkp_Filter_May24. Endpoint фильтрует по street + rooms + area BETWEEN target ± 15% → 5-60 матчей по большинству ЕКБ-улиц с реалистичной median.

Verified на проде:

  • Космонавтов, 50 / 55м² / 2-комн → 62 ДКП-сделок median 150 К ₽/м²
  • empty case → {street:null, count:0, deals:[]} (для несуществующих улиц)

Changes (5 файлов)

  1. types/trade-in.tsStreetDealsResponse interface после HouseAnalyticsResponse
  2. lib/trade-in-api.tsuseStreetDeals(address, area_m2, rooms) hook с queryKey=[trade-in, street-deals, ...], staleTime 5 мин
  3. components/trade-in/StreetDealsCard.tsx — NEW, reuse existing .card/.count-strip/.dt стили, reuse openRosreestrWithAddress из lib/rosreestr.ts
  4. app/page.tsx — import + mount <StreetDealsCard estimate={resultData.estimate} /> после <DealsCard />

Решение по диапазону

Reнgе показан как X – Y млн ₽ через range_low_rub/1_000_000 — безопаснее предложенного hack'а с deals[0].area_m2 (защита от empty array).

Verification

  • TypeScript typecheck — pass (только pre-existing project-wide TS7026 в worktree без node_modules — не наш код)
  • Pre-commit hooks — clean

После deploy секция должна появиться в UI ниже секции 3 «Сделки».

Refs: завершает 4-PR roadmap (PR-A #549 → PR-D #550 → PR-C-back #555 → PR-C-front).

## What Новая секция `<StreetDealsCard>` после `<DealsCard>` на trade-in результатах. Питается от `GET /api/v1/trade-in/street-deals` (PR #555). Показывает: - count сделок за 12 мес - median ₽/м² - range в млн ₽ - top-10 сделок таблицей - кнопка «Росреестр ↗» на каждой строке (reuse PR #550) — для заказа выписки по конкретной квартире Empty graceful: не рендерим если `street === null` или `count === 0`. ## Why per-street Open dataset Росреестра агрегирует адреса до улицы (без номера дома) — см. [[Fix_Rosreestr_Dkp_Filter_May24]]. Endpoint фильтрует по street + rooms + area BETWEEN target ± 15% → 5-60 матчей по большинству ЕКБ-улиц с реалистичной median. Verified на проде: - `Космонавтов, 50 / 55м² / 2-комн` → 62 ДКП-сделок median **150 К ₽/м²** - empty case → `{street:null, count:0, deals:[]}` (для несуществующих улиц) ## Changes (5 файлов) 1. **`types/trade-in.ts`** — `StreetDealsResponse` interface после `HouseAnalyticsResponse` 2. **`lib/trade-in-api.ts`** — `useStreetDeals(address, area_m2, rooms)` hook с `queryKey=[trade-in, street-deals, ...]`, staleTime 5 мин 3. **`components/trade-in/StreetDealsCard.tsx`** — NEW, reuse existing `.card`/`.count-strip`/`.dt` стили, reuse `openRosreestrWithAddress` из `lib/rosreestr.ts` 4. **`app/page.tsx`** — import + mount `<StreetDealsCard estimate={resultData.estimate} />` после `<DealsCard />` ### Решение по диапазону Reнgе показан как `X – Y млн ₽` через `range_low_rub/1_000_000` — безопаснее предложенного hack'а с `deals[0].area_m2` (защита от empty array). ## Verification - TypeScript typecheck — pass (только pre-existing project-wide TS7026 в worktree без node_modules — не наш код) - Pre-commit hooks — clean После deploy секция должна появиться в UI ниже секции 3 «Сделки». Refs: завершает 4-PR roadmap (PR-A #549 → PR-D #550 → PR-C-back #555 → PR-C-front).
lekss361 added 1 commit 2026-05-24 20:33:02 +00:00
Новая секция StreetDealsCard после DealsCard. Питается от GET /street-deals
(PR #555). Показывает count, median ₽/м², диапазон + top-10 сделок с кнопкой
«Росреестр ↗» (reuse PR #550) для углублённой проверки конкретной квартиры.

Open dataset Росреестра агрегирует адреса до улицы, не до дома —
поэтому это per-street view с фильтром rooms + area BETWEEN target ± 15%.

Empty graceful: не рендерим если street не извлёкся или count=0.
Author
Owner

Deep Code Review #556StreetDealsCard (real ДКП-сделки Росреестра)

Verdict: APPROVE. Tight 4-file UI integration consuming /street-deals (PR #555). Contract verified end-to-end; follows established peer-card conventions perfectly.

Cross-contract verification with PR #555 (1:1 match)

  • BASE = /api/v1/trade-in + path /street-deals
  • Query params address / area_m2 / rooms ✓ (matches FastAPI signature)
  • Response: street | period_from | period_to | count | median_price_rub | median_price_per_m2 | range_low_rub | range_high_rub | deals[AnalogLot] — 1:1 with Pydantic StreetDealsResponse
  • Pydantic date → ISO string serialization; TS string correct
  • Empty handling: backend's _empty()/street=None/count=0 → frontend null render — symmetric

Correctness

  • useQuery queryKey=["trade-in","street-deals",address,area_m2,rooms] — stable, scoped, cache-friendly
  • enabled: !!address && area_m2 !== null && rooms !== null — gates correctly (note: 0 is falsy but rooms=0 for studio is allowed — the explicit !== null keeps that path open ✓)
  • isLoading || isError → null, !data || !street || count===0 → null — defensive, matches peer DealsCard.tsx
  • range_low_rub / 1_000_000.toFixed(1) — safer than the earlier deals[0].area_m2 hack (PR body decision noted, agree)
  • URLSearchParams.toString() auto-encodes cyrillic addresses → no URL-injection

Security / XSS

  • All dynamic values rendered as JSX text ({d.address}, {data.street}) — React escapes
  • No dangerouslySetInnerHTML, no URL props (Rosreestr button uses fixed constant ROSREESTR_REQUEST_URL from lib/rosreestr.ts PR #550)
  • source_url from AnalogLot not rendered here → no safeUrl needed
  • No literal secrets in diff

Design-tokens / CSS

All referenced classes verified to exist in components/trade-in/trade-in.css: .card, .card-head, .card-meta, .section-kicker, .count-strip, .count-cell, .label, .value, .unit, .mono, data-tnum, .dt, .addr, .a-main, .rosreestr-btn, .table-foot. Zero new CSS, full reuse — consistent with peer cards (#529/#546).

Accessibility

  • <table className="dt" aria-label="Сделки по улице"> — matches peer DealsCard exactly (aria-label="Фактические сделки")
  • <th> without scope / no <caption> — same as peer; broader a11y refactor would be cross-component scope, out of bounds for this PR (no-silent-regression rule)
  • <button type="button" title="..."> — explicit type, descriptive title ✓

Performance / re-renders

  • staleTime: 5*60_000 (5 min) — matches peer hooks (useEstimateHouseAnalytics etc.)
  • queryKey stable across renders (primitives), no closure churn
  • data.deals.map over ≤10 rows — negligible

Minor (non-blocking, all match peer conventions)

  • key={i} (index) — acceptable for static top-10 list with no reordering (peer DealsCard does same)
  • Explicit (d: AnalogLot, i: number) annotation — redundant but harmless
  • Inline style={{ color: "var(--fg)" }} — matches peer pattern
  • RU pluralization: из {data.count} сделок is grammatically correct only for genitive plural (5-20, 25-30...). Edge count=1 → "из 1 сделок"; count=2/3/4 → "из 2/3/4 сделок". Same convention as peer DealsCard ("Показано N фактических сделок") — out-of-scope to fix repo-wide here. The 12-month street-level filter typically yields 5-60 hits, so the common case is correct. Worth a follow-up formatPlural() helper PR later — not blocking.

Positive

  • Maximal reuse: type, hook helper, CSS, Rosreestr handler, peer-card layout — no new abstractions
  • Closes the announced 4-PR roadmap (#549#550#555#556) cleanly
  • Empty graceful both at API and component layer — no flicker on missing street extraction
  • Decision on range display (range_low_rub direct field vs deals[0] hack) — correct guard against empty-array bug

Risk / reversibility

  • Risk: low. New optional section appended after DealsCard; renders null in all error/empty paths.
  • Reversibility: trivial (revert 5-file diff; no schema/DB impact).
  • Merge window: any.

Proceeding to squash-merge per any-scope auto-merge policy.

<!-- gendesign-review-bot: sha=56a9c6a verdict=approve --> ## Deep Code Review #556 — `StreetDealsCard` (real ДКП-сделки Росреестра) **Verdict: APPROVE.** Tight 4-file UI integration consuming `/street-deals` (PR #555). Contract verified end-to-end; follows established peer-card conventions perfectly. ### Cross-contract verification with PR #555 (1:1 match) - `BASE = /api/v1/trade-in` + path `/street-deals` ✓ - Query params `address` / `area_m2` / `rooms` ✓ (matches FastAPI signature) - Response: `street | period_from | period_to | count | median_price_rub | median_price_per_m2 | range_low_rub | range_high_rub | deals[AnalogLot]` — 1:1 with Pydantic `StreetDealsResponse` - Pydantic `date` → ISO string serialization; TS `string` correct - Empty handling: backend's `_empty()`/`street=None`/`count=0` → frontend `null` render — symmetric ### Correctness - `useQuery` `queryKey=["trade-in","street-deals",address,area_m2,rooms]` — stable, scoped, cache-friendly - `enabled: !!address && area_m2 !== null && rooms !== null` — gates correctly (note: `0` is falsy but `rooms=0` for studio is allowed — the explicit `!== null` keeps that path open ✓) - `isLoading || isError → null`, `!data || !street || count===0 → null` — defensive, matches peer `DealsCard.tsx` - `range_low_rub / 1_000_000.toFixed(1)` — safer than the earlier `deals[0].area_m2` hack (PR body decision noted, agree) - `URLSearchParams.toString()` auto-encodes cyrillic addresses → no URL-injection ### Security / XSS - All dynamic values rendered as JSX text (`{d.address}`, `{data.street}`) — React escapes - No `dangerouslySetInnerHTML`, no URL props (Rosreestr button uses fixed constant `ROSREESTR_REQUEST_URL` from `lib/rosreestr.ts` PR #550) - `source_url` from `AnalogLot` not rendered here → no `safeUrl` needed - No literal secrets in diff ### Design-tokens / CSS All referenced classes verified to exist in `components/trade-in/trade-in.css`: `.card`, `.card-head`, `.card-meta`, `.section-kicker`, `.count-strip`, `.count-cell`, `.label`, `.value`, `.unit`, `.mono`, `data-tnum`, `.dt`, `.addr`, `.a-main`, `.rosreestr-btn`, `.table-foot`. Zero new CSS, full reuse — consistent with peer cards (#529/#546). ### Accessibility - `<table className="dt" aria-label="Сделки по улице">` — matches peer `DealsCard` exactly (`aria-label="Фактические сделки"`) - `<th>` without `scope` / no `<caption>` — same as peer; broader a11y refactor would be cross-component scope, out of bounds for this PR (no-silent-regression rule) - `<button type="button" title="...">` — explicit type, descriptive title ✓ ### Performance / re-renders - `staleTime: 5*60_000` (5 min) — matches peer hooks (`useEstimateHouseAnalytics` etc.) - queryKey stable across renders (primitives), no closure churn - `data.deals.map` over ≤10 rows — negligible ### Minor (non-blocking, all match peer conventions) - `key={i}` (index) — acceptable for static top-10 list with no reordering (peer `DealsCard` does same) - Explicit `(d: AnalogLot, i: number)` annotation — redundant but harmless - Inline `style={{ color: "var(--fg)" }}` — matches peer pattern - RU pluralization: `из {data.count} сделок` is grammatically correct only for genitive plural (5-20, 25-30...). Edge `count=1` → "из 1 сделок"; `count=2/3/4` → "из 2/3/4 сделок". Same convention as peer `DealsCard` ("Показано N фактических сделок") — out-of-scope to fix repo-wide here. The 12-month street-level filter typically yields 5-60 hits, so the common case is correct. Worth a follow-up `formatPlural()` helper PR later — not blocking. ### Positive - Maximal reuse: type, hook helper, CSS, Rosreestr handler, peer-card layout — no new abstractions - Closes the announced 4-PR roadmap (#549 → #550 → #555 → #556) cleanly - Empty graceful both at API and component layer — no flicker on missing street extraction - Decision on range display (`range_low_rub` direct field vs `deals[0]` hack) — correct guard against empty-array bug ### Risk / reversibility - Risk: low. New optional section appended after `DealsCard`; renders null in all error/empty paths. - Reversibility: trivial (revert 5-file diff; no schema/DB impact). - Merge window: any. Proceeding to squash-merge per any-scope auto-merge policy.
lekss361 merged commit 4d1c409e18 into main 2026-05-24 20:37:30 +00:00
lekss361 deleted branch feat/tradein-street-deals-frontend 2026-05-24 20:37:30 +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#556
No description provided.