Command Center analytics now honor picker presets and open-ended date bounds.\n\n- Serialize All time with an explicit upper bound and preserve one-sided custom/preset query params.\n- Resolve server analytics ranges as open windows for from-only and to-only requests instead of defaulting them away.\n- Cover picker query serialization and range-consuming Command Center endpoints with regression tests.\n- Document the restored picker contract and add a patch changeset.\n\nFiles changed:\n .changeset/fn-7019-command-center-range.md | 7 ++\n docs/dashboard-guide.md | 3 +-\n .../components/command-center/DateRangePicker.tsx | 12 ++-\n .../command-center/areas/__tests__/areas.test.tsx | 54 ++++++++++++-\n .../components/command-center/areas/areaShared.ts | 8 +-\n .../register-command-center-routes.test.ts | 92 +++++++++++++++++++++-\n .../src/routes/register-command-center-routes.ts | 28 ++++---\n 7 files changed, 183 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7019 Fusion-Task-Lineage: 327f8f45-8ad8-4103-8cc4-efcf2af6a73a
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Calendar } from "lucide-react";
|
|
import "./DateRangePicker.css";
|
|
|
|
export interface DateRange {
|
|
/** ISO date string/timestamp or null for an open lower bound. */
|
|
from: string | null;
|
|
/** ISO date string/timestamp or null for an open upper bound (now). */
|
|
to: string | null;
|
|
/** Identifier for the active preset, or "custom". */
|
|
preset: string;
|
|
}
|
|
|
|
export interface DateRangePreset {
|
|
id: string;
|
|
label: string;
|
|
/** Days back from now; null = all time. */
|
|
days: number | null;
|
|
}
|
|
|
|
export interface DateRangePickerProps {
|
|
value: DateRange;
|
|
onChange: (range: DateRange) => void;
|
|
presets?: DateRangePreset[];
|
|
}
|
|
|
|
export function defaultPresets(t: (key: string, fallback: string) => string): DateRangePreset[] {
|
|
return [
|
|
{ id: "24h", label: t("commandCenter.range.last24h", "Last 24h"), days: 1 },
|
|
{ id: "7d", label: t("commandCenter.range.last7d", "Last 7 days"), days: 7 },
|
|
{ id: "30d", label: t("commandCenter.range.last30d", "Last 30 days"), days: 30 },
|
|
{ id: "all", label: t("commandCenter.range.allTime", "All time"), days: null },
|
|
];
|
|
}
|
|
|
|
export function rangeFromPreset(preset: DateRangePreset): DateRange {
|
|
/*
|
|
FNXC:CommandCenter 2026-06-25-00:00:
|
|
FN-7019 requires picker presets to serialize windows the server can distinguish. Bounded presets keep an open upper bound (`to: null`) so the server resolves `[from, now]`; All time must carry the selection timestamp as an explicit upper bound so it resolves `[epoch, selected-now]` instead of collapsing into the no-param default window.
|
|
*/
|
|
if (preset.days === null) {
|
|
return { from: null, to: new Date(Date.now()).toISOString(), preset: preset.id };
|
|
}
|
|
const from = new Date(Date.now() - preset.days * 86_400_000);
|
|
return { from: from.toISOString().slice(0, 10), to: null, preset: preset.id };
|
|
}
|
|
|
|
export function DateRangePicker({ value, onChange, presets }: DateRangePickerProps) {
|
|
const { t } = useTranslation("app");
|
|
const resolvedPresets = presets ?? defaultPresets(t);
|
|
const [open, setOpen] = useState(false);
|
|
const [customError, setCustomError] = useState<string | null>(null);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
|
|
const close = useCallback(() => {
|
|
setOpen(false);
|
|
// Return focus to the trigger on dismiss.
|
|
triggerRef.current?.focus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
close();
|
|
}
|
|
};
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
if (
|
|
popoverRef.current &&
|
|
!popoverRef.current.contains(e.target as Node) &&
|
|
triggerRef.current &&
|
|
!triggerRef.current.contains(e.target as Node)
|
|
) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
document.addEventListener("pointerdown", onPointerDown);
|
|
return () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
};
|
|
}, [open, close]);
|
|
|
|
const activeLabel =
|
|
resolvedPresets.find((p) => p.id === value.preset)?.label ??
|
|
t("commandCenter.range.custom", "Custom range");
|
|
|
|
const applyCustom = useCallback(
|
|
(from: string | null, to: string | null) => {
|
|
if (from && to && from > to) {
|
|
setCustomError(t("commandCenter.range.invalidRange", "Start date must be on or before end date"));
|
|
return;
|
|
}
|
|
setCustomError(null);
|
|
onChange({ from, to, preset: "custom" });
|
|
},
|
|
[onChange, t],
|
|
);
|
|
|
|
return (
|
|
<div className="cc-date-range">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className="btn btn-sm cc-date-range-trigger"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
data-testid="cc-date-range-trigger"
|
|
>
|
|
<Calendar size={14} />
|
|
<span>{activeLabel}</span>
|
|
</button>
|
|
{open ? (
|
|
<div
|
|
ref={popoverRef}
|
|
className="cc-date-range-popover"
|
|
role="dialog"
|
|
aria-label={t("commandCenter.range.dialogLabel", "Select date range")}
|
|
data-testid="cc-date-range-popover"
|
|
>
|
|
<div className="cc-date-range-presets">
|
|
{resolvedPresets.map((preset) => (
|
|
<button
|
|
key={preset.id}
|
|
type="button"
|
|
className={`btn btn-sm${value.preset === preset.id ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChange(rangeFromPreset(preset));
|
|
close();
|
|
}}
|
|
data-testid={`cc-date-range-preset-${preset.id}`}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="cc-date-range-custom">
|
|
<label className="cc-date-range-field">
|
|
<span>{t("commandCenter.range.from", "From")}</span>
|
|
<input
|
|
type="date"
|
|
value={value.from ?? ""}
|
|
onChange={(e) => applyCustom(e.target.value || null, value.to)}
|
|
data-testid="cc-date-range-from"
|
|
/>
|
|
</label>
|
|
<label className="cc-date-range-field">
|
|
<span>{t("commandCenter.range.to", "To")}</span>
|
|
<input
|
|
type="date"
|
|
value={value.to?.slice(0, 10) ?? ""}
|
|
onChange={(e) => applyCustom(value.from, e.target.value || null)}
|
|
data-testid="cc-date-range-to"
|
|
/>
|
|
</label>
|
|
{customError ? (
|
|
<div className="form-error" role="alert" data-testid="cc-date-range-error">
|
|
{customError}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|