brickwise 0.1.15 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brickwise",
3
- "version": "0.1.15",
3
+ "version": "0.1.21",
4
4
  "description": "App local para analizar la rentabilidad de alquiler de inmuebles de Property Finder UAE con el histórico real de transacciones DLD.",
5
5
  "license": "MIT",
6
6
  "author": "eljommys",
@@ -43,6 +43,7 @@
43
43
  "dependencies": {
44
44
  "better-sqlite3": "^12.11.1",
45
45
  "leaflet": "^1.9.4",
46
+ "leaflet.heat": "^0.2.0",
46
47
  "next": "16.2.10",
47
48
  "react": "19.2.4",
48
49
  "react-dom": "19.2.4"
@@ -51,6 +52,7 @@
51
52
  "@tailwindcss/postcss": "^4",
52
53
  "@types/better-sqlite3": "^7.6.13",
53
54
  "@types/leaflet": "^1.9.21",
55
+ "@types/leaflet.heat": "^0.2.5",
54
56
  "@types/node": "^20",
55
57
  "@types/react": "^19",
56
58
  "@types/react-dom": "^19",
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { listFavorites, txCountByTowerSince } from "@/lib/store";
3
+
4
+ const MONTHS = 12;
5
+
6
+ /**
7
+ * Liquidity of each located favorite's tower = DLD transactions registered in the
8
+ * last 12 months, split by kind via ?kind=buy|rent (defaults to rent). Feeds the
9
+ * heatmap: each favorite is a weighted point, so nearby towers blend into a
10
+ * "how active is this pocket" surface for that market (rentals vs sales).
11
+ */
12
+ export async function GET(req: NextRequest) {
13
+ try {
14
+ const kindParam = req.nextUrl.searchParams.get("kind");
15
+ const kind: "buy" | "rent" = kindParam === "buy" ? "buy" : "rent";
16
+ const since = new Date();
17
+ since.setMonth(since.getMonth() - MONTHS);
18
+ const sinceISO = since.toISOString().slice(0, 10);
19
+ const counts = txCountByTowerSince(sinceISO, kind);
20
+
21
+ const points = listFavorites()
22
+ .filter((f) => f.lat != null && f.lon != null)
23
+ .map((f) => ({
24
+ id: f.id,
25
+ lat: f.lat as number,
26
+ lon: f.lon as number,
27
+ weight: f.tower_slug ? counts.get(f.tower_slug) ?? 0 : 0,
28
+ }));
29
+
30
+ const max = points.reduce((m, p) => Math.max(m, p.weight), 0);
31
+ return NextResponse.json({ points, max, months: MONTHS, kind });
32
+ } catch (e) {
33
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
34
+ }
35
+ }
@@ -77,6 +77,21 @@ const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
77
77
  // strips javascript: hrefs.
78
78
  const BOOKMARKLET = String.raw`javascript:(function(){var u=new Set();document.querySelectorAll('a[href]').forEach(function(a){var h=(a.href||'').split('?')[0];if(/\/plp\/.*-\d+\.html$/.test(h))u.add(h)});try{var n=document.getElementById('__NEXT_DATA__');if(n){(JSON.stringify(JSON.parse(n.textContent)).match(/https?:\/\/[^"']*\/plp\/[^"']*-\d+\.html/g)||[]).forEach(function(x){u.add(x.split('?')[0])})}}catch(e){}var l=[...u];if(!l.length){alert('No encontre pisos guardados aqui. Abre tu pagina de Guardados de Property Finder con la sesion iniciada.');return}var t=l.join('\n');if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){alert(l.length+' piso(s) copiados. Abre Brickwise y pulsa "Pegar de Property Finder".')},function(){prompt('Copia estos enlaces y pegalos en Brickwise:',t)})}else{prompt('Copia estos enlaces y pegalos en Brickwise:',t)}})();`;
79
79
 
80
+ // leaflet.heat's radius is in screen pixels, so a fixed value shrinks in real-world
81
+ // terms as you zoom in. Anchor the radius to a reference zoom and scale by 2^(Δzoom)
82
+ // (each zoom level doubles pixels-per-meter) so the geographic influence stays constant.
83
+ const HEAT_REF_ZOOM = 12;
84
+ const HEAT_REF_RADIUS = 40;
85
+ const HEAT_REF_BLUR = 22;
86
+ function heatRadiusForZoom(zoom: number): { radius: number; blur: number } {
87
+ const scale = Math.pow(2, zoom - HEAT_REF_ZOOM);
88
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
89
+ return {
90
+ radius: clamp(HEAT_REF_RADIUS * scale, 6, 900),
91
+ blur: clamp(HEAT_REF_BLUR * scale, 4, 500),
92
+ };
93
+ }
94
+
80
95
  function LayerToggle({
81
96
  label,
82
97
  color,
@@ -123,6 +138,7 @@ export default function FavoritesMapPage() {
123
138
  const [input, setInput] = useState("");
124
139
  const [queue, setQueue] = useState<string[]>([]);
125
140
  const [errors, setErrors] = useState<string[]>([]);
141
+ const [skippedMsg, setSkippedMsg] = useState<string | null>(null);
126
142
  const [sidebarW, setSidebarW] = useState(() => {
127
143
  if (typeof window === "undefined") return 440;
128
144
  const saved = Number(localStorage.getItem("bw-sidebar-w"));
@@ -135,6 +151,9 @@ export default function FavoritesMapPage() {
135
151
  const [cafesVisible, setCafesVisible] = useState(false);
136
152
  const [cafesLoading, setCafesLoading] = useState(false);
137
153
  const [cafeCount, setCafeCount] = useState<number | null>(null);
154
+ const [heatKind, setHeatKind] = useState<"rent" | "buy" | null>(null);
155
+ const [heatLoading, setHeatLoading] = useState<"rent" | "buy" | null>(null);
156
+ const [heatMax, setHeatMax] = useState<number | null>(null);
138
157
  const [showLayers, setShowLayers] = useState(false);
139
158
  const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
140
159
  const [bedFilter, setBedFilter] = useState<number[]>([]);
@@ -145,6 +164,7 @@ export default function FavoritesMapPage() {
145
164
  const markersRef = useRef<Record<string, Marker>>({});
146
165
  const gymLayerRef = useRef<LayerGroup | null>(null);
147
166
  const cafeLayerRef = useRef<LayerGroup | null>(null);
167
+ const heatLayerRef = useRef<import("leaflet").Layer | null>(null);
148
168
  const containerRef = useRef<HTMLDivElement | null>(null);
149
169
  const didFitRef = useRef(false);
150
170
  const processing = useRef(false);
@@ -204,6 +224,16 @@ export default function FavoritesMapPage() {
204
224
  };
205
225
  el.addEventListener("wheel", onWheel, { passive: false });
206
226
  map.on("remove", () => el.removeEventListener("wheel", onWheel));
227
+
228
+ // Keep the heat influence constant in real-world size across zoom levels:
229
+ // rescale the pixel radius whenever the zoom settles.
230
+ map.on("zoomend", () => {
231
+ const layer = heatLayerRef.current as unknown as {
232
+ setOptions?: (o: Record<string, unknown>) => void;
233
+ } | null;
234
+ if (!layer?.setOptions) return;
235
+ layer.setOptions(heatRadiusForZoom(map.getZoom()));
236
+ });
207
237
  let tries = 0;
208
238
  const timer = setInterval(() => {
209
239
  tries++;
@@ -307,32 +337,62 @@ export default function FavoritesMapPage() {
307
337
  });
308
338
  }, [queue, load]);
309
339
 
310
- const enqueueUrls = (raw: string): number => {
340
+ // Same id extraction the server does (parseListingUrl in the API route).
341
+ const listingIdOf = (u: string): string | null => {
342
+ try {
343
+ const p = new URL(u).pathname;
344
+ const m = p.match(/-(\d+)\.html$/) || p.match(/\/(\d+)(?:\/)?$/);
345
+ return m ? m[1] : null;
346
+ } catch {
347
+ return null;
348
+ }
349
+ };
350
+
351
+ const enqueueUrls = (raw: string): { added: number; skipped: number } => {
311
352
  const urls = raw
312
353
  .split(/[\n\s,]+/)
313
354
  .map((s) => s.trim())
314
355
  .filter((s) => s.startsWith("http") && /propertyfinder\.ae/.test(s));
315
- if (urls.length) {
356
+ // Discard the ones already saved (and de-dupe the paste itself) so only new
357
+ // listings get analysed — no wasted scrape/round-trip on ones we already have.
358
+ const have = new Set((rows ?? []).map((r) => r.id));
359
+ const seen = new Set<string>();
360
+ const fresh: string[] = [];
361
+ let skipped = 0;
362
+ for (const u of urls) {
363
+ const id = listingIdOf(u);
364
+ if (id && (have.has(id) || seen.has(id))) {
365
+ skipped++;
366
+ continue;
367
+ }
368
+ if (id) seen.add(id);
369
+ fresh.push(u);
370
+ }
371
+ if (fresh.length) {
316
372
  setErrors([]);
317
- setQueue((prev) => [...prev, ...urls]);
373
+ setQueue((prev) => [...prev, ...fresh]);
318
374
  }
319
- return urls.length;
375
+ setSkippedMsg(skipped > 0 ? `${skipped} ya guardado${skipped === 1 ? "" : "s"}, descartado${skipped === 1 ? "" : "s"}.` : null);
376
+ return { added: fresh.length, skipped };
320
377
  };
321
378
 
322
379
  const submit = (e: React.FormEvent) => {
323
380
  e.preventDefault();
324
- if (enqueueUrls(input) > 0) setInput("");
381
+ const { added, skipped } = enqueueUrls(input);
382
+ if (added > 0 || skipped > 0) setInput("");
325
383
  };
326
384
 
327
385
  const pasteFromPf = async () => {
328
386
  setSyncMsg(null);
329
387
  try {
330
388
  const text = await navigator.clipboard.readText();
331
- const n = enqueueUrls(text);
389
+ const { added, skipped } = enqueueUrls(text);
332
390
  setSyncMsg(
333
- n > 0
334
- ? `${n} piso${n === 1 ? "" : "s"} en cola — analizando y añadiendo…`
335
- : "El portapapeles no tenía enlaces de Property Finder. Ejecuta el marcador en tu página de Guardados primero."
391
+ added > 0
392
+ ? `${added} piso${added === 1 ? "" : "s"} nuevo${added === 1 ? "" : "s"} en cola — analizando y añadiendo…${skipped > 0 ? ` (${skipped} ya guardado${skipped === 1 ? "" : "s"})` : ""}`
393
+ : skipped > 0
394
+ ? `Los ${skipped} piso${skipped === 1 ? "" : "s"} del portapapeles ya estaban guardados. Nada nuevo que analizar.`
395
+ : "El portapapeles no tenía enlaces de Property Finder. Ejecuta el marcador en tu página de Guardados primero."
336
396
  );
337
397
  } catch {
338
398
  setSyncMsg("No pude leer el portapapeles. Pega los enlaces manualmente en el cuadro de arriba.");
@@ -435,6 +495,59 @@ export default function FavoritesMapPage() {
435
495
  layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
436
496
  });
437
497
 
498
+ // Liquidity heatmap: DLD transactions per tower (last 12 months) as a heat surface.
499
+ // Rent and sale are separate markets, so only one kind shows at a time.
500
+ const toggleLiquidity = async (kind: "rent" | "buy") => {
501
+ const map = mapRef.current;
502
+ if (!map || !leafletRef.current) return;
503
+ // Clicking the active kind turns it off.
504
+ if (heatKind === kind) {
505
+ if (heatLayerRef.current) map.removeLayer(heatLayerRef.current);
506
+ heatLayerRef.current = null;
507
+ setHeatKind(null);
508
+ return;
509
+ }
510
+ // Switching kinds: drop the current layer first.
511
+ if (heatLayerRef.current) {
512
+ map.removeLayer(heatLayerRef.current);
513
+ heatLayerRef.current = null;
514
+ }
515
+ setHeatLoading(kind);
516
+ try {
517
+ const res = await fetch(`/api/liquidity?kind=${kind}`);
518
+ const j = await res.json();
519
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
520
+ await import("leaflet.heat"); // patches L.heatLayer
521
+ const max = j.max || 1;
522
+ setHeatMax(j.max ?? null);
523
+ // Each favorite blooms proportionally to its tower's recent activity; a small
524
+ // floor keeps even low-liquidity listings visible as a faint mark.
525
+ const pts = (j.points as { lat: number; lon: number; weight: number }[]).map(
526
+ (p) => [p.lat, p.lon, Math.max(0.15, p.weight / max)] as [number, number, number]
527
+ );
528
+ type HeatFn = (latlngs: [number, number, number][], opts?: Record<string, unknown>) => import("leaflet").Layer;
529
+ const Lmod = leafletRef.current as unknown as { heatLayer?: HeatFn; default?: { heatLayer?: HeatFn } };
530
+ const heatLayer = Lmod.heatLayer ?? Lmod.default?.heatLayer;
531
+ if (!heatLayer) throw new Error("plugin de calor no disponible");
532
+ const { radius, blur } = heatRadiusForZoom(map.getZoom());
533
+ const layer = heatLayer(pts, {
534
+ radius,
535
+ blur,
536
+ max: 1,
537
+ minOpacity: 0.55,
538
+ // Yellow (low) → purple (high).
539
+ gradient: { 0.15: "#fef08a", 0.4: "#fde047", 0.65: "#d946ef", 1.0: "#7c3aed" },
540
+ });
541
+ layer.addTo(map);
542
+ heatLayerRef.current = layer;
543
+ setHeatKind(kind);
544
+ } catch {
545
+ setHeatKind(null);
546
+ } finally {
547
+ setHeatLoading(null);
548
+ }
549
+ };
550
+
438
551
  // --- Resizable divider ---------------------------------------------------
439
552
  const startDrag = (e: React.MouseEvent) => {
440
553
  e.preventDefault();
@@ -628,6 +741,9 @@ export default function FavoritesMapPage() {
628
741
  >
629
742
  {busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
630
743
  </button>
744
+ {skippedMsg && (
745
+ <p className="text-[11px] text-neutral-500 dark:text-neutral-400">{skippedMsg}</p>
746
+ )}
631
747
  <div className="relative">
632
748
  <button
633
749
  type="button"
@@ -635,15 +751,25 @@ export default function FavoritesMapPage() {
635
751
  disabled={(rows?.length ?? 0) === 0}
636
752
  className="flex w-full items-center justify-center gap-1 rounded-lg border border-neutral-300 py-2 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
637
753
  >
638
- Puntos cercanos
754
+ Capas del mapa
639
755
  <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform ${showLayers ? "rotate-180" : ""}`}>
640
756
  <path d="M6 9l6 6 6-6" />
641
757
  </svg>
642
758
  </button>
643
759
  {showLayers && (
644
760
  <div className="absolute inset-x-0 top-full z-30 mt-1 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
761
+ <p className="px-2 pb-0.5 pt-1 text-[10px] uppercase tracking-wide text-neutral-400">Puntos cercanos</p>
645
762
  <LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
646
763
  <LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
764
+ <div className="my-1 border-t border-neutral-200 dark:border-neutral-800" />
765
+ <p className="px-2 pb-0.5 pt-0.5 text-[10px] uppercase tracking-wide text-neutral-400">Mapa de calor de liquidez (12m)</p>
766
+ <LayerToggle label="Alquiler" color="#d946ef" loading={heatLoading === "rent"} visible={heatKind === "rent"} count={null} onToggle={() => toggleLiquidity("rent")} />
767
+ <LayerToggle label="Venta" color="#7c3aed" loading={heatLoading === "buy"} visible={heatKind === "buy"} count={null} onToggle={() => toggleLiquidity("buy")} />
768
+ {heatKind && (
769
+ <p className="px-2 pb-1 pt-0.5 text-[10px] leading-tight text-neutral-500">
770
+ {heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} (DLD) por edificio, últimos 12 meses. Amarillo = poco líquido, morado = mucha actividad.
771
+ </p>
772
+ )}
647
773
  </div>
648
774
  )}
649
775
  </div>
@@ -786,23 +912,29 @@ export default function FavoritesMapPage() {
786
912
  {/* ------------------------------------------------ map */}
787
913
  <div className="relative min-h-0 min-w-0 flex-1">
788
914
  <div ref={containerRef} className="absolute inset-0" />
789
- <div className="pointer-events-none absolute bottom-4 left-3 z-[500] rounded border border-black/10 bg-white/90 px-2.5 py-2 text-neutral-700 shadow-lg dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-200">
790
- <div className="btn-font mb-1 text-[9px] text-neutral-500">Rentabilidad anual</div>
791
- <div
792
- className="h-2 w-52 rounded-sm"
793
- style={{ background: "linear-gradient(90deg, hsl(0,66%,44%), hsl(87,66%,44%), hsl(174,66%,44%))" }}
794
- />
795
- <div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
796
- <span>Mediocre</span>
797
- <span>Bueno</span>
798
- <span>Espectacular</span>
799
- </div>
800
- <div className="flex w-52 justify-between text-[9px] text-neutral-500">
801
- <span>≤6%</span>
802
- <span>8–9%</span>
803
- <span>10%+</span>
915
+ {heatKind && (
916
+ <div className="pointer-events-none absolute bottom-4 left-3 z-[500] rounded border border-black/10 bg-white/90 px-2.5 py-2 text-neutral-700 shadow-lg dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-200">
917
+ <div className="btn-font mb-1 text-[9px] text-neutral-500">
918
+ Liquidez {heatKind === "rent" ? "alquiler" : "venta"} · {heatKind === "rent" ? "contratos" : "ventas"}/mes
919
+ </div>
920
+ <div
921
+ className="h-2 w-52 rounded-sm"
922
+ style={{ background: "linear-gradient(90deg, #fef08a, #fde047, #d946ef, #7c3aed)" }}
923
+ />
924
+ <div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
925
+ <span>0</span>
926
+ {heatMax != null && <span>{(heatMax / 24).toFixed(1)}</span>}
927
+ {heatMax != null && <span>{(heatMax / 12).toFixed(1)}</span>}
928
+ </div>
929
+ <div className="flex w-52 justify-between text-[9px] text-neutral-500">
930
+ <span>parado</span>
931
+ <span>activo</span>
932
+ </div>
933
+ <div className="mt-0.5 w-52 text-[9px] leading-tight text-neutral-400">
934
+ {heatKind === "rent" ? "Contratos de alquiler registrados" : "Ventas registradas"} en el DLD por edificio, últimos 12 meses.
935
+ </div>
804
936
  </div>
805
- </div>
937
+ )}
806
938
  </div>
807
939
  </div>
808
940
  );
@@ -79,10 +79,6 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
79
79
  <h1 className="text-2xl font-bold">{listing.title}</h1>
80
80
  <YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
81
81
  </div>
82
- <p className="mt-1 text-sm text-neutral-500">
83
- {listing.tower_name} · {listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} ·{" "}
84
- {listing.bathrooms ?? "?"} baños · {fmtSqft(listing.size_sqft)} · {listing.property_type}
85
- </p>
86
82
  <div className="mt-1 flex items-center gap-4">
87
83
  <a
88
84
  href={listing.url}
@@ -143,41 +139,68 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
143
139
  </div>
144
140
  )}
145
141
 
146
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
147
- <Stat
148
- label="Rentabilidad anual de alquiler"
149
- value={fmtPct(analysis.gross_yield)}
150
- sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
151
- accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
152
- />
153
- <Stat
154
- label="Se alquila por (año, mediana)"
155
- value={fmtAED(analysis.median_rent)}
156
- sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
157
- analysis.rent_p25 != null && analysis.rent_p75 != null
158
- ? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
159
- : ""
160
- }`}
161
- />
162
- <Stat
163
- label="Se vende por (mediana)"
164
- value={fmtAED(analysis.median_sale_price)}
165
- sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
166
- analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
167
- }`}
168
- />
169
- <Stat
170
- label="Rentabilidad sobre el precio del anuncio"
171
- value={fmtPct(analysis.asking_yield)}
172
- sub={`si lo compras por ${fmtAED(listing.price)}`}
173
- />
174
- <Stat
175
- label="Gimnasio más cercano (OSM)"
176
- value={fmtDist(analysis.gym_distance_m)}
177
- sub={analysis.gym_name ?? undefined}
178
- />
179
- <Stat label="AED/sqft del anuncio" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)} AED/sqft` : "—"} />
180
- </div>
142
+ {/* -------- Datos básicos (raw property facts) ------------------------ */}
143
+ <section>
144
+ <h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-neutral-500">Datos básicos</h2>
145
+ <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
146
+ <p className="flex items-baseline gap-2">
147
+ <span className="text-3xl font-bold" style={{ color: "var(--accent)" }}>{fmtAED(listing.price)}</span>
148
+ {listing.size_sqft ? (
149
+ <span className="text-sm text-neutral-500">· {Math.round(listing.price / listing.size_sqft)} AED/sqft</span>
150
+ ) : null}
151
+ </p>
152
+ <div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
153
+ <Fact label="Superficie" value={fmtSqft(listing.size_sqft)} />
154
+ <Fact label="Habitaciones" value={listing.bedrooms === 0 ? "Studio" : listing.bedrooms != null ? String(listing.bedrooms) : "—"} />
155
+ <Fact label="Baños" value={listing.bathrooms != null ? String(listing.bathrooms) : ""} />
156
+ <Fact label="Tipo" value={listing.property_type || "—"} />
157
+ <Fact label="AED/sqft" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)}` : "—"} />
158
+ <Fact label="Edificio" value={listing.tower_name || "—"} />
159
+ </div>
160
+ </div>
161
+ </section>
162
+
163
+ {/* -------- Análisis (computed metrics) ------------------------------- */}
164
+ <section>
165
+ <h2 className="mb-2 flex items-center text-sm font-bold uppercase tracking-wide text-neutral-500">
166
+ Análisis
167
+ <InfoList items={ANALYSIS_HELP} />
168
+ </h2>
169
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
170
+ <Stat
171
+ label="Rentabilidad anual de alquiler"
172
+ value={fmtPct(analysis.gross_yield)}
173
+ sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
174
+ accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
175
+ />
176
+ <Stat
177
+ label="Se alquila por (año, mediana)"
178
+ value={fmtAED(analysis.median_rent)}
179
+ sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
180
+ analysis.rent_p25 != null && analysis.rent_p75 != null
181
+ ? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
182
+ : ""
183
+ }`}
184
+ />
185
+ <Stat
186
+ label="Se vende por (mediana)"
187
+ value={fmtAED(analysis.median_sale_price)}
188
+ sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
189
+ analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
190
+ }`}
191
+ />
192
+ <Stat
193
+ label="Rentabilidad sobre el precio del anuncio"
194
+ value={fmtPct(analysis.asking_yield)}
195
+ sub={`si lo compras por ${fmtAED(listing.price)}`}
196
+ />
197
+ <Stat
198
+ label="Gimnasio más cercano (OSM)"
199
+ value={fmtDist(analysis.gym_distance_m)}
200
+ sub={analysis.gym_name ?? undefined}
201
+ />
202
+ </div>
203
+ </section>
181
204
 
182
205
  {listing.amenities?.length > 0 && (
183
206
  <div className="flex flex-wrap gap-1.5">
@@ -228,6 +251,57 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
228
251
  );
229
252
  }
230
253
 
254
+ const ANALYSIS_HELP: { term: string; desc: string }[] = [
255
+ {
256
+ term: "Rentabilidad anual de alquiler",
257
+ desc: "Renta anual mediana ÷ precio de venta mediana de unidades comparables (mismo edificio, tamaño ±20%, últimos 24 meses). Es la rentabilidad bruta que da el mercado, independiente del precio de este anuncio.",
258
+ },
259
+ {
260
+ term: "Se alquila por (año, mediana)",
261
+ desc: "Mediana de los contratos de alquiler anuales de unidades de tamaño similar (±20%) del edificio, últimos 24 meses. El rango es P25–P75: lo que pagan los pisos peor y mejor acondicionados.",
262
+ },
263
+ {
264
+ term: "Se vende por (mediana)",
265
+ desc: "Mediana de las ventas registradas en el DLD de tamaño similar (±20%), últimos 24 meses. El P25 es el tramo bajo del mercado ≈ precio objetivo de compra para reformar.",
266
+ },
267
+ {
268
+ term: "Rentabilidad sobre el precio del anuncio",
269
+ desc: "Renta anual mediana ÷ el precio que pide ESTE anuncio. Indica si este piso, a su precio, rinde por encima o por debajo de la mediana del edificio.",
270
+ },
271
+ {
272
+ term: "Gimnasio más cercano",
273
+ desc: "Distancia en línea recta al gimnasio más próximo según OpenStreetMap.",
274
+ },
275
+ ];
276
+
277
+ function Fact({ label, value }: { label: string; value: string }) {
278
+ return (
279
+ <div>
280
+ <div className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">{label}</div>
281
+ <div className="mt-0.5 text-sm font-medium">{value}</div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ function InfoList({ items }: { items: { term: string; desc: string }[] }) {
287
+ return (
288
+ <span className="group relative ml-2 inline-flex align-middle normal-case">
289
+ <span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-neutral-400 text-[10px] font-bold text-neutral-500 dark:border-neutral-500">
290
+ ?
291
+ </span>
292
+ <span className="pointer-events-none absolute left-0 top-6 z-30 w-80 rounded-md border border-neutral-200 bg-white p-3 text-left opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-neutral-700 dark:bg-neutral-800">
293
+ <span className="mb-1 block text-[11px] font-bold uppercase tracking-wide text-neutral-500">Cómo leer cada campo</span>
294
+ {items.map((it) => (
295
+ <span key={it.term} className="mb-2 block last:mb-0">
296
+ <span className="block text-[11px] font-semibold text-neutral-700 dark:text-neutral-200">{it.term}</span>
297
+ <span className="block text-[11px] font-normal leading-snug text-neutral-500 dark:text-neutral-400">{it.desc}</span>
298
+ </span>
299
+ ))}
300
+ </span>
301
+ </span>
302
+ );
303
+ }
304
+
231
305
  function Stat({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: "good" | "bad" }) {
232
306
  return (
233
307
  <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
package/src/lib/store.ts CHANGED
@@ -176,6 +176,24 @@ export function listFavorites(): FavoriteListing[] {
176
176
  .all() as FavoriteListing[];
177
177
  }
178
178
 
179
+ /**
180
+ * Liquidity proxy: number of DLD transactions per tower registered on/after
181
+ * `sinceISO`, optionally restricted to one kind ('buy' | 'rent'). Raw totals are
182
+ * capped at ~100/kind by scraping, so recent activity — not the lifetime count —
183
+ * is what separates a liquid tower from a stale one. Returned as tower_slug → count.
184
+ */
185
+ export function txCountByTowerSince(sinceISO: string, kind?: "buy" | "rent"): Map<string, number> {
186
+ const db = getDb();
187
+ const rows = (
188
+ kind
189
+ ? db.prepare(`SELECT tower_slug, COUNT(*) n FROM transactions WHERE date >= ? AND kind = ? GROUP BY tower_slug`).all(sinceISO, kind)
190
+ : db.prepare(`SELECT tower_slug, COUNT(*) n FROM transactions WHERE date >= ? GROUP BY tower_slug`).all(sinceISO)
191
+ ) as { tower_slug: string; n: number }[];
192
+ const m = new Map<string, number>();
193
+ for (const r of rows) m.set(r.tower_slug, r.n);
194
+ return m;
195
+ }
196
+
179
197
  export function listAnalyzed(): AnalyzedListing[] {
180
198
  return getDb()
181
199
  .prepare(