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

@@ -1,8 +1,12 @@
'use client';
import { useState } from 'react';
import { Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import type { DateRange } from '@/lib/services/analytics.service';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
interface DateRangePickerProps {
value: DateRange;
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
className?: string;
}
const OPTIONS: Array<{ value: DateRange; label: string }> = [
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ 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) {
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 (
<div
role="tablist"
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
className,
)}
>
{OPTIONS.map((opt) => {
const active = opt.value === value;
{PRESETS.map((opt) => {
const active = !isCustom && opt.value === value;
return (
<Button
key={opt.value}
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
</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>
);
}