'use client'; import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { usePortContext } from '@/providers/port-provider'; import { apiFetch } from '@/lib/api/client'; import { PageHeader } from '@/components/shared/page-header'; import { ActivityFeed } from './activity-feed'; import { DateRangePicker } from './date-range-picker'; import { PipelineFunnelChart } from './pipeline-funnel-chart'; import { OccupancyTimelineChart } from './occupancy-timeline-chart'; import { RevenueBreakdownChart } from './revenue-breakdown-chart'; import { LeadSourceChart } from './lead-source-chart'; import { MyRemindersRail } from './my-reminders-rail'; import { WebsiteGlanceTile } from './website-glance-tile'; import { WidgetErrorBoundary } from './widget-error-boundary'; import { AlertRail } from '@/components/alerts/alert-rail'; 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?: { firstName?: string | null; displayName?: string | null; }; } function timeOfDayGreeting(): string { 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'; } export function DashboardShell() { const [range, setRange] = useState('30d'); const { currentPort } = usePortContext(); const portName = currentPort?.name ?? 'this port'; // Reuses the existing ['me'] cache (5-minute staleTime) populated by // useTablePreferences elsewhere — usually a cache hit, so no extra // request. Falls back to a generic greeting if the profile isn't // available yet so we never block the dashboard render. const me = useQuery({ queryKey: ['me'], queryFn: ({ signal }) => apiFetch('/api/v1/me', { signal }), staleTime: 5 * 60_000, }); const firstName = me.data?.data?.firstName?.trim(); // Time-aware greeting line, falls back to a generic "Welcome back" when // we don't know the user's first name yet (e.g. profile not filled out). const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : '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 (
{rangeLabel(range)}} variant="gradient" actions={} /> {/* `items-start` is critical: without it, the right-column aside is stretched to match the chart column's row height, which forces MyRemindersRail (or any other child with `h-full`) to push later children out of the aside's box and into the rows below where ActivityFeed renders. */}
); }