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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user