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