diff --git a/src/app/api/v1/analytics/route.ts b/src/app/api/v1/analytics/route.ts index 332d66e..f5e2d87 100644 --- a/src/app/api/v1/analytics/route.ts +++ b/src/app/api/v1/analytics/route.ts @@ -9,6 +9,7 @@ import { getRevenueBreakdown, type DateRange, type MetricBase, + type PresetDateRange, } from '@/lib/services/analytics.service'; const METRICS: Record Promise> = { @@ -18,17 +19,69 @@ const METRICS: Record Promise< lead_source_attribution: getLeadSourceAttribution, }; +const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/; + export const GET = withAuth( withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { const url = new URL(req.url); const metric = url.searchParams.get('metric') as MetricBase | null; - const range = (url.searchParams.get('range') ?? '30d') as DateRange; + const rawRange = url.searchParams.get('range') ?? '30d'; + const fromParam = url.searchParams.get('from'); + const toParam = url.searchParams.get('to'); if (!metric || !(metric in METRICS)) { return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); } - if (!ALL_RANGES.includes(range)) { - return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); + + let range: DateRange; + if (rawRange === 'custom') { + if (!fromParam || !toParam) { + return NextResponse.json( + { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' }, + { status: 400 }, + ); + } + if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) { + return NextResponse.json( + { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' }, + { status: 400 }, + ); + } + if (fromParam > toParam) { + return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 }); + } + // Round-trip date check: regex passes "9999-13-99" or "2026-02-31" + // (rolls over silently when handed to `new Date`). Re-serialize and + // confirm it matches the input to catch invalid calendar values. + for (const [label, raw] of [ + ['from', fromParam], + ['to', toParam], + ] as const) { + const d = new Date(`${raw}T00:00:00.000Z`); + if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) { + return NextResponse.json( + { error: `\`${label}\` is not a valid calendar date` }, + { status: 400 }, + ); + } + } + // Backstop against the occupancy-timeline N+1 query loop. Each day + // in the range issues its own DB query, so a multi-year custom + // range would saturate the connection pool. 365 days is a generous + // ceiling for analytical queries; if a longer span is needed, the + // service should be restructured to use `generate_series` instead + // of a JS loop. + const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime(); + const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime(); + if ((toMs - fromMs) / 86_400_000 > 365) { + return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 }); + } + range = { kind: 'custom', from: fromParam, to: toParam }; + } else { + if (!ALL_RANGES.includes(rawRange as PresetDateRange)) { + return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); + } + range = rawRange as PresetDateRange; } const data = await METRICS[metric](ctx.portId, range); diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 2967a33..124c26b 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { usePortContext } from '@/providers/port-provider'; import { PageHeader } from '@/components/shared/page-header'; import { KpiCardsWithBoundary } from './kpi-cards'; import { ActivityFeed } from './activity-feed'; @@ -12,29 +13,53 @@ 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 type { DateRange } from '@/lib/services/analytics.service'; +import { isCustomRange, type DateRange } from '@/lib/analytics/range'; -const RANGE_LABELS: Record = { +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]; +} + export function DashboardShell() { const [range, setRange] = useState('30d'); + const { currentPort } = usePortContext(); + const portName = currentPort?.name ?? 'this port'; + // 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', range], - ['analytics', 'lead_source_attribution', range], + ['analytics', 'pipeline_funnel'], + ['analytics', 'lead_source_attribution'], ['dashboard', 'kpis'], ], 'client:created': [['dashboard', 'kpis']], 'berth:statusChanged': [ - ['analytics', 'occupancy_timeline', range], + ['analytics', 'occupancy_timeline'], ['dashboard', 'kpis'], ], }); @@ -44,8 +69,8 @@ export function DashboardShell() { {RANGE_LABELS[range]}} + description={`Live snapshot of ${portName} activity`} + kpiLine={{rangeLabel(range)}} variant="gradient" actions={} /> @@ -54,7 +79,12 @@ export function DashboardShell() { -
+ {/* `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. */} +
@@ -70,6 +100,11 @@ export function DashboardShell() {