brickwise 0.1.1 → 0.1.5

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.
@@ -1,20 +1,64 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* ---- Local font (public/fonts) — titles only ------------------------ */
4
+
5
+ @font-face {
6
+ font-family: "Stack Sans Notch";
7
+ src: url("/fonts/Stack_Sans_Notch/StackSansNotch-VariableFont_wght.ttf") format("truetype");
8
+ font-weight: 200 700;
9
+ font-display: swap;
10
+ }
11
+
3
12
  :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
13
+ --background: #f5f5f5;
14
+ --foreground: #202020;
15
+ --font-title: "Stack Sans Notch", system-ui, sans-serif;
16
+ --font-button: var(--font-space-mono), ui-monospace, monospace;
17
+ --accent: #b84f30; /* ladrillo */
18
+ --accent-ink: #1a1208; /* texto casi negro sobre acento */
6
19
  }
7
20
 
8
21
  @theme inline {
9
22
  --color-background: var(--background);
10
23
  --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
24
+ --font-sans: var(--font-montserrat);
12
25
  --font-mono: var(--font-geist-mono);
13
26
  }
14
27
 
28
+ @theme {
29
+ /* Neutral dark grays anchored to the #2c2c2c (44,44,44) background */
30
+ --color-neutral-950: #242424; /* deep insets / header */
31
+ --color-neutral-900: #2c2c2c; /* panels & cards == background */
32
+ --color-neutral-800: #383838; /* borders, dividers, hover (subtle) */
33
+ --color-neutral-700: #4a4a4a;
34
+
35
+ /* Brick accent — remaps every blue-* usage in the app to color ladrillo */
36
+ --color-blue-50: #faeee8;
37
+ --color-blue-100: #f5ddd2;
38
+ --color-blue-200: #eecebf;
39
+ --color-blue-300: #e0a98e;
40
+ --color-blue-400: #d97f5a;
41
+ --color-blue-500: #cd6240;
42
+ --color-blue-600: #b84f30;
43
+ --color-blue-700: #9a4126;
44
+ --color-blue-800: #7c3520;
45
+ --color-blue-900: #5a2718;
46
+ --color-blue-950: #38180e;
47
+
48
+ /* Minimalist: mostly-sharp corners */
49
+ --radius-xs: 0px;
50
+ --radius-sm: 0px;
51
+ --radius-md: 0px;
52
+ --radius-lg: 2px;
53
+ --radius-xl: 2px;
54
+ --radius-2xl: 2px;
55
+ --radius-3xl: 2px;
56
+ --radius-4xl: 2px;
57
+ }
58
+
15
59
  @media (prefers-color-scheme: dark) {
16
60
  :root {
17
- --background: #0a0a0a;
61
+ --background: #2c2c2c; /* gris muy oscuro (44,44,44) */
18
62
  --foreground: #ededed;
19
63
  }
20
64
  }
@@ -22,12 +66,59 @@
22
66
  body {
23
67
  background: var(--background);
24
68
  color: var(--foreground);
25
- font-family: var(--font-geist-sans), system-ui, sans-serif;
69
+ font-family: var(--font-montserrat), system-ui, sans-serif;
70
+ }
71
+
72
+ /* ---- Type roles (base layer, so per-element utilities can override) --- */
73
+
74
+ @layer base {
75
+ /* Titles (logo, headings) → Stack Sans Notch */
76
+ h1,
77
+ h2,
78
+ h3,
79
+ .font-brand {
80
+ font-family: var(--font-title);
81
+ letter-spacing: 0.01em;
82
+ }
83
+
84
+ /* Logo in uppercase */
85
+ .font-brand {
86
+ text-transform: uppercase;
87
+ letter-spacing: 0.04em;
88
+ }
89
+
90
+ /* Button text → Space Mono, uppercase, flat */
91
+ button,
92
+ .btn-font {
93
+ font-family: var(--font-button);
94
+ text-transform: uppercase;
95
+ letter-spacing: 0.03em;
96
+ }
97
+ }
98
+
99
+ /* Accent-background buttons → near-black ink (higher specificity than text-*) */
100
+ button[class*="bg-blue-6"],
101
+ button[class*="bg-blue-5"],
102
+ .btn-font[class*="bg-blue-6"],
103
+ .btn-font[class*="bg-blue-5"] {
104
+ color: var(--accent-ink);
105
+ }
106
+
107
+ /* ---- Minimalist: no hard/offset shadows ------------------------------ */
108
+
109
+ .shadow-sm,
110
+ .shadow-md {
111
+ box-shadow: none;
112
+ }
113
+ .shadow-lg {
114
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
115
+ }
116
+ .rounded-full {
117
+ border-radius: 2px;
26
118
  }
27
119
 
28
120
  /* ---- Map markers ---------------------------------------------------- */
29
121
 
30
- /* Property pin: pill with the yield + a small tail; the tail tip sits on the latlng. */
31
122
  .bw-pin {
32
123
  display: flex;
33
124
  flex-direction: column;
@@ -38,41 +129,47 @@ body {
38
129
  .bw-pin-label {
39
130
  background: var(--pin-color, #737373);
40
131
  color: #fff;
41
- font: 700 11px/1 var(--font-geist-sans), system-ui, sans-serif;
42
- padding: 5px 9px;
43
- border-radius: 999px;
44
- border: 2px solid #fff;
45
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
132
+ font: 700 11px/1 var(--font-button);
133
+ padding: 4px 8px;
134
+ border-radius: 2px;
135
+ border: 1px solid rgba(255, 255, 255, 0.85);
136
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
46
137
  white-space: nowrap;
47
138
  transition: transform 0.12s ease, box-shadow 0.12s ease;
48
139
  }
49
140
  .bw-pin-tail {
50
141
  width: 0;
51
142
  height: 0;
52
- border-left: 6px solid transparent;
53
- border-right: 6px solid transparent;
54
- border-top: 7px solid #fff;
143
+ border-left: 5px solid transparent;
144
+ border-right: 5px solid transparent;
145
+ border-top: 6px solid rgba(255, 255, 255, 0.85);
55
146
  margin-top: -1px;
56
- filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
57
147
  }
58
148
  .bw-pin--hl .bw-pin-label {
59
- transform: scale(1.22);
60
- box-shadow:
61
- 0 0 0 4px rgba(59, 130, 246, 0.5),
62
- 0 3px 10px rgba(0, 0, 0, 0.45);
149
+ transform: scale(1.2);
150
+ box-shadow: 0 0 0 3px rgba(184, 79, 48, 0.6);
151
+ }
152
+
153
+ /* Filtered-out markers: darker + transparent */
154
+ .bw-pin--dim {
155
+ opacity: 0.4;
156
+ }
157
+ .bw-pin--dim .bw-pin-label {
158
+ filter: grayscale(0.75) brightness(0.5);
159
+ border-color: rgba(255, 255, 255, 0.4);
63
160
  }
64
161
 
65
- /* Gym marker: blue disc with a white dumbbell. */
162
+ /* Gym marker: brick square with a white dumbbell. */
66
163
  .bw-gym {
67
- width: 28px;
68
- height: 28px;
69
- border-radius: 50%;
70
- background: #1d4ed8;
71
- border: 2px solid #fff;
164
+ width: 24px;
165
+ height: 24px;
166
+ border-radius: 2px;
167
+ background: var(--accent);
168
+ border: 1px solid rgba(255, 255, 255, 0.85);
72
169
  display: flex;
73
170
  align-items: center;
74
171
  justify-content: center;
75
- box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
172
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
76
173
  transform: translate(-50%, -50%);
77
174
  cursor: pointer;
78
175
  }
@@ -1,10 +1,16 @@
1
1
  import type { Metadata } from "next";
2
- import { Geist, Geist_Mono } from "next/font/google";
2
+ import { Geist_Mono, Montserrat, Space_Mono } from "next/font/google";
3
3
  import ChromeHeader from "@/components/ChromeHeader";
4
4
  import "./globals.css";
5
5
 
6
- const geistSans = Geist({
7
- variable: "--font-geist-sans",
6
+ const montserrat = Montserrat({
7
+ variable: "--font-montserrat",
8
+ subsets: ["latin"],
9
+ });
10
+
11
+ const spaceMono = Space_Mono({
12
+ variable: "--font-space-mono",
13
+ weight: ["400", "700"],
8
14
  subsets: ["latin"],
9
15
  });
10
16
 
@@ -26,7 +32,7 @@ export default function RootLayout({
26
32
  return (
27
33
  <html
28
34
  lang="es"
29
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
35
+ className={`${montserrat.variable} ${spaceMono.variable} ${geistMono.variable} h-full antialiased`}
30
36
  >
31
37
  <body className="flex min-h-svh flex-col bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
32
38
  <ChromeHeader />
@@ -4,6 +4,7 @@ import { use, useEffect, useState } from "react";
4
4
  import YieldBadge from "@/components/YieldBadge";
5
5
  import TransactionsTable from "@/components/TransactionsTable";
6
6
  import PsqftChart from "@/components/PsqftChart";
7
+ import ReformScenario from "@/components/ReformScenario";
7
8
  import { fmtAED, fmtDist, fmtPct, fmtSqft } from "@/lib/format";
8
9
  import { AnalysisRow } from "@/lib/types";
9
10
  import { TxRow } from "@/lib/pf/transactions";
@@ -96,7 +97,7 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
96
97
  disabled={faved}
97
98
  className="rounded-lg border border-neutral-300 px-3 py-1 text-sm font-medium hover:bg-neutral-100 disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
98
99
  >
99
- {faved ? "Guardado" : "Guardar en favoritos"}
100
+ {faved ? "Guardado" : "Guardar en favoritos"}
100
101
  </button>
101
102
  </div>
102
103
  </div>
@@ -152,12 +153,18 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
152
153
  <Stat
153
154
  label="Se alquila por (año, mediana)"
154
155
  value={fmtAED(analysis.median_rent)}
155
- sub={`${analysis.rent_n} contrato${analysis.rent_n === 1 ? "" : "s"} de alquiler comparables (24 meses)`}
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
+ }`}
156
161
  />
157
162
  <Stat
158
163
  label="Se vende por (mediana)"
159
164
  value={fmtAED(analysis.median_sale_price)}
160
- sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} comparables (24 meses)`}
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
+ }`}
161
168
  />
162
169
  <Stat
163
170
  label="Rentabilidad sobre el precio del anuncio"
@@ -182,11 +189,19 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
182
189
  </div>
183
190
  )}
184
191
 
192
+ <ReformScenario
193
+ listingId={listing.id}
194
+ price={listing.price}
195
+ sizeSqft={listing.size_sqft}
196
+ analysis={analysis}
197
+ />
198
+
185
199
  {transactions && (transactions.buy.length > 0 || transactions.rent.length > 0) && (
186
200
  <PsqftChart
187
201
  buy={transactions.buy}
188
202
  rent={transactions.rent}
189
203
  listingBedrooms={listing.bedrooms}
204
+ listingSizeSqft={listing.size_sqft}
190
205
  listingPsqft={listing.size_sqft ? listing.price / listing.size_sqft : null}
191
206
  />
192
207
  )}
package/src/app/page.tsx CHANGED
@@ -70,10 +70,10 @@ export default function Dashboard() {
70
70
  <div className="flex items-center justify-between">
71
71
  <h1 className="text-2xl font-bold">Inmuebles analizados</h1>
72
72
  <Link
73
- href="/search"
74
- className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-500"
73
+ href="/favorites"
74
+ className="btn-font rounded-lg bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-500"
75
75
  >
76
- + Nueva búsqueda
76
+ + Añadir inmuebles
77
77
  </Link>
78
78
  </div>
79
79
 
@@ -81,9 +81,9 @@ export default function Dashboard() {
81
81
  <p className="animate-pulse text-neutral-500">Cargando…</p>
82
82
  ) : sorted.length === 0 ? (
83
83
  <div className="rounded-xl border border-dashed border-neutral-300 p-10 text-center text-neutral-500 dark:border-neutral-700">
84
- Aún no hay inmuebles analizados. Empieza con una{" "}
85
- <Link href="/search" className="font-semibold text-blue-600 hover:underline dark:text-blue-400">
86
- búsqueda
84
+ Aún no hay inmuebles analizados. Añade enlaces de Property Finder en{" "}
85
+ <Link href="/favorites" className="font-semibold text-blue-600 hover:underline dark:text-blue-400">
86
+ Favoritos + Mapa
87
87
  </Link>
88
88
  .
89
89
  </div>
@@ -1,87 +1,5 @@
1
- "use client";
1
+ import { redirect } from "next/navigation";
2
2
 
3
- import { useState } from "react";
4
- import FiltersForm from "@/components/FiltersForm";
5
- import ListingCard, { CardListing } from "@/components/ListingCard";
6
- import { AnalysisRow, SearchFilters } from "@/lib/types";
7
-
8
- export default function SearchPage() {
9
- const [results, setResults] = useState<CardListing[]>([]);
10
- const [analyses, setAnalyses] = useState<Record<string, AnalysisRow>>({});
11
- const [totalCount, setTotalCount] = useState<number | null>(null);
12
- const [duplicatesRemoved, setDuplicatesRemoved] = useState(0);
13
- const [searching, setSearching] = useState(false);
14
- const [error, setError] = useState<string | null>(null);
15
- const [maxGymM, setMaxGymM] = useState<number | undefined>(undefined);
16
-
17
- const search = async (f: SearchFilters) => {
18
- setSearching(true);
19
- setError(null);
20
- setResults([]);
21
- setAnalyses({});
22
- setMaxGymM(f.maxGymDistanceM);
23
- try {
24
- const res = await fetch("/api/search", {
25
- method: "POST",
26
- headers: { "Content-Type": "application/json" },
27
- body: JSON.stringify(f),
28
- });
29
- const j = await res.json();
30
- if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
31
- setResults(j.results);
32
- const cached: Record<string, AnalysisRow> = {};
33
- for (const r of j.results) if (r.analysis) cached[r.id] = r.analysis;
34
- setAnalyses(cached);
35
- setTotalCount(j.totalCount);
36
- setDuplicatesRemoved(j.duplicatesRemoved ?? 0);
37
- } catch (e) {
38
- setError((e as Error).message);
39
- } finally {
40
- setSearching(false);
41
- }
42
- };
43
-
44
- const visible = results.filter((r) => {
45
- if (maxGymM == null) return true;
46
- const a = analyses[r.id];
47
- if (!a) return true; // keep while analysis pending
48
- return a.gym_distance_m != null && a.gym_distance_m <= maxGymM;
49
- });
50
-
51
- return (
52
- <div className="mx-auto flex w-full max-w-7xl flex-col gap-5 px-4 py-6">
53
- <h1 className="text-2xl font-bold">Buscar inmuebles</h1>
54
- <FiltersForm onSearch={search} searching={searching} />
55
-
56
- {error && (
57
- <div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
58
- Error: {error}
59
- </div>
60
- )}
61
-
62
- {totalCount != null && (
63
- <p className="text-sm text-neutral-600 dark:text-neutral-400">
64
- {visible.length} mostrados de {results.length} únicos ({totalCount.toLocaleString()} en Property Finder)
65
- {duplicatesRemoved > 0 && (
66
- <span className="ml-1 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
67
- {duplicatesRemoved} duplicado{duplicatesRemoved === 1 ? "" : "s"} agrupado{duplicatesRemoved === 1 ? "" : "s"}
68
- </span>
69
- )}
70
- . Pulsa <span className="font-semibold">⚡ Analizar</span> en los inmuebles que te interesen (los ya analizados
71
- muestran su rentabilidad al instante).
72
- </p>
73
- )}
74
-
75
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
76
- {visible.map((r) => (
77
- <ListingCard
78
- key={r.id}
79
- listing={r}
80
- analysis={analyses[r.id] ?? null}
81
- onAnalyzed={(id, a) => setAnalyses((prev) => ({ ...prev, [id]: a }))}
82
- />
83
- ))}
84
- </div>
85
- </div>
86
- );
3
+ export default function SearchRemoved() {
4
+ redirect("/favorites");
87
5
  }
@@ -4,17 +4,16 @@ import Link from "next/link";
4
4
  import { usePathname } from "next/navigation";
5
5
 
6
6
  const ITEMS = [
7
- { href: "/search", label: "🔍 Buscar" },
8
- { href: "/favorites", label: "⭐ Favoritos + Mapa" },
9
- { href: "/", label: "📊 Analizados" },
7
+ { href: "/favorites", label: "Favoritos + Mapa" },
8
+ { href: "/", label: "Analizados" },
10
9
  ];
11
10
 
12
11
  export default function Nav() {
13
12
  const pathname = usePathname();
14
13
  return (
15
14
  <nav className="mx-auto flex h-14 w-full max-w-[1800px] items-center gap-2 px-4">
16
- <Link href="/" className="mr-4 flex items-center gap-2 text-lg font-bold tracking-tight">
17
- 🧱 Brickwise
15
+ <Link href="/" className="font-brand mr-4 flex items-center gap-2 text-lg font-bold tracking-tight">
16
+ Brickwise
18
17
  </Link>
19
18
  {ITEMS.map((it) => {
20
19
  const active = it.href === "/" ? pathname === "/" : pathname.startsWith(it.href);
@@ -22,7 +21,7 @@ export default function Nav() {
22
21
  <Link
23
22
  key={it.href}
24
23
  href={it.href}
25
- className={`rounded-full px-3 py-1.5 text-sm font-medium transition ${
24
+ className={`btn-font rounded-full px-3 py-1.5 text-sm font-medium transition ${
26
25
  active
27
26
  ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
28
27
  : "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-white"
@@ -7,9 +7,12 @@ interface Props {
7
7
  buy: TxRow[];
8
8
  rent: TxRow[];
9
9
  listingBedrooms: number | null;
10
+ listingSizeSqft: number | null;
10
11
  listingPsqft: number | null; // asking price / size
11
12
  }
12
13
 
14
+ const SIZE_TOLERANCE = 0.2;
15
+
13
16
  interface Pt {
14
17
  t: number; // date ms
15
18
  y: number; // AED/sqft
@@ -25,7 +28,7 @@ const M = { top: 16, right: 16, bottom: 34, left: 52 };
25
28
  const IW = W - M.left - M.right;
26
29
  const IH = H - M.top - M.bottom;
27
30
 
28
- const ACCENT = "#2563eb"; // comparable (same bedrooms)
31
+ const ACCENT = "#b84f30"; // comparable (similar size) — brick accent
29
32
  const MUTED = "#9ca3af"; // other units
30
33
  const REF = "#059669"; // this listing
31
34
  const TREND = "#7c3aed"; // tendency line
@@ -48,7 +51,7 @@ const fmtMonth = (ms: number) =>
48
51
  const fmtDate = (ms: number) => new Date(ms).toLocaleDateString("es-ES");
49
52
  const fmtAED = (n: number) => new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n);
50
53
 
51
- export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }: Props) {
54
+ export default function PsqftChart({ buy, rent, listingBedrooms, listingSizeSqft, listingPsqft }: Props) {
52
55
  const [mode, setMode] = useState<"buy" | "rent">("buy");
53
56
  const [hover, setHover] = useState<{ p: Pt; x: number; y: number } | null>(null);
54
57
 
@@ -64,11 +67,15 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
64
67
  bedrooms: r.bedrooms,
65
68
  amount: r.amount,
66
69
  size: r.size_sqft,
67
- comparable: listingBedrooms != null && r.bedrooms === listingBedrooms,
70
+ // Same criterion as the yield sample: similar size first, bedrooms fallback.
71
+ comparable:
72
+ listingSizeSqft != null
73
+ ? Math.abs((r.size_sqft as number) - listingSizeSqft) / listingSizeSqft <= SIZE_TOLERANCE
74
+ : listingBedrooms != null && r.bedrooms === listingBedrooms,
68
75
  }))
69
76
  .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
70
77
  .sort((a, b) => a.t - b.t),
71
- [rows, listingBedrooms]
78
+ [rows, listingBedrooms, listingSizeSqft]
72
79
  );
73
80
 
74
81
  const scales = useMemo(() => {
@@ -271,7 +278,7 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
271
278
  <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-neutral-500">
272
279
  <span className="flex items-center gap-1.5">
273
280
  <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
274
- Mismo nº de habitaciones ({compCount})
281
+ {listingSizeSqft != null ? "Tamaño similar (±20%)" : "Mismo nº de habitaciones"} ({compCount})
275
282
  </span>
276
283
  <span className="flex items-center gap-1.5">
277
284
  <span className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px]" style={{ borderColor: MUTED }} />
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { fmtAED, fmtPct } from "@/lib/format";
5
+ import { AnalysisRow } from "@/lib/types";
6
+
7
+ const DEFAULT_AED_PER_SQFT = 300;
8
+ const SIZE_TOLERANCE = 0.2;
9
+
10
+ interface Props {
11
+ listingId: string;
12
+ price: number;
13
+ sizeSqft: number | null;
14
+ analysis: AnalysisRow;
15
+ }
16
+
17
+ /**
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.
21
+ */
22
+ 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;
25
+ if (typeof window === "undefined") return fallback;
26
+ const saved = Number(localStorage.getItem(`bw-reform-${listingId}`));
27
+ return Number.isFinite(saved) && saved > 0 ? saved : fallback;
28
+ });
29
+
30
+ const { rent_p25, rent_p75, median_rent, rent_n, asking_yield } = analysis;
31
+ if (rent_p25 == null || rent_p75 == null || median_rent == null || rent_n < 3) return null;
32
+
33
+ const setAndSave = (v: number) => {
34
+ setCost(v);
35
+ if (Number.isFinite(v) && v >= 0) localStorage.setItem(`bw-reform-${listingId}`, String(v));
36
+ };
37
+
38
+ const postReformYield = rent_p75 / (price + cost);
39
+ const uplift = rent_p75 - median_rent;
40
+ const paybackYears = uplift > 0 ? cost / uplift : null;
41
+ const spread = (rent_p75 - rent_p25) / median_rent;
42
+
43
+ const spreadReading =
44
+ spread >= 0.15
45
+ ? { txt: "El interior mueve mucho la renta en este edificio: hay margen real para la reforma.", cls: "text-emerald-700 dark:text-emerald-400" }
46
+ : spread >= 0.08
47
+ ? { txt: "Margen moderado: la reforma ayuda, pero no esperes milagros en este edificio.", cls: "text-amber-700 dark:text-amber-400" }
48
+ : { txt: "El edificio capa la renta: los comparables se alquilan casi igual estén como estén. La reforma difícilmente se paga aquí.", cls: "text-red-700 dark:text-red-400" };
49
+
50
+ const band = sizeSqft
51
+ ? `${Math.round(sizeSqft * (1 - SIZE_TOLERANCE))}–${Math.round(sizeSqft * (1 + SIZE_TOLERANCE))} sqft`
52
+ : null;
53
+
54
+ return (
55
+ <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
56
+ <h3 className="text-sm font-bold">
57
+ Escenario de reforma{" "}
58
+ <span className="font-normal text-neutral-500">
59
+ ({rent_n} alquileres comparables{band ? ` · ${band}` : ""})
60
+ </span>
61
+ </h3>
62
+
63
+ <div className="mt-3 grid gap-4 lg:grid-cols-[1fr_auto_1fr]">
64
+ {/* Rent range of comparables */}
65
+ <div>
66
+ <div className="mb-1 flex justify-between text-[11px] text-neutral-500">
67
+ <span>P25 (sin reformar)</span>
68
+ <span>mediana</span>
69
+ <span>P75 (reformado)</span>
70
+ </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">
72
+ {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
+ />
78
+ )}
79
+ </div>
80
+ <div className="mt-1 flex justify-between text-xs font-semibold">
81
+ <span>{fmtAED(rent_p25)}</span>
82
+ <span className="text-neutral-500">{fmtAED(median_rent)}</span>
83
+ <span>{fmtAED(rent_p75)}</span>
84
+ </div>
85
+ <p className={`mt-2 text-xs ${spreadReading.cls}`}>
86
+ Dispersión {fmtPct(spread, 0)} de la mediana — {spreadReading.txt}
87
+ </p>
88
+ </div>
89
+
90
+ <div className="hidden w-px bg-neutral-200 lg:block dark:bg-neutral-800" />
91
+
92
+ {/* Calculator */}
93
+ <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}
97
+ </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">
108
+ <span className="text-neutral-500">Yield actual (mediana / precio)</span>
109
+ <b>{fmtPct(asking_yield)}</b>
110
+ <span className="text-neutral-500">Yield post-reforma (P75 / precio+reforma)</span>
111
+ <b className={postReformYield > (asking_yield ?? 0) ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
112
+ {fmtPct(postReformYield)}
113
+ </b>
114
+ <span className="text-neutral-500">Renta extra al año (P75 − mediana)</span>
115
+ <b>{fmtAED(uplift)}</b>
116
+ <span className="text-neutral-500">Payback de la reforma</span>
117
+ <b>{paybackYears != null ? `${paybackYears.toFixed(1)} años` : "—"}</b>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
package/src/lib/db.ts CHANGED
@@ -134,8 +134,15 @@ export function getDb(): Database.Database {
134
134
 
135
135
  function migrate(db: Database.Database) {
136
136
  const cols = (db.prepare(`PRAGMA table_info(analyses)`).all() as { name: string }[]).map((c) => c.name);
137
- if (!cols.includes("median_sale_price")) db.exec(`ALTER TABLE analyses ADD COLUMN median_sale_price REAL`);
138
- if (!cols.includes("asking_yield")) db.exec(`ALTER TABLE analyses ADD COLUMN asking_yield REAL`);
137
+ const add = (name: string) => {
138
+ if (!cols.includes(name)) db.exec(`ALTER TABLE analyses ADD COLUMN ${name} REAL`);
139
+ };
140
+ add("median_sale_price");
141
+ add("asking_yield");
142
+ add("rent_p25");
143
+ add("rent_p75");
144
+ add("sale_p25");
145
+ add("sale_p75");
139
146
  }
140
147
 
141
148
  export function now(): string {
package/src/lib/store.ts CHANGED
@@ -112,8 +112,9 @@ export function saveAnalysis(listingId: string, y: YieldResult, gym: { gym_name:
112
112
  .prepare(
113
113
  `INSERT OR REPLACE INTO analyses
114
114
  (listing_id, gross_yield, asking_yield, median_rent, median_sale_price,
115
+ rent_p25, rent_p75, sale_p25, sale_p75,
115
116
  rent_n, buy_n, gym_distance_m, gym_name, computed_at)
116
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
117
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
117
118
  )
118
119
  .run(
119
120
  listingId,
@@ -121,6 +122,10 @@ export function saveAnalysis(listingId: string, y: YieldResult, gym: { gym_name:
121
122
  y.asking_yield,
122
123
  y.median_rent,
123
124
  y.median_sale_price,
125
+ y.rent_p25,
126
+ y.rent_p75,
127
+ y.sale_p25,
128
+ y.sale_p75,
124
129
  y.rent_n,
125
130
  y.buy_n,
126
131
  gym.distance_m,
@@ -160,6 +165,7 @@ export function listFavorites(): FavoriteListing[] {
160
165
  return getDb()
161
166
  .prepare(
162
167
  `SELECT l.*, a.gross_yield, a.asking_yield, a.median_rent, a.median_sale_price,
168
+ a.rent_p25, a.rent_p75, a.sale_p25, a.sale_p75,
163
169
  a.rent_n, a.buy_n, a.gym_distance_m, a.gym_name, a.computed_at,
164
170
  f.notes, f.added_at
165
171
  FROM favorites f
@@ -174,6 +180,7 @@ export function listAnalyzed(): AnalyzedListing[] {
174
180
  return getDb()
175
181
  .prepare(
176
182
  `SELECT l.*, a.gross_yield, a.asking_yield, a.median_rent, a.median_sale_price,
183
+ a.rent_p25, a.rent_p75, a.sale_p25, a.sale_p75,
177
184
  a.rent_n, a.buy_n, a.gym_distance_m, a.gym_name, a.computed_at
178
185
  FROM listings l JOIN analyses a ON a.listing_id = l.id
179
186
  ORDER BY a.gross_yield DESC NULLS LAST`