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

@@ -9,6 +9,7 @@ import {
getRevenueBreakdown, getRevenueBreakdown,
type DateRange, type DateRange,
type MetricBase, type MetricBase,
type PresetDateRange,
} from '@/lib/services/analytics.service'; } from '@/lib/services/analytics.service';
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = { const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
@@ -18,18 +19,70 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
lead_source_attribution: getLeadSourceAttribution, lead_source_attribution: getLeadSourceAttribution,
}; };
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
export const GET = withAuth( export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url); const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null; 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)) { if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
} }
if (!ALL_RANGES.includes(range)) {
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 }); return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
} }
range = rawRange as PresetDateRange;
}
const data = await METRICS[metric](ctx.portId, range); const data = await METRICS[metric](ctx.portId, range);
return NextResponse.json({ metric, range, data }); return NextResponse.json({ metric, range, data });

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { usePortContext } from '@/providers/port-provider';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { KpiCardsWithBoundary } from './kpi-cards'; import { KpiCardsWithBoundary } from './kpi-cards';
import { ActivityFeed } from './activity-feed'; import { ActivityFeed } from './activity-feed';
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart'; import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart'; import { LeadSourceChart } from './lead-source-chart';
import { MyRemindersRail } from './my-reminders-rail'; import { MyRemindersRail } from './my-reminders-rail';
import { WebsiteGlanceTile } from './website-glance-tile';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail'; 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', today: 'Today',
'7d': 'Last 7 days', '7d': 'Last 7 days',
'30d': 'Last 30 days', '30d': 'Last 30 days',
'90d': 'Last 90 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() { export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d'); 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({ useRealtimeInvalidation({
'interest:stageChanged': [ 'interest:stageChanged': [
['analytics', 'pipeline_funnel', range], ['analytics', 'pipeline_funnel'],
['analytics', 'lead_source_attribution', range], ['analytics', 'lead_source_attribution'],
['dashboard', 'kpis'], ['dashboard', 'kpis'],
], ],
'client:created': [['dashboard', 'kpis']], 'client:created': [['dashboard', 'kpis']],
'berth:statusChanged': [ 'berth:statusChanged': [
['analytics', 'occupancy_timeline', range], ['analytics', 'occupancy_timeline'],
['dashboard', 'kpis'], ['dashboard', 'kpis'],
], ],
}); });
@@ -44,8 +69,8 @@ export function DashboardShell() {
<PageHeader <PageHeader
title="Dashboard" title="Dashboard"
eyebrow="Overview" eyebrow="Overview"
description="Live snapshot of your marina activity" description={`Live snapshot of ${portName} activity`}
kpiLine={<span>{RANGE_LABELS[range]}</span>} kpiLine={<span>{rangeLabel(range)}</span>}
variant="gradient" variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />} actions={<DateRangePicker value={range} onChange={setRange} />}
/> />
@@ -54,7 +79,12 @@ export function DashboardShell() {
<KpiCardsWithBoundary /> <KpiCardsWithBoundary />
</div> </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"> <div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary> <WidgetErrorBoundary>
<PipelineFunnelChart range={range} /> <PipelineFunnelChart range={range} />
@@ -70,6 +100,11 @@ export function DashboardShell() {
</WidgetErrorBoundary> </WidgetErrorBoundary>
</div> </div>
<aside className="min-w-0 space-y-4"> <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> <WidgetErrorBoundary>
<MyRemindersRail /> <MyRemindersRail />
</WidgetErrorBoundary> </WidgetErrorBoundary>

View File

@@ -1,8 +1,12 @@
'use client'; 'use client';
import { useState } from 'react';
import { Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { DateRange } from '@/lib/services/analytics.service'; import { isCustomRange, type DateRange } from '@/lib/analytics/range';
interface DateRangePickerProps { interface DateRangePickerProps {
value: DateRange; value: DateRange;
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
className?: string; className?: string;
} }
const OPTIONS: Array<{ value: DateRange; label: string }> = [ const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
{ value: 'today', label: 'Today' }, { value: 'today', label: 'Today' },
{ value: '7d', label: '7d' }, { value: '7d', label: '7d' },
{ value: '30d', label: '30d' }, { value: '30d', label: '30d' },
{ value: '90d', label: '90d' }, { value: '90d', label: '90d' },
]; ];
/**
* Format a custom range as a compact button label, e.g. "Apr 14 May 4".
* Same year omits the year on both sides; different years includes both.
*/
function formatCustom(range: { from: string; to: string }): string {
const from = new Date(`${range.from}T00:00:00.000Z`);
const to = new Date(`${range.to}T00:00:00.000Z`);
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
const fmt: Intl.DateTimeFormatOptions = sameYear
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
return `${from.toLocaleDateString('en-US', fmt)} ${to.toLocaleDateString('en-US', fmt)}`;
}
/**
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
* "to" picker so users can't accidentally pick a future date by leaving the
* field empty.
*/
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) { export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const isCustom = isCustomRange(value);
// Local state for the popover form. Seeded from the current value if it's
// already custom, otherwise defaults to a 14-day window ending today.
const [draftFrom, setDraftFrom] = useState<string>(() => {
if (isCustom) return value.from;
const d = new Date();
d.setUTCDate(d.getUTCDate() - 14);
return d.toISOString().slice(0, 10);
});
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
const today = todayIso();
const draftValid =
draftFrom !== '' &&
draftTo !== '' &&
draftFrom <= draftTo &&
draftFrom <= today &&
draftTo <= today;
function applyCustom() {
if (!draftValid) return;
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
setOpen(false);
}
return ( return (
<div <div
role="tablist" role="tablist"
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
className, className,
)} )}
> >
{OPTIONS.map((opt) => { {PRESETS.map((opt) => {
const active = opt.value === value; const active = !isCustom && opt.value === value;
return ( return (
<Button <Button
key={opt.value} key={opt.value}
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
</Button> </Button>
); );
})} })}
{/* Custom range - popover with two date inputs and an Apply button */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
role="tab"
aria-selected={isCustom}
variant="ghost"
size="sm"
className={cn(
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
isCustom
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
data-testid="range-custom"
>
<Calendar className="h-3 w-3" aria-hidden />
{isCustom ? formatCustom(value) : 'Custom'}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[260px] p-3">
<div className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Custom range
</div>
<label className="block text-xs">
<span className="block text-muted-foreground mb-1">From</span>
<input
type="date"
value={draftFrom}
/* `max` capped at min(draftTo, today). Without the today
cap, users could pick a future From, end up with an
empty result, and not understand why. */
max={draftTo && draftTo < today ? draftTo : today}
onChange={(e) => setDraftFrom(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<label className="block text-xs">
<span className="block text-muted-foreground mb-1">To</span>
<input
type="date"
value={draftTo}
min={draftFrom || undefined}
max={today}
onChange={(e) => setDraftTo(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
Apply
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useUIStore } from '@/stores/ui-store';
import { KPITile } from '@/components/ui/kpi-tile'; import { KPITile } from '@/components/ui/kpi-tile';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
} }
export function KpiCards() { export function KpiCards() {
// Keying on currentPortId ensures React Query treats a port-resolved fetch
// as a different query than the one that fires on first paint when the
// store hasn't yet hydrated. Without this, an early null-port fetch could
// cache an error and display "-" indefinitely until the staleTime expires.
const portId = useUIStore((s) => s.currentPortId);
const { data, isLoading, isError } = useQuery<KpiData>({ const { data, isLoading, isError } = useQuery<KpiData>({
queryKey: ['dashboard', 'kpis'], queryKey: ['dashboard', 'kpis', portId],
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'), queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
staleTime: 60_000, staleTime: 60_000,
retry: 2, retry: 2,
// Avoid running until we have a port id - gates against the early
// unauth/no-port window where the API would return zeroes/errors.
enabled: !!portId,
}); });
if (isLoading) { if (isLoading) {
@@ -62,22 +71,22 @@ export function KpiCards() {
}> = [ }> = [
{ {
label: 'Total Clients', label: 'Total Clients',
value: isError ? '' : String(data?.totalClients ?? 0), value: isError ? '-' : String(data?.totalClients ?? 0),
accent: 'brand', accent: 'brand',
}, },
{ {
label: 'Active Interests', label: 'Active Interests',
value: isError ? '' : String(data?.activeInterests ?? 0), value: isError ? '-' : String(data?.activeInterests ?? 0),
accent: 'teal', accent: 'teal',
}, },
{ {
label: 'Pipeline Value', label: 'Pipeline Value',
value: isError ? '' : formatCurrency(data?.pipelineValueUsd ?? 0), value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
accent: 'success', accent: 'success',
}, },
{ {
label: 'Occupancy Rate', label: 'Occupancy Rate',
value: isError ? '' : formatPercent(data?.occupancyRate ?? 0), value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
accent: 'purple', accent: 'purple',
}, },
]; ];

View File

@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
/** /**
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned * Compact reminders rail for the dashboard sidebar. Lists reminders assigned
* to the current user (overdue first, then upcoming). Each item links to its * to the current user (overdue first, then upcoming). Each item links to its
* subject interest preferred, then client, then the generic entity ref. * subject - interest preferred, then client, then the generic entity ref.
* *
* Limited to 6 items; "View all" routes to /reminders. * Limited to 6 items; "View all" routes to /reminders.
*/ */
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
return `/${portSlug}/reminders`; return `/${portSlug}/reminders`;
} }
// `h-full` only at xl: where the dashboard grid pairs this rail with // Natural height - the parent dashboard grid uses `items-start` so the
// a sibling chart column. On mobile (stacked) it produced a weirdly // aside column no longer forces this rail to fill the chart column's
// tall empty card. // height. Stretching here pushed AlertRail out of the aside's box and
// into the territory below where ActivityFeed renders, producing a
// visible overlap on tall viewports.
return ( return (
<Card className="xl:h-full"> <Card>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3"> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<CardTitle className="flex items-center gap-1.5 text-base"> <CardTitle className="flex items-center gap-1.5 text-base">
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
</div> </div>
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<p className="py-3 text-center text-sm text-muted-foreground"> <p className="py-3 text-center text-sm text-muted-foreground">
All caught up no reminders. All caught up - no reminders.
</p> </p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">

View File

@@ -3,8 +3,8 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import type { import type {
DateRange,
LeadSourceAttributionData, LeadSourceAttributionData,
MetricBase, MetricBase,
OccupancyTimelineData, OccupancyTimelineData,
@@ -18,12 +18,27 @@ interface MetricResponse<T> {
data: T; data: T;
} }
/**
* Serialize a DateRange (preset or custom) into the URL query params the
* /api/v1/analytics route expects: `range=30d` for presets, or
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD` for custom.
*/
function rangeToQuery(range: DateRange): string {
if (isCustomRange(range)) {
return `range=custom&from=${range.from}&to=${range.to}`;
}
return `range=${range}`;
}
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) { export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
return useQuery<T>({ return useQuery<T>({
// Stringify custom ranges into the cache key so React Query treats
// each {from,to} pair as its own query - otherwise switching dates
// would never refetch.
queryKey: ['analytics', metric, range], queryKey: ['analytics', metric, range],
queryFn: async () => { queryFn: async () => {
const res = await apiFetch<MetricResponse<T>>( const res = await apiFetch<MetricResponse<T>>(
`/api/v1/analytics?metric=${metric}&range=${range}`, `/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
); );
return res.data; return res.data;
}, },

View File

@@ -12,7 +12,7 @@ export type { EventMap, SocketLike } from '@/hooks/realtime-invalidation-core';
/** /**
* Subscribes to socket events and invalidates React Query caches. * Subscribes to socket events and invalidates React Query caches.
* *
* Safe to call with an inline-literal `eventMap` the hook only re-subscribes * Safe to call with an inline-literal `eventMap` - the hook only re-subscribes
* when the SET of event keys actually changes (not when the object identity * when the SET of event keys actually changes (not when the object identity
* changes). The latest query-key list is read at event fire-time via a ref. * changes). The latest query-key list is read at event fire-time via a ref.
* *
@@ -39,7 +39,7 @@ export function useRealtimeInvalidation(eventMap: EventMap) {
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
// eventMapRef is intentionally not in deps it's a ref; we only want to // eventMapRef is intentionally not in deps - it's a ref; we only want to
// re-run when the socket, queryClient, or the event-key SET changes. // re-run when the socket, queryClient, or the event-key SET changes.
return subscribeRealtimeInvalidations( return subscribeRealtimeInvalidations(
socket, socket,