feat(tradein/cian): surface valuation chart + rent + change-pct on /trade-in #530

Merged
lekss361 merged 5 commits from feat/cian-valuation-surface into main 2026-05-24 14:32:54 +00:00
Owner

What

Makes the Cian Valuation Calculator data visible to users. Stage 9 of the estimator already calls Cian Calculator and persists chart_data, rent_price_rub, chart_change_pct, chart_change_direction to external_valuations — but only sale_price_rub was consumed downstream. This PR plumbs the rest through to the response and renders them in a new card.

Backend

  • tradein-mvp/backend/app/schemas/trade_in.py — new CianValuationSummary Pydantic model (sale, rent, chart, change-pct, change-direction Literal). Added cian_valuation: CianValuationSummary | None = None field to AggregatedEstimate.
  • tradein-mvp/backend/app/services/estimator.py — primary AggregatedEstimate (~L873) populates the new field from cian_val; _empty_estimate fallback (~L1617) sets None. Defensive coerce: unknown chart_change_direction values → None (guards against unmapped Cian strings; Pydantic Literal would otherwise raise).

Frontend

  • tradein-mvp/frontend/src/types/trade-in.ts — exported CianValuationSummary interface matching Pydantic shape.
  • tradein-mvp/frontend/src/components/trade-in/CianValuationCard.tsx — new card: sale (большая цифра) + rent (поменьше) + ChangeBadge with arrow colored by direction + inline SVG Sparkline (pure SVG, no recharts/chart.js dep). Hides itself when data is null or chart is empty.
  • tradein-mvp/frontend/src/app/page.tsx — renders <CianValuationCard data={resultData.estimate.cian_valuation} /> after IMVBenchmark.
  • tradein-mvp/frontend/src/components/trade-in/trade-in.css.cian-valuation* BEM block, no conflicts with existing classes.

Why now

Predecessors PR #523 + #525 fixed persistence and added admin triggers. Data is being written for every /estimate call but invisible. This PR completes the user-visible loop.

Follow-up: PR 4 — Celery backfill task for historical fill via PR2 endpoints.

Test plan

  • ruff clean on backend touched files
  • typecheck — no new logic-level errors (envs sees JSX TS7026 across all .tsx, pre-existing)
  • Pre-push review APPROVE with 1 minor — addressed in commit 3f0a49d (defensive coerce)
  • Manual smoke after merge: open /trade-in, run estimate for any ekb address — expect CianValuationCard with sale/rent prices + 7-month sparkline, hidden if Cian Calculator unavailable (no cookies / 0 chart points)
## What Makes the Cian Valuation Calculator data visible to users. Stage 9 of the estimator already calls Cian Calculator and persists chart_data, rent_price_rub, chart_change_pct, chart_change_direction to `external_valuations` — but only `sale_price_rub` was consumed downstream. This PR plumbs the rest through to the response and renders them in a new card. ## Backend - `tradein-mvp/backend/app/schemas/trade_in.py` — new `CianValuationSummary` Pydantic model (sale, rent, chart, change-pct, change-direction Literal). Added `cian_valuation: CianValuationSummary | None = None` field to `AggregatedEstimate`. - `tradein-mvp/backend/app/services/estimator.py` — primary `AggregatedEstimate` (~L873) populates the new field from `cian_val`; `_empty_estimate` fallback (~L1617) sets `None`. Defensive coerce: unknown `chart_change_direction` values → `None` (guards against unmapped Cian strings; Pydantic Literal would otherwise raise). ## Frontend - `tradein-mvp/frontend/src/types/trade-in.ts` — exported `CianValuationSummary` interface matching Pydantic shape. - `tradein-mvp/frontend/src/components/trade-in/CianValuationCard.tsx` — new card: sale (большая цифра) + rent (поменьше) + ChangeBadge with arrow colored by direction + inline SVG Sparkline (pure SVG, no recharts/chart.js dep). Hides itself when data is null or chart is empty. - `tradein-mvp/frontend/src/app/page.tsx` — renders `<CianValuationCard data={resultData.estimate.cian_valuation} />` after IMVBenchmark. - `tradein-mvp/frontend/src/components/trade-in/trade-in.css` — `.cian-valuation*` BEM block, no conflicts with existing classes. ## Why now Predecessors PR #523 + #525 fixed persistence and added admin triggers. Data is being written for every /estimate call but invisible. This PR completes the user-visible loop. Follow-up: PR 4 — Celery backfill task for historical fill via PR2 endpoints. ## Test plan - [x] ruff clean on backend touched files - [x] typecheck — no new logic-level errors (envs sees JSX TS7026 across all .tsx, pre-existing) - [x] Pre-push review ✅ APPROVE with 1 minor — addressed in commit 3f0a49d (defensive coerce) - [ ] Manual smoke after merge: open /trade-in, run estimate for any ekb address — expect CianValuationCard with sale/rent prices + 7-month sparkline, hidden if Cian Calculator unavailable (no cookies / 0 chart points)
lekss361 added 3 commits 2026-05-24 14:17:00 +00:00
lekss361 added 1 commit 2026-05-24 14:22:54 +00:00
Author
Owner

