feat(site-finder): multi-cad сравнение (#50) + analytics поиск/cross-links (#65)
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 47s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 47s

#50: /site-finder/compare — shortlist (localStorage, SSR-guard, cap 2-5) + CompareTable
(per-row winner-highlight, reuse useParcelAnalyzeQuery per cad, hook-order stable в .map,
cache-shared). Cross-link с site-finder.

#65: debounced search (300ms, client-side filter top-15) в DeveloperLeaderboard +
cross-link объект→Site Finder analyze. Existing developers compare не дублирован.

Closes #50
Closes #65
This commit is contained in:
Light1YT 2026-06-13 22:04:52 +05:00
parent 3bbbf25412
commit 7d69364e5f
8 changed files with 839 additions and 5 deletions

View file

@ -1,13 +1,15 @@
"use client";
import { Search } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { Suspense, useState } from "react";
import { DeveloperLeaderboard } from "@/components/analytics/DeveloperLeaderboard";
import { KpiCard } from "@/components/analytics/KpiCard";
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
import { Section } from "@/components/analytics/Section";
import { VelocityScatter } from "@/components/analytics/VelocityScatter";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api";
function DevelopersInner() {
@ -16,6 +18,11 @@ function DevelopersInner() {
const top = useTopDevelopers(15);
const focus = useDeveloperDetail(focusedId ?? "");
// Debounced 300ms search over the leaderboard (client-side filter — the
// top-15 list is already in memory, no extra request).
const [searchInput, setSearchInput] = useState("");
const debouncedSearch = useDebouncedValue(searchInput, 300);
const focused = focusedId ? focus.data : null;
const topNames: Record<string, string> = Object.fromEntries(
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
@ -67,8 +74,43 @@ function DevelopersInner() {
<Section
title="Топ-15 девелоперов Свердл"
subtitle="Сортировка по объёму строительства (тыс м²). Δ пп — рост sold% за всю историю DOM.РФ realization. Клик по строке — drill-down."
right={
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
border: "1px solid var(--border-strong)",
borderRadius: 8,
padding: "6px 10px",
background: "var(--bg-card)",
}}
>
<Search
size={16}
strokeWidth={1.5}
color="var(--fg-tertiary)"
aria-hidden
/>
<input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Найти девелопера…"
aria-label="Поиск девелопера по названию"
style={{
border: "none",
outline: "none",
fontSize: 14,
width: 200,
background: "transparent",
color: "var(--fg-primary)",
}}
/>
</div>
}
>
<DeveloperLeaderboard highlight={focusedId} />
<DeveloperLeaderboard highlight={focusedId} query={debouncedSearch} />
</Section>
<Section

View file

@ -1,5 +1,6 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useParams } from "next/navigation";
@ -90,6 +91,13 @@ export default function ObjectDrillInPage() {
const d = fullDetail.data;
// Cross-link target (#65): first building's cadastral number — a real parcel
// Site Finder can analyze. Объект сам по себе не несёт cad_quarter, но корпуса
// из NSPD несут cad_num, что и есть точка входа в анализ участка.
const siteFinderCad = buildings.data?.find(
(b) => b.cad_num != null && b.cad_num !== "",
)?.cad_num;
return (
<>
<Link
@ -331,6 +339,27 @@ export default function ObjectDrillInPage() {
<Section
title="Корпуса ЖК"
subtitle="Данные из NSPD: кадастровый номер, этажность, площадь, назначение."
right={
siteFinderCad ? (
<Link
href={`/site-finder/analysis/${encodeURIComponent(siteFinderCad)}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 13,
fontWeight: 500,
color: "#1d4ed8",
textDecoration: "none",
whiteSpace: "nowrap",
}}
title={`Анализ участка ${siteFinderCad} в Site Finder`}
>
Посмотреть участок в Site Finder
<ArrowUpRight size={16} strokeWidth={1.5} />
</Link>
) : undefined
}
>
<div style={{ overflowX: "auto" }}>
<table

View file

@ -0,0 +1,257 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CompareTable } from "@/components/site-finder/compare/CompareTable";
import type { CompareColumn } from "@/components/site-finder/compare/CompareTable";
import { ParcelCompareLoader } from "@/components/site-finder/compare/ParcelCompareLoader";
import {
COMPARE_MAX,
COMPARE_MIN,
getShortlist,
isValidCad,
saveShortlist,
} from "@/lib/compare-shortlist";
export default function ComparePage() {
// Shortlist of cads. Hydrated from localStorage after mount (SSR-safe: the
// first client render matches the server's empty list, then the effect fills
// it — avoids a hydration mismatch).
const [shortlist, setShortlist] = useState<string[]>([]);
const [hydrated, setHydrated] = useState(false);
const [input, setInput] = useState("");
const [inputError, setInputError] = useState<string | null>(null);
// Per-cad column state, keyed by cad. Lifted up from each ParcelCompareLoader.
const [columns, setColumns] = useState<Record<string, CompareColumn>>({});
useEffect(() => {
setShortlist(getShortlist());
setHydrated(true);
}, []);
// Persist to localStorage on every change (after hydration so the initial
// empty state never clobbers a saved list).
useEffect(() => {
if (hydrated) saveShortlist(shortlist);
}, [shortlist, hydrated]);
const handleResult = useCallback((col: CompareColumn) => {
setColumns((prev) => {
const existing = prev[col.cad];
// Avoid a needless state update (and re-render loop) when nothing changed.
if (
existing &&
existing.status === col.status &&
existing.score === col.score &&
existing.verdictLabel === col.verdictLabel &&
existing.velocityScore === col.velocityScore &&
existing.pipeline24mo === col.pipeline24mo &&
existing.confidenceValue === col.confidenceValue
) {
return prev;
}
return { ...prev, [col.cad]: col };
});
}, []);
function addCad(e: React.FormEvent) {
e.preventDefault();
const cad = input.trim();
if (!isValidCad(cad)) {
setInputError("Неверный формат. Ожидается 66:41:0204016:10 или 66:41:0204016.");
return;
}
if (shortlist.includes(cad)) {
setInputError("Этот участок уже в сравнении.");
return;
}
if (shortlist.length >= COMPARE_MAX) {
setInputError(`Можно сравнить не более ${COMPARE_MAX} участков.`);
return;
}
setShortlist((prev) => [...prev, cad]);
setInput("");
setInputError(null);
}
const removeCad = useCallback((cad: string) => {
setShortlist((prev) => prev.filter((c) => c !== cad));
setColumns((prev) => {
const next = { ...prev };
delete next[cad];
return next;
});
}, []);
// Build ordered columns from the shortlist; default to a loading column so
// the table renders immediately while each loader resolves.
const orderedColumns: CompareColumn[] = useMemo(
() =>
shortlist.map(
(cad) => columns[cad] ?? { cad, status: "loading" as const },
),
[shortlist, columns],
);
const canCompare = shortlist.length >= COMPARE_MIN;
return (
<main
style={{
minHeight: "100vh",
background: "var(--bg-app)",
padding: "24px 20px",
}}
>
<div style={{ maxWidth: 1280, margin: "0 auto" }}>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
<Link
href="/site-finder"
style={{
color: "var(--accent)",
textDecoration: "none",
fontWeight: 500,
}}
>
Site Finder
</Link>
<span style={{ color: "var(--fg-tertiary)" }}>/</span>
<span style={{ color: "var(--fg-tertiary)" }}>сравнение участков</span>
</div>
<h1
style={{
margin: "8px 0 4px",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
Сравнение участков
</h1>
<p
style={{
margin: "0 0 20px",
color: "var(--fg-secondary)",
fontSize: 13,
}}
>
Добавьте от {COMPARE_MIN} до {COMPARE_MAX} кадастровых номеров метрики
анализа (вердикт МКД, инвест-балл, скорость продаж, цена, пайплайн)
встанут рядом. Лучшее значение в строке подсвечено. Список сохраняется
между сессиями.
</p>
{/* Add form */}
<form
onSubmit={addCad}
style={{ display: "flex", gap: 8, alignItems: "flex-start" }}
>
<div style={{ flex: 1, maxWidth: 420 }}>
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setInputError(null);
}}
placeholder="66:41:0204016:10"
aria-label="Кадастровый номер для сравнения"
disabled={shortlist.length >= COMPARE_MAX}
style={{
width: "100%",
padding: "8px 12px",
fontSize: 14,
fontFamily: "monospace",
border: inputError
? "1px solid var(--danger)"
: "1px solid var(--border-strong)",
borderRadius: 8,
outline: "none",
background: "var(--bg-card)",
}}
/>
{inputError ? (
<p
style={{
margin: "6px 0 0",
fontSize: 12,
color: "var(--danger)",
}}
>
{inputError}
</p>
) : null}
</div>
<button
type="submit"
disabled={!input.trim() || shortlist.length >= COMPARE_MAX}
style={{
padding: "8px 18px",
fontSize: 14,
fontWeight: 600,
background:
!input.trim() || shortlist.length >= COMPARE_MAX
? "var(--fg-tertiary)"
: "var(--accent)",
color: "var(--fg-on-dark)",
border: "none",
borderRadius: 8,
cursor:
!input.trim() || shortlist.length >= COMPARE_MAX
? "default"
: "pointer",
whiteSpace: "nowrap",
}}
>
Добавить
</button>
</form>
{/* Headless loaders — one per cad, lift analyze metrics up */}
{shortlist.map((cad) => (
<ParcelCompareLoader key={cad} cad={cad} onResult={handleResult} />
))}
{/* Body */}
<div
style={{
marginTop: 24,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
}}
>
{!canCompare ? (
<div
style={{
padding: "32px 24px",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 14,
}}
>
{shortlist.length === 0
? "Список пуст. Добавьте участки выше, чтобы начать сравнение."
: `Добавьте ещё минимум ${COMPARE_MIN - shortlist.length} участок для сравнения.`}
</div>
) : (
<CompareTable columns={orderedColumns} onRemove={removeCad} />
)}
</div>
</div>
</main>
);
}

View file

@ -146,6 +146,18 @@ export default function SiteFinderPage() {
>
SiteFinder · карта участков
</h1>
<Link
href="/site-finder/compare"
style={{
marginLeft: "auto",
fontSize: 13,
color: "var(--accent)",
textDecoration: "none",
fontWeight: 500,
}}
>
Сравнить участки
</Link>
</header>
{/* Filter bar */}

View file

@ -46,20 +46,30 @@ const COLS: {
},
];
export function DeveloperLeaderboard({ highlight }: { highlight?: string }) {
export function DeveloperLeaderboard({
highlight,
query,
}: {
highlight?: string;
/** Case-insensitive substring filter on developer_name (debounced upstream). */
query?: string;
}) {
const { data, isLoading } = useTopDevelopers(15);
const [sortBy, setSortBy] = useState<SortKey>("sverdl_sqm_th");
const [desc, setDesc] = useState(true);
const rows = useMemo(() => {
const arr = [...(data ?? [])];
const needle = (query ?? "").trim().toLocaleLowerCase("ru");
const arr = (data ?? []).filter((r) =>
needle ? r.developer_name.toLocaleLowerCase("ru").includes(needle) : true,
);
arr.sort((a, b) => {
const av = (a[sortBy] ?? -Infinity) as number;
const bv = (b[sortBy] ?? -Infinity) as number;
return desc ? bv - av : av - bv;
});
return arr;
}, [data, sortBy, desc]);
}, [data, sortBy, desc, query]);
const click = (k: SortKey) => {
if (k === sortBy) setDesc(!desc);
@ -72,6 +82,15 @@ export function DeveloperLeaderboard({ highlight }: { highlight?: string }) {
if (isLoading)
return <div style={{ padding: 16, color: "#5b6066" }}>Загрузка</div>;
if (rows.length === 0)
return (
<div style={{ padding: 16, color: "#5b6066", fontSize: 14 }}>
{query?.trim()
? `Ничего не найдено по запросу «${query.trim()}».`
: "Данные пока недоступны."}
</div>
);
return (
<div style={{ overflowX: "auto" }}>
<table

View file

@ -0,0 +1,351 @@
"use client";
import Link from "next/link";
import { X } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { BadgeVariant } from "@/components/ui/Badge";
import type { GateVerdictLabel } from "@/types/site-finder";
// ── Per-column state ──────────────────────────────────────────────────────────
/**
* One parcel column. `status` mirrors the TanStack Query lifecycle of the
* per-cad `useParcelAnalyzeQuery` lifted up by the page. Metrics are flattened
* to primitives here so CompareTable stays a pure presentational component.
*/
export interface CompareColumn {
cad: string;
status: "loading" | "error" | "ready";
errorMsg?: string;
districtName?: string | null;
verdictLabel?: GateVerdictLabel | null;
score?: number | null;
scoreLabel?: string | null;
velocityScore?: number | null; // 0..1
medianPricePerM2?: number | null;
pipeline24mo?: number | null; // total flats in 24-month pipeline
confidenceLabel?: "high" | "medium" | "low" | null;
confidenceValue?: number | null; // 0..1
}
interface Props {
columns: CompareColumn[];
onRemove: (cad: string) => void;
}
// ── Metric row definitions ──────────────────────────────────────────────────
type WinnerDir = "high" | "low" | "none";
interface MetricRow {
key: string;
label: string;
/** Which direction wins, for per-row highlight. "none" → not comparable. */
winner: WinnerDir;
/** Numeric value used for winner comparison (null = no data, never wins). */
value: (c: CompareColumn) => number | null;
/** Rendered cell content. */
render: (c: CompareColumn) => React.ReactNode;
}
const VERDICT_BADGE: Record<GateVerdictLabel, BadgeVariant> = {
Можно: "success",
Нельзя: "danger",
"С ограничениями": "warning",
"Нужна проверка": "neutral",
};
const CONFIDENCE_RU: Record<"high" | "medium" | "low", string> = {
high: "высокая",
medium: "средняя",
low: "низкая",
};
const DASH = "—";
function fmtScore(v: number | null | undefined): string {
return v != null && Number.isFinite(v) ? v.toFixed(1) : DASH;
}
function fmtPrice(v: number | null | undefined): string {
// Median ₽/м² — точное число с разделителями тысяч (ru), e.g. «145 320 ₽».
return v != null && Number.isFinite(v)
? `${Math.round(v).toLocaleString("ru")}`
: DASH;
}
function fmtPipeline(v: number | null | undefined): string {
return v != null && Number.isFinite(v)
? `${Math.round(v).toLocaleString("ru")} кв.`
: DASH;
}
const METRIC_ROWS: MetricRow[] = [
{
key: "verdict",
label: "Вердикт МКД",
winner: "none",
value: () => null,
render: (c) =>
c.verdictLabel ? (
<Badge variant={VERDICT_BADGE[c.verdictLabel]} size="sm">
{c.verdictLabel}
</Badge>
) : (
DASH
),
},
{
key: "score",
label: "Инвест-балл",
winner: "high",
value: (c) => c.score ?? null,
render: (c) => (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{fmtScore(c.score)}
{c.scoreLabel ? (
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
{" "}
· {c.scoreLabel}
</span>
) : null}
</span>
),
},
{
key: "velocity",
label: "Скорость продаж",
winner: "high",
value: (c) => c.velocityScore ?? null,
render: (c) =>
c.velocityScore != null && Number.isFinite(c.velocityScore) ? (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{Math.round(c.velocityScore * 100)}
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
{" "}
/ 100
</span>
</span>
) : (
DASH
),
},
{
key: "price",
label: "Медиана ₽ / м²",
// Lower median price = cheaper land context. Valence is ambiguous for a
// developer (cheaper district ≠ better), so it is NOT highlighted.
winner: "none",
value: (c) => c.medianPricePerM2 ?? null,
render: (c) => (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{fmtPrice(c.medianPricePerM2)}
</span>
),
},
{
key: "pipeline",
label: "Пайплайн 24 мес",
// More competing pipeline = more future supply = worse. Lower wins.
winner: "low",
value: (c) => c.pipeline24mo ?? null,
render: (c) => (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{fmtPipeline(c.pipeline24mo)}
</span>
),
},
{
key: "confidence",
label: "Достоверность",
winner: "high",
value: (c) => c.confidenceValue ?? null,
render: (c) =>
c.confidenceLabel ? (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{c.confidenceValue != null
? `${Math.round(c.confidenceValue * 100)}% · `
: ""}
{CONFIDENCE_RU[c.confidenceLabel]}
</span>
) : (
DASH
),
},
];
/**
* Returns the set of column cads that win a given row, or an empty set when the
* metric is not comparable / has fewer than two data points. Ties win jointly.
*/
function winnersFor(row: MetricRow, columns: CompareColumn[]): Set<string> {
if (row.winner === "none") return new Set();
const vals = columns
.map((c) => ({ cad: c.cad, v: row.value(c) }))
.filter((x): x is { cad: string; v: number } => x.v != null);
if (vals.length < 2) return new Set();
const best =
row.winner === "high"
? Math.max(...vals.map((x) => x.v))
: Math.min(...vals.map((x) => x.v));
return new Set(vals.filter((x) => x.v === best).map((x) => x.cad));
}
// ── Component ───────────────────────────────────────────────────────────────
export function CompareTable({ columns, onRemove }: Props) {
const winnerByRow = new Map<string, Set<string>>(
METRIC_ROWS.map((row) => [row.key, winnersFor(row, columns)]),
);
return (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 14,
minWidth: 120 + columns.length * 200,
}}
>
<thead>
<tr>
<th style={cornerTh}>Фактор</th>
{columns.map((c) => (
<th key={c.cad} style={colTh}>
<div style={colHeadInner}>
<Link
href={`/site-finder/analysis/${encodeURIComponent(c.cad)}`}
style={{
color: "var(--accent)",
textDecoration: "none",
fontFamily: "monospace",
fontSize: 13,
wordBreak: "break-all",
}}
title="Открыть полный анализ участка"
>
{c.cad}
</Link>
<button
type="button"
onClick={() => onRemove(c.cad)}
aria-label={`Убрать ${c.cad} из сравнения`}
style={removeBtn}
>
<X size={14} strokeWidth={1.5} />
</button>
</div>
{c.districtName && c.districtName !== DASH ? (
<div
style={{
fontSize: 12,
fontWeight: 400,
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{c.districtName}
</div>
) : null}
</th>
))}
</tr>
</thead>
<tbody>
{METRIC_ROWS.map((row) => {
const winners = winnerByRow.get(row.key) ?? new Set<string>();
return (
<tr key={row.key} style={{ borderTop: "1px solid var(--border-soft)" }}>
<th style={rowTh}>{row.label}</th>
{columns.map((c) => {
const isWinner = winners.has(c.cad);
return (
<td
key={c.cad}
style={{
...cell,
background: isWinner ? "var(--success-soft)" : undefined,
color: isWinner ? "var(--success)" : "var(--fg-primary)",
fontWeight: isWinner ? 600 : 400,
}}
>
{c.status === "loading" ? (
<span style={{ color: "var(--fg-tertiary)" }}></span>
) : c.status === "error" ? (
<span
style={{ color: "var(--danger)", fontSize: 12 }}
title={c.errorMsg ?? "Ошибка анализа"}
>
ошибка
</span>
) : (
row.render(c)
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ── Styles ────────────────────────────────────────────────────────────────
const baseTh: React.CSSProperties = {
padding: "10px 12px",
borderBottom: "1px solid var(--border-card)",
fontWeight: 600,
verticalAlign: "top",
};
const cornerTh: React.CSSProperties = {
...baseTh,
textAlign: "left",
background: "var(--bg-app)",
width: 180,
minWidth: 160,
};
const colTh: React.CSSProperties = {
...baseTh,
textAlign: "left",
background: "var(--bg-app)",
};
const colHeadInner: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
};
const rowTh: React.CSSProperties = {
padding: "10px 12px",
textAlign: "left",
fontWeight: 500,
color: "var(--fg-secondary)",
background: "var(--bg-card-alt)",
};
const cell: React.CSSProperties = {
padding: "10px 12px",
textAlign: "left",
};
const removeBtn: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "none",
border: "none",
padding: 2,
cursor: "pointer",
color: "var(--fg-tertiary)",
flexShrink: 0,
};

View file

@ -0,0 +1,64 @@
"use client";
import { useEffect } from "react";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { CompareColumn } from "./CompareTable";
interface Props {
cad: string;
onResult: (col: CompareColumn) => void;
}
/**
* Headless loader: runs `useParcelAnalyzeQuery` for a single cad and lifts the
* flattened metrics up to the compare page via `onResult`. Rendered one-per-cad
* inside a `.map`, so each instance owns a stable hook order (rules-of-hooks
* safe) the page itself never calls the hook in a loop. Reuses the exact same
* analyze endpoint + 202-poll logic as the single-parcel analysis page, so a
* parcel already analyzed there is served warm from the shared query cache.
*
* Renders nothing; `useEffect` here only mirrors query state into parent state,
* it does not perform HTTP (the hook does, via TanStack Query).
*/
export function ParcelCompareLoader({ cad, onResult }: Props) {
const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
useEffect(() => {
if (isLoading) {
onResult({ cad, status: "loading" });
return;
}
if (error || !data) {
onResult({
cad,
status: "error",
errorMsg:
error instanceof Error ? error.message : "Ошибка загрузки анализа",
});
return;
}
// ParcelAnalyzeResponse is a structural subset of ParcelAnalysis (same
// /analyze endpoint); the richer fields (velocity, gate_verdict, confidence,
// success_recommendation) are optional and may be absent on thin parcels.
const a = data as unknown as ParcelAnalysis;
onResult({
cad,
status: "ready",
districtName: a.district?.district_name ?? null,
verdictLabel: a.gate_verdict?.verdict_label ?? null,
score: a.score ?? null,
scoreLabel: a.score_label ?? null,
velocityScore: a.velocity?.velocity_score ?? null,
medianPricePerM2: a.district?.median_price_per_m2 ?? null,
pipeline24mo: a.pipeline_24mo?.flats_total ?? null,
confidenceLabel: a.confidence_label ?? null,
confidenceValue: a.confidence ?? null,
});
}, [cad, data, isLoading, error, onResult]);
return null;
}

View file

@ -0,0 +1,60 @@
/**
* Site Finder multi-cad compare localStorage-backed shortlist (issue #50).
*
* The compare page lets a developer pin 25 cadastral numbers and view their
* analyze metrics side-by-side. The shortlist survives reload via localStorage
* (per browser, not per server session) mirroring `gd_recent_parcels` in
* site-finder-api.ts. All access is SSR-guarded (`typeof window`).
*/
const STORAGE_KEY = "gd_compare_shortlist";
/** Hard cap from the issue: batch analyze runs max 5 parcels in parallel. */
export const COMPARE_MAX = 5;
/** Need at least two parcels for a meaningful side-by-side. */
export const COMPARE_MIN = 2;
/**
* Validates cadastral number formats (same regex as CadInput):
* 3-part: 66:41:0204016 (quarter)
* 4-part: 66:41:0204016:10 (parcel)
*/
const CAD_REGEX = /^\d+:\d+:\d+(?::\d+)?$/;
export function isValidCad(cad: string): boolean {
return CAD_REGEX.test(cad.trim());
}
export function getShortlist(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// Narrow to a clean, de-duplicated, capped string[] — defends against a
// hand-edited / stale localStorage payload.
const seen = new Set<string>();
const out: string[] = [];
for (const item of parsed) {
if (typeof item !== "string") continue;
const cad = item.trim();
if (!isValidCad(cad) || seen.has(cad)) continue;
seen.add(cad);
out.push(cad);
if (out.length >= COMPARE_MAX) break;
}
return out;
} catch {
return [];
}
}
export function saveShortlist(cads: string[]): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cads.slice(0, COMPARE_MAX)));
} catch {
// ignore quota / disabled-storage errors — shortlist is best-effort
}
}