Customizable Owner Dashboard — Design Spec
Date: 2026-05-31 Goal: Make the owner dashboard personalizable (rearrange, hide, add, resize “to an extent”) and visually revamped (prettier, less crowded), built entirely from deps/patterns already in the repo — no new heavy libraries, no DB writes, no migration, daily load stays fast, and it cannot get into a broken state.Decisions (locked)
- Engine:
@dnd-kit/core+@dnd-kit/sortable(already installed; used by the appointment calendar). NOT react-grid-layout. - Resize = column-span presets S / M / L on the existing 12-col Tailwind grid (the dashboard already uses
lg:col-span-5/4/3). No free-pixel resize. - Persistence = per-user
localStorage(same pattern asThemeCustomizerContext), keyed by user+clinic. Zero DB writes (deliberate — keeps the Neon bill down). Cross-device sync is explicitly out of scope for v1. - Scope: owner/admin dashboard (
AdminOverview) only. Other roles unchanged. - Polish via design tokens +
framer-motion(already installed). No new visual language.
Architecture
- Widget registry (
widgetRegistry.tsx): each existing dental card is registered with{ id, title, render, defaultSpan, minSpan, maxSpan, removable, category }. Single source of truth for “what widgets exist.” - Layout store (
useDashboardLayout.ts): a versioned, validated array[{ id, span, hidden }]in localStorage. On load it reconciles against the registry: unknown ids dropped, newly-shipped widgets appended to an “available” state, malformed data → fall back toDEFAULT_LAYOUT. Always exposesresetToDefault(). This is what makes it unbreakable. - View mode (
DashboardGrid.tsx): renders visible widgets in order into a static CSS 12-col grid using each widget’sspan→lg:col-span-{n}. No dnd-kit in this path — the daily view stays the fast static grid it is today. - Edit mode (
DashboardCustomizer.tsx, lazy-loaded): wraps the grid in dnd-kitSortableContext; each widget gets a drag handle, a hide toggle, and an S/M/L size control. A side “Add widgets” tray lists hidden/available widgets. A “Reset to default” button.framer-motionlayoutanimation for smooth reflow. The dnd-kit code only enters the bundle when the user clicks Customize, so initial load is unaffected. WidgetFrame.tsx: in view mode renders the widget plain; in edit mode adds the drag handle / hide / size chrome. Keeps widgets themselves untouched.AdminOverview.tsx: holds theCustomizetoggle and rendersDashboardGrid(view) or the lazyDashboardCustomizer(edit). Owns the singleuseQueryand passes data slices to widgets via the registry.
Visual revamp (concrete, token-level — not open-ended)
- More breathing room: bump section gaps; group KPI row, then a quiet divider, then content rows.
- Softer card surface:
bg-cardwith a faint top highlight +shadow-sm(no heavy gradients),rounded-xl. - Stronger number typography: KPI values larger/tighter; labels smaller, muted, uppercase tracking.
- Calmer hierarchy: KPIs are the visual anchor; secondary stats stay as the quiet chip band; Action Required keeps its accent.
- Subtle
framer-motionfade/slide-in on first paint (respectsprefers-reduced-motion), no looping animations.
Unbreakable guarantees
- Layout is pure data;
DEFAULT_LAYOUTis always the fallback. - Schema
versionfield; on mismatch → reset (don’t crash). - Registry is the authority: a widget removed from code simply won’t render; a widget added in code appears in the tray.
resetToDefault()always available in edit mode.- View mode never imports dnd-kit (perf + can’t break the daily view).
Performance
- Daily view: static CSS grid, zero extra JS vs today.
- dnd-kit + customizer:
React.lazybehind the Customize toggle. - Persistence: localStorage read once on mount; write on layout change (debounced). No network, no DB.
Non-goals (v1)
- No cross-device server sync, no per-role layout presets, no free-pixel resize, no widgets beyond the existing dental set, no changes to other dashboards.
Verification
uitsc0 errors;vite buildsucceeds; dnd-kit confirmed only in a lazy chunk (not the entry/dashboard chunk).- Unit test the layout reconcile/validate logic (pure function): unknown id dropped, new widget appended, malformed → default, version bump → reset.
- Manual: toggle Customize → drag reorder, hide/add, S/M/L resize, reset; reload persists; corrupt the localStorage key → falls back to default without crashing.

