feat(05-01): dashboard grid, clock widget, widget registry, store, and tests

- Install react-grid-layout@2.2.3 and react-resizable
- Create widget-registry.tsx with all 4 widget types, WIDGET_CONSTRAINTS, WidgetProps
- Create dashboard-api.ts with fetch/save layout and widget CRUD functions
- Create dashboard-store.ts (Zustand, NO persist — D-05) with edit mode and auto-save on exit
- Create DashboardGrid with react-grid-layout v2 Responsive, ResizeObserver width
- Create ClockWidget using Intl.DateTimeFormat (no manual UTC offsets)
- Create WidgetWrapper with drag handle and delete button in edit mode
- Create EditModeToggle (pencil/checkmark), WidgetCatalogModal (2x2 grid)
- Rewrite portal page.tsx as dashboard with grid, edit toggle, widget catalog
- Add ResizeObserver polyfill in test setup, CSS mock support in vitest config
- All 5 tests green (dashboard grid + clock widget)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:21:43 +02:00
parent 792b7661d8
commit d5e1c42e64
14 changed files with 1115 additions and 29 deletions
@@ -0,0 +1,204 @@
import type { ComponentType } from 'react';
/**
* Supported widget types for the dashboard.
* clock is fully implemented in this plan; search/calendar/note are
* declared here so the catalog shows all four types immediately.
*/
export type WidgetType = 'clock' | 'search' | 'calendar' | 'note';
/**
* Props contract that every widget component must accept.
*/
export interface WidgetProps {
instanceId: string;
config: Record<string, unknown>;
isEditMode: boolean;
}
/**
* Per-type size constraints for the react-grid-layout grid.
* Values from UI-SPEC size table (D-06).
*/
export const WIDGET_CONSTRAINTS: Record<
WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
};
/**
* Definition for a widget type in the catalog.
*/
export interface WidgetDefinition {
type: WidgetType;
nameKey: string;
descriptionKey: string;
/** Inline SVG icon as React component */
icon: ComponentType<{ className?: string }>;
minW: number;
minH: number;
defaultW: number;
defaultH: number;
/** Lazy-loaded widget component */
component: ComponentType<WidgetProps>;
}
/**
* Placeholder widget for types not yet implemented.
* Renders the widget type name as a centered label.
*/
function PlaceholderWidget({ config }: WidgetProps) {
const label = (config as Record<string, string>).__widgetType ?? 'Widget';
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
{label}
</div>
);
}
// --- Icon components (inline SVG, established project pattern) ---
function ClockIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
function CalendarIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
}
function NoteIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
*
* clock: Fully implemented this plan.
* search, calendar, note: Placeholder components — implemented in later plans.
*/
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
clock: {
type: 'clock',
nameKey: 'clock.name',
descriptionKey: 'clock.description',
icon: ClockIcon,
...WIDGET_CONSTRAINTS.clock,
// Lazy import replaced at module level for clock
component: PlaceholderWidget, // Replaced below
},
search: {
type: 'search',
nameKey: 'search.name',
descriptionKey: 'search.description',
icon: SearchIcon,
...WIDGET_CONSTRAINTS.search,
component: PlaceholderWidget,
},
calendar: {
type: 'calendar',
nameKey: 'calendar.name',
descriptionKey: 'calendar.description',
icon: CalendarIcon,
...WIDGET_CONSTRAINTS.calendar,
component: PlaceholderWidget,
},
note: {
type: 'note',
nameKey: 'note.name',
descriptionKey: 'note.description',
icon: NoteIcon,
...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget,
},
};
// Wire actual ClockWidget lazily to avoid circular deps
// (import is deferred so widget-registry can be imported by tests without
// pulling in the entire React tree)
let clockWired = false;
export function wireClockWidget(
component: ComponentType<WidgetProps>,
) {
if (!clockWired) {
WIDGET_REGISTRY.clock.component = component;
clockWired = true;
}
}