0015 — Generic saved-views storage for any UI surface
- Status: Proposed
- Date: 2026-04-28
- Deciders: Fede, Jose
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:
- 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.
- Single device. localStorage doesn't follow users between browsers, devices, or after a cache wipe.
- 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").
- 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:
- Reads + writes go through a
SavedViewsBackendinterface. The default backend islocalStorage(anonymous + offline cases). Authenticated sessions swap in the remote backend automatically. - A one-shot migration on first authenticated load pushes any
localStorage views to the remote (stamped with
migratedFromLocal) then clears the local copy.
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:
- Adding a new surface is 3 lines: pick a scope, declare the state
type, drop in
useSavedViews(scope). Storage + API + UI are reused. - The same hook can power "draft" filter combos (just don't persist) and "saved view" combos (persist) by varying the backend.
- Cross-device persistence comes for free with auth.
Harder:
- The state payload is opaque to the DB. Schema changes to a state
shape require either a versioning convention inside
state(e.g.state.version: 'v1') or a tolerant deserialiser at the surface. - Renaming or removing a scope strands existing rows. We mitigate by agreeing scopes are stable and only adding new ones.
- A surface that needs to share state with another surface must agree on a scope/state contract — there's no DB-enforced join.
Follow-up work:
- One ADR per state shape if it's complex enough (Clara page state).
- Telemetry: track
view_saved,view_loaded,view_set_defaultso we know which surfaces actually use the feature. - A
propflow:saved-views:v1localStorage key already exists — the 3c migration must be idempotent and handle conflicts (same view saved on two devices before sync).
Alternatives considered
- 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.
- Discriminated-union state column — enforce
stateshape in DB via ascopeTypeenum and per-type validators. Rejected: every new surface requires a DB migration, cancelling the main benefit. - 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.