Deep Code Review — verdict

Summary

  • Status: 🔴 BLOCK (changes requested)
  • Files: 6 (P0: backend schemas/estimator, frontend page+types+new card; P2: CSS)
  • Lines: +226 / -1 · Head SHA: 3f0a49d

🔴 Critical (BLOCK)

1. Chart key contract mismatch — Sparkline crashes at runtime

Backend stores chart entries with key month_date, frontend reads date. The whole new feature is broken on any populated chart.

tradein-mvp/backend/app/services/scrapers/cian_valuation.py:302-306 (existing, unchanged in this PR):

result.chart.append({
    "month_date": date_str or "",
    "price": _parse_num(entry.get("price")),
    "price_formatted": entry.get("priceFormatted"),
})

Frontend CianValuationCard.tsx:25-26:

const firstDate = points[0].date.slice(0, 7); // ← points[0].date is undefined
const lastDate = points[points.length - 1].date.slice(0, 7);

Result: TypeError: Cannot read properties of undefined (reading 'slice') whenever the chart has ≥2 points (the normal case — Cian Calculator returns 7 monthly points). Pre-push smoke clearly wasn't executed against a Cian-populated address.

Also the TS type at frontend/src/types/trade-in.ts:51 declares the array as Array<{ date: string; price: number }>, which lies about the wire shape — Pydantic schema is list[dict[str, Any]] so the bad shape passes through unvalidated.

Fix — pick one:

(A) Backend-side rename in estimator.py mapper (preferred — frontend contract stable, cached old rows handled too via DB path _row_to_result):

chart=[
    {"date": p.get("month_date") or p.get("date") or "", "price": p.get("price")}
    for p in (cian_val.chart or [])
    if p.get("price") is not None
],

(B) Frontend-side: const dateKey = (points[0] as any).month_date ?? points[0].date; and update the TS type to month_date. Less clean — leaks scraper internals to UI.

Plus tighten the Pydantic schema (replace list[dict[str, Any]] with list[ChartPoint] where ChartPoint = BaseModel(date: str, price: float)) so the next mismatch is a 500, not a silent client crash.


🟡 Medium

2. Sparkline doesn't filter null-price points

_parse_num in cian_valuation.py returns None on parse failures, and chart entries are pushed unconditionally. Frontend computes Math.min(...prices), Math.max(...prices) — a single null propagates as NaN through range and every y value → invalid SVG path, sparkline disappears or renders broken (no error, just silent visual breakage).

CianValuationCard.tsx:18-20:

const prices = points.map((p) => p.price);
const min = Math.min(...prices), max = Math.max(...prices);

Fix in the same backend mapping shown above: if p.get("price") is not None.

3. No regression test for the new contract

Both schema additions and the new component ship without a test asserting chart[*] has date/price keys. A 3-line pytest against estimate_quality (mocking cian_val) or a snapshot test of <CianValuationCard data={fixture} /> would have caught #1 instantly. Test plan checkbox Manual smoke after merge is unchecked — that's the wrong tense; smoke should precede merge.


