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.
@@ -79,10 +79,6 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
79
79
  <h1 className="text-2xl font-bold">{listing.title}</h1>
80
80
  <YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
81
81
  </div>
82
- <p className="mt-1 text-sm text-neutral-500">
83
- {listing.tower_name} · {listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} ·{" "}
84
- {listing.bathrooms ?? "?"} baños · {fmtSqft(listing.size_sqft)} · {listing.property_type}
85
- </p>
86
82
  <div className="mt-1 flex items-center gap-4">
87
83
  <a
88
84
  href={listing.url}
@@ -143,41 +139,68 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
143
139
  </div>
144
140
  )}
145
141
 
146
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
147
- <Stat
148
- label="Rentabilidad anual de alquiler"
149
- value={fmtPct(analysis.gross_yield)}
150
- sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
151
- accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
152
- />
153
- <Stat
154
- label="Se alquila por (año, mediana)"
155
- value={fmtAED(analysis.median_rent)}
156
- sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
157
- analysis.rent_p25 != null && analysis.rent_p75 != null
158
- ? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
159
- : ""
160
- }`}
161
- />
162
- <Stat
163
- label="Se vende por (mediana)"
164
- value={fmtAED(analysis.median_sale_price)}
165
- sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
166
- analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
167
- }`}
168
- />
169
- <Stat
170
- label="Rentabilidad sobre el precio del anuncio"
171
- value={fmtPct(analysis.asking_yield)}
172
- sub={`si lo compras por ${fmtAED(listing.price)}`}
173
- />
174
- <Stat
175
- label="Gimnasio más cercano (OSM)"
176
- value={fmtDist(analysis.gym_distance_m)}
177
- sub={analysis.gym_name ?? undefined}
178
- />
179
- <Stat label="AED/sqft del anuncio" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)} AED/sqft` : "—"} />
180
- </div>
142
+ {/* -------- Datos básicos (raw property facts) ------------------------ */}
143
+ <section>
144
+ <h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-neutral-500">Datos básicos</h2>
145
+ <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
146
+ <p className="flex items-baseline gap-2">
147
+ <span className="text-3xl font-bold" style={{ color: "var(--accent)" }}>{fmtAED(listing.price)}</span>
148
+ {listing.size_sqft ? (
149
+ <span className="text-sm text-neutral-500">· {Math.round(listing.price / listing.size_sqft)} AED/sqft</span>
150
+ ) : null}
151
+ </p>
152
+ <div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
153
+ <Fact label="Superficie" value={fmtSqft(listing.size_sqft)} />
154
+ <Fact label="Habitaciones" value={listing.bedrooms === 0 ? "Studio" : listing.bedrooms != null ? String(listing.bedrooms) : "—"} />
155
+ <Fact label="Baños" value={listing.bathrooms != null ? String(listing.bathrooms) : ""} />
156
+ <Fact label="Tipo" value={listing.property_type || "—"} />
157
+ <Fact label="AED/sqft" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)}` : "—"} />
158
+ <Fact label="Edificio" value={listing.tower_name || "—"} />
159
+ </div>
160
+ </div>
161
+ </section>
162
+
163
+ {/* -------- Análisis (computed metrics) ------------------------------- */}
164
+ <section>
165
+ <h2 className="mb-2 flex items-center text-sm font-bold uppercase tracking-wide text-neutral-500">
166
+ Análisis
167
+ <InfoList items={ANALYSIS_HELP} />
168
+ </h2>
169
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
170
+ <Stat
171
+ label="Rentabilidad anual de alquiler"
172
+ value={fmtPct(analysis.gross_yield)}
173
+ sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
174
+ accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
175
+ />
176
+ <Stat
177
+ label="Se alquila por (año, mediana)"
178
+ value={fmtAED(analysis.median_rent)}
179
+ sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
180
+ analysis.rent_p25 != null && analysis.rent_p75 != null
181
+ ? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
182
+ : ""
183
+ }`}
184
+ />
185
+ <Stat
186
+ label="Se vende por (mediana)"
187
+ value={fmtAED(analysis.median_sale_price)}
188
+ sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
189
+ analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
190
+ }`}
191
+ />
192
+ <Stat
193
+ label="Rentabilidad sobre el precio del anuncio"
194
+ value={fmtPct(analysis.asking_yield)}
195
+ sub={`si lo compras por ${fmtAED(listing.price)}`}
196
+ />
197
+ <Stat
198
+ label="Gimnasio más cercano (OSM)"
199
+ value={fmtDist(analysis.gym_distance_m)}
200
+ sub={analysis.gym_name ?? undefined}
201
+ />
202
+ </div>
203
+ </section>
181
204
 
182
205
  {listing.amenities?.length > 0 && (
183
206
  <div className="flex flex-wrap gap-1.5">
@@ -228,6 +251,57 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
228
251
  );
229
252
  }
230
253
 
