brickwise 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/brickwise.js +100 -0
  4. package/eslint.config.mjs +18 -0
  5. package/next.config.ts +14 -0
  6. package/package.json +62 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/dashboard/route.ts +15 -0
  14. package/src/app/api/favorites/route.ts +71 -0
  15. package/src/app/api/gyms/route.ts +30 -0
  16. package/src/app/api/listing/[id]/route.ts +30 -0
  17. package/src/app/api/locations/route.ts +13 -0
  18. package/src/app/api/recompute/route.ts +28 -0
  19. package/src/app/api/search/route.ts +26 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/favorites/page.tsx +603 -0
  22. package/src/app/globals.css +78 -0
  23. package/src/app/layout.tsx +37 -0
  24. package/src/app/listing/[id]/page.tsx +230 -0
  25. package/src/app/map/page.tsx +5 -0
  26. package/src/app/page.tsx +155 -0
  27. package/src/app/search/page.tsx +87 -0
  28. package/src/components/ChromeHeader.tsx +16 -0
  29. package/src/components/FiltersForm.tsx +240 -0
  30. package/src/components/ListingCard.tsx +145 -0
  31. package/src/components/Nav.tsx +37 -0
  32. package/src/components/PsqftChart.tsx +249 -0
  33. package/src/components/TransactionsTable.tsx +40 -0
  34. package/src/components/YieldBadge.tsx +24 -0
  35. package/src/lib/analyze.ts +51 -0
  36. package/src/lib/db.ts +143 -0
  37. package/src/lib/dedupe.ts +34 -0
  38. package/src/lib/format.ts +13 -0
  39. package/src/lib/gyms.ts +104 -0
  40. package/src/lib/pf/client.ts +45 -0
  41. package/src/lib/pf/listing.ts +16 -0
  42. package/src/lib/pf/locations.ts +73 -0
  43. package/src/lib/pf/parse.ts +15 -0
  44. package/src/lib/pf/search.ts +57 -0
  45. package/src/lib/pf/transactions.ts +85 -0
  46. package/src/lib/store.ts +182 -0
  47. package/src/lib/types.ts +122 -0
  48. package/src/lib/yield.ts +80 -0
  49. package/tsconfig.json +34 -0
