brickwise 0.1.21 → 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.21",
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",
@@ -1,22 +1,17 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { listFavorites, txCountByTowerSince } from "@/lib/store";
3
-
4
- const MONTHS = 12;
2
+ import { annualLiquidityByTower, listFavorites } from "@/lib/store";
5
3
 
6
4
  /**
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).
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.
11
9
  */
12
10
  export async function GET(req: NextRequest) {
13
11
  try {
14
12
  const kindParam = req.nextUrl.searchParams.get("kind");
15
13
  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);
14
+ const rates = annualLiquidityByTower(kind);
20
15
 
21
16
  const points = listFavorites()
22
17
  .filter((f) => f.lat != null && f.lon != null)
@@ -24,11 +19,11 @@ export async function GET(req: NextRequest) {
24
19
  id: f.id,
25
20
  lat: f.lat as number,
26
21
  lon: f.lon as number,
27
- weight: f.tower_slug ? counts.get(f.tower_slug) ?? 0 : 0,
22
+ weight: f.tower_slug ? rates.get(f.tower_slug) ?? 0 : 0,
28
23
  }));
29
24
 
30
25
  const max = points.reduce((m, p) => Math.max(m, p.weight), 0);
31
- return NextResponse.json({ points, max, months: MONTHS, kind });
26
+ return NextResponse.json({ points, max, kind, unit: "year" });
32
27
  } catch (e) {
33
28
  return NextResponse.json({ error: (e as Error).message }, { status: 500 });
34
29
  }
@@ -154,6 +154,7 @@ export default function FavoritesMapPage() {
154
154
  const [heatKind, setHeatKind] = useState<"rent" | "buy" | null>(null);
155
155
  const [heatLoading, setHeatLoading] = useState<"rent" | "buy" | null>(null);
156
156
  const [heatMax, setHeatMax] = useState<number | null>(null);
157
+ const [heatError, setHeatError] = useState<string | null>(null);
157
158
  const [showLayers, setShowLayers] = useState(false);
158
159
  const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
159
160
  const [bedFilter, setBedFilter] = useState<number[]>([]);
@@ -201,6 +202,9 @@ export default function FavoritesMapPage() {
201
202
  zoomControl: true,
202
203
  scrollWheelZoom: false, // replaced by the trackpad handler below
203
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,
204
208
  });
205
209
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
206
210
  maxZoom: 19,
@@ -513,11 +517,14 @@ export default function FavoritesMapPage() {
513
517
  heatLayerRef.current = null;
514
518
  }
515
519
  setHeatLoading(kind);
520
+ setHeatError(null);
516
521
  try {
517
522
  const res = await fetch(`/api/liquidity?kind=${kind}`);
518
523
  const j = await res.json();
519
524
  if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
520
- await import("leaflet.heat"); // patches L.heatLayer
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
521
528
  const max = j.max || 1;
522
529
  setHeatMax(j.max ?? null);
523
530
  // Each favorite blooms proportionally to its tower's recent activity; a small
@@ -541,8 +548,9 @@ export default function FavoritesMapPage() {
541
548
  layer.addTo(map);
542
549
  heatLayerRef.current = layer;
543
550
  setHeatKind(kind);
544
- } catch {
551
+ } catch (e) {
545
552
  setHeatKind(null);
553
+ setHeatError((e as Error).message || "No se pudo cargar el mapa de calor.");
546
554
  } finally {
547
555
  setHeatLoading(null);
548
556
  }
@@ -762,14 +770,17 @@ export default function FavoritesMapPage() {
762
770
  <LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
763
771
  <LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
764
772
  <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>
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>
766
774
  <LayerToggle label="Alquiler" color="#d946ef" loading={heatLoading === "rent"} visible={heatKind === "rent"} count={null} onToggle={() => toggleLiquidity("rent")} />
767
775
  <LayerToggle label="Venta" color="#7c3aed" loading={heatLoading === "buy"} visible={heatKind === "buy"} count={null} onToggle={() => toggleLiquidity("buy")} />
768
776
  {heatKind && (
769
777
  <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.
778
+ {heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} (DLD) por edificio, media al año. Amarillo = poco líquido, morado = mucha actividad.
771
779
  </p>
772
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
+ )}
773
784
  </div>
774
785
  )}
775
786
  </div>
@@ -915,7 +926,7 @@ export default function FavoritesMapPage() {
915
926
  {heatKind && (
916
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">
917
928
  <div className="btn-font mb-1 text-[9px] text-neutral-500">
918
- Liquidez {heatKind === "rent" ? "alquiler" : "venta"} · {heatKind === "rent" ? "contratos" : "ventas"}/mes
929
+ Liquidez media anual · {heatKind === "rent" ? "contratos" : "ventas"}/año
919
930
  </div>
920
931
  <div
921
932
  className="h-2 w-52 rounded-sm"
@@ -923,15 +934,15 @@ export default function FavoritesMapPage() {
923
934
  />
924
935
  <div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
925
936
  <span>0</span>
926
- {heatMax != null && <span>{(heatMax / 24).toFixed(1)}</span>}
927
- {heatMax != null && <span>{(heatMax / 12).toFixed(1)}</span>}
937
+ {heatMax != null && <span>{Math.round(heatMax / 2)}</span>}
938
+ {heatMax != null && <span>{Math.round(heatMax)}</span>}
928
939
  </div>
929
940
  <div className="flex w-52 justify-between text-[9px] text-neutral-500">
930
941
  <span>parado</span>
931
942
  <span>activo</span>
932
943
  </div>
933
944
  <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.
945
+ {heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} en el DLD por edificio, media de operaciones por año.
935
946
  </div>
936
947
  </div>
937
948
  )}
@@ -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">
@@ -143,11 +153,20 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
143
153
  <section>
144
154
  <h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-neutral-500">Datos básicos</h2>
145
155
  <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">
156
+ <p className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
147
157
  <span className="text-3xl font-bold" style={{ color: "var(--accent)" }}>{fmtAED(listing.price)}</span>
148
158
  {listing.size_sqft ? (
149
159
  <span className="text-sm text-neutral-500">· {Math.round(listing.price / listing.size_sqft)} AED/sqft</span>
150
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
+ )}
151
170
  </p>
152
171
  <div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
153
172
  <Fact label="Superficie" value={fmtSqft(listing.size_sqft)} />
@@ -268,6 +287,10 @@ const ANALYSIS_HELP: { term: string; desc: string }[] = [
268
287
  term: "Rentabilidad sobre el precio del anuncio",
269
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.",
270
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
+ },
271
294
  {
272
295
  term: "Gimnasio más cercano",
273
296
  desc: "Distancia en línea recta al gimnasio más próximo según OpenStreetMap.",
package/src/lib/store.ts CHANGED
@@ -177,20 +177,27 @@ export function listFavorites(): FavoriteListing[] {
177
177
  }
178
178
 
179
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.
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.
184
185
  */
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 }[];
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 }[];
192
194
  const m = new Map<string, number>();
193
- for (const r of rows) m.set(r.tower_slug, r.n);
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
+ }
194
201
  return m;
195
202
  }
196
203
 
@@ -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
+ }