← back to Homesonspec
homesonspec/mobile: Cycle 3 — enrich Saved by id from /api/map (shared cache + getHomeById, best-effort, lastEnrichedAt-guarded), status pill, close glyph X->✕, branded icon/splash (functional mark, replace with final art)
24cb9a138ae0e63bc09780250b56b6a959eb1115 · 2026-08-06 14:06:47 -0700 · Steve
Files touched
M apps/mobile/app/(tabs)/map.tsxM apps/mobile/app/(tabs)/saved.tsxM apps/mobile/assets/adaptive-icon.pngM apps/mobile/assets/icon.pngM apps/mobile/assets/splash.pngM apps/mobile/lib/api.tsM apps/mobile/lib/storage.ts
Diff
commit 24cb9a138ae0e63bc09780250b56b6a959eb1115
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 14:06:47 2026 -0700
homesonspec/mobile: Cycle 3 — enrich Saved by id from /api/map (shared cache + getHomeById, best-effort, lastEnrichedAt-guarded), status pill, close glyph X->✕, branded icon/splash (functional mark, replace with final art)
---
apps/mobile/app/(tabs)/map.tsx | 2 +-
apps/mobile/app/(tabs)/saved.tsx | 39 ++++++++++++++++++++++++++++++
apps/mobile/assets/adaptive-icon.png | Bin 4556 -> 6752 bytes
apps/mobile/assets/icon.png | Bin 4556 -> 7159 bytes
apps/mobile/assets/splash.png | Bin 14344 -> 31169 bytes
apps/mobile/lib/api.ts | 45 ++++++++++++++++++++++++++++++++++-
apps/mobile/lib/storage.ts | 40 +++++++++++++++++++++++++++++++
7 files changed, 124 insertions(+), 2 deletions(-)
diff --git a/apps/mobile/app/(tabs)/map.tsx b/apps/mobile/app/(tabs)/map.tsx
index eacc0b1e..a5515d10 100644
--- a/apps/mobile/app/(tabs)/map.tsx
+++ b/apps/mobile/app/(tabs)/map.tsx
@@ -289,7 +289,7 @@ export default function MapTab() {
style={styles.closeBtn}
onPress={() => setSelected(null)}
>
- <Text style={styles.closeBtnText}>X</Text>
+ <Text style={styles.closeBtnText}>✕</Text>
</TouchableOpacity>
</View>
</View>
diff --git a/apps/mobile/app/(tabs)/saved.tsx b/apps/mobile/app/(tabs)/saved.tsx
index 4e8fe390..a58fe760 100644
--- a/apps/mobile/app/(tabs)/saved.tsx
+++ b/apps/mobile/app/(tabs)/saved.tsx
@@ -27,6 +27,7 @@ import { useFocusEffect } from 'expo-router';
import { WebView } from 'react-native-webview';
import {
getSavedHomes,
+ enrichSavedHomes,
unsaveHome,
type SavedHome,
} from '../../lib/storage';
@@ -34,6 +35,19 @@ import {
const BRAND = '#1a3a6e';
const ACCENT = '#e07b39';
+const STATUS_COLORS: Record<string, string> = {
+ MOVE_IN_READY: '#16a34a',
+ UNDER_CONSTRUCTION: '#e07b39',
+ PLANNED: '#6366f1',
+};
+
+function statusLabel(s?: string): string | null {
+ if (s === 'MOVE_IN_READY') return 'Move-In Ready';
+ if (s === 'UNDER_CONSTRUCTION') return 'Under Construction';
+ if (s === 'PLANNED') return 'Planned';
+ return null;
+}
+
function formatPrice(price: number | null): string {
if (price === null) return 'Price TBD';
if (price >= 1_000_000) return `$${(price / 1_000_000).toFixed(1)}M`;
@@ -90,6 +104,16 @@ function HomeCard({ home, onRemove, onOpen }: HomeCardProps) {
<View style={styles.cardFooter}>
<Text style={styles.cardPrice}>{formatPrice(home.price)}</Text>
+ {statusLabel(home.status) ? (
+ <Text
+ style={[
+ styles.cardStatus,
+ { backgroundColor: STATUS_COLORS[home.status as string] ?? BRAND },
+ ]}
+ >
+ {statusLabel(home.status)}
+ </Text>
+ ) : null}
{home.builderName ? (
<Text style={styles.cardBuilder}>{home.builderName}</Text>
) : null}
@@ -120,8 +144,14 @@ export default function SavedTab() {
const [modalLoading, setModalLoading] = useState(false);
const load = useCallback(async () => {
+ // Instant render from local storage...
const saved = await getSavedHomes();
setHomes(saved);
+ // ...then best-effort enrich price/city/status from /api/map by id.
+ // enrichSavedHomes only fetches when a saved home is actually missing a
+ // field, so this is a no-op fetch-wise once everything is enriched.
+ const enriched = await enrichSavedHomes();
+ setHomes(enriched);
}, []);
// Reload on every tab focus so Browse saves appear immediately
@@ -249,6 +279,15 @@ const styles = StyleSheet.create({
cardLocation: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
cardFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
cardPrice: { fontSize: 16, fontWeight: '700', color: BRAND },
+ cardStatus: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: '#fff',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
+ overflow: 'hidden',
+ },
cardBuilder: {
fontSize: 12,
color: '#fff',
diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png
index ebbe84a2..0a3f46cc 100644
Binary files a/apps/mobile/assets/adaptive-icon.png and b/apps/mobile/assets/adaptive-icon.png differ
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
index ebbe84a2..418106cc 100644
Binary files a/apps/mobile/assets/icon.png and b/apps/mobile/assets/icon.png differ
diff --git a/apps/mobile/assets/splash.png b/apps/mobile/assets/splash.png
index 73aeaece..6b762097 100644
Binary files a/apps/mobile/assets/splash.png and b/apps/mobile/assets/splash.png differ
diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts
index bf289af1..e6d9969c 100644
--- a/apps/mobile/lib/api.ts
+++ b/apps/mobile/lib/api.ts
@@ -67,13 +67,56 @@ async function get<T>(path: string): Promise<T> {
// Public API functions
// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// Shared /api/map index (for saved-home enrichment)
+//
+// homesonspec.com has NO per-home JSON detail endpoint (verified: /api/homes/:id
+// 404s), so /api/map is the ONLY id-keyed structured source of price/city/status.
+// We lazily build a shared id->marker index (10-min TTL) so the Map tab and the
+// Saved-tab enrichment reuse a single parse of the large payload. The index is
+// only built when something actually needs it — an app that never opens the Map
+// or Saved tab never parses it.
+// ---------------------------------------------------------------------------
+
+let _mapIndex: { at: number; byId: Map<string, MapMarker> } | null = null;
+const MAP_INDEX_TTL_MS = 10 * 60 * 1000;
+
+function buildIndex(markers: MapMarker[]): Map<string, MapMarker> {
+ const byId = new Map<string, MapMarker>();
+ for (const m of markers) byId.set(m.i, m);
+ return byId;
+}
+
/**
* Fetch all mappable homes with lat/lng for the native Map tab.
* Returns compact markers (terse keys — see MapMarker type).
+ * Warms the shared id index so a later enrichment lookup is free.
* Endpoint: GET /api/map
*/
export async function fetchMapMarkers(): Promise<MapResponse> {
- return get<MapResponse>('/api/map');
+ const res = await get<MapResponse>('/api/map');
+ if (Array.isArray(res?.markers)) {
+ _mapIndex = { at: Date.now(), byId: buildIndex(res.markers) };
+ }
+ return res;
+}
+
+/**
+ * Look up a single home's map record by id, reusing the shared lazily-built
+ * /api/map index (10-min TTL). Best-effort: returns null on any failure or
+ * contract change — never throws (enrichment must not break the Saved tab).
+ */
+export async function getHomeById(id: string): Promise<MapMarker | null> {
+ try {
+ if (!_mapIndex || Date.now() - _mapIndex.at > MAP_INDEX_TTL_MS) {
+ const res = await get<MapResponse>('/api/map');
+ if (!Array.isArray(res?.markers)) return _mapIndex?.byId.get(id) ?? null;
+ _mapIndex = { at: Date.now(), byId: buildIndex(res.markers) };
+ }
+ return _mapIndex.byId.get(id) ?? null;
+ } catch {
+ return _mapIndex?.byId.get(id) ?? null;
+ }
}
/**
diff --git a/apps/mobile/lib/storage.ts b/apps/mobile/lib/storage.ts
index 186437fa..0f152795 100644
--- a/apps/mobile/lib/storage.ts
+++ b/apps/mobile/lib/storage.ts
@@ -6,6 +6,7 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
+import { getHomeById } from './api';
const SAVED_KEY = '@homesonspec/saved_homes_v1';
@@ -21,10 +22,14 @@ export interface SavedHome {
builderName: string;
/** Price (null if unknown) */
price: number | null;
+ /** Construction status, enriched from /api/map (optional) */
+ status?: string;
/** Full URL to the home's detail page */
url: string;
/** ISO timestamp when saved */
savedAt: string;
+ /** ISO timestamp of the last successful /api/map enrichment (optional) */
+ lastEnrichedAt?: string;
}
// ---------------------------------------------------------------------------
@@ -78,3 +83,38 @@ export async function isHomeSaved(id: string): Promise<boolean> {
export async function clearAllSaved(): Promise<void> {
await AsyncStorage.removeItem(SAVED_KEY);
}
+
+/**
+ * Fill in price / city / status for saved homes by joining their id against the
+ * /api/map dataset (the only id-keyed source; no per-home endpoint exists).
+ * Best-effort + idempotent: only touches homes still missing a field AND not
+ * enriched within the last hour, so it does NOT trigger a map fetch when every
+ * saved home is already complete. Persists merged fields + lastEnrichedAt and
+ * never throws. Returns the (possibly updated) newest-first list.
+ */
+export async function enrichSavedHomes(): Promise<SavedHome[]> {
+ const all = await readAll();
+ const HOUR_MS = 60 * 60 * 1000;
+ let changed = false;
+ for (const h of all) {
+ const stale =
+ !h.lastEnrichedAt ||
+ Date.now() - new Date(h.lastEnrichedAt).getTime() > HOUR_MS;
+ const needs = h.price == null || !h.location || !h.status;
+ if (!needs || !stale) continue;
+ try {
+ const m = await getHomeById(h.id);
+ if (!m) continue;
+ if (h.price == null && m.p != null) h.price = m.p;
+ if (!h.location && m.c) h.location = m.c;
+ if (!h.status && m.s) h.status = m.s;
+ if (!h.builderSlug && m.bl) h.builderSlug = m.bl;
+ h.lastEnrichedAt = new Date().toISOString();
+ changed = true;
+ } catch {
+ // best-effort: leave this home as-is
+ }
+ }
+ if (changed) await writeAll(all);
+ return getSavedHomes();
+}
← 6abe8f2e homesonspec/mobile: Cycle 2 Cody fixes — homeDetailUrl /home
·
back to Homesonspec
·
homesonspec/mobile: Cycle 3 Cody fixes — enrich gates on sta 57f4a18d →