export const DASHBOARD_LAYOUT_VERSION = 5 as const /** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [ 'capture', 'next-paths', 'intelligence', 'resume', 'mind-map', 'sentiment', 'inbox', 'revision', 'stats', 'reminders', 'flashcards-progress', 'agents', ] as const export type DashboardWidgetId = | 'capture' | 'next-paths' | 'resume' | 'intelligence' | 'reminders' | 'mind-map' | 'agents' | 'sentiment' | 'inbox' | 'revision' | 'stats' | 'agent-activity' | 'gmail' | 'activity' | 'pinned' | 'usage' | 'daily-review' | 'open-loops' | 'daily-note' | 'link-suggestions' | 'bridges' | 'flashcards-progress' export type DashboardWidgetZone = 'full' | 'main' | 'side' export type DashboardWidgetCategory = | 'essential' | 'paths' | 'cognition' | 'productivity' | 'agents' export interface DashboardWidgetPlacement { id: DashboardWidgetId visible: boolean order: number zone: DashboardWidgetZone } export interface DashboardLayout { version: typeof DASHBOARD_LAYOUT_VERSION widgets: DashboardWidgetPlacement[] } export const DASHBOARD_WIDGET_META: Record = { capture: { required: true, zone: 'full', category: 'essential', defaultVisible: true }, 'next-paths': { required: false, zone: 'main', category: 'paths', defaultVisible: true }, resume: { required: true, zone: 'main', category: 'essential', defaultVisible: true }, intelligence: { required: true, zone: 'main', category: 'essential', defaultVisible: true }, reminders: { required: false, zone: 'side', category: 'productivity', defaultVisible: true }, 'mind-map': { required: false, zone: 'main', category: 'cognition', defaultVisible: true }, agents: { required: false, zone: 'side', category: 'agents', defaultVisible: true }, sentiment: { required: false, zone: 'side', category: 'agents', defaultVisible: true }, inbox: { required: false, zone: 'side', category: 'productivity', defaultVisible: false }, revision: { required: false, zone: 'side', category: 'productivity', defaultVisible: false }, stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false }, 'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false }, gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false }, activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: false }, pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: false }, usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false }, 'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false }, 'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false }, 'daily-note': { required: false, zone: 'side', category: 'paths', defaultVisible: false }, 'link-suggestions': { required: false, zone: 'main', category: 'paths', defaultVisible: false }, bridges: { required: false, zone: 'main', category: 'cognition', defaultVisible: false }, 'flashcards-progress': { required: false, zone: 'side', category: 'productivity', defaultVisible: false }, } export const DASHBOARD_CATEGORY_ORDER: DashboardWidgetCategory[] = [ 'essential', 'paths', 'cognition', 'productivity', 'agents', ] export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { version: DASHBOARD_LAYOUT_VERSION, widgets: [ // Pleine largeur { id: 'capture', visible: true, order: 0, zone: 'full' }, // Colonne principale (gauche) — ordre vertical { id: 'next-paths', visible: true, order: 1, zone: 'main' }, { id: 'intelligence', visible: true, order: 2, zone: 'main' }, { id: 'resume', visible: true, order: 3, zone: 'main' }, { id: 'mind-map', visible: true, order: 4, zone: 'main' }, // Colonne latérale (droite) — cartes compactes + widgets IA { id: 'sentiment', visible: true, order: 5, zone: 'side' }, { id: 'inbox', visible: true, order: 6, zone: 'side' }, { id: 'revision', visible: true, order: 7, zone: 'side' }, { id: 'stats', visible: true, order: 8, zone: 'side' }, { id: 'reminders', visible: true, order: 9, zone: 'side' }, { id: 'flashcards-progress', visible: true, order: 10, zone: 'side' }, { id: 'agents', visible: true, order: 11, zone: 'side' }, // Catalogue — masqués par défaut, ajoutables via « Personnaliser » { id: 'daily-review', visible: false, order: 12, zone: 'side' }, { id: 'agent-activity', visible: false, order: 13, zone: 'side' }, { id: 'gmail', visible: false, order: 14, zone: 'side' }, { id: 'activity', visible: false, order: 15, zone: 'main' }, { id: 'pinned', visible: false, order: 16, zone: 'side' }, { id: 'usage', visible: false, order: 17, zone: 'side' }, { id: 'open-loops', visible: false, order: 18, zone: 'side' }, { id: 'daily-note', visible: false, order: 19, zone: 'side' }, { id: 'link-suggestions', visible: false, order: 20, zone: 'main' }, { id: 'bridges', visible: false, order: 21, zone: 'main' }, ], } /** Clone frais — évite de muter le constant partagé via useState. */ export function getDefaultDashboardLayout(): DashboardLayout { return structuredClone(DEFAULT_DASHBOARD_LAYOUT) } export function resetDashboardLayout(): DashboardLayout { return getDefaultDashboardLayout() } const ALL_WIDGET_IDS = Object.keys(DASHBOARD_WIDGET_META) as DashboardWidgetId[] const LEGACY_SPAN_ZONE: Record = { full: 'full', large: 'main', medium: 'main', small: 'side', compact: 'side', } function isValidZone(zone: unknown): zone is DashboardWidgetZone { return zone === 'full' || zone === 'main' || zone === 'side' } function legacyZoneFromItem(item: Record, id: DashboardWidgetId): DashboardWidgetZone { if (isValidZone(item.zone)) return item.zone const span = item.span if (typeof span === 'string' && LEGACY_SPAN_ZONE[span]) return LEGACY_SPAN_ZONE[span] return DASHBOARD_WIDGET_META[id].zone } export function normalizeDashboardLayout(raw: unknown): DashboardLayout { if (!raw || typeof raw !== 'object') return getDefaultDashboardLayout() const data = raw as Partial & { version?: number } if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout() // Layout périmé ou vide → preset canonique const incomingVersion = typeof data.version === 'number' ? data.version : 0 if (incomingVersion < DASHBOARD_LAYOUT_VERSION) { return getDefaultDashboardLayout() } const byId = new Map() for (const item of data.widgets) { if (!item || typeof item !== 'object') continue const id = (item as DashboardWidgetPlacement).id if (!ALL_WIDGET_IDS.includes(id)) continue const meta = DASHBOARD_WIDGET_META[id] byId.set(id, { id, visible: meta.required ? true : Boolean((item as DashboardWidgetPlacement).visible), order: Number.isFinite((item as DashboardWidgetPlacement).order) ? Number((item as DashboardWidgetPlacement).order) : 0, zone: legacyZoneFromItem(item as unknown as Record, id), }) } for (const id of ALL_WIDGET_IDS) { if (!byId.has(id)) { const meta = DASHBOARD_WIDGET_META[id] const fallback = DEFAULT_DASHBOARD_LAYOUT.widgets.find(w => w.id === id) byId.set(id, { id, visible: meta.defaultVisible, order: fallback?.order ?? 99, zone: meta.zone, }) } } const widgets = [...byId.values()] .sort((a, b) => a.order - b.order) .map((w, index) => ({ ...w, order: index })) const layout = { version: DASHBOARD_LAYOUT_VERSION, widgets } const visibleCount = layout.widgets.filter(w => w.visible).length if (visibleCount === 0) return getDefaultDashboardLayout() return layout } export function reorderWidgets( layout: DashboardLayout, activeId: DashboardWidgetId, overId: DashboardWidgetId, ): DashboardLayout { const activeMeta = DASHBOARD_WIDGET_META[activeId] const overMeta = DASHBOARD_WIDGET_META[overId] if (activeMeta.zone !== overMeta.zone) return layout const visible = layout.widgets .filter(w => w.visible && w.zone === activeMeta.zone) .sort((a, b) => a.order - b.order) const activeIdx = visible.findIndex(w => w.id === activeId) const overIdx = visible.findIndex(w => w.id === overId) if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return layout const next = [...visible] const [moved] = next.splice(activeIdx, 1) next.splice(overIdx, 0, moved) const orderMap = new Map(next.map((w, i) => [w.id, i])) return { ...layout, widgets: layout.widgets.map(w => { if (w.zone !== activeMeta.zone || !w.visible) return w return { ...w, order: orderMap.has(w.id) ? orderMap.get(w.id)! : w.order + next.length } }), } } export function setWidgetVisibility( layout: DashboardLayout, id: DashboardWidgetId, visible: boolean, ): DashboardLayout { if (DASHBOARD_WIDGET_META[id].required && !visible) return layout return { ...layout, widgets: layout.widgets.map(w => w.id === id ? { ...w, visible } : w), } } export function visibleWidgets(layout: DashboardLayout): DashboardWidgetPlacement[] { return layout.widgets.filter(w => w.visible).sort((a, b) => a.order - b.order) } export function visibleWidgetsInZone( layout: DashboardLayout, zone: DashboardWidgetZone, ): DashboardWidgetPlacement[] { return visibleWidgets(layout).filter(w => w.zone === zone) } export function hiddenWidgetIds(layout: DashboardLayout): DashboardWidgetId[] { return layout.widgets.filter(w => !w.visible).map(w => w.id) } export function catalogByCategory(layout: DashboardLayout): Record { const result = Object.fromEntries( DASHBOARD_CATEGORY_ORDER.map(c => [c, [] as DashboardWidgetId[]]), ) as Record for (const id of ALL_WIDGET_IDS) { result[DASHBOARD_WIDGET_META[id].category].push(id) } for (const cat of DASHBOARD_CATEGORY_ORDER) { result[cat].sort((a, b) => { const aVis = layout.widgets.find(w => w.id === a)?.visible ? 0 : 1 const bVis = layout.widgets.find(w => w.id === b)?.visible ? 0 : 1 if (aVis !== bVis) return aVis - bVis return a.localeCompare(b) }) } return result } export function isWidgetVisible(layout: DashboardLayout, id: DashboardWidgetId): boolean { return layout.widgets.find(w => w.id === id)?.visible ?? DASHBOARD_WIDGET_META[id].defaultVisible }