import type { ReactNode } from 'react'; /** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */ export type TodayDashboardWidth = 1 | 2; export type TodayDashboardHeight = 1 | 2 | 3; export interface TodayDashboardLayout { width: TodayDashboardWidth; height: TodayDashboardHeight; } /** Shared layout presets — assign when registering a dashboard widget. */ export const TODAY_DASHBOARD_LAYOUT = { kpi: { width: 1, height: 1 }, subscription: { width: 1, height: 2 }, upcoming: { width: 2, height: 3 }, /** Tall charts: area, stacked bar, pie with side legend */ chart: { width: 2, height: 3 }, /** Medium charts: horizontal bar, vertical bar, radial gauge */ chartMedium: { width: 2, height: 2 }, } as const satisfies Record; export interface TodayDashboardCell { id: string; layout: TodayDashboardLayout; content: ReactNode; } export interface PackedDashboardCell extends TodayDashboardCell { gridColumn: string; gridRow: string; } export function compareDashboardLayout( a: TodayDashboardLayout, b: TodayDashboardLayout, ): number { if (a.width !== b.width) return a.width - b.width; return a.height - b.height; } export function sortDashboardCells( cells: T[], ): T[] { return [...cells].sort((a, b) => { const byLayout = compareDashboardLayout(a.layout, b.layout); if (byLayout !== 0) return byLayout; return a.id.localeCompare(b.id); }); } /** Wide widgets (width > 1) anchor to column pairs — never straddle the grid center. */ export function allowedStartColumns( width: number, columns: number, ): number[] { if (width <= 1) { return Array.from({ length: columns }, (_, index) => index); } if (width === 2 && columns === 4) { return [0, 2]; } return Array.from({ length: columns - width + 1 }, (_, index) => index); } /** * First-fit placement in ascending layout order (top-left scan). * Multi-column widgets may only start at aligned column pairs (1–2 or 3–4 on a 4-col grid). */ export function packDashboardCells( cells: TodayDashboardCell[], columns = 4, ): PackedDashboardCell[] { const sorted = sortDashboardCells(cells); const occupied = new Set(); function canPlace(row: number, col: number, width: number, height: number): boolean { if (col + width > columns) return false; for (let r = row; r < row + height; r += 1) { for (let c = col; c < col + width; c += 1) { if (occupied.has(`${r}-${c}`)) return false; } } return true; } function mark(row: number, col: number, width: number, height: number) { for (let r = row; r < row + height; r += 1) { for (let c = col; c < col + width; c += 1) { occupied.add(`${r}-${c}`); } } } const placed: PackedDashboardCell[] = []; for (const cell of sorted) { const { width, height } = cell.layout; let found = false; const startColumns = allowedStartColumns(width, columns); for (let row = 0; !found; row += 1) { for (const col of startColumns) { if (!canPlace(row, col, width, height)) continue; mark(row, col, width, height); placed.push({ ...cell, gridColumn: `${col + 1} / span ${width}`, gridRow: `${row + 1} / span ${height}`, }); found = true; break; } } } return placed; } export function packedCellClassName(layout: TodayDashboardLayout): string { const rowSpan = layout.height === 3 ? 'row-span-3' : layout.height === 2 ? 'row-span-2' : 'row-span-1'; const colSpan = layout.width === 2 ? 'col-span-2 max-sm:col-span-1' : 'col-span-1'; return `${colSpan} ${rowSpan} min-h-0 min-w-0 overflow-hidden flex flex-col max-lg:${colSpan}`; } export const TODAY_DASHBOARD_GRID_CLASS = 'today-dashboard-grid grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4';