brickwise 0.1.5 → 0.1.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brickwise",
3
- "version": "0.1.5",
3
+ "version": "0.1.15",
4
4
  "description": "App local para analizar la rentabilidad de alquiler de inmuebles de Property Finder UAE con el histórico real de transacciones DLD.",
5
5
  "license": "MIT",
6
6
  "author": "eljommys",
@@ -0,0 +1,16 @@
1
+ import { NextResponse } from "next/server";
2
+ import { poisForPoints } from "@/lib/pois";
3
+ import { listFavorites } from "@/lib/store";
4
+
5
+ /** All cafés near any located favorite, in a single Overpass query. */
6
+ export async function GET() {
7
+ try {
8
+ const points = listFavorites()
9
+ .filter((f) => f.lat != null && f.lon != null)
10
+ .map((f) => ({ lat: f.lat!, lon: f.lon! }));
11
+ const { pois, failed } = await poisForPoints(points, "cafe");
12
+ return NextResponse.json({ cafes: pois, favoritesQueried: points.length, failed });
13
+ } catch (e) {
14
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
15
+ }
16
+ }
@@ -1,29 +1,15 @@
1
1
  import { NextResponse } from "next/server";
2
- import { gymsNear } from "@/lib/gyms";
2
+ import { poisForPoints } from "@/lib/pois";
3
3
  import { listFavorites } from "@/lib/store";
4
4
 