🟢 Low / Positive

  • Defensive chart_change_direction coerce against unmapped Cian strings (estimator.py:879-883) — good catch given Pydantic Literal would otherwise raise.
  • No XSS surface: all numeric values, no dangerouslySetInnerHTML, SVG d built from server numbers, aria-hidden on the chart with aria-label on the section wrapper.
  • No new chart library — inline SVG is the right call for a 7-point sparkline.
  • "use client" directive present (Next 15 App Router requirement for Intl.NumberFormat + interactive component pattern).
  • BEM CSS, no conflicts with existing .cian-valuation* (new namespace).
  • _empty_estimate fallback explicitly sets cian_valuation=None — no Pydantic ValidationError on the no-data path.
  • Empty-chart guard (!data || data.chart.length === 0) prevents 0/1-point rendering.
  • 🤔 Question (non-blocking): formatRub returns for null but the metric block already conditionally renders rent (data.rent_price_rub != null && ...). formatRub null-branch is dead for that call site, but used for sale_price_rub which has no conditional. If sale is null but chart is non-empty, you'd render Продажа — which is fine UX. Keep.

Cross-file impact

  • cian_valuation.py (untouched) is the source of truth for chart shape — anyone changing it must also update this card. Add a comment near result.chart.append(...) linking to CianValuationCard.tsx to make the coupling visible.
  • external_valuations cached rows persist chart as JSONB with month_date key (see _save_to_cache and _row_to_result). Backend-side mapping (Fix A) handles both fresh and cached payloads uniformly. Frontend-side fix would not — old cached rows would still surface as month_date.

  1. Apply Fix (A) in estimator.py — rename + null-filter in the dict comprehension.
  2. Tighten CianValuationSummary.chart Pydantic type to a typed ChartPoint model.
  3. Add one pytest covering estimate_quality populating cian_valuation.chart[0].date.
  4. Re-run a real /estimate against an EKB address that hits Cian Calculator (not no-cookies path), verify sparkline renders.

Complexity / blast radius

  • Risk: HIGH if merged as-is — visible card promised by the PR is the broken path.
  • Reversibility: trivial (frontend hot-fix, ~5 lines).
  • Merge window: any (no schema migration).
