brickwise 0.1.21 → 0.1.24
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/bin/brickwise.js +31 -2
- package/package.json +1 -1
- package/src/app/api/liquidity/route.ts +8 -13
- package/src/app/favorites/page.tsx +19 -8
- package/src/app/listing/[id]/page.tsx +25 -2
- package/src/lib/store.ts +19 -12
- package/src/lib/trend.ts +76 -0
package/bin/brickwise.js
CHANGED
|
@@ -74,9 +74,38 @@ function openBrowser(url) {
|
|
|
74
74
|
syncSources();
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Reinstall deps on a fresh copy AND whenever the app version changed — an
|
|
78
|
+
// update can add dependencies (e.g. leaflet.heat), and node_modules is not
|
|
79
|
+
// copied over, so a stale install would be missing them. A version marker
|
|
80
|
+
// inside node_modules tells us what was last installed there.
|
|
81
|
+
const marker = path.join(workdir, "node_modules", ".brickwise-version");
|
|
82
|
+
const currentVer = (() => {
|
|
83
|
+
try {
|
|
84
|
+
return require(path.join(workdir, "package.json")).version || "";
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
const installedVer = (() => {
|
|
90
|
+
try {
|
|
91
|
+
return fs.readFileSync(marker, "utf8").trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
const nextMissing = !fs.existsSync(path.join(workdir, "node_modules", "next"));
|
|
97
|
+
if (nextMissing || installedVer !== currentVer) {
|
|
98
|
+
step(
|
|
99
|
+
nextMissing
|
|
100
|
+
? "Instalando dependencias (solo la primera vez, ~1 min)…"
|
|
101
|
+
: "Actualizando dependencias tras la actualización…"
|
|
102
|
+
);
|
|
79
103
|
run(npm, ["install", "--include=dev", "--no-audit", "--no-fund"], workdir);
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(marker, currentVer);
|
|
106
|
+
} catch {
|
|
107
|
+
/* non-fatal: worst case we reinstall next launch */
|
|
108
|
+
}
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
const port = await findFreePort(Number(process.env.PORT) || 3000);
|
package/package.json
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
const MONTHS = 12;
|
|
2
|
+
import { annualLiquidityByTower, listFavorites } from "@/lib/store";
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
|
-
* Liquidity of each located favorite's tower = DLD transactions
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Liquidity of each located favorite's tower = average annual DLD transactions of
|
|
6
|
+
* `kind` (via ?kind=buy|rent, defaults to rent). Feeds the heatmap: each favorite
|
|
7
|
+
* is a weighted point, so nearby towers blend into a "how active is this pocket"
|
|
8
|
+
* surface for that market (rentals vs sales). Weight unit is transactions/year.
|
|
11
9
|
*/
|
|
12
10
|
export async function GET(req: NextRequest) {
|
|
13
11
|
try {
|
|
14
12
|
const kindParam = req.nextUrl.searchParams.get("kind");
|
|
15
13
|
const kind: "buy" | "rent" = kindParam === "buy" ? "buy" : "rent";
|
|
16
|
-
const
|
|
17
|
-
since.setMonth(since.getMonth() - MONTHS);
|
|
18
|
-
const sinceISO = since.toISOString().slice(0, 10);
|
|
19
|
-
const counts = txCountByTowerSince(sinceISO, kind);
|
|
14
|
+
const rates = annualLiquidityByTower(kind);
|
|
20
15
|
|
|
21
16
|
const points = listFavorites()
|
|
22
17
|
.filter((f) => f.lat != null && f.lon != null)
|
|
@@ -24,11 +19,11 @@ export async function GET(req: NextRequest) {
|
|
|
24
19
|
id: f.id,
|
|
25
20
|
lat: f.lat as number,
|
|
26
21
|
lon: f.lon as number,
|
|
27
|
-
weight: f.tower_slug ?
|
|
22
|
+
weight: f.tower_slug ? rates.get(f.tower_slug) ?? 0 : 0,
|
|
28
23
|
}));
|
|
29
24
|
|
|
30
25
|
const max = points.reduce((m, p) => Math.max(m, p.weight), 0);
|
|
31
|
-
return NextResponse.json({ points, max,
|
|
26
|
+
return NextResponse.json({ points, max, kind, unit: "year" });
|
|
32
27
|
} catch (e) {
|
|
33
28
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
34
29
|
}
|
|
@@ -154,6 +154,7 @@ export default function FavoritesMapPage() {
|
|
|
154
154
|
const [heatKind, setHeatKind] = useState<"rent" | "buy" | null>(null);
|
|
155
155
|
const [heatLoading, setHeatLoading] = useState<"rent" | "buy" | null>(null);
|
|
156
156
|
const [heatMax, setHeatMax] = useState<number | null>(null);
|
|
157
|
+
const [heatError, setHeatError] = useState<string | null>(null);
|
|
157
158
|
const [showLayers, setShowLayers] = useState(false);
|
|
158
159
|
const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
|
|
159
160
|
const [bedFilter, setBedFilter] = useState<number[]>([]);
|
|
@@ -201,6 +202,9 @@ export default function FavoritesMapPage() {
|
|
|
201
202
|
zoomControl: true,
|
|
202
203
|
scrollWheelZoom: false, // replaced by the trackpad handler below
|
|
203
204
|
zoomSnap: 0, // allow fractional zoom for smooth pinch
|
|
205
|
+
// leaflet.heat ghosts (duplicate shifted blobs) during the animated-zoom
|
|
206
|
+
// transform; snap zoom so the heat canvas redraws cleanly at each zoom end.
|
|
207
|
+
zoomAnimation: false,
|
|
204
208
|
});
|
|
205
209
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
206
210
|
maxZoom: 19,
|
|
@@ -513,11 +517,14 @@ export default function FavoritesMapPage() {
|
|
|
513
517
|
heatLayerRef.current = null;
|
|
514
518
|
}
|
|
515
519
|
setHeatLoading(kind);
|
|
520
|
+
setHeatError(null);
|
|
516
521
|
try {
|
|
517
522
|
const res = await fetch(`/api/liquidity?kind=${kind}`);
|
|
518
523
|
const j = await res.json();
|
|
519
524
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
520
|
-
await import("leaflet.heat")
|
|
525
|
+
await import("leaflet.heat").catch(() => {
|
|
526
|
+
throw new Error("Falta la dependencia del mapa de calor. Cierra la app y vuelve a arrancarla para que se instale.");
|
|
527
|
+
}); // patches L.heatLayer
|
|
521
528
|
const max = j.max || 1;
|
|
522
529
|
setHeatMax(j.max ?? null);
|
|
523
530
|
// Each favorite blooms proportionally to its tower's recent activity; a small
|
|
@@ -541,8 +548,9 @@ export default function FavoritesMapPage() {
|
|
|
541
548
|
layer.addTo(map);
|
|
542
549
|
heatLayerRef.current = layer;
|
|
543
550
|
setHeatKind(kind);
|
|
544
|
-
} catch {
|
|
551
|
+
} catch (e) {
|
|
545
552
|
setHeatKind(null);
|
|
553
|
+
setHeatError((e as Error).message || "No se pudo cargar el mapa de calor.");
|
|
546
554
|
} finally {
|
|
547
555
|
setHeatLoading(null);
|
|
548
556
|
}
|
|
@@ -762,14 +770,17 @@ export default function FavoritesMapPage() {
|
|
|
762
770
|
<LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
|
|
763
771
|
<LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
|
|
764
772
|
<div className="my-1 border-t border-neutral-200 dark:border-neutral-800" />
|
|
765
|
-
<p className="px-2 pb-0.5 pt-0.5 text-[10px] uppercase tracking-wide text-neutral-400">Mapa de calor de liquidez
|
|
773
|
+
<p className="px-2 pb-0.5 pt-0.5 text-[10px] uppercase tracking-wide text-neutral-400">Mapa de calor de liquidez media anual</p>
|
|
766
774
|
<LayerToggle label="Alquiler" color="#d946ef" loading={heatLoading === "rent"} visible={heatKind === "rent"} count={null} onToggle={() => toggleLiquidity("rent")} />
|
|
767
775
|
<LayerToggle label="Venta" color="#7c3aed" loading={heatLoading === "buy"} visible={heatKind === "buy"} count={null} onToggle={() => toggleLiquidity("buy")} />
|
|
768
776
|
{heatKind && (
|
|
769
777
|
<p className="px-2 pb-1 pt-0.5 text-[10px] leading-tight text-neutral-500">
|
|
770
|
-
{heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} (DLD) por edificio,
|
|
778
|
+
{heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} (DLD) por edificio, media al año. Amarillo = poco líquido, morado = mucha actividad.
|
|
771
779
|
</p>
|
|
772
780
|
)}
|
|
781
|
+
{heatError && (
|
|
782
|
+
<p className="px-2 pb-1 pt-0.5 text-[10px] leading-tight text-red-600 dark:text-red-400">{heatError}</p>
|
|
783
|
+
)}
|
|
773
784
|
</div>
|
|
774
785
|
)}
|
|
775
786
|
</div>
|
|
@@ -915,7 +926,7 @@ export default function FavoritesMapPage() {
|
|
|
915
926
|
{heatKind && (
|
|
916
927
|
<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">
|
|
917
928
|
<div className="btn-font mb-1 text-[9px] text-neutral-500">
|
|
918
|
-
Liquidez
|
|
929
|
+
Liquidez media anual · {heatKind === "rent" ? "contratos" : "ventas"}/año
|
|
919
930
|
</div>
|
|
920
931
|
<div
|
|
921
932
|
className="h-2 w-52 rounded-sm"
|
|
@@ -923,15 +934,15 @@ export default function FavoritesMapPage() {
|
|
|
923
934
|
/>
|
|
924
935
|
<div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
|
|
925
936
|
<span>0</span>
|
|
926
|
-
{heatMax != null && <span>{(heatMax /
|
|
927
|
-
{heatMax != null && <span>{(heatMax
|
|
937
|
+
{heatMax != null && <span>{Math.round(heatMax / 2)}</span>}
|
|
938
|
+
{heatMax != null && <span>{Math.round(heatMax)}</span>}
|
|
928
939
|
</div>
|
|
929
940
|
<div className="flex w-52 justify-between text-[9px] text-neutral-500">
|
|
930
941
|
<span>parado</span>
|
|
931
942
|
<span>activo</span>
|
|
932
943
|
</div>
|
|
933
944
|
<div className="mt-0.5 w-52 text-[9px] leading-tight text-neutral-400">
|
|
934
|
-
{heatKind === "rent" ? "Contratos de alquiler
|
|
945
|
+
{heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} en el DLD por edificio, media de operaciones por año.
|
|
935
946
|
</div>
|
|
936
947
|
</div>
|
|
937
948
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { use, useEffect, useState } from "react";
|
|
3
|
+
import { use, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import YieldBadge from "@/components/YieldBadge";
|
|
5
5
|
import TransactionsTable from "@/components/TransactionsTable";
|
|
6
6
|
import PsqftChart from "@/components/PsqftChart";
|
|
@@ -8,6 +8,7 @@ import ReformScenario from "@/components/ReformScenario";
|
|
|
8
8
|
import { fmtAED, fmtDist, fmtPct, fmtSqft } from "@/lib/format";
|
|
9
9
|
import { AnalysisRow } from "@/lib/types";
|
|
10
10
|
import { TxRow } from "@/lib/pf/transactions";
|
|
11
|
+
import { discountVsTrend, psqftTrend } from "@/lib/trend";
|
|
11
12
|
|
|
12
13
|
interface Detail {
|
|
13
14
|
listing: {
|
|
@@ -56,6 +57,15 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
56
57
|
.catch((e) => setError(e.message));
|
|
57
58
|
}, [id]);
|
|
58
59
|
|
|
60
|
+
// Discount of the asking price vs the sale trend (same trend line as the chart).
|
|
61
|
+
const discount = useMemo(() => {
|
|
62
|
+
if (!data) return null;
|
|
63
|
+
const { listing, transactions } = data;
|
|
64
|
+
const trend = psqftTrend(transactions?.buy ?? [], listing.size_sqft, listing.bedrooms);
|
|
65
|
+
const askingPsqft = listing.size_sqft ? listing.price / listing.size_sqft : null;
|
|
66
|
+
return discountVsTrend(trend, askingPsqft);
|
|
67
|
+
}, [data]);
|
|
68
|
+
|
|
59
69
|
if (error)
|
|
60
70
|
return (
|
|
61
71
|
<div className="mx-auto w-full max-w-7xl px-4 py-6">
|
|
@@ -143,11 +153,20 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
143
153
|
<section>
|
|
144
154
|
<h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-neutral-500">Datos básicos</h2>
|
|
145
155
|
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
146
|
-
<p className="flex items-baseline gap-2">
|
|
156
|
+
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
147
157
|
<span className="text-3xl font-bold" style={{ color: "var(--accent)" }}>{fmtAED(listing.price)}</span>
|
|
148
158
|
{listing.size_sqft ? (
|
|
149
159
|
<span className="text-sm text-neutral-500">· {Math.round(listing.price / listing.size_sqft)} AED/sqft</span>
|
|
150
160
|
) : null}
|
|
161
|
+
{discount != null && (
|
|
162
|
+
<span
|
|
163
|
+
className="btn-font ml-1 self-center rounded px-2 py-0.5 text-[11px] font-bold text-white"
|
|
164
|
+
style={{ background: discount >= 0 ? "#059669" : "#dc2626" }}
|
|
165
|
+
title="Precio del anuncio frente a la tendencia de AED/sqft de ventas comparables del edificio"
|
|
166
|
+
>
|
|
167
|
+
{discount >= 0 ? "▼" : "▲"} {Math.abs(discount * 100).toFixed(0)}% {discount >= 0 ? "bajo tendencia" : "sobre tendencia"}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
151
170
|
</p>
|
|
152
171
|
<div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
153
172
|
<Fact label="Superficie" value={fmtSqft(listing.size_sqft)} />
|
|
@@ -268,6 +287,10 @@ const ANALYSIS_HELP: { term: string; desc: string }[] = [
|
|
|
268
287
|
term: "Rentabilidad sobre el precio del anuncio",
|
|
269
288
|
desc: "Renta anual mediana ÷ el precio que pide ESTE anuncio. Indica si este piso, a su precio, rinde por encima o por debajo de la mediana del edificio.",
|
|
270
289
|
},
|
|
290
|
+
{
|
|
291
|
+
term: "Descuento vs tendencia",
|
|
292
|
+
desc: "AED/sqft del anuncio frente a la línea de tendencia de ventas comparables (la misma del gráfico), evaluada en la fecha más reciente. Verde = por debajo de la tendencia (descuento); rojo = por encima (sobreprecio).",
|
|
293
|
+
},
|
|
271
294
|
{
|
|
272
295
|
term: "Gimnasio más cercano",
|
|
273
296
|
desc: "Distancia en línea recta al gimnasio más próximo según OpenStreetMap.",
|
package/src/lib/store.ts
CHANGED
|
@@ -177,20 +177,27 @@ export function listFavorites(): FavoriteListing[] {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
180
|
+
* Average annual liquidity per tower: DLD transactions of `kind` PER YEAR, over
|
|
181
|
+
* the full stored history (total count ÷ years spanned by min→max date). Scraping
|
|
182
|
+
* caps each tower at ~100 tx, so the *rate* — not the raw count — is what compares
|
|
183
|
+
* a busy tower to a quiet one: 100 sales across 1 year is far more liquid than 100
|
|
184
|
+
* across 4. Returned as tower_slug → transactions/year.
|
|
184
185
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const rows = (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
const MS_PER_YEAR = 365.25 * 24 * 3600 * 1000;
|
|
187
|
+
export function annualLiquidityByTower(kind: "buy" | "rent"): Map<string, number> {
|
|
188
|
+
const rows = getDb()
|
|
189
|
+
.prepare(
|
|
190
|
+
`SELECT tower_slug, COUNT(*) n, MIN(date) mn, MAX(date) mx
|
|
191
|
+
FROM transactions WHERE kind = ? GROUP BY tower_slug`
|
|
192
|
+
)
|
|
193
|
+
.all(kind) as { tower_slug: string; n: number; mn: string; mx: string }[];
|
|
192
194
|
const m = new Map<string, number>();
|
|
193
|
-
for (const r of rows)
|
|
195
|
+
for (const r of rows) {
|
|
196
|
+
const spanY = (Date.parse(r.mx) - Date.parse(r.mn)) / MS_PER_YEAR;
|
|
197
|
+
// Floor the span so a burst of transactions in a short window can't explode the rate.
|
|
198
|
+
const years = Math.max(spanY, 0.5);
|
|
199
|
+
m.set(r.tower_slug, r.n / years);
|
|
200
|
+
}
|
|
194
201
|
return m;
|
|
195
202
|
}
|
|
196
203
|
|
package/src/lib/trend.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TxRow } from "./pf/transactions";
|
|
2
|
+
|
|
3
|
+
// Same ±20% "comparable" window the yield sample and the AED/sqft chart use.
|
|
4
|
+
const SIZE_TOLERANCE = 0.2;
|
|
5
|
+
const YEAR_MS = 365.25 * 24 * 3600 * 1000;
|
|
6
|
+
|
|
7
|
+
export interface TrendResult {
|
|
8
|
+
/** Trend value (AED/sqft) at the most recent transaction date — the current fair price. */
|
|
9
|
+
currentPsqft: number;
|
|
10
|
+
/** Annual drift of the trend, as a fraction (e.g. 0.08 = +8 %/yr). */
|
|
11
|
+
annualPct: number | null;
|
|
12
|
+
/** Number of comparable points the fit used. */
|
|
13
|
+
n: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Least-squares trend of AED/sqft over time for a building's transactions,
|
|
18
|
+
* fitted on comparable units (similar size ±20%, bedrooms fallback) — the exact
|
|
19
|
+
* line drawn by PsqftChart. `currentPsqft` is that line evaluated at the latest
|
|
20
|
+
* date, i.e. what a comparable unit is worth per sqft right now.
|
|
21
|
+
*/
|
|
22
|
+
export function psqftTrend(
|
|
23
|
+
rows: TxRow[],
|
|
24
|
+
listingSizeSqft: number | null,
|
|
25
|
+
listingBedrooms: number | null
|
|
26
|
+
): TrendResult | null {
|
|
27
|
+
const pts = rows
|
|
28
|
+
.filter((r) => r.size_sqft && r.size_sqft > 0 && r.amount > 0 && r.date)
|
|
29
|
+
.map((r) => ({
|
|
30
|
+
t: Date.parse(r.date),
|
|
31
|
+
y: r.amount / (r.size_sqft as number),
|
|
32
|
+
comparable:
|
|
33
|
+
listingSizeSqft != null
|
|
34
|
+
? Math.abs((r.size_sqft as number) - listingSizeSqft) / listingSizeSqft <= SIZE_TOLERANCE
|
|
35
|
+
: listingBedrooms != null && r.bedrooms === listingBedrooms,
|
|
36
|
+
}))
|
|
37
|
+
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
|
|
38
|
+
.sort((a, b) => a.t - b.t);
|
|
39
|
+
if (!pts.length) return null;
|
|
40
|
+
|
|
41
|
+
const tMin = pts[0].t;
|
|
42
|
+
const tMax = pts[pts.length - 1].t;
|
|
43
|
+
const comp = pts.filter((p) => p.comparable);
|
|
44
|
+
const fitSet = comp.length >= 2 ? comp : pts;
|
|
45
|
+
if (fitSet.length < 2 || tMax <= tMin) return null;
|
|
46
|
+
|
|
47
|
+
const n = fitSet.length;
|
|
48
|
+
const mt = fitSet.reduce((s, p) => s + p.t, 0) / n;
|
|
49
|
+
const my = fitSet.reduce((s, p) => s + p.y, 0) / n;
|
|
50
|
+
let num = 0;
|
|
51
|
+
let den = 0;
|
|
52
|
+
for (const p of fitSet) {
|
|
53
|
+
num += (p.t - mt) * (p.y - my);
|
|
54
|
+
den += (p.t - mt) ** 2;
|
|
55
|
+
}
|
|
56
|
+
const slope = den ? num / den : 0; // AED/sqft per ms
|
|
57
|
+
const intercept = my - slope * mt;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
currentPsqft: intercept + slope * tMax,
|
|
61
|
+
annualPct: my ? (slope * YEAR_MS) / my : null,
|
|
62
|
+
n: comp.length >= 2 ? comp.length : pts.length,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Discount of the asking price vs the current trend, as a fraction: positive when
|
|
68
|
+
* the listing is cheaper than the trend (a discount), negative when it's above it.
|
|
69
|
+
*/
|
|
70
|
+
export function discountVsTrend(
|
|
71
|
+
trend: TrendResult | null,
|
|
72
|
+
askingPsqft: number | null
|
|
73
|
+
): number | null {
|
|
74
|
+
if (!trend || askingPsqft == null || trend.currentPsqft <= 0) return null;
|
|
75
|
+
return (trend.currentPsqft - askingPsqft) / trend.currentPsqft;
|
|
76
|
+
}
|