feat(dashboard): custom date range + KPI port-hydration gate

DateRangePicker grows a "Custom range" mode (From/To inputs capped
at today, mutually-bounded so From <= To). dashboard-shell threads
the range through to /api/v1/analytics, which validates calendar
dates via ISO round-trip and enforces a 365-day cap as a backstop
against the occupancy timeline N+1.

KpiCards now gates its query on currentPortId so the early
unhydrated-store fetch can't cache a zeroed/error response and
display "-" until staleTime expires.

MyRemindersRail drops xl:h-full so the rail no longer stretches
past its grid row and overlaps ActivityFeed below.

useRealtimeInvalidation switches to partial-prefix queryKeys so a
realtime mutation invalidates every cached range bucket at once
instead of just the one currently visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:54:55 +02:00
parent e598cc0708
commit 77ad10ced1
7 changed files with 260 additions and 30 deletions

View File

@@ -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<DateRange, string> = {
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<DateRange>('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() {
<PageHeader
title="Dashboard"
eyebrow="Overview"
description="Live snapshot of your marina activity"
kpiLine={<span>{RANGE_LABELS[range]}</span>}
description={`Live snapshot of ${portName} activity`}
kpiLine={<span>{rangeLabel(range)}</span>}
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
/>
@@ -54,7 +79,12 @@ export function DashboardShell() {
<KpiCardsWithBoundary />
</div>
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
{/* `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. */}
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary>
<PipelineFunnelChart range={range} />
@@ -70,6 +100,11 @@ export function DashboardShell() {
</WidgetErrorBoundary>
</div>
<aside className="min-w-0 space-y-4">
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
isn't configured for this port. */}
<WidgetErrorBoundary>
<WebsiteGlanceTile />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<MyRemindersRail />
</WidgetErrorBoundary>