<!-- gendesign-review-bot: sha=3f0a49d verdict=changes --> ## Deep Code Review — verdict ### Summary - Status: 🔴 BLOCK (changes requested) - Files: 6 (P0: backend schemas/estimator, frontend page+types+new card; P2: CSS) - Lines: +226 / -1 · Head SHA: `3f0a49d` --- ### 🔴 Critical (BLOCK) **1. Chart key contract mismatch — Sparkline crashes at runtime** Backend stores chart entries with key `month_date`, frontend reads `date`. The whole new feature is broken on any populated chart. `tradein-mvp/backend/app/services/scrapers/cian_valuation.py:302-306` (existing, unchanged in this PR): ```python result.chart.append({ "month_date": date_str or "", "price": _parse_num(entry.get("price")), "price_formatted": entry.get("priceFormatted"), }) ``` Frontend `CianValuationCard.tsx:25-26`: ```ts const firstDate = points[0].date.slice(0, 7); // ← points[0].date is undefined const lastDate = points[points.length - 1].date.slice(0, 7); ``` Result: `TypeError: Cannot read properties of undefined (reading 'slice')` whenever the chart has ≥2 points (the normal case — Cian Calculator returns 7 monthly points). Pre-push smoke clearly wasn't executed against a Cian-populated address. Also the TS type at `frontend/src/types/trade-in.ts:51` declares the array as `Array<{ date: string; price: number }>`, which lies about the wire shape — Pydantic schema is `list[dict[str, Any]]` so the bad shape passes through unvalidated. **Fix — pick one:** (A) Backend-side rename in `estimator.py` mapper (preferred — frontend contract stable, cached old rows handled too via DB path `_row_to_result`): ```python chart=[ {"date": p.get("month_date") or p.get("date") or "", "price": p.get("price")} for p in (cian_val.chart or []) if p.get("price") is not None ], ``` (B) Frontend-side: `const dateKey = (points[0] as any).month_date ?? points[0].date;` and update the TS type to `month_date`. Less clean — leaks scraper internals to UI. Plus tighten the Pydantic schema (replace `list[dict[str, Any]]` with `list[ChartPoint]` where `ChartPoint = BaseModel(date: str, price: float)`) so the next mismatch is a 500, not a silent client crash. --- ### 🟡 Medium **2. Sparkline doesn't filter null-price points** `_parse_num` in `cian_valuation.py` returns `None` on parse failures, and chart entries are pushed unconditionally. Frontend computes `Math.min(...prices), Math.max(...prices)` — a single `null` propagates as `NaN` through `range` and every `y` value → invalid SVG path, sparkline disappears or renders broken (no error, just silent visual breakage). `CianValuationCard.tsx:18-20`: ```ts const prices = points.map((p) => p.price); const min = Math.min(...prices), max = Math.max(...prices); ``` Fix in the same backend mapping shown above: `if p.get("price") is not None`. **3. No regression test for the new contract** Both schema additions and the new component ship without a test asserting `chart[*]` has `date`/`price` keys. A 3-line pytest against `estimate_quality` (mocking `cian_val`) or a snapshot test of `<CianValuationCard data={fixture} />` would have caught #1 instantly. Test plan checkbox `Manual smoke after merge` is unchecked — that's the wrong tense; smoke should precede merge. --- ### 🟢 Low / Positive - ✅ Defensive `chart_change_direction` coerce against unmapped Cian strings (estimator.py:879-883) — good catch given Pydantic Literal would otherwise raise. - ✅ No XSS surface: all numeric values, no `dangerouslySetInnerHTML`, SVG `d` built from server numbers, `aria-hidden` on the chart with `aria-label` on the section wrapper. - ✅ No new chart library — inline SVG is the right call for a 7-point sparkline. - ✅ `"use client"` directive present (Next 15 App Router requirement for `Intl.NumberFormat` + interactive component pattern). - ✅ BEM CSS, no conflicts with existing `.cian-valuation*` (new namespace). - ✅ `_empty_estimate` fallback explicitly sets `cian_valuation=None` — no Pydantic ValidationError on the no-data path. - ✅ Empty-chart guard (`!data || data.chart.length === 0`) prevents 0/1-point rendering. - 🤔 Question (non-blocking): `formatRub` returns `—` for null but the metric block already conditionally renders rent (`data.rent_price_rub != null && ...`). `formatRub` null-branch is dead for that call site, but used for `sale_price_rub` which has no conditional. If sale is null but chart is non-empty, you'd render `Продажа —` which is fine UX. Keep. --- ### Cross-file impact - `cian_valuation.py` (untouched) is the source of truth for chart shape — anyone changing it must also update this card. Add a comment near `result.chart.append(...)` linking to `CianValuationCard.tsx` to make the coupling visible. - `external_valuations` cached rows persist `chart` as JSONB with `month_date` key (see `_save_to_cache` and `_row_to_result`). Backend-side mapping (Fix A) handles both fresh and cached payloads uniformly. Frontend-side fix would not — old cached rows would still surface as `month_date`. --- ### Recommended next steps 1. Apply Fix (A) in `estimator.py` — rename + null-filter in the dict comprehension. 2. Tighten `CianValuationSummary.chart` Pydantic type to a typed `ChartPoint` model. 3. Add one pytest covering `estimate_quality` populating `cian_valuation.chart[0].date`. 4. Re-run a real `/estimate` against an EKB address that hits Cian Calculator (not no-cookies path), verify sparkline renders. ### Complexity / blast radius - Risk: HIGH if merged as-is — visible card promised by the PR is the broken path. - Reversibility: trivial (frontend hot-fix, ~5 lines). - Merge window: any (no schema migration).
lekss361 added 1 commit 2026-05-24 14:26:09 +00:00
Author
Owner

Re-review verdict: CHANGES REQUESTED

Head SHA: e73f947 · prior critical issue still present.

Status of prior critical issues

# Issue Status
1 Chart key contract mismatch (month_date vs date) NOT FIXED
2 Null-price filter for chart points NOT FIXED
3 Typed ChartPoint Pydantic schema NOT FIXED
4 Regression test for chart contract NOT FIXED (no test files in diff)

Critical (BLOCK)

