0015 — Generic saved-views storage for any UI surface

Context

Saved views currently exist only inside MetricInsight (the dashboard inspector). They persist to localStorage keyed by MetricKey and capture aggregator + granularity + dateRangePreset + custom range + compareOn + filters. The shape is hard-coded to SavedViewState.

Limitations today:

  1. Single surface. Only the inspector uses it. Other surfaces with substantial UI state (Clara Command Center, /leasing/prospects, /leasing/renewals, /maintenance/costs, /maintenance/turnovers, future agents) have no equivalent.
  2. Single device. localStorage doesn't follow users between browsers, devices, or after a cache wipe.
  3. No defaults. Users can't pin a view as their personal default (e.g. "always open the Occupancy inspector with Compare on, filtered to 1BR").
  4. No sharing. No path to share a view with the team — relevant for "weekly review" boards and renewal-team handoffs.

The codebase already follows the DynamoDB single-table pattern (see ADR-0013 for metric snapshots). Auth is handled by Better Auth with sessions accessible from server actions / route handlers.

Decision

Introduce a generic SavedView primitive scoped by an opaque string identifier. The state payload is a JSON blob whose shape is enforced in TypeScript at the surface boundary, not in the database. One table, one API, one frontend hook.

Shape

interface SavedView<TState = unknown> {
  id: string;            // server-assigned UUID
  userId: string;        // owner
  scope: string;         // namespacing — see "Scope conventions"
  name: string;
  state: TState;         // opaque payload, surface-specific
  isDefault: boolean;    // applied automatically when entering the scope
  isShared: boolean;     // visible to teammates (future; default false)
  createdAt: string;     // ISO 8601
  updatedAt: string;
}

Scope conventions

A scope is a string of the form <domain>:<id>. Surfaces pick their own conventions; the storage layer doesn't parse them.

Surface Scope State shape
Dashboard inspector (per-metric) metric:<MetricKey> MetricInsightSavedState (current SavedViewState)
Clara Command Center page page:clara ClaraPageSavedState (activeTab, sourceFilter, propertyFilter, …)
/leasing/prospects page page:leasing-prospects ProspectsTableSavedState (filters, sort, columns, dateWindow)
/leasing/renewals page page:leasing-renewals RenewalsTableSavedState (status filter, dateWindow, sort)
/maintenance/costs page page:maintenance-costs CostsPageSavedState (vendorExpanded, …)
Per-agent inspector (Clara breakdown) agent:<agentId> AgentInsightSavedState (same as metric inspector for now)

Scopes are case-sensitive and stable. Renaming a scope requires a migration. New scopes can be added without DB changes — just declare the state shape next to the surface that owns it.

Storage

Single DynamoDB table (uses the existing propflow-prod table or a sibling — TBD with Fede).

Attribute Value
PK USER#<userId>
SK SAVED_VIEW#<id>
scope string
name string
state JSON-serialised payload
isDefault bool
isShared bool
createdAt ISO timestamp
updatedAt ISO timestamp
gsi1pk USER#<userId>
gsi1sk SAVED_VIEW#<scope>#<createdAt>

GSI1 lets the API list a user's views for a given scope without a table scan: Query(PK=USER#<id>, SK begins_with SAVED_VIEW#<scope>#).

API

GET    /api/saved-views?scope=<scope>      → list user's views in scope
POST   /api/saved-views                    → create (body: { scope, name, state, isDefault? })
PATCH  /api/saved-views/[id]               → rename, toggle default, share
DELETE /api/saved-views/[id]               → delete

All endpoints require an authenticated session. Cross-user access returns 404 (avoid leaking existence).

Frontend abstraction

A hook hides storage details from surfaces:

function useSavedViews<TState>(scope: string): {
  views: SavedView<TState>[];
  defaultView: SavedView<TState> | null;
  isLoading: boolean;
  save: (name: string, state: TState, opts?: { isDefault?: boolean }) => Promise<SavedView<TState>>;
  rename: (id: string, name: string) => Promise<void>;
  remove: (id: string) => Promise<void>;
  setDefault: (id: string | null) => Promise<void>;
};

Behaviour:

Migration plan

Phase Work
3a (this PR) Define types + useSavedViews hook + localStorage backend. Refactor SavedViewsMenu to use the hook. No behaviour change for users.
3b DynamoDB schema + 4 API endpoints + tests. Remote backend implementation.
3c Hook auto-detects auth and switches to remote backend. One-shot localStorage → remote migration.
3d Add isDefault UX to SavedViewsMenu (star icon). Add per-page surfaces (Clara Command Center, prospect/renewal pages) one at a time, each declaring its own TState type.
3e isShared UX + team-shared views. Requires team membership data.

Consequences

Easier:

Harder:

Follow-up work:

Alternatives considered

  1. Per-surface tables — separate DynamoDB tables for inspector views, page views, etc. Rejected: lots of duplicated CRUD code, no path to shared "favorite views across the app" UI.
  2. Discriminated-union state column — enforce state shape in DB via a scopeType enum and per-type validators. Rejected: every new surface requires a DB migration, cancelling the main benefit.
  3. GitHub-Gist-style sharing — store state in URL fragments and skip the DB entirely. Rejected: no defaults, no list, no rename; only solves the "share a one-off" problem, not the "I have a preferred default" one.