brickwise 0.1.15 → 0.1.24

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/bin/brickwise.js CHANGED
@@ -74,9 +74,38 @@ function openBrowser(url) {
74
74
  syncSources();
75
75
  }
76
76
 
77
- if (!fs.existsSync(path.join(workdir, "node_modules", "next"))) {
78
- step("Instalando dependencias (solo la primera vez, ~1 min)…");
77
+ // Reinstall deps on a fresh copy AND whenever the app version changed — an
78
+ // update can add dependencies (e.g. leaflet.heat), and node_modules is not
79
+ // copied over, so a stale install would be missing them. A version marker
80
+ // inside node_modules tells us what was last installed there.
81
+ const marker = path.join(workdir, "node_modules", ".brickwise-version");
82
+ const currentVer = (() => {
83
+ try {
84
+ return require(path.join(workdir, "package.json")).version || "";
85
+ } catch {
86
+ return "";
87
+ }
88
+ })();
89
+ const installedVer = (() => {
90
+ try {
91
+ return fs.readFileSync(marker, "utf8").trim();
92
+ } catch {
93
+ return null;
94
+ }
95
+ })();
96
+ const nextMissing = !fs.existsSync(path.join(workdir, "node_modules", "next"));
97
+ if (nextMissing || installedVer !== currentVer) {
98
+ step(
99
+ nextMissing
100
+ ? "Instalando dependencias (solo la primera vez, ~1 min)…"
101
+ : "Actualizando dependencias tras la actualización…"
102
+ );
79
103
  run(npm, ["install", "--include=dev", "--no-audit", "--no-fund"], workdir);
104
+ try {
105
+ fs.writeFileSync(marker, currentVer);
106
+ } catch {
107
+ /* non-fatal: worst case we reinstall next launch */
108
+ }
80
109
  }
81
110
 
82
111
  const port = await findFreePort(Number(process.env.PORT) || 3000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brickwise",
3
- "version": "0.1.15",
3
+ "version": "0.1.24",
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,30 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { annualLiquidityByTower, listFavorites } from "@/lib/store";
3
+
4
+ /**
5
+ * Liquidity of each located favorite's tower = average annual DLD transactions of
6
+ * `kind` (via ?kind=buy|rent, defaults to rent). Feeds the heatmap: each favorite
7
+ * is a weighted point, so nearby towers blend into a "how active is this pocket"
8
+ * surface for that market (rentals vs sales). Weight unit is transactions/year.
9
+ */
10
+ export async function GET(req: NextRequest) {
11
+ try {
12
+ const kindParam = req.nextUrl.searchParams.get("kind");
13
+ const kind: "buy" | "rent" = kindParam === "buy" ? "buy" : "rent";
14
+ const rates = annualLiquidityByTower(kind);
15
+
16
+ const points = listFavorites()
17
+ .filter((f) => f.lat != null && f.lon != null)
18
+ .map((f) => ({
19
+ id: f.id,
20
+ lat: f.lat as number,
21
+ lon: f.lon as number,
22
+ weight: f.tower_slug ? rates.get(f.tower_slug) ?? 0 : 0,
23
+ }));
24
+
25
+ const max = points.reduce((m, p) => Math.max(m, p.weight), 0);
26
+ return NextResponse.json({ points, max, kind, unit: "year" });
27
+ } catch (e) {
28
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
29
+ }
30
+ }
@@ -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,10 @@ 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);
157
+ const [heatError, setHeatError] = useState<string | null>(null);
138
158
  const [showLayers, setShowLayers] = useState(false);
139
159
  const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
140
160
  const [bedFilter, setBedFilter] = useState<number[]>([]);
@@ -145,6 +165,7 @@ export default function FavoritesMapPage() {
145
165
  const markersRef = useRef<Record<string, Marker>>({});
146
166
  const gymLayerRef = useRef<LayerGroup | null>(null);
147
167
  const cafeLayerRef = useRef<LayerGroup | null>(null);
168
+ const heatLayerRef = useRef<import("leaflet").Layer | null>(null);
148
169
  const containerRef = useRef<HTMLDivElement | null>(null);
149
170
  const didFitRef = useRef(false);
150
171
  const processing = useRef(false);
@@ -181,6 +202,9 @@ export default function FavoritesMapPage() {
181
202
  zoomControl: true,
182
203
  scrollWheelZoom: false, // replaced by the trackpad handler below
183
204
  zoomSnap: 0, // allow fractional zoom for smooth pinch
205
+ // leaflet.heat ghosts (duplicate shifted blobs) during the animated-zoom
206
+ // transform; snap zoom so the heat canvas redraws cleanly at each zoom end.
207
+ zoomAnimation: false,
184
208
  });
185
209
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
186
210
  maxZoom: 19,
@@ -204,6 +228,16 @@ export default function FavoritesMapPage() {
204
228
  };
205
229
  el.addEventListener("wheel", onWheel, { passive: false });
206
230
  map.on("remove", () => el.removeEventListener("wheel", onWheel));
231
+
232
+ // Keep the heat influence constant in real-world size across zoom levels:
233
+ // rescale the pixel radius whenever the zoom settles.
234
+ map.on("zoomend", () => {
235
+ const layer = heatLayerRef.current as unknown as {
236
+ setOptions?: (o: Record<string, unknown>) => void;
237
+ } | null;
238
+ if (!layer?.setOptions) return;
239
+ layer.setOptions(heatRadiusForZoom(map.getZoom()));
240
+ });
207
241
  let tries = 0;
208
242
  const timer = setInterval(() => {
209
243
  tries++;
@@ -307,32 +341,62 @@ export default function FavoritesMapPage() {
307
341
  });
308
342
  }, [queue, load]);
309
343
 
310
- const enqueueUrls = (raw: string): number => {
344
+ // Same id extraction the server does (parseListingUrl in the API route).
345
+ const listingIdOf = (u: string): string | null => {
346
+ try {
347
+ const p = new URL(u).pathname;
348
+ const m = p.match(/-(\d+)\.html$/) || p.match(/\/(\d+)(?:\/)?$/);
349
+ return m ? m[1] : null;
350
+ } catch {
351
+ return null;
352
+ }
353
+ };
354
+
355
+ const enqueueUrls = (raw: string): { added: number; skipped: number } => {
311
356
  const urls = raw
312
357
  .split(/[\n\s,]+/)
313
358
  .map((s) => s.trim())
314
359
  .filter((s) => s.startsWith("http") && /propertyfinder\.ae/.test(s));
315
- if (urls.length) {
360
+ // Discard the ones already saved (and de-dupe the paste itself) so only new
361
+ // listings get analysed — no wasted scrape/round-trip on ones we already have.
362
+ const have = new Set((rows ?? []).map((r) => r.id));
363
+ const seen = new Set<string>();
364
+ const fresh: string[] = [];
365
+ let skipped = 0;
366
+ for (const u of urls) {
367
+ const id = listingIdOf(u);
368
+ if (id && (have.has(id) || seen.has(id))) {
369
+ skipped++;
370
+ continue;
371
+ }
372
+ if (id) seen.add(id);
373
+ fresh.push(u);
374
+ }
375
+ if (fresh.length) {
316
376
  setErrors([]);
317
- setQueue((prev) => [...prev, ...urls]);
377
+ setQueue((prev) => [...prev, ...fresh]);
318
378
  }
319
- return urls.length;
379
+ setSkippedMsg(skipped > 0 ? `${skipped} ya guardado${skipped === 1 ? "" : "s"}, descartado${skipped === 1 ? "" : "s"}.` : null);
380
+ return { added: fresh.length, skipped };
320
381
  };
321
382
 
322
383
  const submit = (e: React.FormEvent) => {
323
384
  e.preventDefault();
324
- if (enqueueUrls(input) > 0) setInput("");
385
+ const { added, skipped } = enqueueUrls(input);
386
+ if (added > 0 || skipped > 0) setInput("");
325
387
  };
326
388
 
327
389
  const pasteFromPf = async () => {
328
390
  setSyncMsg(null);
329
391
  try {
330
392
  const text = await navigator.clipboard.readText();
331
- const n = enqueueUrls(text);
393
+ const { added, skipped } = enqueueUrls(text);
332
394
  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."
395
+ added > 0
396
+ ? `${added} piso${added === 1 ? "" : "s"} nuevo${added === 1 ? "" : "s"} en cola — analizando y añadiendo…${skipped > 0 ? ` (${skipped} ya guardado${skipped === 1 ? "" : "s"})` : ""}`
397
+ : skipped > 0
398
+ ? `Los ${skipped} piso${skipped === 1 ? "" : "s"} del portapapeles ya estaban guardados. Nada nuevo que analizar.`
399
+ : "El portapapeles no tenía enlaces de Property Finder. Ejecuta el marcador en tu página de Guardados primero."
336
400
  );
337
401
  } catch {
338
402
  setSyncMsg("No pude leer el portapapeles. Pega los enlaces manualmente en el cuadro de arriba.");
@@ -435,6 +499,63 @@ export default function FavoritesMapPage() {
435
499
  layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
436
500
  });
437
501
 
502
+ // Liquidity heatmap: DLD transactions per tower (last 12 months) as a heat surface.
503
+ // Rent and sale are separate markets, so only one kind shows at a time.
504
+ const toggleLiquidity = async (kind: "rent" | "buy") => {
505
+ const map = mapRef.current;
506
+ if (!map || !leafletRef.current) return;
507
+ // Clicking the active kind turns it off.
508
+ if (heatKind === kind) {
509
+ if (heatLayerRef.current) map.removeLayer(heatLayerRef.current);
510
+ heatLayerRef.current = null;
511
+ setHeatKind(null);
512
+ return;
513
+ }
514
+ // Switching kinds: drop the current layer first.
515
+ if (heatLayerRef.current) {
516
+ map.removeLayer(heatLayerRef.current);
517
+ heatLayerRef.current = null;
518
+ }
519
+ setHeatLoading(kind);
520
+ setHeatError(null);
521
+ try {
522
+ const res = await fetch(`/api/liquidity?kind=${kind}`);
523
+ const j = await res.json();
524
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
525
+ await import("leaflet.heat").catch(() => {
526
+ throw new Error("Falta la dependencia del mapa de calor. Cierra la app y vuelve a arrancarla para que se instale.");
527
+ }); // patches L.heatLayer
528
+ const max = j.max || 1;
529
+ setHeatMax(j.max ?? null);
530
+ // Each favorite blooms proportionally to its tower's recent activity; a small
531
+ // floor keeps even low-liquidity listings visible as a faint mark.
532
+ const pts = (j.points as { lat: number; lon: number; weight: number }[]).map(
533
+ (p) => [p.lat, p.lon, Math.max(0.15, p.weight / max)] as [number, number, number]
534
+ );
535
+ type HeatFn = (latlngs: [number, number, number][], opts?: Record<string, unknown>) => import("leaflet").Layer;
536
+ const Lmod = leafletRef.current as unknown as { heatLayer?: HeatFn; default?: { heatLayer?: HeatFn } };
537
+ const heatLayer = Lmod.heatLayer ?? Lmod.default?.heatLayer;
538
+ if (!heatLayer) throw new Error("plugin de calor no disponible");
539
+ const { radius, blur } = heatRadiusForZoom(map.getZoom());
540
+ const layer = heatLayer(pts, {
541
+ radius,
542
+ blur,
543
+ max: 1,
544
+ minOpacity: 0.55,
545
+ // Yellow (low) → purple (high).
546
+ gradient: { 0.15: "#fef08a", 0.4: "#fde047", 0.65: "#d946ef", 1.0: "#7c3aed" },
547
+ });
548
+ layer.addTo(map);
549
+ heatLayerRef.current = layer;
550
+ setHeatKind(kind);
551
+ } catch (e) {
552
+ setHeatKind(null);
553
+ setHeatError((e as Error).message || "No se pudo cargar el mapa de calor.");
554
+ } finally {
555
+ setHeatLoading(null);
556
+ }
557
+ };
558
+
438
559
  // --- Resizable divider ---------------------------------------------------
439
560
  const startDrag = (e: React.MouseEvent) => {
440
561
  e.preventDefault();
@@ -628,6 +749,9 @@ export default function FavoritesMapPage() {
628
749
  >
629
750
  {busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
630
751
  </button>
752
+ {skippedMsg && (
753
+ <p className="text-[11px] text-neutral-500 dark:text-neutral-400">{skippedMsg}</p>
754
+ )}
631
755
  <div className="relative">
632
756
  <button
633
757
  type="button"
@@ -635,15 +759,28 @@ export default function FavoritesMapPage() {
635
759
  disabled={(rows?.length ?? 0) === 0}
636
760
  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
761
  >
638
- Puntos cercanos
762
+ Capas del mapa
639
763
  <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
764
  <path d="M6 9l6 6 6-6" />
641
765
  </svg>
642
766
  </button>
643
767
  {showLayers && (
644
768
  <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">
769
+ <p className="px-2 pb-0.5 pt-1 text-[10px] uppercase tracking-wide text-neutral-400">Puntos cercanos</p>
645
770
  <LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
646
771
  <LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
772
+ <div className="my-1 border-t border-neutral-200 dark:border-neutral-800" />
773
+ <p className="px-2 pb-0.5 pt-0.5 text-[10px] uppercase tracking-wide text-neutral-400">Mapa de calor de liquidez media anual</p>
774
+ <LayerToggle label="Alquiler" color="#d946ef" loading={heatLoading === "rent"} visible={heatKind === "rent"} count={null} onToggle={() => toggleLiquidity("rent")} />
775
+ <LayerToggle label="Venta" color="#7c3aed" loading={heatLoading === "buy"} visible={heatKind === "buy"} count={null} onToggle={() => toggleLiquidity("buy")} />
776
+ {heatKind && (
777
+ <p className="px-2 pb-1 pt-0.5 text-[10px] leading-tight text-neutral-500">
778
+ {heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} (DLD) por edificio, media al año. Amarillo = poco líquido, morado = mucha actividad.
779
+ </p>
780
+ )}
781
+ {heatError && (
782
+ <p className="px-2 pb-1 pt-0.5 text-[10px] leading-tight text-red-600 dark:text-red-400">{heatError}</p>
783
+ )}
647
784
  </div>
648
785
  )}
649
786
  </div>
@@ -786,23 +923,29 @@ export default function FavoritesMapPage() {
786
923
  {/* ------------------------------------------------ map */}
787
924
  <div className="relative min-h-0 min-w-0 flex-1">
788
925
  <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>
926
+ {heatKind && (
927
+ <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">
928
+ <div className="btn-font mb-1 text-[9px] text-neutral-500">
929
+ Liquidez media anual · {heatKind === "rent" ? "contratos" : "ventas"}/año
930
+ </div>
931
+ <div
932
+ className="h-2 w-52 rounded-sm"
933
+ style={{ background: "linear-gradient(90deg, #fef08a, #fde047, #d946ef, #7c3aed)" }}
934
+ />
935
+ <div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
936
+ <span>0</span>
937
+ {heatMax != null && <span>{Math.round(heatMax / 2)}</span>}
938
+ {heatMax != null && <span>{Math.round(heatMax)}</span>}
939
+ </div>
940
+ <div className="flex w-52 justify-between text-[9px] text-neutral-500">
941
+ <span>parado</span>
942
+ <span>activo</span>
943
+ </div>
944
+ <div className="mt-0.5 w-52 text-[9px] leading-tight text-neutral-400">
945
+ {heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} en el DLD por edificio, media de operaciones por año.
946
+ </div>
804
947
  </div>
805
- </div>
948
+ )}
806
949
  </div>
807
950
  </div>
808
951
  );
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { use, useEffect, useState } from "react";
3
+ import { use, useEffect, useMemo, useState } from "react";
4
4
  import YieldBadge from "@/components/YieldBadge";
5
5
  import TransactionsTable from "@/components/TransactionsTable";
6
6
  import PsqftChart from "@/components/PsqftChart";
@@ -8,6 +8,7 @@ import ReformScenario from "@/components/ReformScenario";
8
8
  import { fmtAED, fmtDist, fmtPct, fmtSqft } from "@/lib/format";
9
9
  import { AnalysisRow } from "@/lib/types";
10
10
  import { TxRow } from "@/lib/pf/transactions";
11
+ import { discountVsTrend, psqftTrend } from "@/lib/trend";
11
12
 
12
13
  interface Detail {
13
14
  listing: {
@@ -56,6 +57,15 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
56
57
  .catch((e) => setError(e.message));
57
58
  }, [id]);
58
59
 
60
+ // Discount of the asking price vs the sale trend (same trend line as the chart).
61
+ const discount = useMemo(() => {
62
+ if (!data) return null;
63
+ const { listing, transactions } = data;
64
+ const trend = psqftTrend(transactions?.buy ?? [], listing.size_sqft, listing.bedrooms);
65
+ const askingPsqft = listing.size_sqft ? listing.price / listing.size_sqft : null;
66
+ return discountVsTrend(trend, askingPsqft);
67
+ }, [data]);
68
+
59
69
  if (error)
60
70
  return (
61
71
  <div className="mx-auto w-full max-w-7xl px-4 py-6">
@@ -79,10 +89,6 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
79
89
  <h1 className="text-2xl font-bold">{listing.title}</h1>
80
90
  <YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
81
91
  </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
92
  <div className="mt-1 flex items-center gap-4">
87
93
  <a
88
94
  href={listing.url}
@@ -143,41 +149,77 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
143
149
  </div>
144
150
  )}
145
151
 
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>
152
+ {/* -------- Datos básicos (raw property facts) ------------------------ */}
153
+ <section>
154
+ <h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-neutral-500">Datos básicos</h2>
155
+ <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
156
+ <p className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
157
+ <span className="text-3xl font-bold" style={{ color: "var(--accent)" }}>{fmtAED(listing.price)}</span>
158
+ {listing.size_sqft ? (
159
+ <span className="text-sm text-neutral-500">· {Math.round(listing.price / listing.size_sqft)} AED/sqft</span>
160
+ ) : null}
161
+ {discount != null && (
162
+ <span
163
+ className="btn-font ml-1 self-center rounded px-2 py-0.5 text-[11px] font-bold text-white"
164
+ style={{ background: discount >= 0 ? "#059669" : "#dc2626" }}
165
+ title="Precio del anuncio frente a la tendencia de AED/sqft de ventas comparables del edificio"
166
+ >
167
+ {discount >= 0 ? "▼" : "▲"} {Math.abs(discount * 100).toFixed(0)}% {discount >= 0 ? "bajo tendencia" : "sobre tendencia"}
168
+ </span>
169
+ )}
170
+ </p>
171
+ <div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
172
+ <Fact label="Superficie" value={fmtSqft(listing.size_sqft)} />
173
+ <Fact label="Habitaciones" value={listing.bedrooms === 0 ? "Studio" : listing.bedrooms != null ? String(listing.bedrooms) : "—"} />
174
+ <Fact label="Baños" value={listing.bathrooms != null ? String(listing.bathrooms) : "—"} />
175
+ <Fact label="Tipo" value={listing.property_type || "—"} />
176
+ <Fact label="AED/sqft" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)}` : "—"} />
177
+ <Fact label="Edificio" value={listing.tower_name || "—"} />
178
+ </div>
179
+ </div>
180
+ </section>
181
+
182
+ {/* -------- Análisis (computed metrics) ------------------------------- */}
183
+ <section>
184
+ <h2 className="mb-2 flex items-center text-sm font-bold uppercase tracking-wide text-neutral-500">
185
+ Análisis
186
+ <InfoList items={ANALYSIS_HELP} />
187
+ </h2>
188
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
189
+ <Stat
190
+ label="Rentabilidad anual de alquiler"
191
+ value={fmtPct(analysis.gross_yield)}
192
+ sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
193
+ accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
194
+ />
195
+ <Stat
196
+ label="Se alquila por (año, mediana)"
197
+ value={fmtAED(analysis.median_rent)}
198
+ sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
199
+ analysis.rent_p25 != null && analysis.rent_p75 != null
200
+ ? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
201
+ : ""
202
+ }`}
203
+ />
204
+ <Stat
205
+ label="Se vende por (mediana)"
206
+ value={fmtAED(analysis.median_sale_price)}
207
+ sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
208
+ analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
209
+ }`}
210
+ />
211
+ <Stat
212
+ label="Rentabilidad sobre el precio del anuncio"
213
+ value={fmtPct(analysis.asking_yield)}
214
+ sub={`si lo compras por ${fmtAED(listing.price)}`}
215
+ />
216
+ <Stat
217
+ label="Gimnasio más cercano (OSM)"
218
+ value={fmtDist(analysis.gym_distance_m)}
219
+ sub={analysis.gym_name ?? undefined}
220
+ />
221
+ </div>
222
+ </section>
181
223
 
182
224
  {listing.amenities?.length > 0 && (
183
225
  <div className="flex flex-wrap gap-1.5">
@@ -228,6 +270,61 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
228
270
  );
229
271
  }
230
272
 
273
+ const ANALYSIS_HELP: { term: string; desc: string }[] = [
274
+ {
275
+ term: "Rentabilidad anual de alquiler",
276
+ 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.",
277
+ },
278
+ {
279
+ term: "Se alquila por (año, mediana)",
280
+ 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.",
281
+ },
282
+ {
283
+ term: "Se vende por (mediana)",
284
+ 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.",
285
+ },
286
+ {
287
+ term: "Rentabilidad sobre el precio del anuncio",
288
+ 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.",
289
+ },
290
+ {
291
+ term: "Descuento vs tendencia",
292
+ desc: "AED/sqft del anuncio frente a la línea de tendencia de ventas comparables (la misma del gráfico), evaluada en la fecha más reciente. Verde = por debajo de la tendencia (descuento); rojo = por encima (sobreprecio).",
293
+ },
294
+ {
295
+ term: "Gimnasio más cercano",
296
+ desc: "Distancia en línea recta al gimnasio más próximo según OpenStreetMap.",
297
+ },
298
+ ];
299
+
300
+ function Fact({ label, value }: { label: string; value: string }) {
301
+ return (
302
+ <div>
303
+ <div className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">{label}</div>
304
+ <div className="mt-0.5 text-sm font-medium">{value}</div>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function InfoList({ items }: { items: { term: string; desc: string }[] }) {
310
+ return (
311
+ <span className="group relative ml-2 inline-flex align-middle normal-case">
312
+ <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">
313
+ ?
314
+ </span>
315
+ <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">
316
+ <span className="mb-1 block text-[11px] font-bold uppercase tracking-wide text-neutral-500">Cómo leer cada campo</span>
317
+ {items.map((it) => (
318
+ <span key={it.term} className="mb-2 block last:mb-0">
319
+ <span className="block text-[11px] font-semibold text-neutral-700 dark:text-neutral-200">{it.term}</span>
320
+ <span className="block text-[11px] font-normal leading-snug text-neutral-500 dark:text-neutral-400">{it.desc}</span>
321
+ </span>
322
+ ))}
323
+ </span>
324
+ </span>
325
+ );
326
+ }
327
+
231
328
  function Stat({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: "good" | "bad" }) {
232
329
  return (
233
330
  <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,31 @@ export function listFavorites(): FavoriteListing[] {
176
176
  .all() as FavoriteListing[];
177
177
  }
178
178
 
179
+ /**
180
+ * Average annual liquidity per tower: DLD transactions of `kind` PER YEAR, over
181
+ * the full stored history (total count ÷ years spanned by min→max date). Scraping
182
+ * caps each tower at ~100 tx, so the *rate* — not the raw count — is what compares
183
+ * a busy tower to a quiet one: 100 sales across 1 year is far more liquid than 100
184
+ * across 4. Returned as tower_slug → transactions/year.
185
+ */
186
+ const MS_PER_YEAR = 365.25 * 24 * 3600 * 1000;
187
+ export function annualLiquidityByTower(kind: "buy" | "rent"): Map<string, number> {
188
+ const rows = getDb()
189
+ .prepare(
190
+ `SELECT tower_slug, COUNT(*) n, MIN(date) mn, MAX(date) mx
191
+ FROM transactions WHERE kind = ? GROUP BY tower_slug`
192
+ )
193
+ .all(kind) as { tower_slug: string; n: number; mn: string; mx: string }[];
194
+ const m = new Map<string, number>();
195
+ for (const r of rows) {
196
+ const spanY = (Date.parse(r.mx) - Date.parse(r.mn)) / MS_PER_YEAR;
197
+ // Floor the span so a burst of transactions in a short window can't explode the rate.
198
+ const years = Math.max(spanY, 0.5);
199
+ m.set(r.tower_slug, r.n / years);
200
+ }
201
+ return m;
202
+ }
203
+
179
204
  export function listAnalyzed(): AnalyzedListing[] {
180
205
  return getDb()
181
206
  .prepare(
@@ -0,0 +1,76 @@
1
+ import { TxRow } from "./pf/transactions";
2
+
3
+ // Same ±20% "comparable" window the yield sample and the AED/sqft chart use.
4
+ const SIZE_TOLERANCE = 0.2;
5
+ const YEAR_MS = 365.25 * 24 * 3600 * 1000;
6
+
7
+ export interface TrendResult {
8
+ /** Trend value (AED/sqft) at the most recent transaction date — the current fair price. */
9
+ currentPsqft: number;
10
+ /** Annual drift of the trend, as a fraction (e.g. 0.08 = +8 %/yr). */
11
+ annualPct: number | null;
12
+ /** Number of comparable points the fit used. */
13
+ n: number;
14
+ }
15
+
16
+ /**
17
+ * Least-squares trend of AED/sqft over time for a building's transactions,
18
+ * fitted on comparable units (similar size ±20%, bedrooms fallback) — the exact
19
+ * line drawn by PsqftChart. `currentPsqft` is that line evaluated at the latest
20
+ * date, i.e. what a comparable unit is worth per sqft right now.
21
+ */
22
+ export function psqftTrend(
23
+ rows: TxRow[],
24
+ listingSizeSqft: number | null,
25
+ listingBedrooms: number | null
26
+ ): TrendResult | null {
27
+ const pts = rows
28
+ .filter((r) => r.size_sqft && r.size_sqft > 0 && r.amount > 0 && r.date)
29
+ .map((r) => ({
30
+ t: Date.parse(r.date),
31
+ y: r.amount / (r.size_sqft as number),
32
+ comparable:
33
+ listingSizeSqft != null
34
+ ? Math.abs((r.size_sqft as number) - listingSizeSqft) / listingSizeSqft <= SIZE_TOLERANCE
35
+ : listingBedrooms != null && r.bedrooms === listingBedrooms,
36
+ }))
37
+ .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
38
+ .sort((a, b) => a.t - b.t);
39
+ if (!pts.length) return null;
40
+
41
+ const tMin = pts[0].t;
42
+ const tMax = pts[pts.length - 1].t;
43
+ const comp = pts.filter((p) => p.comparable);
44
+ const fitSet = comp.length >= 2 ? comp : pts;
45
+ if (fitSet.length < 2 || tMax <= tMin) return null;
46
+
47
+ const n = fitSet.length;
48
+ const mt = fitSet.reduce((s, p) => s + p.t, 0) / n;
49
+ const my = fitSet.reduce((s, p) => s + p.y, 0) / n;
50
+ let num = 0;
51
+ let den = 0;
52
+ for (const p of fitSet) {
53
+ num += (p.t - mt) * (p.y - my);
54
+ den += (p.t - mt) ** 2;
55
+ }
56
+ const slope = den ? num / den : 0; // AED/sqft per ms
57
+ const intercept = my - slope * mt;
58
+
59
+ return {
60
+ currentPsqft: intercept + slope * tMax,
61
+ annualPct: my ? (slope * YEAR_MS) / my : null,
62
+ n: comp.length >= 2 ? comp.length : pts.length,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Discount of the asking price vs the current trend, as a fraction: positive when
68
+ * the listing is cheaper than the trend (a discount), negative when it's above it.
69
+ */
70
+ export function discountVsTrend(
71
+ trend: TrendResult | null,
72
+ askingPsqft: number | null
73
+ ): number | null {
74
+ if (!trend || askingPsqft == null || trend.currentPsqft <= 0) return null;
75
+ return (trend.currentPsqft - askingPsqft) / trend.currentPsqft;
76
+ }