Backend still emits month_date, frontend still reads date — runtime crash on first chart render.

  • tradein-mvp/backend/app/services/scrapers/cian_valuation.py:302-305 — chart points persisted as {"month_date": date_str, "price": ..., "price_formatted": ...} (JSONB column, keys round-trip verbatim from DB cache loader at line 383: chart=row["chart"] if isinstance(row["chart"], list) else []).
  • tradein-mvp/backend/app/services/estimator.py:877chart=list(cian_val.chart or []) passes raw dicts through without remapping or null filter.
  • tradein-mvp/backend/app/schemas/trade_in.py:58chart: list[dict[str, Any]] = Field(default_factory=list) accepts arbitrary keys; no model_serializer / @field_validator to remap month_date -> date or strip null-price points.
  • tradein-mvp/frontend/src/components/trade-in/CianValuationCard.tsx:25-26points[0].date.slice(0, 7) will throw TypeError: Cannot read properties of undefined (reading 'slice') (key date is undefined; backend dict has month_date).
  • Even if .date were defined: points.map((p) => p.price) includes None-valued points -> Math.min(...[..., NaN, ...]) === NaN -> SVG path d attribute becomes M..,NaN L..,NaN ... -> sparkline renders as empty/broken.

Required fixes (re-iterating prior recommendation)

  1. Backend mapping in estimator.py (after line 876):
    chart=[
        {"date": p["month_date"], "price": p["price"]}
        for p in (cian_val.chart or [])
        if p.get("month_date") and p.get("price") is not None
    ],
    
  2. Tighten Pydantic schema in trade_in.py:
    class ChartPoint(BaseModel):
        date: str
        price: float
    
    class CianValuationSummary(BaseModel):
        ...
        chart: list[ChartPoint] = Field(default_factory=list)
    
    This enforces the contract at API boundary; if backend ever emits month_date again, the response fails fast (422) instead of silently corrupting the frontend.
  3. Regression test (e.g. tradein-mvp/backend/tests/test_estimator_cian_chart_contract.py): assert that estimate_quality output's cian_valuation.chart[0] has keys {"date", "price"} and excludes null-price points. Without this, the same bug will regress on the next scraper refactor.

Positive (kept)

  • chart_change_direction whitelist guard in estimator.py:879-883 correctly enforces the Literal["increase", "decrease", "neutral"] schema before assignment (defensive — good).
  • Sparkline gracefully return null when points.length < 2 (good).
  • ChangeBadge correctly handles null pct/direction (good).

Verdict: changes

Merge blocked — same contract bug as 3f0a49d. The single backend edit in this fixup addresses a different concern (direction enum), not the chart key/null issue called out in comment id 3218. Please apply items 1+2+3 above and re-request review.

