'use client'; import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; import { apiFetch } from '@/lib/api/client'; import { PageHeader } from '@/components/shared/page-header'; import { CustomizeWidgetsMenu } from './customize-widgets-menu'; import { DateRangePicker } from './date-range-picker'; import { TimezoneDriftBanner } from './timezone-drift-banner'; import { WidgetErrorBoundary } from './widget-error-boundary'; import type { DashboardWidget } from './widget-registry'; import { isCustomRange, type DateRange } from '@/lib/analytics/range'; const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = { today: 'Today', '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days', }; function rangeLabel(range: DateRange): string { if (isCustomRange(range)) { const fmt: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC', }; const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt); const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt); return `${from} – ${to}`; } return PRESET_LABELS[range]; } interface MeData { data?: { profile?: { firstName?: string | null; displayName?: string | null; }; }; } function timeOfDayGreeting(): string { // new Date().getHours() uses the browser's local timezone, which the OS // updates automatically on most modern devices when the user travels (laptop // "set timezone automatically" on macOS, phones via cell-tower / GPS). So // this reflects the user's physical location as long as their machine clock // does. We still defer the compute to a useEffect on mount so the rendered // HTML can never disagree with the server's clock during hydration. const hour = new Date().getHours(); if (hour < 5) return 'Up late'; if (hour < 12) return 'Good morning'; if (hour < 18) return 'Good afternoon'; return 'Good evening'; } interface DashboardShellProps { /** SSR-prefetched first name. When provided, the greeting renders with it * on first paint instead of flickering "Welcome back" → "Hello, Matt". */ initialFirstName?: string | null; /** SSR-prefetched widget visibility map. Seeds the preferences cache so the * layout doesn't reflow once the client-side fetch resolves. */ initialWidgetVisibility?: Record | null; } export function DashboardShell({ initialFirstName, initialWidgetVisibility, }: DashboardShellProps = {}) { const [range, setRange] = useState('30d'); const { visibleWidgets } = useDashboardWidgets({ initialVisibility: initialWidgetVisibility ?? null, }); // Bucket once so the JSX stays readable. Registry order is preserved // inside each bucket, so reordering the registry reorders the render. const charts = visibleWidgets.filter((w) => w.group === 'chart'); const rails = visibleWidgets.filter((w) => w.group === 'rail'); const feed = visibleWidgets.filter((w) => w.group === 'feed'); // Reuses the existing ['me'] cache (5-minute staleTime) populated by // useTablePreferences elsewhere — usually a cache hit, so no extra // request. When the page server-prefetches the first name we seed it // here via `initialData` so the cache is warm before the post-mount // fetch resolves, eliminating the "Welcome back → Hello, Matt" flash. const me = useQuery({ queryKey: ['me'], queryFn: ({ signal }) => apiFetch('/api/v1/me', { signal }), staleTime: 5 * 60_000, initialData: initialFirstName !== undefined ? ({ data: { profile: { firstName: initialFirstName } } } as MeData) : undefined, }); const firstName = me.data?.data?.profile?.firstName?.trim(); // Greeting word is computed in a useEffect so the rendered HTML can't lock // to the server's clock during hydration. Until the effect fires, the // header reads "Welcome" — a neutral phrase that's correct at every hour // and never produces a hydration warning. `clientGreeting` flips to the // local-time-aware phrasing once the component has mounted. const [clientGreeting, setClientGreeting] = useState(null); useEffect(() => { setClientGreeting(timeOfDayGreeting()); // Re-evaluate hourly so a rep who leaves the dashboard open through a // boundary (5am, noon, 6pm) doesn't keep stale text on screen. const interval = window.setInterval(() => setClientGreeting(timeOfDayGreeting()), 60 * 60_000); return () => window.clearInterval(interval); }, []); const greeting = firstName ? `${clientGreeting ?? 'Welcome'}, ${firstName}` : (clientGreeting ?? 'Welcome back'); // Use a partial query-key prefix (no range segment) for invalidations. // Reading: "any cached analytics result, regardless of range, please // refetch on this event." This avoids any chance that a custom-range // object literal hashes differently than the one stored in the cache, // and keeps the invalidation surface broad enough to refresh whichever // range the user is currently looking at. useRealtimeInvalidation({ 'interest:stageChanged': [ ['analytics', 'pipeline_funnel'], ['analytics', 'lead_source_attribution'], ['dashboard', 'kpis'], ], 'client:created': [['dashboard', 'kpis']], 'berth:statusChanged': [ ['analytics', 'occupancy_timeline'], ['dashboard', 'kpis'], ], }); return (
{/* Mobile-only greeting strip. The shared PageHeader is hidden below `sm` (its title is normally duplicated by the topbar), so we render the welcome message inline here for mobile — keeps the personalized touch from desktop without polluting the topbar (which stays "Dashboard" for wayfinding). */}

Dashboard

{greeting}

); } /** * Placeholder shown when the rep has hidden every widget. Without this, * the dashboard collapses to just the gradient header strip and looks * like a broken page — this hints at the "Customize" button to bring * widgets back. */ function EmptyDashboardHint() { return (

No widgets on your dashboard yet

Click Customize above to pick which analytics cards appear here.

); } function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) { return {widget.render(range)}; }