brickwise 0.1.7 → 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 +3 -1
- package/src/app/api/cafes/route.ts +16 -0
- package/src/app/api/gyms/route.ts +7 -21
- package/src/app/api/liquidity/route.ts +35 -0
- package/src/app/favorites/page.tsx +339 -103
- package/src/app/globals.css +18 -5
- package/src/app/listing/[id]/page.tsx +113 -39
- package/src/components/ReformScenario.tsx +195 -40
- package/src/components/YieldBadge.tsx +6 -10
- package/src/lib/db.ts +8 -0
- package/src/lib/gyms.ts +5 -89
- package/src/lib/pois.ts +188 -0
- package/src/lib/store.ts +18 -0
- package/src/lib/yieldColor.ts +11 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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">
|
|
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|
|
4
4
|
import { fmtAED, fmtPct } from "@/lib/format";
|
|
5
5
|
import { AnalysisRow } from "@/lib/types";
|
|
6
6
|
|
|
7
|
-
const DEFAULT_AED_PER_SQFT = 300;
|
|
8
7
|
const SIZE_TOLERANCE = 0.2;
|
|
9
8
|
|
|
10
9
|
interface Props {
|
|
@@ -15,30 +14,101 @@ interface Props {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Itemized reform model. The rent uplift you can claim depends on the SCOPE of
|
|
18
|
+
* the reform (which items you do), not on the money you type in: each item
|
|
19
|
+
* captures a share ("weight") of the median→P75 spread. Costs only affect the
|
|
20
|
+
* payback. This prevents "cheap reform + full P75 rent" from inflating yields.
|
|
21
|
+
*
|
|
22
|
+
* Default costs are rough Dubai light-reform figures per sqft; edit them freely.
|
|
21
23
|
*/
|
|
24
|
+
const ITEMS = {
|
|
25
|
+
mobiliario: { label: "Mobiliario", defaults: { comprado: 45, medida: 100 }, weights: { comprado: 0.4, medida: 0.5 } },
|
|
26
|
+
suelo: { label: "Suelo", perSqft: 55, weight: 0.25 },
|
|
27
|
+
paredes: { label: "Paredes (pintura y arreglos)", perSqft: 12, weight: 0.2 },
|
|
28
|
+
techos: { label: "Techos (falso techo e iluminación)", perSqft: 20, weight: 0.1 },
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
type FurnitureType = "comprado" | "medida";
|
|
32
|
+
|
|
33
|
+
interface ReformState {
|
|
34
|
+
mobiliario: { on: boolean; tipo: FurnitureType; cost: number };
|
|
35
|
+
suelo: { on: boolean; cost: number };
|
|
36
|
+
paredes: { on: boolean; cost: number };
|
|
37
|
+
techos: { on: boolean; cost: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultState(sizeSqft: number | null): ReformState {
|
|
41
|
+
const s = sizeSqft ?? 500;
|
|
42
|
+
return {
|
|
43
|
+
mobiliario: { on: true, tipo: "comprado", cost: Math.round(s * ITEMS.mobiliario.defaults.comprado) },
|
|
44
|
+
suelo: { on: true, cost: Math.round(s * ITEMS.suelo.perSqft) },
|
|
45
|
+
paredes: { on: true, cost: Math.round(s * ITEMS.paredes.perSqft) },
|
|
46
|
+
techos: { on: false, cost: Math.round(s * ITEMS.techos.perSqft) },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const REFORM_HELP =
|
|
51
|
+
"Comparamos los alquileres de pisos de TAMAÑO SIMILAR (±20%) del mismo edificio (24 meses). " +
|
|
52
|
+
"P25 = piso sin reformar; P75 = bien reformado; mediana = el típico. La reforma solo puede capturar " +
|
|
53
|
+
"el margen mediana→P75, y CADA PARTIDA captura una parte: mobiliario 40% (50% si es a medida), " +
|
|
54
|
+
"suelo 25%, paredes 20%, techos 10%. Marcar o desmarcar partidas cambia la renta esperada; " +
|
|
55
|
+
"el coste que escribas solo cambia el payback — así una reforma barata no infla la rentabilidad.";
|
|
56
|
+
|
|
57
|
+
/** Small "?" badge with a hover tooltip. */
|
|
58
|
+
function InfoTip({ text }: { text: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<span className="group relative ml-1 inline-flex align-middle normal-case">
|
|
61
|
+
<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">
|
|
62
|
+
?
|
|
63
|
+
</span>
|
|
64
|
+
<span className="pointer-events-none absolute left-0 top-5 z-30 w-72 rounded-md border border-neutral-200 bg-white p-2.5 text-[11px] font-normal leading-snug text-neutral-600 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
|
65
|
+
{text}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
export default function ReformScenario({ listingId, price, sizeSqft, analysis }: Props) {
|
|
23
|
-
const [
|
|
24
|
-
const fallback =
|
|
72
|
+
const [state, setState] = useState<ReformState>(() => {
|
|
73
|
+
const fallback = defaultState(sizeSqft);
|
|
25
74
|
if (typeof window === "undefined") return fallback;
|
|
26
|
-
|
|
27
|
-
|
|
75
|
+
try {
|
|
76
|
+
const saved = JSON.parse(localStorage.getItem(`bw-reform-${listingId}`) ?? "");
|
|
77
|
+
// Older versions stored a plain number — ignore those.
|
|
78
|
+
if (saved && typeof saved === "object" && saved.mobiliario) return saved as ReformState;
|
|
79
|
+
} catch {}
|
|
80
|
+
return fallback;
|
|
28
81
|
});
|
|
29
82
|
|
|
30
83
|
const { rent_p25, rent_p75, median_rent, rent_n, asking_yield } = analysis;
|
|
31
84
|
if (rent_p25 == null || rent_p75 == null || median_rent == null || rent_n < 3) return null;
|
|
32
85
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
86
|
+
const update = (next: ReformState) => {
|
|
87
|
+
setState(next);
|
|
88
|
+
localStorage.setItem(`bw-reform-${listingId}`, JSON.stringify(next));
|
|
36
89
|
};
|
|
37
90
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
91
|
+
const uplift = rent_p75 - median_rent; // full spread captured by a complete reform
|
|
92
|
+
const weightOf = {
|
|
93
|
+
mobiliario: state.mobiliario.on ? ITEMS.mobiliario.weights[state.mobiliario.tipo] : 0,
|
|
94
|
+
suelo: state.suelo.on ? ITEMS.suelo.weight : 0,
|
|
95
|
+
paredes: state.paredes.on ? ITEMS.paredes.weight : 0,
|
|
96
|
+
techos: state.techos.on ? ITEMS.techos.weight : 0,
|
|
97
|
+
};
|
|
98
|
+
const scope = Math.min(1, weightOf.mobiliario + weightOf.suelo + weightOf.paredes + weightOf.techos);
|
|
99
|
+
const totalCost =
|
|
100
|
+
(state.mobiliario.on ? state.mobiliario.cost : 0) +
|
|
101
|
+
(state.suelo.on ? state.suelo.cost : 0) +
|
|
102
|
+
(state.paredes.on ? state.paredes.cost : 0) +
|
|
103
|
+
(state.techos.on ? state.techos.cost : 0);
|
|
104
|
+
|
|
105
|
+
const expectedRent = median_rent + uplift * scope;
|
|
106
|
+
const capturedUplift = expectedRent - median_rent;
|
|
107
|
+
const postReformYield = expectedRent / (price + totalCost);
|
|
108
|
+
const paybackYears = capturedUplift > 0 && totalCost > 0 ? totalCost / capturedUplift : null;
|
|
41
109
|
const spread = (rent_p75 - rent_p25) / median_rent;
|
|
110
|
+
const medianPct =
|
|
111
|
+
rent_p75 > rent_p25 ? Math.min(96, Math.max(4, ((median_rent - rent_p25) / (rent_p75 - rent_p25)) * 100)) : 50;
|
|
42
112
|
|
|
43
113
|
const spreadReading =
|
|
44
114
|
spread >= 0.15
|
|
@@ -51,16 +121,48 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
51
121
|
? `${Math.round(sizeSqft * (1 - SIZE_TOLERANCE))}–${Math.round(sizeSqft * (1 + SIZE_TOLERANCE))} sqft`
|
|
52
122
|
: null;
|
|
53
123
|
|
|
124
|
+
const rowCls = "flex items-center gap-2 py-1";
|
|
125
|
+
const inputCls =
|
|
126
|
+
"w-24 rounded border border-neutral-300 bg-white px-1.5 py-0.5 text-right text-xs outline-none focus:border-blue-500 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950";
|
|
127
|
+
|
|
128
|
+
const itemRow = (
|
|
129
|
+
key: "suelo" | "paredes" | "techos",
|
|
130
|
+
label: string,
|
|
131
|
+
weight: number
|
|
132
|
+
) => (
|
|
133
|
+
<label className={rowCls}>
|
|
134
|
+
<input
|
|
135
|
+
type="checkbox"
|
|
136
|
+
checked={state[key].on}
|
|
137
|
+
onChange={(e) => update({ ...state, [key]: { ...state[key], on: e.target.checked } })}
|
|
138
|
+
className="h-3.5 w-3.5 accent-blue-600"
|
|
139
|
+
/>
|
|
140
|
+
<span className="flex-1 text-xs">{label}</span>
|
|
141
|
+
<span className="text-[10px] text-neutral-400">+{Math.round(weight * 100)}%</span>
|
|
142
|
+
<input
|
|
143
|
+
type="number"
|
|
144
|
+
min={0}
|
|
145
|
+
step={1000}
|
|
146
|
+
disabled={!state[key].on}
|
|
147
|
+
value={state[key].cost}
|
|
148
|
+
suppressHydrationWarning
|
|
149
|
+
onChange={(e) => update({ ...state, [key]: { ...state[key], cost: Number(e.target.value) } })}
|
|
150
|
+
className={inputCls}
|
|
151
|
+
/>
|
|
152
|
+
</label>
|
|
153
|
+
);
|
|
154
|
+
|
|
54
155
|
return (
|
|
55
156
|
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
56
157
|
<h3 className="text-sm font-bold">
|
|
57
|
-
Escenario de reforma
|
|
158
|
+
Escenario de reforma
|
|
159
|
+
<InfoTip text={REFORM_HELP} />{" "}
|
|
58
160
|
<span className="font-normal text-neutral-500">
|
|
59
161
|
({rent_n} alquileres comparables{band ? ` · ${band}` : ""})
|
|
60
162
|
</span>
|
|
61
163
|
</h3>
|
|
62
164
|
|
|
63
|
-
<div className="mt-3 grid gap-4 lg:grid-cols-[
|
|
165
|
+
<div className="mt-3 grid gap-4 lg:grid-cols-[1fr_auto_1.15fr]">
|
|
64
166
|
{/* Rent range of comparables */}
|
|
65
167
|
<div>
|
|
66
168
|
<div className="mb-1 flex justify-between text-[11px] text-neutral-500">
|
|
@@ -68,20 +170,32 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
68
170
|
<span>mediana</span>
|
|
69
171
|
<span>P75 (reformado)</span>
|
|
70
172
|
</div>
|
|
71
|
-
<div className="relative h-2 rounded-full bg-
|
|
173
|
+
<div className="relative h-2.5 rounded-full bg-neutral-200 dark:bg-neutral-800">
|
|
72
174
|
{rent_p75 > rent_p25 && (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
<>
|
|
176
|
+
{/* upside captured by the CURRENT scope */}
|
|
177
|
+
<div
|
|
178
|
+
className="absolute inset-y-0 rounded-r-full bg-emerald-400 dark:bg-emerald-600"
|
|
179
|
+
style={{ left: `${medianPct}%`, width: `${(100 - medianPct) * scope}%` }}
|
|
180
|
+
title={`Margen capturado por esta reforma (${Math.round(scope * 100)}%)`}
|
|
181
|
+
/>
|
|
182
|
+
<span
|
|
183
|
+
className="absolute -top-[3px] h-[17px] w-[3px] -translate-x-1/2 rounded bg-neutral-900 dark:bg-white"
|
|
184
|
+
style={{ left: `${medianPct}%` }}
|
|
185
|
+
title={`mediana ${fmtAED(median_rent)}`}
|
|
186
|
+
/>
|
|
187
|
+
</>
|
|
78
188
|
)}
|
|
79
189
|
</div>
|
|
80
|
-
<div className="mt-1 flex justify-between text-xs font-semibold">
|
|
190
|
+
<div className="mt-1.5 flex justify-between text-xs font-semibold">
|
|
81
191
|
<span>{fmtAED(rent_p25)}</span>
|
|
82
192
|
<span className="text-neutral-500">{fmtAED(median_rent)}</span>
|
|
83
193
|
<span>{fmtAED(rent_p75)}</span>
|
|
84
194
|
</div>
|
|
195
|
+
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-neutral-500">
|
|
196
|
+
<span className="inline-block h-2 w-3 rounded-sm bg-emerald-400 dark:bg-emerald-600" />
|
|
197
|
+
margen capturado por esta reforma ({Math.round(scope * 100)}% del total)
|
|
198
|
+
</div>
|
|
85
199
|
<p className={`mt-2 text-xs ${spreadReading.cls}`}>
|
|
86
200
|
Dispersión {fmtPct(spread, 0)} de la mediana — {spreadReading.txt}
|
|
87
201
|
</p>
|
|
@@ -89,30 +203,71 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
89
203
|
|
|
90
204
|
<div className="hidden w-px bg-neutral-200 lg:block dark:bg-neutral-800" />
|
|
91
205
|
|
|
92
|
-
{/*
|
|
206
|
+
{/* Itemized reform */}
|
|
93
207
|
<div className="text-sm">
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
208
|
+
<div className="mb-1 flex items-center justify-between">
|
|
209
|
+
<span className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">Partidas de la reforma</span>
|
|
210
|
+
<span className="text-[10px] text-neutral-400">% del margen · coste AED</span>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Mobiliario: checkbox + type selector */}
|
|
214
|
+
<label className={rowCls}>
|
|
215
|
+
<input
|
|
216
|
+
type="checkbox"
|
|
217
|
+
checked={state.mobiliario.on}
|
|
218
|
+
onChange={(e) => update({ ...state, mobiliario: { ...state.mobiliario, on: e.target.checked } })}
|
|
219
|
+
className="h-3.5 w-3.5 accent-blue-600"
|
|
220
|
+
/>
|
|
221
|
+
<span className="text-xs">Mobiliario</span>
|
|
222
|
+
<select
|
|
223
|
+
disabled={!state.mobiliario.on}
|
|
224
|
+
value={state.mobiliario.tipo}
|
|
225
|
+
onChange={(e) => {
|
|
226
|
+
const tipo = e.target.value as FurnitureType;
|
|
227
|
+
const cost = sizeSqft ? Math.round(sizeSqft * ITEMS.mobiliario.defaults[tipo]) : state.mobiliario.cost;
|
|
228
|
+
update({ ...state, mobiliario: { ...state.mobiliario, tipo, cost } });
|
|
229
|
+
}}
|
|
230
|
+
className="rounded border border-neutral-300 bg-white px-1 py-0.5 text-[11px] disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950"
|
|
231
|
+
>
|
|
232
|
+
<option value="comprado">comprado</option>
|
|
233
|
+
<option value="medida">a medida</option>
|
|
234
|
+
</select>
|
|
235
|
+
<span className="flex-1" />
|
|
236
|
+
<span className="text-[10px] text-neutral-400">
|
|
237
|
+
+{Math.round(ITEMS.mobiliario.weights[state.mobiliario.tipo] * 100)}%
|
|
238
|
+
</span>
|
|
239
|
+
<input
|
|
240
|
+
type="number"
|
|
241
|
+
min={0}
|
|
242
|
+
step={1000}
|
|
243
|
+
disabled={!state.mobiliario.on}
|
|
244
|
+
value={state.mobiliario.cost}
|
|
245
|
+
suppressHydrationWarning
|
|
246
|
+
onChange={(e) => update({ ...state, mobiliario: { ...state.mobiliario, cost: Number(e.target.value) } })}
|
|
247
|
+
className={inputCls}
|
|
248
|
+
/>
|
|
97
249
|
</label>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
250
|
+
|
|
251
|
+
{itemRow("suelo", "Suelo", ITEMS.suelo.weight)}
|
|
252
|
+
{itemRow("paredes", "Paredes (pintura y arreglos)", ITEMS.paredes.weight)}
|
|
253
|
+
{itemRow("techos", "Techos (falso techo, iluminación)", ITEMS.techos.weight)}
|
|
254
|
+
|
|
255
|
+
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1.5 border-t border-neutral-200 pt-2 text-xs dark:border-neutral-800">
|
|
256
|
+
<span className="text-neutral-500">Coste total</span>
|
|
257
|
+
<b>{fmtAED(totalCost)}</b>
|
|
258
|
+
<span className="text-neutral-500">Renta esperada post-reforma</span>
|
|
259
|
+
<b>
|
|
260
|
+
{fmtAED(Math.round(expectedRent))}
|
|
261
|
+
<span className="ml-1 font-normal text-neutral-400">({Math.round(scope * 100)}% del margen)</span>
|
|
262
|
+
</b>
|
|
108
263
|
<span className="text-neutral-500">Yield actual (mediana / precio)</span>
|
|
109
264
|
<b>{fmtPct(asking_yield)}</b>
|
|
110
|
-
<span className="text-neutral-500">Yield post-reforma
|
|
265
|
+
<span className="text-neutral-500">Yield post-reforma</span>
|
|
111
266
|
<b className={postReformYield > (asking_yield ?? 0) ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
|
112
267
|
{fmtPct(postReformYield)}
|
|
113
268
|
</b>
|
|
114
|
-
<span className="text-neutral-500">Renta extra al año
|
|
115
|
-
<b>{fmtAED(
|
|
269
|
+
<span className="text-neutral-500">Renta extra al año</span>
|
|
270
|
+
<b>{fmtAED(Math.round(capturedUplift))}</b>
|
|
116
271
|
<span className="text-neutral-500">Payback de la reforma</span>
|
|
117
272
|
<b>{paybackYears != null ? `${paybackYears.toFixed(1)} años` : "—"}</b>
|
|
118
273
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fmtPct } from "@/lib/format";
|
|
2
|
+
import { yieldColor } from "@/lib/yieldColor";
|
|
2
3
|
|
|
3
4
|
export default function YieldBadge({ value, n }: { value: number | null; n?: number }) {
|
|
4
5
|
if (value == null)
|
|
@@ -7,18 +8,13 @@ export default function YieldBadge({ value, n }: { value: number | null; n?: num
|
|
|
7
8
|
sin datos
|
|
8
9
|
</span>
|
|
9
10
|
);
|
|
10
|
-
const color =
|
|
11
|
-
value >= 0.07
|
|
12
|
-
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
|
13
|
-
: value >= 0.055
|
|
14
|
-
? "bg-lime-100 text-lime-800 dark:bg-lime-950 dark:text-lime-300"
|
|
15
|
-
: value >= 0.04
|
|
16
|
-
? "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
|
17
|
-
: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
|
|
18
11
|
return (
|
|
19
|
-
<span
|
|
12
|
+
<span
|
|
13
|
+
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
14
|
+
style={{ background: yieldColor(value) }}
|
|
15
|
+
>
|
|
20
16
|
{fmtPct(value)}
|
|
21
|
-
{n != null && <span className="font-normal opacity-
|
|
17
|
+
{n != null && <span className="font-normal opacity-80">({n} tx)</span>}
|
|
22
18
|
</span>
|
|
23
19
|
);
|
|
24
20
|
}
|
package/src/lib/db.ts
CHANGED
|
@@ -100,6 +100,14 @@ CREATE TABLE IF NOT EXISTS gyms_list_cache (
|
|
|
100
100
|
fetched_at TEXT NOT NULL
|
|
101
101
|
);
|
|
102
102
|
|
|
103
|
+
CREATE TABLE IF NOT EXISTS poi_list_cache (
|
|
104
|
+
geo_key TEXT NOT NULL,
|
|
105
|
+
kind TEXT NOT NULL,
|
|
106
|
+
json TEXT NOT NULL,
|
|
107
|
+
fetched_at TEXT NOT NULL,
|
|
108
|
+
PRIMARY KEY (geo_key, kind)
|
|
109
|
+
);
|
|
110
|
+
|
|
103
111
|
CREATE TABLE IF NOT EXISTS favorites (
|
|
104
112
|
listing_id TEXT PRIMARY KEY REFERENCES listings(id),
|
|
105
113
|
notes TEXT NOT NULL DEFAULT '',
|
package/src/lib/gyms.ts
CHANGED
|
@@ -1,104 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
import { getDb, now } from "./db";
|
|
1
|
+
import { poisNear, Poi } from "./pois";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
const OVERPASS_ENDPOINTS = [
|
|
6
|
-
"https://overpass-api.de/api/interpreter",
|
|
7
|
-
"https://overpass-api.de/api/interpreter",
|
|
8
|
-
"https://overpass.private.coffee/api/interpreter",
|
|
9
|
-
"https://overpass.kumi.systems/api/interpreter",
|
|
10
|
-
];
|
|
11
|
-
const RADIUS_M = 2000;
|
|
12
|
-
const TTL_MS = 30 * 24 * 3600 * 1000;
|
|
13
|
-
|
|
14
|
-
export interface Gym {
|
|
15
|
-
name: string;
|
|
16
|
-
lat: number;
|
|
17
|
-
lon: number;
|
|
18
|
-
distance_m: number;
|
|
19
|
-
}
|
|
3
|
+
export type Gym = Poi;
|
|
20
4
|
|
|
21
5
|
export interface GymResult {
|
|
22
6
|
gym_name: string | null;
|
|
23
7
|
distance_m: number | null;
|
|
24
8
|
}
|
|
25
9
|
|
|
26
|
-
|
|
27
|
-
const R = 6371000;
|
|
28
|
-
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
29
|
-
const dLat = toRad(lat2 - lat1);
|
|
30
|
-
const dLon = toRad(lon2 - lon1);
|
|
31
|
-
const a =
|
|
32
|
-
Math.sin(dLat / 2) ** 2 +
|
|
33
|
-
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
34
|
-
return 2 * R * Math.asin(Math.sqrt(a));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Overpass allows very few concurrent requests per IP — serialize ours.
|
|
38
|
-
let queue: Promise<unknown> = Promise.resolve();
|
|
39
|
-
|
|
40
|
-
/** All gyms (OSM fitness centres) within 2 km, nearest first; cached by ~100 m grid cell. */
|
|
10
|
+
/** All gyms (OSM fitness centres) within 2 km, nearest first; cached. */
|
|
41
11
|
export function gymsNear(lat: number, lon: number): Promise<Gym[] | null> {
|
|
42
|
-
|
|
43
|
-
queue = task.catch(() => null);
|
|
44
|
-
return task;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** null = Overpass unavailable (not cached); [] = confirmed no gyms nearby. */
|
|
48
|
-
async function doGymsNear(lat: number, lon: number): Promise<Gym[] | null> {
|
|
49
|
-
const db = getDb();
|
|
50
|
-
const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
|
51
|
-
const cached = db.prepare(`SELECT * FROM gyms_list_cache WHERE geo_key = ?`).get(geoKey) as any;
|
|
52
|
-
if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
|
|
53
|
-
return JSON.parse(cached.gyms_json);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const query = `[out:json][timeout:15];(node["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon});way["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon}););out center 60;`;
|
|
57
|
-
for (const endpoint of OVERPASS_ENDPOINTS) {
|
|
58
|
-
try {
|
|
59
|
-
const res = await fetch(endpoint, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
body: "data=" + encodeURIComponent(query),
|
|
62
|
-
headers: {
|
|
63
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
64
|
-
// overpass-api.de answers 406 to requests without a User-Agent
|
|
65
|
-
"User-Agent": "brickwise-local/0.1 (personal use)",
|
|
66
|
-
},
|
|
67
|
-
signal: AbortSignal.timeout(20000),
|
|
68
|
-
});
|
|
69
|
-
if (!res.ok) continue;
|
|
70
|
-
const j = await res.json();
|
|
71
|
-
// Overloaded instances answer 200 + empty elements + a "remark" runtime error.
|
|
72
|
-
if (j.remark && !(j.elements || []).length) continue;
|
|
73
|
-
const gyms: Gym[] = [];
|
|
74
|
-
for (const el of j.elements || []) {
|
|
75
|
-
const eLat = el.lat ?? el.center?.lat;
|
|
76
|
-
const eLon = el.lon ?? el.center?.lon;
|
|
77
|
-
if (eLat == null || eLon == null) continue;
|
|
78
|
-
gyms.push({
|
|
79
|
-
name: el.tags?.name || "Gym",
|
|
80
|
-
lat: eLat,
|
|
81
|
-
lon: eLon,
|
|
82
|
-
distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
gyms.sort((a, b) => a.distance_m - b.distance_m);
|
|
86
|
-
db.prepare(
|
|
87
|
-
`INSERT OR REPLACE INTO gyms_list_cache (geo_key, gyms_json, fetched_at) VALUES (?, ?, ?)`
|
|
88
|
-
).run(geoKey, JSON.stringify(gyms), now());
|
|
89
|
-
return gyms;
|
|
90
|
-
} catch {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return null; // all endpoints down: unknown, not cached
|
|
12
|
+
return poisNear(lat, lon, "gym");
|
|
95
13
|
}
|
|
96
14
|
|
|
97
15
|
/** Nearest gym within 2 km (derived from the cached list). */
|
|
98
16
|
export async function nearestGym(lat: number, lon: number): Promise<GymResult> {
|
|
99
17
|
const gyms = await gymsNear(lat, lon);
|
|
100
18
|
const best = gyms?.[0];
|
|
101
|
-
return best
|
|
102
|
-
? { gym_name: best.name, distance_m: best.distance_m }
|
|
103
|
-
: { gym_name: null, distance_m: null };
|
|
19
|
+
return best ? { gym_name: best.name, distance_m: best.distance_m } : { gym_name: null, distance_m: null };
|
|
104
20
|
}
|