<!-- gendesign-review-bot: sha=e73f947 verdict=changes --> ## Re-review verdict: CHANGES REQUESTED Head SHA: `e73f947` · prior critical issue still present. ### Status of prior critical issues | # | Issue | Status | |---|---|---| | 1 | Chart key contract mismatch (`month_date` vs `date`) | NOT FIXED | | 2 | Null-price filter for chart points | NOT FIXED | | 3 | Typed `ChartPoint` Pydantic schema | NOT FIXED | | 4 | Regression test for chart contract | NOT FIXED (no test files in diff) | ### Critical (BLOCK) **Backend still emits `month_date`, frontend still reads `date` — runtime crash on first chart render.** - `tradein-mvp/backend/app/services/scrapers/cian_valuation.py:302-305` — chart points persisted as `{"month_date": date_str, "price": ..., "price_formatted": ...}` (JSONB column, keys round-trip verbatim from DB cache loader at line 383: `chart=row["chart"] if isinstance(row["chart"], list) else []`). - `tradein-mvp/backend/app/services/estimator.py:877` — `chart=list(cian_val.chart or [])` passes raw dicts through without remapping or null filter. - `tradein-mvp/backend/app/schemas/trade_in.py:58` — `chart: list[dict[str, Any]] = Field(default_factory=list)` accepts arbitrary keys; no `model_serializer` / `@field_validator` to remap `month_date` -> `date` or strip null-price points. - `tradein-mvp/frontend/src/components/trade-in/CianValuationCard.tsx:25-26` — `points[0].date.slice(0, 7)` will throw `TypeError: Cannot read properties of undefined (reading 'slice')` (key `date` is `undefined`; backend dict has `month_date`). - Even if `.date` were defined: `points.map((p) => p.price)` includes None-valued points -> `Math.min(...[..., NaN, ...]) === NaN` -> SVG `path` `d` attribute becomes `M..,NaN L..,NaN ...` -> sparkline renders as empty/broken. ### Required fixes (re-iterating prior recommendation) 1. **Backend mapping** in `estimator.py` (after line 876): ```python chart=[ {"date": p["month_date"], "price": p["price"]} for p in (cian_val.chart or []) if p.get("month_date") and p.get("price") is not None ], ``` 2. **Tighten Pydantic schema** in `trade_in.py`: ```python class ChartPoint(BaseModel): date: str price: float class CianValuationSummary(BaseModel): ... chart: list[ChartPoint] = Field(default_factory=list) ``` This enforces the contract at API boundary; if backend ever emits `month_date` again, the response fails fast (422) instead of silently corrupting the frontend. 3. **Regression test** (e.g. `tradein-mvp/backend/tests/test_estimator_cian_chart_contract.py`): assert that `estimate_quality` output's `cian_valuation.chart[0]` has keys `{"date", "price"}` and excludes null-price points. Without this, the same bug will regress on the next scraper refactor. ### Positive (kept) - `chart_change_direction` whitelist guard in `estimator.py:879-883` correctly enforces the `Literal["increase", "decrease", "neutral"]` schema before assignment (defensive — good). - Sparkline gracefully `return null` when `points.length < 2` (good). - `ChangeBadge` correctly handles `null` `pct`/`direction` (good). ### Verdict: changes Merge blocked — same contract bug as 3f0a49d. The single backend edit in this fixup addresses a different concern (direction enum), not the chart key/null issue called out in comment id 3218. Please apply items 1+2+3 above and re-request review.
Author
Owner

Re-review verdict: APPROVE

Head SHA: 8cdc784 · 3 of 4 prior critical issues resolved; #4 (regression test) downgraded to follow-up since the contract is now defended on both API ends.

Status of prior critical issues

# Issue Status @ 8cdc784
1 Chart key contract mismatch (month_date vs date) FIXED — estimator.py:876-881 maps p.get('month_date') or p.get('date') or '' -> emits date to API
2 Null-price filter for chart points FIXED — same dict-comp uses if p.get('price') is not None
3 Typed ChartPoint Pydantic schema FIXED — schemas/trade_in.py:49-54 adds CianChartPoint(date: str, price: float); chart: list[CianChartPoint]
4 Regression test for chart contract NOT ADDED — downgraded to follow-up (rationale below)

Why approve despite missing test

  • The Pydantic CianChartPoint(date: str, price: float) now validates the wire contract at the API boundary. Any future regression that re-introduces month_date would raise 422 ValidationError at FastAPI response serialization — fail-fast at integration boundary, not silent UI crash.
  • Frontend Sparkline already guards points.length < 2 and Math.min/max over prices: float[] is now safe because backend filters nulls before serialization.
  • Backward-compat: the p.get('month_date') or p.get('date') fallback transparently handles both fresh-scraped chart dicts and any hypothetical cached future-shape rows.
  • chart_change_direction whitelist guard correctly defends the Literal constraint against unmapped Cian enum strings.

Cross-file impact verified

  • cian_valuation.py:302-306 (unchanged) emits {month_date, price, price_formatted}; estimator mapper at estimator.py:876-881 consumes correctly with fallback.
  • _row_to_result cache loader (cian_valuation.py:383) round-trips JSONB chart verbatim — backend-side remap correctly handles both fresh and cached payloads.
  • No Any import becomes dead (still used at schemas/trade_in.py:144,157).
  • Frontend type at types/trade-in.ts:53 declares chart: Array<{date: string; price: number}> — matches Pydantic output now.

Follow-up (non-blocking)

  • Add tradein-mvp/backend/tests/test_estimator_cian_chart_contract.py:
    async def test_estimate_quality_chart_contract():
        # mock cian_val with [{'month_date': '2026-05-01', 'price': 145000}, {'month_date': '2026-04-01', 'price': None}]
        # assert result.cian_valuation.chart == [CianChartPoint(date='2026-05-01', price=145000.0)]
    
    Defends against future scraper refactors silently renaming the key.
  • Consider adding a comment near cian_valuation.py:302 linking to CianValuationCard.tsx to flag the coupling.

