← back to Homesonspec
apps/mobile/lib/storage.ts
125 lines
/**
* Homes on Spec — native favorites / saved-homes storage
*
* Persisted via @react-native-async-storage/async-storage.
* All operations are async and safe to call from any component.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getHomeById } from './api';
const SAVED_KEY = '@homesonspec/saved_homes_v1';
export interface SavedHome {
id: string;
/** Display title (e.g. "4 bed · 2 bath · $350k") */
title: string;
/** City, State */
location: string;
/** Builder slug (e.g. "lennar") */
builderSlug: string;
/** Builder display name */
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;
}
// ---------------------------------------------------------------------------
// Core read/write
// ---------------------------------------------------------------------------
async function readAll(): Promise<SavedHome[]> {
try {
const raw = await AsyncStorage.getItem(SAVED_KEY);
if (!raw) return [];
return JSON.parse(raw) as SavedHome[];
} catch {
return [];
}
}
async function writeAll(homes: SavedHome[]): Promise<void> {
await AsyncStorage.setItem(SAVED_KEY, JSON.stringify(homes));
}
// ---------------------------------------------------------------------------
// Public helpers
// ---------------------------------------------------------------------------
export async function getSavedHomes(): Promise<SavedHome[]> {
const all = await readAll();
// Sort newest-first
return [...all].sort(
(a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
);
}
export async function saveHome(home: Omit<SavedHome, 'savedAt'>): Promise<void> {
const all = await readAll();
// Idempotent — replace if already saved
const filtered = all.filter((h) => h.id !== home.id);
filtered.push({ ...home, savedAt: new Date().toISOString() });
await writeAll(filtered);
}
export async function unsaveHome(id: string): Promise<void> {
const all = await readAll();
await writeAll(all.filter((h) => h.id !== id));
}
export async function isHomeSaved(id: string): Promise<boolean> {
const all = await readAll();
return all.some((h) => h.id === id);
}
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 TTL_MS = 12 * 60 * 60 * 1000; // refresh at most every 12h per home
let changed = false;
for (const h of all) {
// Gate on staleness ALONE (not on missing-field): this both (a) refreshes a
// complete home's status if it changed upstream, and (b) rate-limits retries
// for a home whose id isn't in /api/map — we stamp the ATTEMPT below even on a
// null lookup, so a missing home can't trigger an 8MB fetch on every open.
const stale =
!h.lastEnrichedAt ||
Date.now() - new Date(h.lastEnrichedAt).getTime() > TTL_MS;
if (!stale) continue;
try {
const m = await getHomeById(h.id);
h.lastEnrichedAt = new Date().toISOString(); // stamp the attempt regardless
changed = true;
if (m) {
if (m.p != null) h.price = m.p;
if (m.c) h.location = m.c;
if (m.s) h.status = m.s;
if (!h.builderSlug && m.bl) h.builderSlug = m.bl;
}
} catch {
// best-effort: leave this home as-is; it stays stale and retries next window
}
}
if (changed) await writeAll(all);
return getSavedHomes();
}