[object Object]

← 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

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 →