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 +31 -2
- package/package.json +3 -1
- package/src/app/api/liquidity/route.ts +30 -0
- package/src/app/favorites/page.tsx +169 -26
- package/src/app/listing/[id]/page.tsx +137 -40
- package/src/lib/store.ts +25 -0
- package/src/lib/trend.ts +76 -0
package/bin/brickwise.js
CHANGED
|
@@ -74,9 +74,38 @@ function openBrowser(url) {
|
|
|
74
74
|
syncSources();
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
377
|
+
setQueue((prev) => [...prev, ...fresh]);
|
|
318
378
|
}
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
393
|
+
const { added, skipped } = enqueueUrls(text);
|
|
332
394
|
setSyncMsg(
|
|
333
|
-
|
|
334
|
-
? `${
|
|
335
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
790
|
-
<div className="
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
<
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
package/src/lib/trend.ts
ADDED
|
@@ -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
|
+
}
|