@@ -0,0 +1,85 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { fetchPfPage } from "./client";
3
+ import { extractPageProps } from "./parse";
4
+ import { getDb, now } from "../db";
5
+ import { PfTransaction } from "../types";
6
+
7
+ const BASE = "https://www.propertyfinder.ae";
8
+ const MAX_TX_PAGES = 10;
9
+ const SYNC_TTL_MS = 7 * 24 * 3600 * 1000;
10
+
11
+ async function scrapeTransactionPages(
12
+ citySlug: string,
13
+ towerSlug: string,
14
+ kind: "buy" | "rent"
15
+ ): Promise<PfTransaction[]> {
16
+ const all: PfTransaction[] = [];
17
+ let pageCount = 1;
18
+ for (let page = 1; page <= Math.min(pageCount, MAX_TX_PAGES); page++) {
19
+ const url = `${BASE}/en/transactions/${kind}/${citySlug}/${towerSlug}${page > 1 ? `?page=${page}` : ""}`;
20
+ let pp: any;
21
+ try {
22
+ pp = extractPageProps(await fetchPfPage(url));
23
+ } catch {
24
+ break; // tower without transaction page
25
+ }
26
+ const list = pp.list;
27
+ if (!list?.transactionList) break;
28
+ all.push(...list.transactionList);
29
+ pageCount = list.totalPageCount ?? 1;
30
+ }
31
+ return all;
32
+ }
33
+
34
+ /** Scrape buy+rent transaction history for a tower into SQLite (7-day TTL). */
35
+ export async function syncTransactions(citySlug: string, towerSlug: string): Promise<void> {
36
+ const db = getDb();
37
+ const insert = db.prepare(
38
+ `INSERT OR REPLACE INTO transactions (id, tower_slug, kind, date, amount, size_sqft, price_per_sqft, bedrooms, unit, scraped_at)
39
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
40
+ );
41
+ const getSync = db.prepare(`SELECT synced_at FROM tx_sync WHERE tower_slug = ? AND kind = ?`);
42
+ const setSync = db.prepare(`INSERT OR REPLACE INTO tx_sync (tower_slug, kind, synced_at) VALUES (?, ?, ?)`);
43
+
44
+ for (const kind of ["buy", "rent"] as const) {
45
+ const sync = getSync.get(towerSlug, kind) as { synced_at: string } | undefined;
46
+ if (sync && Date.now() - Date.parse(sync.synced_at) < SYNC_TTL_MS) continue;
47
+ const txs = await scrapeTransactionPages(citySlug, towerSlug, kind);
48
+ const ts = now();
49
+ const write = db.transaction(() => {
50
+ for (const t of txs) {
51
+ insert.run(
52
+ t.id,
53
+ towerSlug,
54
+ kind,
55
+ kind === "rent" ? (t.contractStartDate || t.transactionDate) : t.transactionDate,
56
+ t.price,
57
+ t.propertySize || null,
58
+ t.pricePerSqft || null,
59
+ typeof t.bedrooms === "number" ? t.bedrooms : parseInt(String(t.bedrooms)) || null,
60
+ t.propertyNumber || null,
61
+ ts
62
+ );
63
+ }
64
+ setSync.run(towerSlug, kind, ts);
65
+ });
66
+ write();
67
+ }
68
+ }
69
+
70
+ export interface TxRow {
71
+ id: string;
72
+ kind: "buy" | "rent";
73
+ date: string;
74
+ amount: number;
75
+ size_sqft: number | null;
76
+ price_per_sqft: number | null;
77
+ bedrooms: number | null;
78
+ unit: string | null;
79
+ }
80
+
81
+ export function getTransactions(towerSlug: string, kind: "buy" | "rent"): TxRow[] {
82
+ return getDb()
83
+ .prepare(`SELECT * FROM transactions WHERE tower_slug = ? AND kind = ? ORDER BY date DESC`)
84
+ .all(towerSlug, kind) as TxRow[];
85
+ }
@@ -0,0 +1,182 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { getDb, now } from "./db";
3
+ import { AnalysisRow, ListingRow, PfProperty, YieldResult } from "./types";
4
+
5
+ function sizeSqft(p: PfProperty): number | null {
6
+ const s: any = p.size;
7
+ if (s == null) return null;
8
+ if (typeof s === "number") return s;
9
+ const parsed = parseFloat(String(s.value ?? s));
10
+ return Number.isFinite(parsed) ? parsed : null;
11
+ }
12
+
13
+ /** Coerce PF values that may come as number, numeric string, {value} or {price} object. */
14
+ function toNum(v: unknown): number | null {
15
+ if (v == null) return null;
16
+ if (typeof v === "object") {
17
+ const o = v as { value?: unknown; price?: unknown };
18
+ return toNum(o.value ?? o.price);
19
+ }
20
+ const n = typeof v === "number" ? v : parseFloat(String(v));
21
+ return Number.isFinite(n) ? n : null;
22
+ }
23
+
24
+ /** Search pages: images is an array; detail pages: {property: [...]} with other size keys. */
25
+ function normalizeImages(p: PfProperty): { small: string; medium: string }[] {
26
+ const raw: any = p.images;
27
+ const arr: any[] = Array.isArray(raw) ? raw : raw?.property || [];
28
+ return arr
29
+ .map((i) => ({ small: i.small || i.thumbnail || i.medium || "", medium: i.medium || i.full || i.small || "" }))
30
+ .filter((i) => i.medium);
31
+ }
32
+
33
+ function toInt(v: unknown): number | null {
34
+ if (v == null) return null;
35
+ if (typeof v === "number") return v;
36
+ const s = String(v).toLowerCase();
37
+ if (s === "studio") return 0;
38
+ const n = parseInt(s, 10);
39
+ return Number.isFinite(n) ? n : null;
40
+ }
41
+
42
+ /** The tower node (or deepest location) the transactions history is keyed on. */
43
+ function towerNode(p: PfProperty) {
44
+ const tree = p.location_tree;
45
+ if (tree?.length) {
46
+ const tower = [...tree].reverse().find((l) => l.type === "TOWER");
47
+ return tower || tree[tree.length - 1];
48
+ }
49
+ return p.location;
50
+ }
51
+
52
+ export function upsertListing(p: PfProperty, citySlug = "dubai"): ListingRow {
53
+ const db = getDb();
54
+ const tower = towerNode(p);
55
+ const size = sizeSqft(p);
56
+ const price = p.price?.value ?? 0;
57
+ const ts = now();
58
+ const existing = db
59
+ .prepare(`SELECT first_seen_at, amenities_json, description, images_json FROM listings WHERE id = ?`)
60
+ .get(p.id) as
61
+ | { first_seen_at: string; amenities_json: string; description: string | null; images_json: string }
62
+ | undefined;
63
+ const amenities = p.amenity_names?.length
64
+ ? JSON.stringify(p.amenity_names)
65
+ : existing?.amenities_json ?? "[]";
66
+ // Search cards only carry ~3 images; never let them clobber a richer detail-page set.
67
+ let images = normalizeImages(p);
68
+ if (existing) {
69
+ try {
70
+ const prev = JSON.parse(existing.images_json);
71
+ if (Array.isArray(prev) && prev.length > images.length) images = prev;
72
+ } catch {}
73
+ }
74
+
75
+ db.prepare(
76
+ `INSERT OR REPLACE INTO listings
77
+ (id, url, title, price, size_sqft, bedrooms, bathrooms, property_type, tower_id, tower_slug, tower_name,
78
+ city_slug, lat, lon, images_json, amenities_json, agent_json, price_per_sqft, description, first_seen_at, last_scraped_at)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
80
+ ).run(
81
+ String(p.id),
82
+ p.share_url || `https://www.propertyfinder.ae${p.details_path || ""}`,
83
+ p.title || "",
84
+ price,
85
+ size,
86
+ toInt(p.bedrooms),
87
+ toInt(p.bathrooms),
88
+ p.property_type || "",
89
+ tower?.id != null ? String(tower.id) : null,
90
+ tower?.slug ?? null,
91
+ tower?.name ?? null,
92
+ citySlug,
93
+ toNum(p.location?.coordinates?.lat),
94
+ toNum(p.location?.coordinates?.lon),
95
+ JSON.stringify(images),
96
+ amenities,
97
+ JSON.stringify({ agent: p.agent?.name ?? null, broker: p.broker?.name ?? null }),
98
+ toNum(p.price_per_area) ?? (size && price ? price / size : null),
99
+ p.description ?? existing?.description ?? null,
100
+ existing?.first_seen_at ?? ts,
101
+ ts
102
+ );
103
+ return getListing(p.id)!;
104
+ }
105
+
106
+ export function getListing(id: string): ListingRow | null {
107
+ return (getDb().prepare(`SELECT * FROM listings WHERE id = ?`).get(id) as ListingRow) ?? null;
108
+ }
109
+
110
+ export function saveAnalysis(listingId: string, y: YieldResult, gym: { gym_name: string | null; distance_m: number | null }) {
111
+ getDb()
112
+ .prepare(
113
+ `INSERT OR REPLACE INTO analyses
114
+ (listing_id, gross_yield, asking_yield, median_rent, median_sale_price,
115
+ rent_n, buy_n, gym_distance_m, gym_name, computed_at)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
117
+ )
118
+ .run(
119
+ listingId,
120
+ y.gross_yield,
121
+ y.asking_yield,
122
+ y.median_rent,
123
+ y.median_sale_price,
124
+ y.rent_n,
125
+ y.buy_n,
126
+ gym.distance_m,
127
+ gym.gym_name,
128
+ now()
129
+ );
130
+ }
131
+
132
+ export function getAnalysis(listingId: string): AnalysisRow | null {
133
+ return (getDb().prepare(`SELECT * FROM analyses WHERE listing_id = ?`).get(listingId) as AnalysisRow) ?? null;
134
+ }
135
+
136
+ export interface AnalyzedListing extends ListingRow, Omit<AnalysisRow, "listing_id" | "computed_at"> {
137
+ computed_at: string | null;
138
+ }
139
+
140
+ export function addFavorite(listingId: string) {
141
+ getDb()
142
+ .prepare(`INSERT OR IGNORE INTO favorites (listing_id, notes, added_at) VALUES (?, '', ?)`)
143
+ .run(listingId, now());
144
+ }
145
+
146
+ export function removeFavorite(listingId: string) {
147
+ getDb().prepare(`DELETE FROM favorites WHERE listing_id = ?`).run(listingId);
148
+ }
149
+
150
+ export function setFavoriteNotes(listingId: string, notes: string) {
151
+ getDb().prepare(`UPDATE favorites SET notes = ? WHERE listing_id = ?`).run(notes, listingId);
152
+ }
153
+
154
+ export interface FavoriteListing extends AnalyzedListing {
155
+ notes: string;
156
+ added_at: string;
157
+ }
158
+
159
+ export function listFavorites(): FavoriteListing[] {
160
+ return getDb()
161
+ .prepare(
162
+ `SELECT l.*, a.gross_yield, a.asking_yield, a.median_rent, a.median_sale_price,
163
+ a.rent_n, a.buy_n, a.gym_distance_m, a.gym_name, a.computed_at,
164
+ f.notes, f.added_at
165
+ FROM favorites f
166
+ JOIN listings l ON l.id = f.listing_id
167
+ LEFT JOIN analyses a ON a.listing_id = f.listing_id
168
+ ORDER BY a.gross_yield DESC NULLS LAST`
169
+ )
170
+ .all() as FavoriteListing[];
171
+ }
172
+
173
+ export function listAnalyzed(): AnalyzedListing[] {
174
+ return getDb()
175
+ .prepare(
176
+ `SELECT l.*, a.gross_yield, a.asking_yield, a.median_rent, a.median_sale_price,
177
+ a.rent_n, a.buy_n, a.gym_distance_m, a.gym_name, a.computed_at
178
+ FROM listings l JOIN analyses a ON a.listing_id = l.id
179
+ ORDER BY a.gross_yield DESC NULLS LAST`
180
+ )
181
+ .all() as AnalyzedListing[];
182
+ }
@@ -0,0 +1,122 @@
1
+ export interface PfImage {
2
+ small: string;
3
+ medium: string;
4
+ }
5
+
6
+ export interface PfLocation {
7
+ id: string;
8
+ full_name: string;
9
+ coordinates?: { lat: number; lon: number };
10
+ slug: string;
11
+ path: string;
12
+ type: string;
13
+ name: string;
14
+ path_name: string;
15
+ }
16
+
17
+ export interface PfProperty {
18
+ id: string;
19
+ title: string;
20
+ price: { value: number; currency: string; period: string };
21
+ size?: { value: number; unit: string } | number;
22
+ bedrooms?: number | string;
23
+ bathrooms?: number | string;
24
+ property_type: string;
25
+ images: PfImage[];
26
+ share_url: string;
27
+ details_path: string;
28
+ location: PfLocation;
29
+ location_tree?: PfLocation[];
30
+ amenity_names?: string[];
31
+ amenities?: string[];
32
+ price_per_area?: number;
33
+ description?: string;
34
+ listed_date?: string;
35
+ broker?: { name?: string };
36
+ agent?: { name?: string };
37
+ similar_price_transactions?: {
38
+ buy: SimilarTransaction[];
39
+ rent: SimilarTransaction[];
40
+ };
41
+ }
42
+
43
+ export interface SimilarTransaction {
44
+ date: string;
45
+ amount: string;
46
+ size: number;
47
+ }
48
+
49
+ export interface PfTransaction {
50
+ id: string;
51
+ transactionDate: string;
52
+ price: number;
53
+ propertySize: number;
54
+ pricePerSqft: number;
55
+ bedrooms: number | string;
56
+ propertyNumber: string;
57
+ propertyType: string;
58
+ locationName: string;
59
+ contractStartDate?: string;
60
+ contractEndDate?: string;
61
+ }
62
+
63
+ export interface SearchFilters {
64
+ locationId?: string;
65
+ locationLabel?: string;
66
+ minPrice?: number;
67
+ maxPrice?: number;
68
+ minArea?: number;
69
+ maxArea?: number;
70
+ bedrooms?: string[];
71
+ bathrooms?: string[];
72
+ propertyTypeId?: string;
73
+ amenities?: string[];
74
+ maxGymDistanceM?: number;
75
+ maxPages?: number;
76
+ }
77
+
78
+ export interface ListingRow {
79
+ id: string;
80
+ url: string;
81
+ title: string;
82
+ price: number;
83
+ size_sqft: number | null;
84
+ bedrooms: number | null;
85
+ bathrooms: number | null;
86
+ property_type: string;
87
+ tower_id: string | null;
88
+ tower_slug: string | null;
89
+ tower_name: string | null;
90
+ city_slug: string | null;
91
+ lat: number | null;
92
+ lon: number | null;
93
+ images_json: string;
94
+ amenities_json: string;
95
+ agent_json: string;
96
+ price_per_sqft: number | null;
97
+ description: string | null;
98
+ first_seen_at: string;
99
+ last_scraped_at: string;
100
+ }
101
+
102
+ export interface AnalysisRow {
103
+ listing_id: string;
104
+ gross_yield: number | null;
105
+ asking_yield: number | null;
106
+ median_rent: number | null;
107
+ median_sale_price: number | null;
108
+ rent_n: number;
109
+ buy_n: number;
110
+ gym_distance_m: number | null;
111
+ gym_name: string | null;
112
+ computed_at: string;
113
+ }
114
+
115
+ export interface YieldResult {
116
+ gross_yield: number | null;
117
+ asking_yield: number | null;
118
+ median_rent: number | null;
119
+ median_sale_price: number | null;
120
+ rent_n: number;
121
+ buy_n: number;
122
+ }
@@ -0,0 +1,80 @@
1
+ import { TxRow } from "./pf/transactions";
2
+ import { SimilarTransaction, YieldResult } from "./types";
3
+
4
+ const WINDOW_MS = 24 * 30.44 * 24 * 3600 * 1000; // ~24 months
5
+ const MIN_SAMPLES = 3;
6
+ const SIZE_TOLERANCE = 0.2;
7
+
8
+ function median(xs: number[]): number | null {
9
+ if (xs.length === 0) return null;
10
+ const s = [...xs].sort((a, b) => a - b);
11
+ const m = Math.floor(s.length / 2);
12
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
13
+ }
14
+
15
+ /**
16
+ * Comparable units only: same bedrooms + similar size when possible, relaxing to
17
+ * same bedrooms, then similar size. Never falls back to "every transaction in the
18
+ * building" — mixing unit types is what produces bogus yields.
19
+ */
20
+ function pickSample(txs: TxRow[], bedrooms: number | null, sizeSqft: number | null): TxRow[] {
21
+ const cutoff = Date.now() - WINDOW_MS;
22
+ const recent = txs.filter((t) => Date.parse(t.date) >= cutoff);
23
+ const sameBdr = (t: TxRow) => bedrooms != null && t.bedrooms === bedrooms;
24
+ const sameSize = (t: TxRow) =>
25
+ sizeSqft != null && t.size_sqft != null && Math.abs(t.size_sqft - sizeSqft) / sizeSqft <= SIZE_TOLERANCE;
26
+
27
+ const byBoth = recent.filter((t) => sameBdr(t) && sameSize(t));
28
+ if (byBoth.length >= MIN_SAMPLES) return byBoth;
29
+ const byBdr = recent.filter(sameBdr);
30
+ if (byBdr.length >= MIN_SAMPLES) return byBdr;
31
+ const bySize = recent.filter(sameSize);
32
+ if (bySize.length >= MIN_SAMPLES) return bySize;
33
+ // Below MIN_SAMPLES everywhere: use the best comparable set we have, even if tiny.
34
+ if (byBoth.length) return byBoth;
35
+ if (byBdr.length) return byBdr;
36
+ return bySize;
37
+ }
38
+
39
+ /**
40
+ * Annual rental yield from the building's real transactions:
41
+ * gross_yield = median annual rent of comparable units / median sale price of comparable units
42
+ * asking_yield = median annual rent of comparable units / asking price of this listing
43
+ * No appreciation / revaluation metrics.
44
+ */
45
+ export function computeYield(
46
+ price: number,
47
+ sizeSqft: number | null,
48
+ bedrooms: number | null,
49
+ buyTxs: TxRow[],
50
+ rentTxs: TxRow[]
51
+ ): YieldResult {
52
+ const buySample = pickSample(buyTxs, bedrooms, sizeSqft);
53
+ const rentSample = pickSample(rentTxs, bedrooms, sizeSqft);
54
+
55
+ const medianRent = median(rentSample.map((t) => t.amount));
56
+ const medianSalePrice = median(buySample.map((t) => t.amount));
57
+
58
+ return {
59
+ gross_yield: medianRent != null && medianSalePrice ? medianRent / medianSalePrice : null,
60
+ asking_yield: medianRent != null && price > 0 ? medianRent / price : null,
61
+ median_rent: medianRent,
62
+ median_sale_price: medianSalePrice,
63
+ rent_n: rentSample.length,
64
+ buy_n: buySample.length,
65
+ };
66
+ }
67
+
68
+ /** Fallback: the 5+5 size-matched transactions embedded in the listing detail page. */
69
+ export function similarToTxRows(sims: SimilarTransaction[], kind: "buy" | "rent"): TxRow[] {
70
+ return sims.map((s, i) => ({
71
+ id: `sim-${kind}-${i}-${s.date}`,
72
+ kind,
73
+ date: s.date,
74
+ amount: parseFloat(s.amount),
75
+ size_sqft: s.size ?? null,
76
+ price_per_sqft: s.size ? parseFloat(s.amount) / s.size : null,
77
+ bedrooms: null,
78
+ unit: null,
79
+ }));
80
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }