brickwise 0.1.0 → 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,9 +28,10 @@ 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
34
+ const TREND = "#7c3aed"; // tendency line
31
35
 
32
36
  function niceTicks(min: number, max: number, count: number): number[] {
33
37
  if (min === max) return [min];
@@ -47,7 +51,7 @@ const fmtMonth = (ms: number) =>
47
51
  const fmtDate = (ms: number) => new Date(ms).toLocaleDateString("es-ES");
48
52
  const fmtAED = (n: number) => new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n);
49
53
 
50
- export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }: Props) {
54
+ export default function PsqftChart({ buy, rent, listingBedrooms, listingSizeSqft, listingPsqft }: Props) {
51
55
  const [mode, setMode] = useState<"buy" | "rent">("buy");
52
56
  const [hover, setHover] = useState<{ p: Pt; x: number; y: number } | null>(null);
53
57
 
@@ -63,11 +67,15 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
63
67
  bedrooms: r.bedrooms,
64
68
  amount: r.amount,
65
69
  size: r.size_sqft,
66
- 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,
67
75
  }))
68
76
  .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
69
77
  .sort((a, b) => a.t - b.t),
70
- [rows, listingBedrooms]
78
+ [rows, listingBedrooms, listingSizeSqft]
71
79
  );
72
80
 
73
81
  const scales = useMemo(() => {
@@ -77,18 +85,43 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
77
85
  const refY = mode === "buy" ? listingPsqft ?? null : null;
78
86
  const tMin = Math.min(...ts);
79
87
  const tMax = Math.max(...ts);
80
- let yMin = Math.min(...ys, ...(refY ? [refY] : []));
81
- let yMax = Math.max(...ys, ...(refY ? [refY] : []));
88
+
89
+ // Least-squares trend over comparable units (fallback to all) the tendency.
90
+ const comp = pts.filter((p) => p.comparable);
91
+ const fitSet = comp.length >= 2 ? comp : pts;
92
+ let trend: { y0: number; y1: number; annualPct: number | null } | null = null;
93
+ if (fitSet.length >= 2 && tMax > tMin) {
94
+ const n = fitSet.length;
95
+ const mt = fitSet.reduce((s, p) => s + p.t, 0) / n;
96
+ const my = fitSet.reduce((s, p) => s + p.y, 0) / n;
97
+ let num = 0;
98
+ let den = 0;
99
+ for (const p of fitSet) {
100
+ num += (p.t - mt) * (p.y - my);
101
+ den += (p.t - mt) ** 2;
102
+ }
103
+ const slope = den ? num / den : 0; // AED/sqft per ms
104
+ const intercept = my - slope * mt;
105
+ const YEAR = 365.25 * 24 * 3600 * 1000;
106
+ trend = {
107
+ y0: intercept + slope * tMin,
108
+ y1: intercept + slope * tMax,
109
+ annualPct: my ? (slope * YEAR) / my : null,
110
+ };
111
+ }
112
+
113
+ const extra = [...(refY ? [refY] : []), ...(trend ? [trend.y0, trend.y1] : [])];
114
+ let yMin = Math.min(...ys, ...extra);
115
+ let yMax = Math.max(...ys, ...extra);
82
116
  const pad = (yMax - yMin) * 0.1 || yMax * 0.1 || 1;
83
117
  yMin = Math.max(0, yMin - pad);
84
118
  yMax = yMax + pad;
85
119
  const x = (t: number) => (tMax === tMin ? IW / 2 : ((t - tMin) / (tMax - tMin)) * IW);
86
120
  const y = (v: number) => IH - ((v - yMin) / (yMax - yMin)) * IH;
87
121
  // median of comparable set (fallback to all)
88
- const comp = pts.filter((p) => p.comparable);
89
122
  const base = (comp.length ? comp : pts).map((p) => p.y).sort((a, b) => a - b);
90
123
  const median = base.length ? base[Math.floor(base.length / 2)] : null;
91
- return { x, y, tMin, tMax, yMin, yMax, refY, median };
124
+ return { x, y, tMin, tMax, yMin, yMax, refY, median, trend };
92
125
  }, [pts, mode, listingPsqft]);
