← 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();
}