5
- /** All gyms within 2 km of every located favorite, deduplicated. */
5
+ /** All gyms near any located favorite, in a single Overpass query. */
6
6
  export async function GET() {
7
7
  try {
8
- const favorites = listFavorites().filter((f) => f.lat != null && f.lon != null);
9
- const seen = new Map<string, { name: string; lat: number; lon: number }>();
10
- let failed = 0;
11
- for (const f of favorites) {
12
- const gyms = await gymsNear(f.lat!, f.lon!);
13
- if (gyms == null) {
14
- failed++;
15
- continue;
16
- }
17
- for (const g of gyms) {
18
- const key = `${g.lat.toFixed(5)},${g.lon.toFixed(5)}`;
19
- if (!seen.has(key)) seen.set(key, { name: g.name, lat: g.lat, lon: g.lon });
20
- }
21
- }
22
- return NextResponse.json({
23
- gyms: [...seen.values()],
24
- favoritesQueried: favorites.length,
25
- failed,
26
- });
8
+ const points = listFavorites()
9
+ .filter((f) => f.lat != null && f.lon != null)
10
+ .map((f) => ({ lat: f.lat!, lon: f.lon! }));
11
+ const { pois, failed } = await poisForPoints(points, "gym");
12
+ return NextResponse.json({ gyms: pois, favoritesQueried: points.length, failed });
27
13
  } catch (e) {
28
14
  return NextResponse.json({ error: (e as Error).message }, { status: 500 });
29
15
  }
@@ -6,6 +6,7 @@ import "leaflet/dist/leaflet.css";
6
6
  import type { LayerGroup, Map as LeafletMap, Marker } from "leaflet";
7
7
  import YieldBadge from "@/components/YieldBadge";
8
8
  import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
9
+ import { yieldColor } from "@/lib/yieldColor";
9
10
 
10
11
  interface FavRow {
11
12
  id: string;
@@ -29,23 +30,24 @@ interface FavRow {
29
30
  gym_distance_m: number | null;
30
31
  gym_name: string | null;
31
32
  price_per_sqft: number | null;
33
+ description: string | null;
32
34
  notes: string;
33
35
  }
34
36
 
37
+ const MORE_SVG = (
38
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
39
+ <circle cx="11" cy="11" r="7" />
40
+ <path d="M11 8v6M8 11h6M20 20l-3.5-3.5" />
41
+ </svg>
42
+ );
43
+
35
44
  const GYM_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" fill="#fff" xmlns="http://www.w3.org/2000/svg"><path d="M2 10h2v4H2zM5 8h2v8H5zM17 8h2v8h-2zM20 10h2v4h-2zM8 11h8v2H8z"/></svg>`;
45
+ const CAFE_SVG = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 8h12v5a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8z"/><path d="M16 9h2a2 2 0 0 1 0 4h-2"/><path d="M6 3v2M10 3v2M14 3v2"/></svg>`;
36
46
 
37
47
  function matchesBedFilter(r: { bedrooms: number | null }, bedFilter: number[]): boolean {
38
48
  return bedFilter.length === 0 || (r.bedrooms != null && bedFilter.includes(r.bedrooms));
39
49
  }
40
50
 
41
- function yieldColor(y: number | null): string {
42
- if (y == null) return "#737373";
43
- if (y >= 0.07) return "#059669";
44
- if (y >= 0.055) return "#65a30d";
45
- if (y >= 0.04) return "#d97706";
46
- return "#dc2626";
47
- }
48
-
49
51
  function popupHtml(r: FavRow): string {
50
52
  const img = r.images?.[0]?.medium || r.images?.[0]?.small;
51
53
  const yieldTxt = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + " %" : "sin datos";
@@ -75,6 +77,44 @@ const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
75
77
  // strips javascript: hrefs.
76
78
  const BOOKMARKLET = String.raw`javascript:(function(){var u=new Set();document.querySelectorAll('a[href]').forEach(function(a){var h=(a.href||'').split('?')[0];if(/\/plp\/.*-\d+\.html$/.test(h))u.add(h)});try{var n=document.getElementById('__NEXT_DATA__');if(n){(JSON.stringify(JSON.parse(n.textContent)).match(/https?:\/\/[^"']*\/plp\/[^"']*-\d+\.html/g)||[]).forEach(function(x){u.add(x.split('?')[0])})}}catch(e){}var l=[...u];if(!l.length){alert('No encontre pisos guardados aqui. Abre tu pagina de Guardados de Property Finder con la sesion iniciada.');return}var t=l.join('\n');if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){alert(l.length+' piso(s) copiados. Abre Brickwise y pulsa "Pegar de Property Finder".')},function(){prompt('Copia estos enlaces y pegalos en Brickwise:',t)})}else{prompt('Copia estos enlaces y pegalos en Brickwise:',t)}})();`;
77
79
 
80
+ function LayerToggle({
81
+ label,
82
+ color,
83
+ loading,
84
+ visible,
85
+ count,
86
+ onToggle,
87
+ }: {
88
+ label: string;
89
+ color: string;
90
+ loading: boolean;
91
+ visible: boolean;
92
+ count: number | null;
93
+ onToggle: () => void;
94
+ }) {
95
+ return (
96
+ <button
97
+ type="button"
98
+ onClick={onToggle}
99
+ disabled={loading}
100
+ className="btn-font flex w-full items-center gap-2 rounded px-2 py-1.5 text-[11px] hover:bg-neutral-100 disabled:opacity-60 dark:hover:bg-neutral-800"
101
+ >
102
+ <span
103
+ className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border"
104
+ style={{ background: visible ? color : "transparent", borderColor: color, opacity: 0.85 }}
105
+ >
106
+ {visible && (
107
+ <svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
108
+ <path d="M5 12l5 5L20 6" />
109
+ </svg>
110
+ )}
111
+ </span>
112
+ <span className="flex-1 text-left">{label}</span>
113
+ <span className="text-neutral-500">{loading ? "Buscando…" : count != null ? count : ""}</span>
114
+ </button>
115
+ );
116
+ }
117
+
78
118
  export default function FavoritesMapPage() {
79
119
  const [rows, setRows] = useState<FavRow[] | null>(null);
80
120
  const [hoveredId, setHoveredId] = useState<string | null>(null);
@@ -92,13 +132,19 @@ export default function FavoritesMapPage() {
92
132
  const [gymsVisible, setGymsVisible] = useState(false);
93
133
  const [gymsLoading, setGymsLoading] = useState(false);
94
134
  const [gymCount, setGymCount] = useState<number | null>(null);
135
+ const [cafesVisible, setCafesVisible] = useState(false);
136
+ const [cafesLoading, setCafesLoading] = useState(false);
137
+ const [cafeCount, setCafeCount] = useState<number | null>(null);
138
+ const [showLayers, setShowLayers] = useState(false);
95
139
  const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
96
140
  const [bedFilter, setBedFilter] = useState<number[]>([]);
141
+ const [expandedId, setExpandedId] = useState<string | null>(null);
97
142
 
98
143
  const mapRef = useRef<LeafletMap | null>(null);
99
144
  const leafletRef = useRef<typeof import("leaflet") | null>(null);
100
145
  const markersRef = useRef<Record<string, Marker>>({});
101
146
  const gymLayerRef = useRef<LayerGroup | null>(null);
147
+ const cafeLayerRef = useRef<LayerGroup | null>(null);
102
148
  const containerRef = useRef<HTMLDivElement | null>(null);
103
149
  const didFitRef = useRef(false);
104
150
  const processing = useRef(false);
@@ -181,6 +227,7 @@ export default function FavoritesMapPage() {
181
227
  mapRef.current = null;
182
228
  markersRef.current = {};
183
229
  gymLayerRef.current = null;
230
+ cafeLayerRef.current = null;
184
231
  didFitRef.current = false;
185
232
  setMapReady(false);
186
233
  };
@@ -313,52 +360,81 @@ export default function FavoritesMapPage() {
313
360
  }
314
361
  };
315
362
 
316
- const toggleGyms = async () => {
363
+ // Build a Leaflet layer of POI markers (gyms / cafés) with a Google Maps popup.
364
+ const buildPoiLayer = (
365
+ L: typeof import("leaflet"),
366
+ items: { name: string; lat: number; lon: number }[],
367
+ cssClass: string,
368
+ svg: string
369
+ ) => {
370
+ const layer = L.layerGroup();
371
+ for (const p of items) {
372
+ const icon = L.divIcon({ className: "", html: `<div class="${cssClass}">${svg}</div>`, iconSize: [0, 0], iconAnchor: [0, 0] });
373
+ const safeName = p.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
374
+ // Business name in the query, exact spot in the /@lat,lon path (not the query text):
375
+ // a named POI opens its own Maps place card; the coordinates never clutter the search
376
+ // box and only bias the map to Dubai so the right branch/spot is what's shown.
377
+ const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(p.name)}/@${p.lat},${p.lon},17z`;
378
+ L.marker([p.lat, p.lon], { icon })
379
+ .bindPopup(
380
+ `<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
381
+ { offset: [0, -16] }
382
+ )
383
+ .addTo(layer);
384
+ }
385
+ return layer;
386
+ };
387
+
388
+ const togglePoi = async (opts: {
389
+ api: string;
390
+ dataKey: string;
391
+ cssClass: string;
392
+ svg: string;
393
+ layerRef: React.MutableRefObject<LayerGroup | null>;
394
+ visible: boolean;
395
+ setVisible: (v: boolean) => void;
396
+ setLoading: (v: boolean) => void;
397
+ setCount: (v: number | null) => void;
398
+ }) => {
317
399
  const map = mapRef.current;
318
400
  const L = leafletRef.current;
319
401
  if (!map || !L) return;
320
- if (gymLayerRef.current) {
321
- if (gymsVisible) map.removeLayer(gymLayerRef.current);
322
- else gymLayerRef.current.addTo(map);
323
- setGymsVisible(!gymsVisible);
402
+ if (opts.layerRef.current) {
403
+ if (opts.visible) map.removeLayer(opts.layerRef.current);
404
+ else opts.layerRef.current.addTo(map);
405
+ opts.setVisible(!opts.visible);
324
406
  return;
325
407
  }
326
- setGymsLoading(true);
408
+ opts.setLoading(true);
327
409
  try {
328
- const res = await fetch("/api/gyms");
410
+ const res = await fetch(opts.api);
329
411
  const j = await res.json();
330
412
  if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
331
- const layer = L.layerGroup();
332
- for (const g of j.gyms as { name: string; lat: number; lon: number }[]) {
333
- const icon = L.divIcon({
334
- className: "",
335
- html: `<div class="bw-gym">${GYM_SVG}</div>`,
336
- iconSize: [0, 0],
337
- iconAnchor: [0, 0],
338
- });
339
- const safeName = g.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
340
- // Link to the exact coordinates, not a text search: a name search gets biased
341
- // to the viewer's own location (e.g. Andorra), while coordinates always drop
342
- // the pin on this gym's spot in Dubai.
343
- const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${g.lat}%2C${g.lon}`;
344
- L.marker([g.lat, g.lon], { icon })
345
- .bindPopup(
346
- `<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
347
- { offset: [0, -16] }
348
- )
349
- .addTo(layer);
350
- }
413
+ const items = j[opts.dataKey] as { name: string; lat: number; lon: number }[];
414
+ const layer = buildPoiLayer(L, items, opts.cssClass, opts.svg);
351
415
  layer.addTo(map);
352
- gymLayerRef.current = layer;
353
- setGymCount(j.gyms.length);
354
- setGymsVisible(true);
416
+ opts.layerRef.current = layer;
417
+ opts.setCount(items.length);
418
+ opts.setVisible(true);
355
419
  } catch {
356
- setGymCount(null);
420
+ opts.setCount(null);
357
421
  } finally {
358
- setGymsLoading(false);
422
+ opts.setLoading(false);
359
423
  }
360
424
  };
361
425
 
426
+ const toggleGyms = () =>
427
+ togglePoi({
428
+ api: "/api/gyms", dataKey: "gyms", cssClass: "bw-gym", svg: GYM_SVG,
429
+ layerRef: gymLayerRef, visible: gymsVisible, setVisible: setGymsVisible, setLoading: setGymsLoading, setCount: setGymCount,
430
+ });
431
+
432
+ const toggleCafes = () =>
433
+ togglePoi({
434
+ api: "/api/cafes", dataKey: "cafes", cssClass: "bw-cafe", svg: CAFE_SVG,
435
+ layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
436
+ });
437
+
362
438
  // --- Resizable divider ---------------------------------------------------
363
439
  const startDrag = (e: React.MouseEvent) => {
364
440
  e.preventDefault();
@@ -422,11 +498,24 @@ export default function FavoritesMapPage() {
422
498
  <div className="min-w-0 flex-1">
423
499
  <div className="flex items-start justify-between gap-2">
424
500
  <button
425
- onClick={() => focus(r)}
426
- className="line-clamp-1 text-left font-sans text-sm font-semibold normal-case tracking-normal hover:underline"
427
- title="Centrar en el mapa"
501
+ onClick={() => setExpandedId((id) => (id === r.id ? null : r.id))}
502
+ className="flex min-w-0 items-center gap-1 text-left font-sans text-sm font-semibold normal-case tracking-normal hover:underline"
503
+ title="Ver previsualización"
428
504
  >
429
- {r.title}
505
+ <span className="truncate">{r.title}</span>
506
+ <svg
507
+ viewBox="0 0 24 24"
508
+ width="12"
509
+ height="12"
510
+ fill="none"
511
+ stroke="currentColor"
512
+ strokeWidth="2.5"
513
+ strokeLinecap="round"
514
+ strokeLinejoin="round"
515
+ className={`shrink-0 text-neutral-400 transition-transform ${expandedId === r.id ? "rotate-180" : ""}`}
516
+ >
517
+ <path d="M6 9l6 6 6-6" />
518
+ </svg>
430
519
  </button>
431
520
  <button
432
521
  onClick={() => remove(r.id)}
@@ -473,6 +562,29 @@ export default function FavoritesMapPage() {
473
562
  </span>
474
563
  </div>
475
564
 
565
+ {/* Preview desplegable al pulsar el título */}
566
+ {expandedId === r.id && (
567
+ <div className="mt-2 rounded-md border border-neutral-200 p-2 dark:border-neutral-800">
568
+ {r.images.length > 0 && (
569
+ <div className="flex gap-1.5 overflow-x-auto pb-1">
570
+ {r.images.slice(0, 10).map((img, i) => (
571
+ // eslint-disable-next-line @next/next/no-img-element
572
+ <img key={i} src={img.medium} alt="" className="h-24 w-36 shrink-0 rounded object-cover" loading="lazy" />
573
+ ))}
574
+ </div>
575
+ )}
576
+ {r.description && (
577
+ <p className="mt-1.5 line-clamp-4 whitespace-pre-line text-xs text-neutral-500">{r.description}</p>
578
+ )}
579
+ <Link
580
+ href={`/listing/${r.id}`}
581
+ className="btn-font mt-2 inline-flex items-center gap-1.5 rounded bg-blue-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-blue-500"
582
+ >
583
+ {MORE_SVG} Ver más
584
+ </Link>
585
+ </div>
586
+ )}
587
+
476
588
  <textarea
477
589
  className="mt-2 h-9 w-full resize-none rounded-md border border-transparent bg-neutral-50 px-2 py-1 text-xs outline-none focus:border-neutral-300 dark:bg-neutral-950 dark:focus:border-neutral-700"
478
590
  defaultValue={r.notes}
@@ -508,32 +620,37 @@ export default function FavoritesMapPage() {
508
620
  value={input}
509
621
  onChange={(e) => setInput(e.target.value)}
510
622
  />
511
- <div className="mt-2 flex items-center gap-2">
623
+ <div className="mt-2 flex flex-col gap-1.5">
512
624
  <button
513
625
  type="submit"
514
626
  disabled={!input.trim() || busy}
515
- className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
627
+ className="w-full rounded-lg bg-blue-600 py-2 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
516
628
  >
517
629
  {busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
518
630
  </button>
519
- <button
520
- type="button"
521
- onClick={toggleGyms}
522
- disabled={gymsLoading || (rows?.length ?? 0) === 0}
523
- className="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
524
- >
525
- {gymsLoading
526
- ? "Buscando…"
527
- : gymCount != null
528
- ? gymsVisible
529
- ? `Ocultar gyms (${gymCount})`
530
- : `Mostrar gyms (${gymCount})`
531
- : "Gimnasios"}
532
- </button>
631
+ <div className="relative">
632
+ <button
633
+ type="button"
634
+ onClick={() => setShowLayers((v) => !v)}
635
+ disabled={(rows?.length ?? 0) === 0}
636
+ className="flex w-full items-center justify-center gap-1 rounded-lg border border-neutral-300 py-2 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
637
+ >
638
+ Puntos cercanos
639
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform ${showLayers ? "rotate-180" : ""}`}>
640
+ <path d="M6 9l6 6 6-6" />
641
+ </svg>
642
+ </button>
643
+ {showLayers && (
644
+ <div className="absolute inset-x-0 top-full z-30 mt-1 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
645
+ <LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
646
+ <LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
647
+ </div>
648
+ )}
649
+ </div>
533
650
  <button
534
651
  type="button"
535
652
  onClick={() => setShowSync((v) => !v)}
536
- className={`rounded-lg border px-3 py-1.5 text-xs font-semibold ${
653
+ className={`w-full rounded-lg border py-2 text-xs font-semibold ${
537
654
  showSync
538
655
  ? "border-blue-500 text-blue-600 dark:text-blue-400"
539
656
  : "border-neutral-300 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
@@ -667,7 +784,26 @@ export default function FavoritesMapPage() {
667
784
  />
668
785
 
669
786
  {/* ------------------------------------------------ map */}
670
- <div ref={containerRef} className="min-h-0 min-w-0 flex-1" />
787
+ <div className="relative min-h-0 min-w-0 flex-1">
788
+ <div ref={containerRef} className="absolute inset-0" />
789
+ <div className="pointer-events-none absolute bottom-4 left-3 z-[500] rounded border border-black/10 bg-white/90 px-2.5 py-2 text-neutral-700 shadow-lg dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-200">
790
+ <div className="btn-font mb-1 text-[9px] text-neutral-500">Rentabilidad anual</div>
791
+ <div
792
+ className="h-2 w-52 rounded-sm"
793
+ style={{ background: "linear-gradient(90deg, hsl(0,66%,44%), hsl(87,66%,44%), hsl(174,66%,44%))" }}
794
+ />
795
+ <div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
796
+ <span>Mediocre</span>
797
+ <span>Bueno</span>
798
+ <span>Espectacular</span>
799
+ </div>
800
+ <div className="flex w-52 justify-between text-[9px] text-neutral-500">
801
+ <span>≤6%</span>
802
+ <span>8–9%</span>
803
+ <span>10%+</span>
804
+ </div>
805
+ </div>
806
+ </div>
671
807
  </div>
672
808
  );
673
809
  }
@@ -26,11 +26,11 @@
26
26
  }
27
27
 
28
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;
29
+ /* Neutral dark grays anchored to the #181818 background */
30
+ --color-neutral-950: #121212; /* deep insets / header */
31
+ --color-neutral-900: #181818; /* panels & cards == background */
32
+ --color-neutral-800: #2a2a2a; /* borders, dividers, hover (subtle) */
33
+ --color-neutral-700: #3a3a3a;
34
34
 
35
35
  /* Brick accent — remaps every blue-* usage in the app to color ladrillo */
36
36
  --color-blue-50: #faeee8;
@@ -58,7 +58,7 @@
58
58
 
59
59
  @media (prefers-color-scheme: dark) {
60
60
  :root {
61
- --background: #2c2c2c; /* gris muy oscuro (44,44,44) */
61
+ --background: #181818;
62
62
  --foreground: #ededed;
63
63
  }
64
64
  }
@@ -159,17 +159,30 @@ button[class*="bg-blue-5"],
159
159
  border-color: rgba(255, 255, 255, 0.4);
160
160
  }
161
161
 
162
- /* Gym marker: brick square with a white dumbbell. */
163
- .bw-gym {
162
+ /* POI markers (gym / café): semi-transparent square with a white icon. */
163
+ .bw-gym,
164
+ .bw-cafe {
164
165
  width: 24px;
165
166
  height: 24px;
166
167
  border-radius: 2px;
167
- background: var(--accent);
168
- border: 1px solid rgba(255, 255, 255, 0.85);
168
+ border: 1px solid rgba(255, 255, 255, 0.7);
169
169
  display: flex;
170
170
  align-items: center;
171
171
  justify-content: center;
172
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
172
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
173
173
  transform: translate(-50%, -50%);
174
174
  cursor: pointer;
175
+ transition: opacity 0.12s ease;
176
+ }
177
+ .bw-gym {
178
+ background: rgba(184, 79, 48, 0.68); /* brick, transparent */
179
+ opacity: 0.9;
180
+ }
181
+ .bw-cafe {
182
+ background: rgba(111, 78, 55, 0.68); /* coffee, transparent */
183
+ opacity: 0.9;
184
+ }
185
+ .bw-gym:hover,
186
+ .bw-cafe:hover {
187
+ opacity: 1;
175
188
  }
@@ -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
  }
@@ -0,0 +1,188 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { createHash } from "crypto";
3
+ import { getDb, now } from "./db";
4
+
5
+ // Overpass instances are flaky under load; try each twice, in order.
6
+ const OVERPASS_ENDPOINTS = [
7
+ "https://overpass-api.de/api/interpreter",
8
+ "https://overpass-api.de/api/interpreter",
9
+ "https://overpass.private.coffee/api/interpreter",
10
+ "https://overpass.kumi.systems/api/interpreter",
11
+ ];
12
+ const RADIUS_M = 2000;
13
+ const TTL_MS = 30 * 24 * 3600 * 1000;
14
+
15
+ export type PoiKind = "gym" | "cafe";
16
+
17
+ // OSM tag filters + default label per kind.
18
+ const POI = {
19
+ gym: { tag: `["leisure"="fitness_centre"]`, label: "Gym" },
20
+ cafe: { tag: `["amenity"="cafe"]`, label: "Café" },
21
+ } as const;
22
+
23
+ export interface Poi {
24
+ name: string;
25
+ lat: number;
26
+ lon: number;
27
+ distance_m: number;
28
+ }
29
+
30
+ function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number {
31
+ const R = 6371000;
32
+ const toRad = (d: number) => (d * Math.PI) / 180;
33
+ const dLat = toRad(lat2 - lat1);
34
+ const dLon = toRad(lon2 - lon1);
35
+ const a =
36
+ Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
37
+ return 2 * R * Math.asin(Math.sqrt(a));
38
+ }
39
+
40
+ // Overpass allows very few concurrent requests per IP — serialize ours.
41
+ let queue: Promise<unknown> = Promise.resolve();
42
+
43
+ /** All POIs of `kind` within 2 km, nearest first; cached by ~100 m grid cell. */
44
+ export function poisNear(lat: number, lon: number, kind: PoiKind): Promise<Poi[] | null> {
45
+ const task = queue.then(() => doPoisNear(lat, lon, kind));
46
+ queue = task.catch(() => null);
47
+ return task;
48
+ }
49
+
50
+ /** null = Overpass unavailable (not cached); [] = confirmed none nearby. */
51
+ async function doPoisNear(lat: number, lon: number, kind: PoiKind): Promise<Poi[] | null> {
52
+ const db = getDb();
53
+ const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
54
+ const cached = db.prepare(`SELECT * FROM poi_list_cache WHERE geo_key = ? AND kind = ?`).get(geoKey, kind) as any;
55
+ if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
56
+ return JSON.parse(cached.json);
57
+ }
58
+
59
+ const tag = POI[kind].tag;
60
+ const query = `[out:json][timeout:15];(node${tag}(around:${RADIUS_M},${lat},${lon});way${tag}(around:${RADIUS_M},${lat},${lon}););out center 80;`;
61
+ const j = await overpassJson(query, 12000);
62
+ if (!j) return null; // all endpoints down: unknown, not cached
63
+ const pois: Poi[] = [];
64
+ for (const el of j.elements || []) {
65
+ const eLat = el.lat ?? el.center?.lat;
66
+ const eLon = el.lon ?? el.center?.lon;
67
+ if (eLat == null || eLon == null) continue;
68
+ pois.push({
69
+ name: el.tags?.name || POI[kind].label,
70
+ lat: eLat,
71
+ lon: eLon,
72
+ distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
73
+ });
74
+ }
75
+ pois.sort((a, b) => a.distance_m - b.distance_m);
76
+ db.prepare(`INSERT OR REPLACE INTO poi_list_cache (geo_key, kind, json, fetched_at) VALUES (?, ?, ?, ?)`).run(
77
+ geoKey,
78
+ kind,
79
+ JSON.stringify(pois),
80
+ now()
81
+ );
82
+ return pois;
83
+ }
84
+
85
+ /** POST an Overpass query, trying each endpoint until one answers; null if all fail. */
86
+ async function overpassJson(query: string, timeoutMs: number): Promise<any | null> {
87
+ for (const endpoint of OVERPASS_ENDPOINTS) {
88
+ try {
89
+ const res = await fetch(endpoint, {
90
+ method: "POST",
91
+ body: "data=" + encodeURIComponent(query),
92
+ headers: {
93
+ "Content-Type": "application/x-www-form-urlencoded",
94
+ "User-Agent": "brickwise-local/0.1 (personal use)",
95
+ },
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ });
98
+ if (!res.ok) continue;
99
+ const j = await res.json();
100
+ if (j.remark && !(j.elements || []).length) continue;
101
+ return j;
102
+ } catch {
103
+ continue;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * All POIs of `kind` near ANY of the given points, in a SINGLE Overpass query
111
+ * (one request for the whole map, not one per property). Cached by the point set.
112
+ */
113
+ export function poisForPoints(
114
+ points: { lat: number; lon: number }[],
115
+ kind: PoiKind
116
+ ): Promise<{ pois: Poi[]; failed: boolean }> {
117
+ const task = queue.then(() => doPoisForPoints(points, kind));
118
+ queue = task.catch(() => ({ pois: [] as Poi[], failed: true }));
119
+ return task;
120
+ }
121
+
122
+ async function doPoisForPoints(
123
+ points: { lat: number; lon: number }[],
124
+ kind: PoiKind
125
+ ): Promise<{ pois: Poi[]; failed: boolean }> {
126
+ const db = getDb();
127
+ // One ~100 m cell per cluster of nearby favorites (key format == doPoisNear's).
128
+ const cellMap = new Map<string, { lat: number; lon: number }>();
129
+ for (const p of points) {
130
+ const key = `${p.lat.toFixed(3)},${p.lon.toFixed(3)}`;
131
+ if (!cellMap.has(key)) cellMap.set(key, { lat: p.lat, lon: p.lon });
132
+ }
133
+ if (!cellMap.size) return { pois: [], failed: false };
134
+
135
+ const dedupe = (list: Poi[]) => {
136
+ const m = new Map<string, Poi>();
137
+ for (const p of list) m.set(`${p.lat.toFixed(5)},${p.lon.toFixed(5)}`, p);
138
+ return [...m.values()];
139
+ };
140
+ const fresh = (row: any) => row && Date.now() - Date.parse(row.fetched_at) < TTL_MS;
141
+ const get = (key: string) => db.prepare(`SELECT * FROM poi_list_cache WHERE geo_key = ? AND kind = ?`).get(key, kind) as any;
142
+ const put = (key: string, pois: Poi[]) =>
143
+ db.prepare(`INSERT OR REPLACE INTO poi_list_cache (geo_key, kind, json, fetched_at) VALUES (?, ?, ?, ?)`).run(key, kind, JSON.stringify(pois), now());
144
+
145
+ const allKeys = [...cellMap.keys()].sort();
146
+ const multiKey = "multi:" + createHash("md5").update(allKeys.join(";")).digest("hex");
147
+
148
+ // 1) Whole-set cache.
149
+ const multi = get(multiKey);
150
+ if (fresh(multi)) return { pois: JSON.parse(multi.json), failed: false };
151
+
152
+ // 2) Reuse per-cell cache; only query Overpass for what's missing.
153
+ const collected: Poi[] = [];
154
+ const missing: { lat: number; lon: number }[] = [];
155
+ for (const [key, c] of cellMap) {
156
+ const cc = get(key);
157
+ if (fresh(cc)) collected.push(...(JSON.parse(cc.json) as Poi[]));
158
+ else missing.push(c);
159
+ }
160
+ if (missing.length === 0) {
161
+ const pois = dedupe(collected);
162
+ put(multiKey, pois);
163
+ return { pois, failed: false };
164
+ }
165
+
166
+ // 3) One combined Overpass query for the missing cells.
167
+ const tag = POI[kind].tag;
168
+ const clauses = missing
169
+ .map((c) => `node${tag}(around:${RADIUS_M},${c.lat},${c.lon});way${tag}(around:${RADIUS_M},${c.lat},${c.lon});`)
170
+ .join("");
171
+ const query = `[out:json][timeout:90];(${clauses});out center 600;`;
172
+ const j = await overpassJson(query, 40000);
173
+ if (!j) {
174
+ // 4) Overpass down: return whatever we already had cached (partial).
175
+ const pois = dedupe(collected);
176
+ return { pois, failed: pois.length === 0 };
177
+ }
178
+
179
+ for (const el of j.elements || []) {
180
+ const eLat = el.lat ?? el.center?.lat;
181
+ const eLon = el.lon ?? el.center?.lon;
182
+ if (eLat == null || eLon == null) continue;
183
+ collected.push({ name: el.tags?.name || POI[kind].label, lat: eLat, lon: eLon, distance_m: 0 });
184
+ }
185
+ const pois = dedupe(collected);
186
+ put(multiKey, pois);
187
+ return { pois, failed: false };
188
+ }
@@ -0,0 +1,11 @@
1
+ // Shared yield → color scale used by both the map pins and the list badge.
2
+ // ≤6% red (mediocre) → 10%+ turquoise (espectacular); 8–9% around green (bueno).
3
+ export const YIELD_LOW = 0.06;
4
+ export const YIELD_HIGH = 0.1;
5
+
6
+ export function yieldColor(y: number | null): string {
7
+ if (y == null) return "#8a8a8a";
8
+ const t = Math.min(1, Math.max(0, (y - YIELD_LOW) / (YIELD_HIGH - YIELD_LOW)));
9
+ const hue = t * 174; // 0° red → 174° turquoise
10
+ return `hsl(${Math.round(hue)}, 66%, 44%)`;
11
+ }