254
+ const ANALYSIS_HELP: { term: string; desc: string }[] = [
255
+ {
256
+ term: "Rentabilidad anual de alquiler",
257
+ desc: "Renta anual mediana ÷ precio de venta mediana de unidades comparables (mismo edificio, tamaño ±20%, últimos 24 meses). Es la rentabilidad bruta que da el mercado, independiente del precio de este anuncio.",
258
+ },
259
+ {
260
+ term: "Se alquila por (año, mediana)",
261
+ desc: "Mediana de los contratos de alquiler anuales de unidades de tamaño similar (±20%) del edificio, últimos 24 meses. El rango es P25–P75: lo que pagan los pisos peor y mejor acondicionados.",
262
+ },
263
+ {
264
+ term: "Se vende por (mediana)",
265
+ desc: "Mediana de las ventas registradas en el DLD de tamaño similar (±20%), últimos 24 meses. El P25 es el tramo bajo del mercado ≈ precio objetivo de compra para reformar.",
266
+ },
267
+ {
268
+ term: "Rentabilidad sobre el precio del anuncio",
269
+ desc: "Renta anual mediana ÷ el precio que pide ESTE anuncio. Indica si este piso, a su precio, rinde por encima o por debajo de la mediana del edificio.",
270
+ },
271
+ {
272
+ term: "Gimnasio más cercano",
273
+ desc: "Distancia en línea recta al gimnasio más próximo según OpenStreetMap.",
274
+ },
275
+ ];
276
+
277
+ function Fact({ label, value }: { label: string; value: string }) {
278
+ return (
279
+ <div>
280
+ <div className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">{label}</div>
281
+ <div className="mt-0.5 text-sm font-medium">{value}</div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ function InfoList({ items }: { items: { term: string; desc: string }[] }) {
287
+ return (
288
+ <span className="group relative ml-2 inline-flex align-middle normal-case">
289
+ <span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-neutral-400 text-[10px] font-bold text-neutral-500 dark:border-neutral-500">
290
+ ?
291
+ </span>
292
+ <span className="pointer-events-none absolute left-0 top-6 z-30 w-80 rounded-md border border-neutral-200 bg-white p-3 text-left opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-neutral-700 dark:bg-neutral-800">
293
+ <span className="mb-1 block text-[11px] font-bold uppercase tracking-wide text-neutral-500">Cómo leer cada campo</span>
294
+ {items.map((it) => (
295
+ <span key={it.term} className="mb-2 block last:mb-0">
296
+ <span className="block text-[11px] font-semibold text-neutral-700 dark:text-neutral-200">{it.term}</span>
297
+ <span className="block text-[11px] font-normal leading-snug text-neutral-500 dark:text-neutral-400">{it.desc}</span>
298
+ </span>
299
+ ))}
300
+ </span>
301
+ </span>
302
+ );
303
+ }
304
+
231
305
  function Stat({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: "good" | "bad" }) {
232
306
  return (
233
307
  <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
@@ -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
- * Buy light reform rent higher. Within one building + size band the location
19
- * is fixed, so the comparable-rent spread (P25→P75) approximates what the market
20
- * pays for interior condition; renting at ~P75 is the realistic post-reform rent.
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 [cost, setCost] = useState(() => {
24
- const fallback = sizeSqft ? Math.round(sizeSqft * DEFAULT_AED_PER_SQFT) : 100000;
72
+ const [state, setState] = useState<ReformState>(() => {
73
+ const fallback = defaultState(sizeSqft);
25
74
  if (typeof window === "undefined") return fallback;
26
- const saved = Number(localStorage.getItem(`bw-reform-${listingId}`));
27
- return Number.isFinite(saved) && saved > 0 ? saved : fallback;
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 setAndSave = (v: number) => {
34
- setCost(v);
35
- if (Number.isFinite(v) && v >= 0) localStorage.setItem(`bw-reform-${listingId}`, String(v));
86
+ const update = (next: ReformState) => {
87
+ setState(next);
88
+ localStorage.setItem(`bw-reform-${listingId}`, JSON.stringify(next));
36
89
  };
37
90
 
38
- const postReformYield = rent_p75 / (price + cost);
39
- const uplift = rent_p75 - median_rent;
40
- const paybackYears = uplift > 0 ? cost / uplift : null;
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-[1fr_auto_1fr]">
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-gradient-to-r from-red-300 via-amber-300 to-emerald-400 dark:from-red-900 dark:via-amber-700 dark:to-emerald-600">
173
+ <div className="relative h-2.5 rounded-full bg-neutral-200 dark:bg-neutral-800">
72
174
  {rent_p75 > rent_p25 && (
73
- <span
74
- className="absolute -top-1 h-4 w-1 rounded bg-neutral-900 dark:bg-white"
75
- style={{ left: `${Math.min(99, Math.max(1, ((median_rent - rent_p25) / (rent_p75 - rent_p25)) * 100))}%` }}
76
- title={`mediana ${fmtAED(median_rent)}`}
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
- {/* Calculator */}
206
+ {/* Itemized reform */}
93
207
  <div className="text-sm">
94
- <label className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">
95
- Coste de la reforma (AED)
96
- {sizeSqft ? <span className="ml-1 font-normal text-neutral-400">(defecto {DEFAULT_AED_PER_SQFT}/sqft)</span> : null}
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
- <input
99
- type="number"
100
- min={0}
101
- step={5000}
102
- suppressHydrationWarning
103
- value={cost}
104
- onChange={(e) => setAndSave(Number(e.target.value))}
105
- className="mt-1 w-40 rounded-lg border border-neutral-300 bg-white px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 dark:border-neutral-700 dark:bg-neutral-950"
106
- />
107
- <div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
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 (P75 / precio+reforma)</span>
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 (P75 − mediana)</span>
115
- <b>{fmtAED(uplift)}</b>
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 className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}>
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-70">({n} tx)</span>}
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
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { getDb, now } from "./db";
1
+ import { poisNear, Poi } from "./pois";
3
2
 
4
- // Overpass instances are flaky under load; try each twice, in order.
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
- function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number {
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
- const task = queue.then(() => doGymsNear(lat, lon));
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
  }