brickwise 0.1.1 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/types.ts CHANGED
@@ -105,6 +105,10 @@ export interface AnalysisRow {
105
105
  asking_yield: number | null;
106
106
  median_rent: number | null;
107
107
  median_sale_price: number | null;
108
+ rent_p25: number | null;
109
+ rent_p75: number | null;
110
+ sale_p25: number | null;
111
+ sale_p75: number | null;
108
112
  rent_n: number;
109
113
  buy_n: number;
110
114
  gym_distance_m: number | null;
@@ -117,6 +121,10 @@ export interface YieldResult {
117
121
  asking_yield: number | null;
118
122
  median_rent: number | null;
119
123
  median_sale_price: number | null;
124
+ rent_p25: number | null;
125
+ rent_p75: number | null;
126
+ sale_p25: number | null;
127
+ sale_p75: number | null;
120
128
  rent_n: number;
121
129
  buy_n: number;
122
130
  }
package/src/lib/yield.ts CHANGED
@@ -5,16 +5,21 @@ const WINDOW_MS = 24 * 30.44 * 24 * 3600 * 1000; // ~24 months
5
5
  const MIN_SAMPLES = 3;
6
6
  const SIZE_TOLERANCE = 0.2;
7
7
 
8
- function median(xs: number[]): number | null {
8
+ function percentile(xs: number[], p: number): number | null {
9
9
  if (xs.length === 0) return null;
10
10
  const s = [...xs].sort((a, b) => a - b);
11
- const m = Math.floor(s.length / 2);
12
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
11
+ const idx = (s.length - 1) * p;
12
+ const lo = Math.floor(idx);
13
+ const hi = Math.ceil(idx);
14
+ return lo === hi ? s[lo] : s[lo] + (s[hi] - s[lo]) * (idx - lo);
13
15
  }
14
16
 
17
+ const median = (xs: number[]) => percentile(xs, 0.5);
18
+
15
19
  /**
16
- * Comparable units only: same bedrooms + similar size when possible, relaxing to
17
- * same bedrooms, then similar size. Never falls back to "every transaction in the
20
+ * Comparable units: SIMILAR SIZE (±20%) is the primary criterion, refined by same
21
+ * bedroom count when that still leaves a usable sample. Bedrooms-only is a last
22
+ * resort for listings with no size. Never falls back to "every transaction in the
18
23
  * building" — mixing unit types is what produces bogus yields.
19
24
  */
20
25
  function pickSample(txs: TxRow[], bedrooms: number | null, sizeSqft: number | null): TxRow[] {
@@ -24,16 +29,14 @@ function pickSample(txs: TxRow[], bedrooms: number | null, sizeSqft: number | nu
24
29
  const sameSize = (t: TxRow) =>
25
30
  sizeSqft != null && t.size_sqft != null && Math.abs(t.size_sqft - sizeSqft) / sizeSqft <= SIZE_TOLERANCE;
26
31
 
27
- const byBoth = recent.filter((t) => sameBdr(t) && sameSize(t));
28
- if (byBoth.length >= MIN_SAMPLES) return byBoth;
29
- const byBdr = recent.filter(sameBdr);
30
- if (byBdr.length >= MIN_SAMPLES) return byBdr;
32
+ const bySizeAndBdr = recent.filter((t) => sameSize(t) && sameBdr(t));
33
+ if (bySizeAndBdr.length >= MIN_SAMPLES) return bySizeAndBdr;
31
34
  const bySize = recent.filter(sameSize);
32
35
  if (bySize.length >= MIN_SAMPLES) return bySize;
33
- // Below MIN_SAMPLES everywhere: use the best comparable set we have, even if tiny.
34
- if (byBoth.length) return byBoth;
35
- if (byBdr.length) return byBdr;
36
- return bySize;
36
+ // Below MIN_SAMPLES everywhere: best comparable set we have, even if tiny.
37
+ if (bySizeAndBdr.length) return bySizeAndBdr;
38
+ if (bySize.length) return bySize;
39
+ return recent.filter(sameBdr); // only reachable when the listing has no size
37
40
  }
38
41
 
39
42
  /**
@@ -52,14 +55,22 @@ export function computeYield(
52
55
  const buySample = pickSample(buyTxs, bedrooms, sizeSqft);
53
56
  const rentSample = pickSample(rentTxs, bedrooms, sizeSqft);
54
57
 
55
- const medianRent = median(rentSample.map((t) => t.amount));
56
- const medianSalePrice = median(buySample.map((t) => t.amount));
58
+ const rents = rentSample.map((t) => t.amount);
59
+ const sales = buySample.map((t) => t.amount);
60
+ const medianRent = median(rents);
61
+ const medianSalePrice = median(sales);
57
62
 
58
63
  return {
59
64
  gross_yield: medianRent != null && medianSalePrice ? medianRent / medianSalePrice : null,
60
65
  asking_yield: medianRent != null && price > 0 ? medianRent / price : null,
61
66
  median_rent: medianRent,
62
67
  median_sale_price: medianSalePrice,
68
+ // Spread of comparable rents/sales: within one building + size band the location
69
+ // is fixed, so the P25→P75 range mostly reflects interior condition/furnishing.
70
+ rent_p25: percentile(rents, 0.25),
71
+ rent_p75: percentile(rents, 0.75),
72
+ sale_p25: percentile(sales, 0.25),
73
+ sale_p75: percentile(sales, 0.75),
63
74
  rent_n: rentSample.length,
64
75
  buy_n: buySample.length,
65
76
  };
@@ -1,26 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { scrapeSearch } from "@/lib/pf/search";
3
- import { getAnalysis, upsertListing } from "@/lib/store";
4
- import { dedupeListings } from "@/lib/dedupe";
5
- import { SearchFilters } from "@/lib/types";
6
-
7
- export async function POST(req: NextRequest) {
8
- try {
9
- const filters = (await req.json()) as SearchFilters;
10
- const { properties, totalCount } = await scrapeSearch(filters);
11
- const rows = properties.map((p) => upsertListing(p));
12
- // Collapse the same physical unit listed by several agents.
13
- const { unique, removed } = dedupeListings(rows);
14
- const results = unique.map((row) => ({
15
- ...row,
16
- images: JSON.parse(row.images_json),
17
- amenities: JSON.parse(row.amenities_json),
18
- // Cached analysis only — nothing is scraped here; the user analyzes on demand.
19
- analysis: getAnalysis(row.id),
20
- }));
21
- return NextResponse.json({ totalCount, count: results.length, duplicatesRemoved: removed, results });
22
- } catch (e) {
23
- console.error("search failed:", e);
24
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
25
- }
26
- }
@@ -1,240 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect, useRef, useState } from "react";
4
- import { SearchFilters } from "@/lib/types";
5
-
6
- const AMENITIES = [
7
- { value: "SY", label: "Gimnasio compartido" },
8
- { value: "PY", label: "Gimnasio privado" },
9
- { value: "CP", label: "Parking cubierto" },
10
- { value: "SP", label: "Piscina compartida" },
11
- { value: "PP", label: "Piscina privada" },
12
- { value: "BA", label: "Balcón" },
13
- { value: "AC", label: "A/C central" },
14
- { value: "MR", label: "Cuarto de servicio" },
15
- { value: "BW", label: "Armarios empotrados" },
16
- { value: "ST", label: "Estudio/despacho" },
17
- { value: "VW", label: "Vistas al agua" },
18
- { value: "PA", label: "Mascotas" },
19
- ];
20
-
21
- const PROPERTY_TYPES = [
22
- { value: "", label: "Cualquiera" },
23
- { value: "1", label: "Apartamento" },
24
- { value: "35", label: "Villa" },
25
- { value: "22", label: "Townhouse" },
26
- { value: "20", label: "Penthouse" },
27
- { value: "24", label: "Dúplex" },
28
- ];
29
-
30
- const BEDROOM_OPTS = ["0", "1", "2", "3", "4", "5"];
31
-
32
- interface LocOption {
33
- id: string;
34
- name: string;
35
- path_name: string;
36
- }
37
-
38
- export default function FiltersForm({
39
- onSearch,
40
- searching,
41
- }: {
42
- onSearch: (f: SearchFilters) => void;
43
- searching: boolean;
44
- }) {
45
- const [locQuery, setLocQuery] = useState("");
46
- const [locOptions, setLocOptions] = useState<LocOption[]>([]);
47
- const [location, setLocation] = useState<LocOption | null>(null);
48
- const [showDropdown, setShowDropdown] = useState(false);
49
- const [minPrice, setMinPrice] = useState("");
50
- const [maxPrice, setMaxPrice] = useState("");
51
- const [minArea, setMinArea] = useState("");
52
- const [maxArea, setMaxArea] = useState("");
53
- const [bedrooms, setBedrooms] = useState<string[]>([]);
54
- const [propertyTypeId, setPropertyTypeId] = useState("");
55
- const [amenities, setAmenities] = useState<string[]>([]);
56
- const [maxGymKm, setMaxGymKm] = useState("");
57
- const [maxPages, setMaxPages] = useState("2");
58
- const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
59
-
60
- useEffect(() => {
61
- if (debounce.current) clearTimeout(debounce.current);
62
- debounce.current = setTimeout(() => {
63
- if (locQuery.trim().length < 2 || locQuery === location?.name) {
64
- setLocOptions([]);
65
- return;
66
- }
67
- fetch(`/api/locations?q=${encodeURIComponent(locQuery)}`)
68
- .then((r) => r.json())
69
- .then((j) => setLocOptions(j.locations || []))
70
- .catch(() => {});
71
- }, 250);
72
- }, [locQuery, location]);
73
-
74
- const toggle = (arr: string[], v: string, set: (x: string[]) => void) =>
75
- set(arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
76
-
77
- const submit = (e: React.FormEvent) => {
78
- e.preventDefault();
79
- onSearch({
80
- locationId: location?.id,
81
- locationLabel: location ? `${location.name} (${location.path_name})` : "Dubai",
82
- minPrice: minPrice ? Number(minPrice) : undefined,
83
- maxPrice: maxPrice ? Number(maxPrice) : undefined,
84
- minArea: minArea ? Number(minArea) : undefined,
85
- maxArea: maxArea ? Number(maxArea) : undefined,
86
- bedrooms: bedrooms.length ? bedrooms : undefined,
87
- propertyTypeId: propertyTypeId || undefined,
88
- amenities: amenities.length ? amenities : undefined,
89
- maxGymDistanceM: maxGymKm ? Number(maxGymKm) * 1000 : undefined,
90
- maxPages: Number(maxPages) || 2,
91
- });
92
- };
93
-
94
- const inputCls =
95
- "w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500 dark:border-neutral-700 dark:bg-neutral-900";
96
-
97
- return (
98
- <form onSubmit={submit} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
99
- <div className="grid gap-3 md:grid-cols-4">
100
- <div className="relative md:col-span-2">
101
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Ubicación</label>
102
- <input
103
- className={inputCls}
104
- placeholder="Ciudad, zona o edificio (p. ej. Dubai Marina)"
105
- value={locQuery}
106
- onChange={(e) => {
107
- setLocQuery(e.target.value);
108
- setLocation(null);
109
- setShowDropdown(true);
110
- }}
111
- onFocus={() => setShowDropdown(true)}
112
- />
113
- {showDropdown && locOptions.length > 0 && (
114
- <ul className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
115
- {locOptions.map((o) => (
116
- <li key={o.id}>
117
- <button
118
- type="button"
119
- className="w-full px-3 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800"
120
- onClick={() => {
121
- setLocation(o);
122
- setLocQuery(o.name);
123
- setShowDropdown(false);
124
- }}
125
- >
126
- <span className="font-medium">{o.name}</span>
127
- <span className="ml-2 text-xs text-neutral-500">{o.path_name}</span>
128
- </button>
129
- </li>
130
- ))}
131
- </ul>
132
- )}
133
- </div>
134
-
135
- <div>
136
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Tipo</label>
137
- <select className={inputCls} value={propertyTypeId} onChange={(e) => setPropertyTypeId(e.target.value)}>
138
- {PROPERTY_TYPES.map((t) => (
139
- <option key={t.value} value={t.value}>
140
- {t.label}
141
- </option>
142
- ))}
143
- </select>
144
- </div>
145
-
146
- <div>
147
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Páginas a scrapear (×25)</label>
148
- <select className={inputCls} value={maxPages} onChange={(e) => setMaxPages(e.target.value)}>
149
- {["1", "2", "3", "4", "5"].map((p) => (
150
- <option key={p} value={p}>
151
- {p}
152
- </option>
153
- ))}
154
- </select>
155
- </div>
156
-
157
- <div>
158
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Precio mín. (AED)</label>
159
- <input className={inputCls} type="number" min="0" placeholder="500000" value={minPrice} onChange={(e) => setMinPrice(e.target.value)} />
160
- </div>
161
- <div>
162
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Precio máx. (AED)</label>
163
- <input className={inputCls} type="number" min="0" placeholder="2000000" value={maxPrice} onChange={(e) => setMaxPrice(e.target.value)} />
164
- </div>
165
- <div>
166
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Superficie mín. (sqft)</label>
167
- <input className={inputCls} type="number" min="0" placeholder="800" value={minArea} onChange={(e) => setMinArea(e.target.value)} />
168
- </div>
169
- <div>
170
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Superficie máx. (sqft)</label>
171
- <input className={inputCls} type="number" min="0" placeholder="1500" value={maxArea} onChange={(e) => setMaxArea(e.target.value)} />
172
- </div>
173
- </div>
174
-
175
- <div className="mt-3">
176
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Habitaciones</label>
177
- <div className="flex flex-wrap gap-2">
178
- {BEDROOM_OPTS.map((b) => (
179
- <button
180
- key={b}
181
- type="button"
182
- onClick={() => toggle(bedrooms, b, setBedrooms)}
183
- className={`rounded-full border px-3 py-1 text-sm ${
184
- bedrooms.includes(b)
185
- ? "border-neutral-900 bg-neutral-900 text-white dark:border-white dark:bg-white dark:text-neutral-900"
186
- : "border-neutral-300 dark:border-neutral-700"
187
- }`}
188
- >
189
- {b === "0" ? "Studio" : b === "5" ? "5+" : b}
190
- </button>
191
- ))}
192
- </div>
193
- </div>
194
-
195
- <div className="mt-3">
196
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Características</label>
197
- <div className="flex flex-wrap gap-2">
198
- {AMENITIES.map((a) => (
199
- <button
200
- key={a.value}
201
- type="button"
202
- onClick={() => toggle(amenities, a.value, setAmenities)}
203
- className={`rounded-full border px-3 py-1 text-xs ${
204
- amenities.includes(a.value)
205
- ? "border-neutral-900 bg-neutral-900 text-white dark:border-white dark:bg-white dark:text-neutral-900"
206
- : "border-neutral-300 dark:border-neutral-700"
207
- }`}
208
- >
209
- {a.label}
210
- </button>
211
- ))}
212
- </div>
213
- </div>
214
-
215
- <div className="mt-3 flex flex-wrap items-end gap-3">
216
- <div>
217
- <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">
218
- Dist. máx. a gimnasio externo (km, opcional)
219
- </label>
220
- <input
221
- className={inputCls}
222
- type="number"
223
- step="0.1"
224
- min="0"
225
- placeholder="p. ej. 0.5"
226
- value={maxGymKm}
227
- onChange={(e) => setMaxGymKm(e.target.value)}
228
- />
229
- </div>
230
- <button
231
- type="submit"
232
- disabled={searching}
233
- className="ml-auto rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white hover:bg-blue-500 disabled:opacity-50"
234
- >
235
- {searching ? "Scrapeando…" : "Buscar en Property Finder"}
236
- </button>
237
- </div>
238
- </form>
239
- );
240
- }
@@ -1,145 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import Link from "next/link";
5
- import YieldBadge from "./YieldBadge";
6
- import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
7
- import { AnalysisRow } from "@/lib/types";
8
-
9
- export interface CardListing {
10
- id: string;
11
- url: string;
12
- title: string;
13
- price: number;
14
- size_sqft: number | null;
15
- bedrooms: number | null;
16
- bathrooms: number | null;
17
- property_type: string;
18
- tower_name: string | null;
19
- images: { small: string; medium: string }[];
20
- amenities: string[];
21
- }
22
-
23
- export default function ListingCard({
24
- listing,
25
- analysis: initialAnalysis,
26
- onAnalyzed,
27
- }: {
28
- listing: CardListing;
29
- analysis?: AnalysisRow | null;
30
- onAnalyzed?: (id: string, a: AnalysisRow) => void;
31
- }) {
32
- const [analysis, setAnalysis] = useState<AnalysisRow | null>(initialAnalysis ?? null);
33
- const [loading, setLoading] = useState(false);
34
- const [imgIdx, setImgIdx] = useState(0);
35
- const images = listing.images || [];
36
-
37
- const analyze = () => {
38
- if (loading || analysis) return;
39
- setLoading(true);
40
- fetch(`/api/listing/${listing.id}?url=${encodeURIComponent(listing.url)}`)
41
- .then(async (r) => {
42
- const j = await r.json();
43
- if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
44
- setAnalysis(j.analysis);
45
- onAnalyzed?.(listing.id, j.analysis);
46
- })
47
- .catch(() => {})
48
- .finally(() => setLoading(false));
49
- };
50
-
51
- return (
52
- <div className="group flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm transition hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900">
53
- <div className="relative aspect-[3/2] bg-neutral-100 dark:bg-neutral-800">
54
- {images.length > 0 && (
55
- // eslint-disable-next-line @next/next/no-img-element
56
- <img
57
- src={images[imgIdx]?.medium || images[imgIdx]?.small}
58
- alt={listing.title}
59
- className="h-full w-full object-cover"
60
- loading="lazy"
61
- />
62
- )}
63
- {images.length > 1 && (
64
- <div className="absolute inset-x-0 bottom-0 flex items-center justify-between p-2 opacity-0 transition group-hover:opacity-100">
65
- <button
66
- onClick={() => setImgIdx((imgIdx - 1 + images.length) % images.length)}
67
- className="rounded-full bg-black/50 px-2.5 py-1 text-sm text-white"
68
- >
69
-
70
- </button>
71
- <span className="rounded-full bg-black/50 px-2 py-0.5 text-xs text-white">
72
- {imgIdx + 1}/{images.length}
73
- </span>
74
- <button
75
- onClick={() => setImgIdx((imgIdx + 1) % images.length)}
76
- className="rounded-full bg-black/50 px-2.5 py-1 text-sm text-white"
77
- >
78
-
79
- </button>
80
- </div>
81
- )}
82
- <div className="absolute left-2 top-2">
83
- {loading ? (
84
- <span className="inline-flex items-center rounded-full bg-black/60 px-2 py-0.5 text-xs font-medium text-white">
85
- analizando…
86
- </span>
87
- ) : analysis ? (
88
- <YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
89
- ) : null}
90
- </div>
91
- </div>
92
-
93
- <div className="flex flex-1 flex-col gap-1.5 p-3">
94
- <div className="flex items-baseline justify-between gap-2">
95
- <span className="text-lg font-bold">{fmtAED(listing.price)}</span>
96
- <span className="shrink-0 text-xs text-neutral-500">{listing.property_type}</span>
97
- </div>
98
- <Link href={`/listing/${listing.id}`} className="line-clamp-2 text-sm font-medium hover:underline">
99
- {listing.title}
100
- </Link>
101
- <div className="text-xs text-neutral-500">
102
- {listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} · {listing.bathrooms ?? "?"} baños ·{" "}
103
- {fmtSqft(listing.size_sqft)}
104
- </div>
105
- {listing.tower_name && (
106
- <div className="truncate text-xs text-neutral-500">📍 {listing.tower_name}</div>
107
- )}
108
- {analysis && (
109
- <div className="mt-1 grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs text-neutral-600 dark:text-neutral-400">
110
- <span>Alquiler/año: {fmtAED(analysis.median_rent)}</span>
111
- <span>🏋️ {fmtDist(analysis.gym_distance_m)}</span>
112
- <span>Venta med.: {fmtAED(analysis.median_sale_price)}</span>
113
- <span>s/ anuncio: {analysis.asking_yield != null ? (analysis.asking_yield * 100).toFixed(1) + " %" : "—"}</span>
114
- </div>
115
- )}
116
- <div className="mt-auto flex items-center gap-3 pt-2">
117
- {analysis ? (
118
- <Link
119
- href={`/listing/${listing.id}`}
120
- className="rounded-lg bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
121
- >
122
- Ver análisis
123
- </Link>
124
- ) : (
125
- <button
126
- onClick={analyze}
127
- disabled={loading}
128
- className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-500 disabled:opacity-60"
129
- >
130
- {loading ? "Analizando…" : "⚡ Analizar"}
131
- </button>
132
- )}
133
- <a
134
- href={listing.url}
135
- target="_blank"
136
- rel="noopener noreferrer"
137
- className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
138
- >
139
- Ver en Property Finder ↗
140
- </a>
141
- </div>
142
- </div>
143
- </div>
144
- );
145
- }
package/src/lib/dedupe.ts DELETED
@@ -1,34 +0,0 @@
1
- interface UnitLike {
2
- id: string;
3
- tower_slug: string | null;
4
- bedrooms: number | null;
5
- size_sqft: number | null;
6
- price: number;
7
- }
8
-
9
- /**
10
- * The same physical apartment is often listed by several agents under different
11
- * Property Finder ids. Those share the building, bedroom count, size and asking
12
- * price (agents copy the same DLD figures), so collapse them to one card.
13
- *
14
- * When any of those fields is missing we cannot be confident two rows are the
15
- * same unit, so we key on the id and never merge.
16
- */
17
- function unitSignature(r: UnitLike): string {
18
- if (!r.tower_slug || r.size_sqft == null || !r.price) return `id:${r.id}`;
19
- const bd = r.bedrooms == null ? "?" : r.bedrooms;
20
- return `${r.tower_slug}|${bd}|${Math.round(r.size_sqft)}|${r.price}`;
21
- }
22
-
23
- /** Keep the first occurrence of each physical unit (newest first with ob=mr). */
24
- export function dedupeListings<T extends UnitLike>(rows: T[]): { unique: T[]; removed: number } {
25
- const seen = new Set<string>();
26
- const unique: T[] = [];
27
- for (const r of rows) {
28
- const sig = unitSignature(r);
29
- if (seen.has(sig)) continue;
30
- seen.add(sig);
31
- unique.push(r);
32
- }
33
- return { unique, removed: rows.length - unique.length };
34
- }