Positive

  • Clean dict-comp transform; correct dual-key fallback; null-price filter at the right layer (transform, not view).
  • Pydantic schema now enforces the wire shape — prior soft list[dict[str, Any]] is gone.
  • No XSS surface, SVG built from numeric server values, aria-hidden/aria-label set correctly.
  • BEM CSS namespace .cian-valuation* is new — no collision.

Complexity / blast radius

  • Risk: LOW — contract now defended on both ends; visible card path is correct.
  • Reversibility: trivial.
  • Merge window: any (no migration).
<!-- gendesign-review-bot: sha=8cdc784 verdict=approve --> ## Re-review verdict: APPROVE Head SHA: `8cdc784` · 3 of 4 prior critical issues resolved; #4 (regression test) downgraded to follow-up since the contract is now defended on both API ends. ### Status of prior critical issues | # | Issue | Status @ 8cdc784 | |---|---|---| | 1 | Chart key contract mismatch (`month_date` vs `date`) | FIXED — `estimator.py:876-881` maps `p.get('month_date') or p.get('date') or ''` -> emits `date` to API | | 2 | Null-price filter for chart points | FIXED — same dict-comp uses `if p.get('price') is not None` | | 3 | Typed `ChartPoint` Pydantic schema | FIXED — `schemas/trade_in.py:49-54` adds `CianChartPoint(date: str, price: float)`; `chart: list[CianChartPoint]` | | 4 | Regression test for chart contract | NOT ADDED — downgraded to follow-up (rationale below) | ### Why approve despite missing test - The Pydantic `CianChartPoint(date: str, price: float)` now validates the wire contract at the API boundary. Any future regression that re-introduces `month_date` would raise `422 ValidationError` at FastAPI response serialization — fail-fast at integration boundary, not silent UI crash. - Frontend `Sparkline` already guards `points.length < 2` and `Math.min/max` over `prices: float[]` is now safe because backend filters nulls before serialization. - Backward-compat: the `p.get('month_date') or p.get('date')` fallback transparently handles both fresh-scraped chart dicts and any hypothetical cached future-shape rows. - `chart_change_direction` whitelist guard correctly defends the `Literal` constraint against unmapped Cian enum strings. ### Cross-file impact verified - `cian_valuation.py:302-306` (unchanged) emits `{month_date, price, price_formatted}`; estimator mapper at `estimator.py:876-881` consumes correctly with fallback. - `_row_to_result` cache loader (`cian_valuation.py:383`) round-trips JSONB `chart` verbatim — backend-side remap correctly handles both fresh and cached payloads. - No `Any` import becomes dead (still used at `schemas/trade_in.py:144,157`). - Frontend type at `types/trade-in.ts:53` declares `chart: Array<{date: string; price: number}>` — matches Pydantic output now. ### Follow-up (non-blocking) - Add `tradein-mvp/backend/tests/test_estimator_cian_chart_contract.py`: ```python async def test_estimate_quality_chart_contract(): # mock cian_val with [{'month_date': '2026-05-01', 'price': 145000}, {'month_date': '2026-04-01', 'price': None}] # assert result.cian_valuation.chart == [CianChartPoint(date='2026-05-01', price=145000.0)] ``` Defends against future scraper refactors silently renaming the key. - Consider adding a comment near `cian_valuation.py:302` linking to `CianValuationCard.tsx` to flag the coupling. ### Positive - Clean dict-comp transform; correct dual-key fallback; null-price filter at the right layer (transform, not view). - Pydantic schema now enforces the wire shape — prior soft `list[dict[str, Any]]` is gone. - No XSS surface, SVG built from numeric server values, `aria-hidden`/`aria-label` set correctly. - BEM CSS namespace `.cian-valuation*` is new — no collision. ### Complexity / blast radius - Risk: LOW — contract now defended on both ends; visible card path is correct. - Reversibility: trivial. - Merge window: any (no migration).
lekss361 merged commit 08cf615ad7 into main 2026-05-24 14:32:54 +00:00
lekss361 deleted branch feat/cian-valuation-surface 2026-05-24 14:32:55 +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#530
No description provided.