93
126
 
94
127
  const compCount = pts.filter((p) => p.comparable).length;
@@ -181,24 +214,44 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
181
214
  </>
182
215
  )}
183
216
 
184
- {/* points: muted first, comparable on top */}
217
+ {/* trend (least-squares) line */}
218
+ {scales.trend && (
219
+ <line
220
+ x1={scales.x(scales.tMin)}
221
+ x2={scales.x(scales.tMax)}
222
+ y1={scales.y(scales.trend.y0)}
223
+ y2={scales.y(scales.trend.y1)}
224
+ stroke={TREND}
225
+ strokeWidth={2.5}
226
+ strokeLinecap="round"
227
+ />
228
+ )}
229
+
230
+ {/* points: muted first, comparable on top; small dot + invisible hit area */}
185
231
  {pts
186
232
  .slice()
187
233
  .sort((a, b) => Number(a.comparable) - Number(b.comparable))
188
234
  .map((p, i) => (
189
- <circle
190
- key={i}
191
- cx={scales.x(p.t)}
192
- cy={scales.y(p.y)}
193
- r={p.comparable ? 5 : 3.5}
194
- fill={p.comparable ? ACCENT : "none"}
195
- stroke={p.comparable ? "#fff" : MUTED}
196
- strokeWidth={p.comparable ? 1.5 : 1.5}
197
- opacity={p.comparable ? 1 : 0.7}
198
- onMouseEnter={() => setHover({ p, x: scales.x(p.t), y: scales.y(p.y) })}
199
- onMouseLeave={() => setHover(null)}
200
- style={{ cursor: "pointer" }}
201
- />
235
+ <g key={i}>
236
+ <circle
237
+ cx={scales.x(p.t)}
238
+ cy={scales.y(p.y)}
239
+ r={p.comparable ? 3 : 2}
240
+ fill={p.comparable ? ACCENT : MUTED}
241
+ stroke="#fff"
242
+ strokeWidth={p.comparable ? 1 : 0.5}
243
+ opacity={p.comparable ? 1 : 0.7}
244
+ />
245
+ <circle
246
+ cx={scales.x(p.t)}
247
+ cy={scales.y(p.y)}
248
+ r={8}
249
+ fill="transparent"
250
+ onMouseEnter={() => setHover({ p, x: scales.x(p.t), y: scales.y(p.y) })}
251
+ onMouseLeave={() => setHover(null)}
252
+ style={{ cursor: "pointer" }}
253
+ />
254
+ </g>
202
255
  ))}
203
256
  </g>
204
257
  </svg>
@@ -225,7 +278,7 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
225
278
  <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-neutral-500">
226
279
  <span className="flex items-center gap-1.5">
227
280
  <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
228
- Mismo nº de habitaciones ({compCount})
281
+ {listingSizeSqft != null ? "Tamaño similar (±20%)" : "Mismo nº de habitaciones"} ({compCount})
229
282
  </span>
230
283
  <span className="flex items-center gap-1.5">
231
284
  <span className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px]" style={{ borderColor: MUTED }} />
@@ -235,6 +288,16 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
235
288
  <span className="inline-block h-0 w-4 border-t-[1.5px] border-dashed" style={{ borderColor: MUTED }} />
236
289
  Mediana
237
290
  </span>
291
+ {scales.trend && (
292
+ <span className="flex items-center gap-1.5">
293
+ <span className="inline-block h-0 w-4 border-t-[2.5px]" style={{ borderColor: TREND }} />
294
+ <span style={{ color: TREND }} className="font-semibold">
295
+ Tendencia
296
+ {scales.trend.annualPct != null &&
297
+ ` (${scales.trend.annualPct >= 0 ? "+" : ""}${(scales.trend.annualPct * 100).toFixed(1)} %/año)`}
298
+ </span>
299
+ </span>
300
+ )}
238
301
  {mode === "buy" && listingPsqft != null && (
239
302
  <span className="flex items-center gap-1.5">
240
303
  <span className="inline-block h-0 w-4 border-t-2" style={{ borderColor: REF }} />