diff --git a/src/ui/CLAUDE.md b/src/ui/CLAUDE.md index 163180517..de492339e 100644 --- a/src/ui/CLAUDE.md +++ b/src/ui/CLAUDE.md @@ -22,6 +22,52 @@ cd external/src/ui && pnpm type-check && pnpm lint && pnpm test --run pnpm format ``` +## Reasoning Integrity: Avoiding Systematic Failure Modes + +These principles guard against how Claude tends to reason incorrectly about code changes. + +### Fix at the source — never by convergence + +When two things are inconsistent, identify which is *correct* and fix the other. Do not flatten both to a common (often weaker) state to create false consistency. + +```text +❌ A strips time from a datetime-local input → downgrade input to type="date" for "consistency" +✅ A strips time from a datetime-local input → remove the stripping; the datetime was correct +``` + +**The pattern to catch:** "These two things are inconsistent, so I'll make them both match the simpler one." This is always wrong. One side is the bug; find it. + +### Capability loss is a regression — always + +Reducing precision, expressiveness, or user capability requires explicit user approval even when it creates consistency or simplifies the implementation. This includes: + +- Input type downgrades (`datetime-local` → `date`, `number` → `text`) +- Data truncation (full ISO datetime → date-only string, float → int) +- Feature removal (range picker → single value, multi-select → single-select) +- API parameter removal or narrowing + +If the rationale for a change involves "simpler" at the cost of capability, stop and ask. + +### Apply the reversal test before citing evidence + +Before using a fact to justify a change, ask: *"Does this same evidence equally support the opposite conclusion?"* + +```text +Fact: backend accepts datetime.datetime +→ Wrong: "date-only strings work too, so use type='date'" +→ Right: "the backend supports full precision — use datetime-local and pass full timestamps" +``` + +If evidence supports both a conclusion and its opposite, it is not justifying the change — it is post-hoc rationalization. Recognizing this pattern should trigger a full re-examination of the decision. + +### Design intent vs. implementation bug: assume capability when uncertain + +When implementation looks inconsistent (e.g., `datetime-local` input but time is then stripped), one side is correct intent and the other is the bug. **Default assumption: the richer/more capable side is the intent, the lossy transformation is the bug.** If genuinely uncertain, ask the user — do not silently resolve the ambiguity by picking the simpler option. + +### Post-hoc rationalization: when evidence confirms a prior decision, be suspicious + +Evidence found *after* a decision that *perfectly supports* it is a red flag. When you notice you are building a case for something already decided, explicitly argue the opposite before proceeding. + ## Development Commands ```bash @@ -51,7 +97,7 @@ pnpm generate-api # Regenerate from backend OpenAPI spec ## Architecture: The Critical Layer Pattern -``` +```text Page → Headless Hook → Adapter Hook → Generated API → Backend ↓ Themed Components diff --git a/src/ui/next.config.ts b/src/ui/next.config.ts index 630f1f456..d65685c4e 100644 --- a/src/ui/next.config.ts +++ b/src/ui/next.config.ts @@ -204,6 +204,9 @@ const nextConfig: NextConfig = { // Dataset file proxy route - alias to production version (zero mock code) "@/app/proxy/dataset/file/route.impl": "@/app/proxy/dataset/file/route.impl.production", + + // Auth server utilities - alias to production version (zero env fallbacks) + "@/lib/auth/server": "@/lib/auth/server.production", } : {}, }, diff --git a/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx b/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx new file mode 100644 index 000000000..1fa9d6dfe --- /dev/null +++ b/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * FilterBarDatePicker - Date/range picker panel rendered inside the FilterBar dropdown. + * + * B1 "Split Rail" layout: preset list on the left with active indicator, + * stacked From/To date inputs on the right. + * + * Selecting a preset or applying custom dates calls onCommit(value) where value + * is either a preset label ("last 7 days"), a single ISO date ("2026-03-11"), + * or an ISO range ("2026-03-01..2026-03-11"). + */ + +"use client"; + +import { useState, useCallback, useMemo, memo, useRef, useEffect } from "react"; +import { DATE_RANGE_PRESETS } from "@/lib/date-range-utils"; +import { DATE_CUSTOM_FROM, DATE_CUSTOM_TO, DATE_CUSTOM_APPLY } from "@/components/filter-bar/lib/types"; +import { MONTHS_SHORT } from "@/lib/format-date"; + +interface FilterBarDatePickerProps { + /** Called when a date or range is committed. Value is preset label, ISO date, or ISO range. */ + onCommit: (value: string) => void; + /** Preset label currently highlighted via keyboard navigation (shows active indicator). */ + highlightedLabel?: string; + /** Called when Tab/Shift-Tab should wrap the cycle (e.g. Tab past Apply, Shift-Tab on From). */ + onCycleStep?: (direction: "forward" | "backward", fromValue: string) => void; +} + +/** Format a UTC YYYY-MM-DD string as "Mar 4" or "Mar 4 '25" (if year differs from currentYear). */ +function fmtUtcDate(isoDate: string, currentYear: number): string { + const d = new Date(`${isoDate}T00:00:00Z`); + const mon = MONTHS_SHORT[d.getUTCMonth()]; + const day = d.getUTCDate(); + const year = d.getUTCFullYear(); + return year !== currentYear ? `${mon} ${day} '${String(year).slice(2)}` : `${mon} ${day}`; +} + +/** + * Build hint text from the raw preset value (before next-midnight adjustment). + * Single date → "Mar 11"; range → "Mar 4 – Mar 11". + */ +function buildPresetHint(rawValue: string, currentYear: number): string { + if (rawValue.includes("..")) { + const sep = rawValue.indexOf(".."); + return `${fmtUtcDate(rawValue.slice(0, sep), currentYear)} – ${fmtUtcDate(rawValue.slice(sep + 2), currentYear)}`; + } + return fmtUtcDate(rawValue, currentYear); +} + +export const FilterBarDatePicker = memo(function FilterBarDatePicker({ + onCommit, + highlightedLabel, + onCycleStep, +}: FilterBarDatePickerProps) { + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + + const fromRef = useRef(null); + const toRef = useRef(null); + const applyRef = useRef(null); + + // When keyboard navigation highlights a custom input sentinel, move DOM focus there. + // For the To input, also open the calendar picker — programmatic focus() always lands + // at the first sub-field (MM), but entering backward should start at the calendar end. + useEffect(() => { + if (highlightedLabel === DATE_CUSTOM_FROM) { + fromRef.current?.focus(); + } else if (highlightedLabel === DATE_CUSTOM_TO) { + toRef.current?.focus(); + } else if (highlightedLabel === DATE_CUSTOM_APPLY) { + applyRef.current?.focus(); + } + }, [highlightedLabel]); + + // Compute once per render (client-only component, only mounted on interaction). + const currentYear = useMemo(() => new Date().getUTCFullYear(), []); + + const presetHints = useMemo( + () => Object.fromEntries(DATE_RANGE_PRESETS.map((p) => [p.label, buildPresetHint(p.getValue(), currentYear)])), + [currentYear], + ); + + // toDate must be strictly after fromDate (same minute = zero-second window after +1min adjustment) + const rangeError = !!fromDate && !!toDate && toDate <= fromDate; + + const handleApply = useCallback(() => { + if (!fromDate || rangeError) return; + if (toDate) { + onCommit(`${fromDate}..${toDate}`); + } else { + onCommit(fromDate); + } + }, [fromDate, toDate, rangeError, onCommit]); + + const handleFromChange = useCallback((value: string) => { + setFromDate(value); + // Clear "to" if it's now at or before "from" (equal = invalid range after +1min adjustment) + setToDate((prev) => (prev && prev <= value ? "" : prev)); + }, []); + + return ( +
e.stopPropagation()} + > +
+ {/* Left rail: presets with right-aligned date hints */} +
+
Presets
+ {DATE_RANGE_PRESETS.map((preset) => ( + + ))} +
+ + {/* Right: custom range */} +
+
Custom range
+
+ + handleFromChange(e.target.value)} + className="fb-date-input" + /> +
+
+ + setToDate(e.target.value)} + min={fromDate || undefined} + className="fb-date-input" + data-error={rangeError ? "" : undefined} + aria-invalid={rangeError} + aria-describedby={rangeError ? "fb-date-range-error" : undefined} + /> + {rangeError && ( + + “To” must be after “From” + + )} +
+ +
+
+ {/* Focus sentinel: the last focusable element inside the picker. + When Tab exits Apply (or To when Apply is disabled), the browser naturally + focuses this sentinel. onFocus immediately redirects back into the cycle, + keeping focus trapped inside the filter bar. It must live inside the picker + (inside the container) so handleBlur sees relatedTarget as within-container. */} +
+ ); +}); diff --git a/src/ui/src/components/filter-bar/filter-bar-dropdown.tsx b/src/ui/src/components/filter-bar/filter-bar-dropdown.tsx index 9e39ebd7e..47281c2de 100644 --- a/src/ui/src/components/filter-bar/filter-bar-dropdown.tsx +++ b/src/ui/src/components/filter-bar/filter-bar-dropdown.tsx @@ -36,7 +36,14 @@ import { memo, useRef, useMemo, useEffect } from "react"; import { Loader2 } from "lucide-react"; import { CommandList, CommandItem, CommandGroup } from "@/components/shadcn/command"; import { useVirtualizerCompat } from "@/hooks/use-virtualizer-compat"; -import type { FieldSuggestion, PresetSuggestion, SearchPreset, Suggestion } from "@/components/filter-bar/lib/types"; +import type { + FieldSuggestion, + PresetSuggestion, + SearchPreset, + Suggestion, + DateRangeSearchField, +} from "@/components/filter-bar/lib/types"; +import { FilterBarDatePicker } from "@/components/filter-bar/filter-bar-date-picker"; // --------------------------------------------------------------------------- // Constants @@ -82,6 +89,12 @@ interface FilterBarDropdownProps { loadingFieldLabel?: string; /** Currently highlighted cmdk value — drives scroll-into-view */ highlightedSuggestionValue?: string; + /** Active date-range field, if any — renders date picker instead of suggestions */ + activeDateRangeField?: DateRangeSearchField | null; + /** Callback when a date value is committed from the date picker */ + onDateCommit?: (value: string) => void; + /** Advance the date picker cycle from a sentinel when Tab/Shift-Tab fires on a picker element */ + onDateCycleStep?: (direction: "forward" | "backward", fromValue: string) => void; } // --------------------------------------------------------------------------- @@ -99,6 +112,9 @@ function FilterBarDropdownInner({ isFieldLoading, loadingFieldLabel, highlightedSuggestionValue, + activeDateRangeField, + onDateCommit, + onDateCycleStep, }: FilterBarDropdownProps) { const listRef = useRef(null); @@ -166,37 +182,57 @@ function FilterBarDropdownInner({ overflow-hidden (not overflow-y-auto) means CommandList itself never scrolls; the inner .fb-suggestions-scroll is the sole scroll container. */} - {/* Presets — present in selectables when input is empty */} - {presetGroups.length > 0 && ( - - )} - - {/* Hints (non-interactive, shown above suggestions) */} - {hints.length > 0 && } - - {/* Async field loading state */} - {isFieldLoading ? ( - ) : ( - /* Suggestions - virtualized when large */ - fieldSelectables.length > 0 && ( - - ) + <> + {/* Presets — present in selectables when input is empty */} + {presetGroups.length > 0 && ( + + )} + + {/* Hints (non-interactive, shown above suggestions) */} + {hints.length > 0 && } + + {/* Async field loading state */} + {isFieldLoading ? ( + + ) : ( + /* Suggestions - virtualized when large */ + fieldSelectables.length > 0 && ( + + ) + )} + )} {/* Footer */}
- ↑↓ Tab fill{" "} - Enter accept Esc undo + {activeDateRangeField ? ( + <> + Tab navigate Enter apply{" "} + Esc cancel + + ) : ( + <> + ↑↓ Tab fill{" "} + Enter accept Esc undo + + )}
diff --git a/src/ui/src/components/filter-bar/filter-bar.css b/src/ui/src/components/filter-bar/filter-bar.css index aa8122a25..ea86e646e 100644 --- a/src/ui/src/components/filter-bar/filter-bar.css +++ b/src/ui/src/components/filter-bar/filter-bar.css @@ -40,6 +40,10 @@ /* Dropdown max height */ --fb-dropdown-max-height: 380px; + + /* Date picker column sizing */ + --fb-date-right-min: 185px; + --fb-date-left-max: 270px; } /* Dark mode blue accents */ @@ -355,6 +359,239 @@ border-bottom: 1px solid var(--border); } +/* ============================================================================= + Date Picker Panel — B1 "Split Rail" layout + Left: preset list (label + right-aligned date hint) with active border indicator. + Right: stacked From/To inputs + Apply. + ============================================================================= */ + +.fb-date-picker { + overflow-y: auto; + overscroll-behavior: contain; +} + +/* + Right column minimum: + datetime-local input content ≈ 160px (mm/dd/yyyy, --:-- -- at 0.75rem + calendar icon) + .fb-date-custom h-padding = 24px (0.75rem × 2) + → --fb-date-right-min = 184px (rounded up to 185px) + + Left rail maximum (cap so extra space flows to the right column): + Row left-padding = 14px (0.875rem) + Longest label "last 365 days" ≈ 100px + Minimum gap (padding-left) = 16px (1rem, enforced on hint) + Longest hint "MMM DD 'YY – MMM DD" ≈ 130px (19 chars × ~6.6px/ch at 0.6875rem monospace) + Row right-padding = 10px (0.625rem) + ───────────────────────────────────────────────────── + Total ≈ 270px → --fb-date-left-max: 270px + + Grid: left capped at --fb-date-left-max, right gets all remaining space (min --fb-date-right-min). + + Hint hide threshold (= max content without right-padding): + 14px + 100px + 16px + 130px = 260px + Container query hides hints at rail < 260px so they are always fully shown or fully hidden. +*/ +.fb-date-split { + display: grid; + grid-template-columns: minmax(0, var(--fb-date-left-max)) minmax(var(--fb-date-right-min), 1fr); + min-height: 12rem; +} + +/* Left rail */ +.fb-date-rail { + border-right: 1px solid var(--border); + padding: 0.5rem 0; + display: flex; + flex-direction: column; + container-type: inline-size; + container-name: date-rail; +} + +.fb-date-section-label { + padding: 0 0.75rem 0.375rem; + font-size: 0.6875rem; + line-height: 1rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-foreground); + pointer-events: none; + user-select: none; + opacity: 0.55; +} + +.fb-date-preset-row { + display: flex; + width: 100%; + align-items: center; + gap: 0.375rem; + padding: 0.4375rem 0.625rem 0.4375rem 0.875rem; + font-size: 0.8125rem; + line-height: 1.25rem; + color: var(--fb-accent); + cursor: pointer; + text-align: left; + border-left: 2px solid transparent; + transition: + background-color 150ms, + color 150ms, + border-color 150ms; +} + +.fb-date-preset-row:hover { + background-color: var(--accent); +} + +.fb-date-preset-row[data-active] { + border-left-color: var(--fb-accent); + background-color: color-mix(in oklch, var(--fb-accent) 10%, transparent); + color: var(--fb-accent); + font-weight: 500; + padding-left: 0.75rem; /* compensate for 2px border so text stays aligned */ +} + +.fb-date-preset-row:focus-visible { + outline: 2px solid var(--ring); + outline-offset: -2px; +} + +/* Label: never wraps or shrinks — always takes its natural width */ +.fb-date-preset-label { + flex-shrink: 0; + white-space: nowrap; +} + +/* Hint: right-aligned, minimum gap from label via padding-left. + Collapses to nothing when there is no room (min-width: 0 + overflow: hidden). + Fully hidden via container query before any clipping can occur. */ +.fb-date-preset-hint { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-align: right; + /* padding-left enforces the minimum visual gap between label and hint */ + padding-left: 1rem; + font-size: 0.6875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: var(--foreground); + opacity: 0.5; +} + +.fb-date-preset-row[data-active] .fb-date-preset-hint { + opacity: 0.7; + color: currentColor; +} + +/* Hide hints when the rail is narrower than the space needed to show them fully. + Threshold = row-padding + longest-label + min-gap + longest-hint = 14+100+16+130 = 260px. + This guarantees hints are always fully visible or completely absent. */ +@container date-rail (max-width: 259px) { + .fb-date-preset-hint { + display: none; + } +} + +/* Right column: custom range */ +.fb-date-custom { + padding: 0.5rem 0.75rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.fb-date-custom .fb-date-section-label { + padding-left: 0; + padding-right: 0; +} + +.fb-date-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.fb-date-label { + font-size: 0.6875rem; + line-height: 1rem; + color: var(--foreground); + opacity: 0.7; +} + +.fb-date-input { + width: 100%; + border-radius: 0.375rem; + border: 1px solid var(--border); + background-color: var(--background); + color: var(--foreground); + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + line-height: 1.25rem; + transition: border-color 150ms; +} + +.fb-date-input:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 1px var(--ring); +} + +.fb-date-input[data-error] { + border-color: var(--destructive); + box-shadow: 0 0 0 1px var(--destructive); +} + +.fb-date-error { + font-size: 0.6875rem; + line-height: 1rem; + color: var(--destructive); +} + +.fb-date-input::-webkit-calendar-picker-indicator { + opacity: 0.45; + cursor: pointer; +} + +.fb-date-input::-webkit-calendar-picker-indicator:hover { + opacity: 1; +} + +.fb-date-apply { + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background-color: var(--secondary); + color: var(--fb-accent); + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + line-height: 1.25rem; + font-weight: 500; + cursor: pointer; + transition: + background-color 150ms, + color 150ms; + width: 100%; + margin-top: auto; +} + +.fb-date-apply:hover:not(:disabled) { + background-color: var(--accent); + color: var(--accent-foreground); +} + +.fb-date-apply:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.fb-date-apply:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + /* ============================================================================= Animations ============================================================================= */ diff --git a/src/ui/src/components/filter-bar/filter-bar.tsx b/src/ui/src/components/filter-bar/filter-bar.tsx index c6708a6a5..596f7b1db 100644 --- a/src/ui/src/components/filter-bar/filter-bar.tsx +++ b/src/ui/src/components/filter-bar/filter-bar.tsx @@ -83,6 +83,9 @@ function FilterBarInner( handleBackdropDismiss, handleKeyDown, isPresetActive, + activeDateRangeField, + handleDateCommit, + stepDateCycle, setInputRefCallbacks, } = useFilterState({ chips, @@ -183,6 +186,9 @@ function FilterBarInner( isFieldLoading={isFieldLoading} loadingFieldLabel={loadingFieldLabel} highlightedSuggestionValue={highlightedSuggestionValue} + activeDateRangeField={activeDateRangeField} + onDateCommit={handleDateCommit} + onDateCycleStep={stepDateCycle} /> diff --git a/src/ui/src/components/filter-bar/hooks/use-filter-keyboard.ts b/src/ui/src/components/filter-bar/hooks/use-filter-keyboard.ts index 1721831ff..ae01cf8ee 100644 --- a/src/ui/src/components/filter-bar/hooks/use-filter-keyboard.ts +++ b/src/ui/src/components/filter-bar/hooks/use-filter-keyboard.ts @@ -28,6 +28,12 @@ import { useState, useCallback, useMemo } from "react"; import type { FieldSuggestion, ParsedInput, SearchField, Suggestion } from "@/components/filter-bar/lib/types"; +import { + isDateRangeField, + DATE_CUSTOM_FROM, + DATE_CUSTOM_TO, + DATE_CUSTOM_APPLY, +} from "@/components/filter-bar/lib/types"; // --------------------------------------------------------------------------- // External interfaces @@ -74,6 +80,12 @@ interface UseFilterKeyboardReturn { navigationLevel: "field" | "value" | null; /** Reset all navigation state */ resetNavigation: () => void; + /** + * Advance the cycle one step from a known sentinel position. + * Used when Tab/Shift-Tab fires on a date-picker DOM element (not the filter bar input) + * so the stale highlightedIndex is bypassed. + */ + stepCycle: (direction: "forward" | "backward", fromValue: string) => void; } // --------------------------------------------------------------------------- @@ -132,6 +144,28 @@ export function useFilterKeyboard( [navState, resetNavigation], ); + const stepCycle = useCallback( + (direction: "forward" | "backward", fromValue: string) => { + actions.focusInput(); + if (nav.state.level === null || nav.state.items.length === 0) { + enterNavigationMode(state, nav, actions, direction); + return; + } + const fromIdx = nav.state.items.findIndex((item) => item.value === fromValue); + const startIdx = fromIdx >= 0 ? fromIdx : nav.state.highlightedIndex; + const nextIdx = + direction === "forward" + ? (startIdx + 1) % nav.state.items.length + : startIdx <= 0 + ? nav.state.items.length - 1 + : startIdx - 1; + nav.setState({ ...nav.state, highlightedIndex: nextIdx }); + const item = nav.state.items[nextIdx]; + if (item) actions.fillInput(item.inputValue); + }, + [state, nav, actions], + ); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { @@ -173,6 +207,7 @@ export function useFilterKeyboard( displaySelectables, navigationLevel: navState.level, resetNavigation, + stepCycle, }; } @@ -365,6 +400,26 @@ function onEnter( } if (nav.state.level === "value" && nav.state.highlightedIndex >= 0) { + const current = nav.state.items[nav.state.highlightedIndex]; + if ( + current && + current.suggestion.type !== "preset" && + current.suggestion.type !== "hint" && + isDateRangeField(current.suggestion.field) + ) { + // Sentinel items (From/To/Apply): focus is already moved by the date picker's useEffect. + // Don't commit — let the focused element handle subsequent interaction. + if ( + current.value === DATE_CUSTOM_FROM || + current.value === DATE_CUSTOM_TO || + current.value === DATE_CUSTOM_APPLY + ) { + return; + } + // Date-range preset: select directly (parsedInput.query is empty, input held at prefix) + actions.selectSuggestion(current.value); + return; + } if (parsedInput.hasPrefix && parsedInput.field && parsedInput.query.trim()) { if (actions.addChipFromParsedInput()) { actions.resetInput(); @@ -436,7 +491,9 @@ function buildValueCycleItems(selectables: Suggestion[]): CycleItem[] { .filter((s): s is FieldSuggestion => s.type !== "preset") .map((s) => ({ value: s.value, - inputValue: s.field.prefix ? `${s.field.prefix}${s.value}` : s.value, + // Date-range presets: keep input at just the prefix so the picker stays open + // and shows all presets; the highlighted preset is indicated via the picker's UI. + inputValue: isDateRangeField(s.field) ? s.field.prefix : s.field.prefix ? `${s.field.prefix}${s.value}` : s.value, suggestion: s, })); } diff --git a/src/ui/src/components/filter-bar/hooks/use-filter-state.ts b/src/ui/src/components/filter-bar/hooks/use-filter-state.ts index 4f29c359d..34a36761f 100644 --- a/src/ui/src/components/filter-bar/hooks/use-filter-state.ts +++ b/src/ui/src/components/filter-bar/hooks/use-filter-state.ts @@ -31,8 +31,14 @@ */ import { useState, useCallback, useMemo, useRef, useEffect } from "react"; -import type { SearchChip, SearchField, SearchPreset, Suggestion } from "@/components/filter-bar/lib/types"; -import { isAsyncField } from "@/components/filter-bar/lib/types"; +import type { + SearchChip, + SearchField, + SearchPreset, + Suggestion, + DateRangeSearchField, +} from "@/components/filter-bar/lib/types"; +import { isAsyncField, isDateRangeField } from "@/components/filter-bar/lib/types"; import { useChips } from "@/components/filter-bar/hooks/use-chips"; import { useSuggestions } from "@/components/filter-bar/hooks/use-suggestions"; import { useFilterKeyboard } from "@/components/filter-bar/hooks/use-filter-keyboard"; @@ -83,6 +89,14 @@ interface UseFilterStateReturn { // Preset state isPresetActive: (preset: SearchPreset) => boolean; + // Date range + /** Active date-range field (when user has typed its prefix), or null */ + activeDateRangeField: DateRangeSearchField | null; + /** Commit a date value for the active date-range field */ + handleDateCommit: (value: string) => void; + /** Advance the date picker cycle from a sentinel position (for Tab/Shift-Tab wrap) */ + stepDateCycle: (direction: "forward" | "backward", fromValue: string) => void; + // Ref setup setInputRefCallbacks: (callbacks: InputRefCallbacks) => void; } @@ -246,6 +260,17 @@ export function useFilterState({ clearValidationError(); }, [clearValidationError]); + const handleDateCommit = useCallback( + (value: string) => { + if (parsedInput.field && isDateRangeField(parsedInput.field)) { + addChip(parsedInput.field, value); + resetInput(); + inputCallbacksRef.current.focus(); + } + }, + [parsedInput, addChip, resetInput], + ); + // ========== Keyboard hook ========== const keyboardState = useMemo>( @@ -287,7 +312,7 @@ export function useFilterState({ [removeChip, resetInput, parsedInput, addChip, handleSelect, setValidationError, addTextChip], ); - const { handleKeyDown, highlightedSuggestionValue, displaySelectables, navigationLevel, resetNavigation } = + const { handleKeyDown, highlightedSuggestionValue, displaySelectables, navigationLevel, resetNavigation, stepCycle } = useFilterKeyboard(keyboardState, keyboardActions); // Wire up the bridge ref so resetInput/handleInputChange/handleClearAll can reset navigation @@ -312,9 +337,23 @@ export function useFilterState({ // with a prefix, hints flow naturally from the matched field. const visibleHints = navigationLevel === "field" ? [] : hints; + // Derive active date-range field. + // Suppress while navigationLevel === "field": the user is still cycling through field + // suggestions (input is temporarily filled with each prefix as a preview). Only activate + // the date picker once the user has committed — either by pressing Enter (transitions to + // "value" level) or by typing the prefix themselves (level === null). + const activeDateRangeField = useMemo((): DateRangeSearchField | null => { + if (navigationLevel === "field") return null; + if (parsedInput.hasPrefix && parsedInput.field && isDateRangeField(parsedInput.field)) { + return parsedInput.field; + } + return null; + }, [parsedInput, navigationLevel]); + const hasContent = displaySelectables.length > 0 || visibleHints.length > 0; // Preset suggestions are in displaySelectables when input is empty, so hasContent covers them. - const showDropdown = (isOpen && hasContent) || !!validationError || isFieldLoading; + // Also show dropdown when a date-range field is active (to show the date picker). + const showDropdown = (isOpen && (hasContent || !!activeDateRangeField)) || !!validationError || isFieldLoading; // ========== Return ========== @@ -337,6 +376,9 @@ export function useFilterState({ handleBackdropDismiss, handleKeyDown, isPresetActive, + activeDateRangeField, + handleDateCommit, + stepDateCycle: stepCycle, setInputRefCallbacks, }; } diff --git a/src/ui/src/components/filter-bar/hooks/use-suggestions.ts b/src/ui/src/components/filter-bar/hooks/use-suggestions.ts index 3b1bc1740..2097a9f02 100644 --- a/src/ui/src/components/filter-bar/hooks/use-suggestions.ts +++ b/src/ui/src/components/filter-bar/hooks/use-suggestions.ts @@ -23,8 +23,15 @@ import { useMemo } from "react"; import type { SearchField, SearchChip, SearchPreset, Suggestion, ParsedInput } from "@/components/filter-bar/lib/types"; -import { getFieldValues } from "@/components/filter-bar/lib/types"; +import { + getFieldValues, + isDateRangeField, + DATE_CUSTOM_FROM, + DATE_CUSTOM_TO, + DATE_CUSTOM_APPLY, +} from "@/components/filter-bar/lib/types"; import { parseInput, getFieldHint } from "@/components/filter-bar/lib/parse-input"; +import { DATE_RANGE_PRESETS } from "@/lib/date-range-utils"; interface UseSuggestionsOptions { /** Current input value */ @@ -105,6 +112,29 @@ function generateSuggestions( } if (parsedInput.hasPrefix && parsedInput.field) { + // Date-range fields show a picker in the dropdown — surface presets and custom inputs + // as value suggestions so keyboard navigation (Tab/↑↓/Enter) works like other fields. + if (isDateRangeField(parsedInput.field)) { + const lowerQuery = parsedInput.query.toLowerCase(); + for (const preset of DATE_RANGE_PRESETS) { + if (!lowerQuery || preset.label.toLowerCase().includes(lowerQuery)) { + items.push({ + type: "value", + field: parsedInput.field, + value: preset.label, + label: preset.label, + }); + } + } + // Custom range inputs are always shown (only when not filtering presets) + if (!lowerQuery) { + items.push({ type: "value", field: parsedInput.field, value: DATE_CUSTOM_FROM, label: "From date" }); + items.push({ type: "value", field: parsedInput.field, value: DATE_CUSTOM_TO, label: "To date" }); + items.push({ type: "value", field: parsedInput.field, value: DATE_CUSTOM_APPLY, label: "Apply" }); + } + return items; + } + // Show values for the selected field const field = parsedInput.field; const currentPrefix = field.prefix; diff --git a/src/ui/src/components/filter-bar/lib/types.ts b/src/ui/src/components/filter-bar/lib/types.ts index 800d76a65..b3cf410c4 100644 --- a/src/ui/src/components/filter-bar/lib/types.ts +++ b/src/ui/src/components/filter-bar/lib/types.ts @@ -136,16 +136,27 @@ export interface AsyncSearchField extends BaseSearchField { } /** - * A search field that is either sync (derives values from parent data) - * or async (loads its own data from an API). + * Date-range search field: shows a date picker in the dropdown instead of text suggestions. + * Selecting a date or range creates a chip with an ISO date string or range string. + * @template T - The data item type (kept for union compatibility) + */ +export interface DateRangeSearchField extends BaseSearchField { + /** Discriminant: date-range fields render a date picker in the dropdown */ + type: "date-range"; +} + +/** + * A search field that is either sync (derives values from parent data), + * async (loads its own data from an API), or date-range (renders a date picker). * * Use the `type` discriminant to narrow: * - `type: undefined | 'sync'` -> SyncSearchField (default, backward compatible) * - `type: 'async'` -> AsyncSearchField (self-loading, has isLoading) + * - `type: 'date-range'` -> DateRangeSearchField (shows date picker in dropdown) * * @template T - The data item type being searched */ -export type SearchField = SyncSearchField | AsyncSearchField; +export type SearchField = SyncSearchField | AsyncSearchField | DateRangeSearchField; /** * Type guard: check if a field is async. @@ -155,11 +166,20 @@ export function isAsyncField(field: SearchField): field is AsyncSearchFiel return field.type === "async"; } +/** + * Type guard: check if a field is a date-range field. + */ +export function isDateRangeField(field: SearchField): field is DateRangeSearchField { + return field.type === "date-range"; +} + /** * Get values from a field, handling both sync and async variants. * For sync fields, passes the data array. For async fields, calls with no args. + * For date-range fields, returns empty array (picker shown in dropdown instead). */ export function getFieldValues(field: SearchField, data: T[]): string[] { + if (isDateRangeField(field)) return []; if (isAsyncField(field)) { return field.getValues(); } @@ -324,6 +344,15 @@ export interface PresetSuggestion { */ export type Suggestion = FieldSuggestion | PresetSuggestion; +/** + * Sentinel suggestion values for date picker custom input navigation. + * When the keyboard cycle highlights one of these, the date picker + * moves DOM focus to the corresponding element (From/To input or Apply button). + */ +export const DATE_CUSTOM_FROM = "__date-custom-from__"; +export const DATE_CUSTOM_TO = "__date-custom-to__"; +export const DATE_CUSTOM_APPLY = "__date-custom-apply__"; + /** * Parsed input result. */ diff --git a/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx b/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx index 5366584ee..803806abc 100644 --- a/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx +++ b/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx @@ -68,6 +68,7 @@ export const WorkflowsToolbar = memo(function WorkflowsToolbar({ WORKFLOW_FIELD.status, userField, poolField, + WORKFLOW_FIELD.submitted, WORKFLOW_FIELD.priority, WORKFLOW_FIELD.app, WORKFLOW_FIELD.tag, @@ -141,7 +142,7 @@ export const WorkflowsToolbar = memo(function WorkflowsToolbar({ searchChips={searchChips} onSearchChipsChange={onSearchChipsChange} defaultField="name" - placeholder="Search workflows... (try 'name:', 'status:', 'user:', 'pool:')" + placeholder="Search workflows... (try 'name:', 'status:', 'submitted:', 'pool:')" searchPresets={searchPresets} resultsCount={resultsCount} autoRefreshProps={autoRefreshProps} diff --git a/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts b/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts index 63d34449d..bffc5a561 100644 --- a/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts +++ b/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect } from "vitest"; import { WORKFLOW_STATIC_FIELDS } from "@/features/workflows/list/lib/workflow-search-fields"; import { STATUS_PRESETS, createPresetChips } from "@/lib/workflows/workflow-status-presets"; import type { WorkflowListEntry } from "@/lib/api/adapter/types"; +import { isDateRangeField, getFieldValues } from "@/components/filter-bar/lib/types"; function createWorkflow(overrides: Partial = {}): WorkflowListEntry { return { @@ -53,8 +54,11 @@ describe("WORKFLOW_STATIC_FIELDS structure", () => { expect(field).toHaveProperty("id"); expect(field).toHaveProperty("label"); expect(field).toHaveProperty("prefix"); - expect(field).toHaveProperty("getValues"); - expect(typeof field.getValues).toBe("function"); + // date-range fields don't have getValues — they show a picker instead + if (!isDateRangeField(field)) { + expect(field).toHaveProperty("getValues"); + expect(typeof field.getValues).toBe("function"); + } } }); @@ -83,7 +87,7 @@ describe("name field", () => { createWorkflow({ name: "gamma" }), ]; - const values = nameField.getValues(workflows); + const values = getFieldValues(nameField, workflows); expect(values).toContain("alpha"); expect(values).toContain("beta"); @@ -93,7 +97,7 @@ describe("name field", () => { it("limits values to 20 suggestions", () => { const workflows = Array.from({ length: 30 }, (_, i) => createWorkflow({ name: `workflow-${i}` })); - const values = nameField.getValues(workflows); + const values = getFieldValues(nameField, workflows); expect(values.length).toBe(20); }); @@ -111,7 +115,7 @@ describe("status field", () => { }); it("returns all workflow statuses", () => { - const values = statusField.getValues([]); + const values = getFieldValues(statusField, []); expect(values).toContain("RUNNING"); expect(values).toContain("COMPLETED"); @@ -133,7 +137,7 @@ describe("priority field", () => { }); it("returns fixed priority values", () => { - const values = priorityField.getValues([]); + const values = getFieldValues(priorityField, []); expect(values).toEqual(["HIGH", "NORMAL", "LOW"]); }); @@ -149,7 +153,7 @@ describe("app field", () => { createWorkflow({ app_name: undefined }), ]; - const values = appField.getValues(workflows); + const values = getFieldValues(appField, workflows); expect(values).toContain("app-a"); expect(values).toContain("app-b"); @@ -163,7 +167,7 @@ describe("tag field", () => { it("returns empty values (tags not in list response)", () => { const workflows = [createWorkflow()]; - const values = tagField.getValues(workflows); + const values = getFieldValues(tagField, workflows); expect(values).toEqual([]); }); diff --git a/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts b/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts index a6e40a57f..d2b2f449d 100644 --- a/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts +++ b/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts @@ -49,6 +49,14 @@ export const WORKFLOW_FIELD: Readonly 0 ? userChips : undefined, statuses: statusChips.length > 0 ? (statusChips as WorkflowStatus[]) : undefined, pools: poolChips.length > 0 ? poolChips : undefined, @@ -102,13 +114,10 @@ function buildApiParams( app: getFirstChipValue(chips, "app"), priority: priorityChips.length > 0 ? (priorityChips as WorkflowPriority[]) : undefined, tags: tagChips.length > 0 ? tagChips : undefined, - // Toggles - // Only send all_users=true when no user chips exist (to show all users' workflows) - // When user chips exist, don't send all_users (backend filters by those specific users) - all_users: userChips.length === 0 ? showAllUsers : undefined, - // all_pools is implicit: true when no pool filter, false when pool filter exists + all_users: userChips.length === 0 && showAllUsers ? true : undefined, all_pools: poolChips.length === 0, - submitted_after: submittedAfter, + submitted_after: resolvedAfter, + submitted_before: resolvedBefore, }; } @@ -201,6 +210,7 @@ export function buildWorkflowsQueryKey( const pools = getChipValues(searchChips, "pool").sort(); const priority = getChipValues(searchChips, "priority").sort(); const tags = getChipValues(searchChips, "tag").sort(); + const submitted = getFirstChipValue(searchChips, "submitted"); // Build query key - only include filters that have values const filters: Record = {}; @@ -211,6 +221,7 @@ export function buildWorkflowsQueryKey( if (pools.length > 0) filters.pools = pools; if (priority.length > 0) filters.priority = priority; if (tags.length > 0) filters.tags = tags; + if (submitted) filters.submitted = submitted; return [ "workflows", diff --git a/src/ui/src/lib/auth/server.production.ts b/src/ui/src/lib/auth/server.production.ts new file mode 100644 index 000000000..2c73aa476 --- /dev/null +++ b/src/ui/src/lib/auth/server.production.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Server-side authentication utilities — production build. + * + * Pure header readers: all identity comes from Envoy-injected OAuth2 Proxy headers. + * No environment fallbacks. Aliased in by next.config.ts for production builds. + */ + +import { headers } from "next/headers"; +import { hasAdminRole } from "@/lib/auth/roles"; +import type { User } from "@/lib/auth/user-context"; + +export async function getServerUserRoles(): Promise { + const headersList = await headers(); + const rolesHeader = headersList.get("x-osmo-roles") || ""; + return rolesHeader + .split(/[,\s]+/) + .map((role) => role.trim()) + .filter(Boolean); +} + +export async function hasServerAdminRole(): Promise { + return hasAdminRole(await getServerUserRoles()); +} + +export async function getServerUsername(): Promise { + const headersList = await headers(); + return headersList.get("x-auth-request-preferred-username") || headersList.get("x-auth-request-user") || null; +} + +export async function getServerUser(): Promise { + const headersList = await headers(); + const username = headersList.get("x-auth-request-preferred-username") || headersList.get("x-auth-request-user"); + if (!username) return null; + + const email = headersList.get("x-auth-request-email") || username; + const name = headersList.get("x-auth-request-name") || deriveDisplayName(username); + const roles = await getServerUserRoles(); + + return { + id: username, + name, + email, + username, + isAdmin: hasAdminRole(roles), + initials: getInitials(name), + }; +} + +export function deriveDisplayName(username: string): string { + const namePart = username.includes("@") ? username.split("@")[0] : username; + if (!namePart) return "User"; + const parts = namePart.split(/[._-]+/).filter(Boolean); + if (parts.length <= 1) return namePart; + return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); +} + +export function getInitials(name: string): string { + return name + .split(/[\s@]+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || "") + .join(""); +} diff --git a/src/ui/src/lib/auth/server.ts b/src/ui/src/lib/auth/server.ts index aad7cfb4d..29c484f52 100644 --- a/src/ui/src/lib/auth/server.ts +++ b/src/ui/src/lib/auth/server.ts @@ -15,119 +15,58 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Server-side authentication utilities. + * Server-side authentication utilities — development build. * - * These functions read from Envoy-injected headers to determine user - * authentication and authorization status in Next.js server components. + * Re-exports everything from server.production.ts. Overrides only the functions + * that need a local dev fallback: each override calls the production version first + * and falls back to DEV_USER_* env vars when headers are absent. + * + * In production this file is replaced entirely by server.production.ts via + * Turbopack resolveAlias in next.config.ts — no fallbacks, no env checks. + * + * Set in .env.local: + * DEV_USER_NAME – username / display name + * DEV_USER_EMAIL – email address (defaults to DEV_USER_NAME) + * DEV_USER_ROLES – comma-separated roles */ -import { headers } from "next/headers"; +import { + getServerUserRoles as prodGetServerUserRoles, + getServerUsername as prodGetServerUsername, + getServerUser as prodGetServerUser, + deriveDisplayName, + getInitials, +} from "@/lib/auth/server.production"; import { hasAdminRole } from "@/lib/auth/roles"; import type { User } from "@/lib/auth/user-context"; -/** - * Get user roles from Envoy-injected x-osmo-roles header. - * - * In production, Envoy injects the x-osmo-roles header with a - * comma-separated or space-separated list of user roles. - * - * @returns Array of role strings - * - * @example - * ```ts - * const roles = await getServerUserRoles(); - * if (roles.includes("osmo-admin")) { - * // User is admin - * } - * ``` - */ export async function getServerUserRoles(): Promise { - const headersList = await headers(); - const rolesHeader = headersList.get("x-osmo-roles") || ""; - - // Parse roles (comma-separated or space-separated) - const roles = rolesHeader + const roles = await prodGetServerUserRoles(); + if (roles.length > 0) return roles; + return (process.env.DEV_USER_ROLES ?? "") .split(/[,\s]+/) - .map((role) => role.trim()) + .map((r) => r.trim()) .filter(Boolean); - - return roles; } -/** - * Check if the current user has admin role. - * - * Reads from Envoy-injected x-osmo-roles header and checks if - * it contains any admin role (osmo-admin or dashboard-admin). - * - * @returns true if user has admin role - * - * @example - * ```ts - * const isAdmin = await hasServerAdminRole(); - * if (!isAdmin) { - * return
Unauthorized
; - * } - * ``` - */ export async function hasServerAdminRole(): Promise { - const roles = await getServerUserRoles(); - return hasAdminRole(roles); + return hasAdminRole(await getServerUserRoles()); } -/** - * Get username from OAuth2 Proxy headers. - * - * Reads x-auth-request-preferred-username (human-readable username from - * the preferred_username OIDC claim) with fallback to x-auth-request-user - * (user ID, typically email). - * - * @returns Username or null if not authenticated - * - * @example - * ```ts - * const username = await getServerUsername(); - * if (!username) { - * return
Not authenticated
; - * } - * ``` - */ export async function getServerUsername(): Promise { - const headersList = await headers(); - return headersList.get("x-auth-request-preferred-username") || headersList.get("x-auth-request-user") || null; + return (await prodGetServerUsername()) ?? process.env.DEV_USER_NAME ?? null; } -/** - * Build a User object from OAuth2 Proxy and Envoy-injected headers. - * - * Reads x-auth-request-preferred-username, x-auth-request-email, - * x-auth-request-name, and x-osmo-roles set by OAuth2 Proxy + Envoy - * on every authenticated request. - * - * Returns null if no user headers are present (e.g., local dev without Envoy). - */ export async function getServerUser(): Promise { - const headersList = await headers(); + const user = await prodGetServerUser(); + if (user) return user; - const username = headersList.get("x-auth-request-preferred-username") || headersList.get("x-auth-request-user"); + const username = process.env.DEV_USER_NAME; + if (!username) return null; - if (!username) { - if (process.env.NODE_ENV === "development") { - return { - id: "dev-user", - name: process.env.DEV_USER_NAME || "Dev User", - email: process.env.DEV_USER_EMAIL || "dev@localhost", - username: process.env.DEV_USER_NAME || "dev-user", - isAdmin: true, - initials: "DU", - }; - } - return null; - } - - const email = headersList.get("x-auth-request-email") || username; - const name = headersList.get("x-auth-request-name") || deriveDisplayName(username); + const email = process.env.DEV_USER_EMAIL ?? username; const roles = await getServerUserRoles(); + const name = deriveDisplayName(username); return { id: username, @@ -138,19 +77,3 @@ export async function getServerUser(): Promise { initials: getInitials(name), }; } - -function deriveDisplayName(username: string): string { - const namePart = username.includes("@") ? username.split("@")[0] : username; - if (!namePart) return "User"; - const parts = namePart.split(/[._-]+/).filter(Boolean); - if (parts.length <= 1) return namePart; - return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); -} - -function getInitials(name: string): string { - return name - .split(/[\s@]+/) - .slice(0, 2) - .map((part) => part[0]?.toUpperCase() || "") - .join(""); -} diff --git a/src/ui/src/lib/date-range-utils.ts b/src/ui/src/lib/date-range-utils.ts index a75417789..8720af3af 100644 --- a/src/ui/src/lib/date-range-utils.ts +++ b/src/ui/src/lib/date-range-utils.ts @@ -100,12 +100,20 @@ export function parseDateRangeValue(value: string): { start: Date; end: Date } | return parseIsoRangeString(value); } - // Handle single ISO date: "YYYY-MM-DD" — treated as full UTC day + // Handle single date or datetime const singleDate = parseIsoDate(value); if (singleDate) { - const end = new Date(singleDate.getTime()); - end.setUTCHours(23, 59, 59, 999); - return { start: singleDate, end }; + // Date-only ("YYYY-MM-DD"): treat as the full UTC day. + // Use midnight of the *next* day as the exclusive upper bound so that + // submit_time < end captures every event on this day including 23:59:59.999Z. + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const end = new Date(singleDate.getTime()); + end.setUTCDate(end.getUTCDate() + 1); // advance to next midnight + return { start: singleDate, end }; + } + // Datetime ("YYYY-MM-DDTHH:mm"): treat as the full minute. + // Advance end by 1 minute so submitted_before captures any event at HH:mm:ss. + return { start: singleDate, end: new Date(singleDate.getTime() + 60_000) }; } // Backward compat: check preset labels (e.g., "last 7 days") @@ -121,25 +129,42 @@ export function parseDateRangeValue(value: string): { start: Date; end: Date } | // Internal helpers // ============================================================================= -/** Parse "YYYY-MM-DD..YYYY-MM-DD" → { start, end } with end extended to 23:59:59.999Z */ +/** Parse "YYYY-MM-DD..YYYY-MM-DD" or "YYYY-MM-DDTHH:mm..YYYY-MM-DDTHH:mm" range strings */ function parseIsoRangeString(value: string): { start: Date; end: Date } | null { const parts = value.split(".."); if (parts.length !== 2) return null; + const endStr = parts[1].trim(); const start = parseIsoDate(parts[0].trim()); - const end = parseIsoDate(parts[1].trim()); + const end = parseIsoDate(endStr); if (!start || !end || start > end) return null; - // Make end inclusive: extend to last ms of the UTC day - const endInclusive = new Date(end.getTime()); - endInclusive.setUTCHours(23, 59, 59, 999); + // Date-only end: advance to midnight of the next day so that the exclusive + // submitted_before < end captures every event on the chosen end date (including 23:59:59.999Z). + // Datetime end: the user chose an explicit exclusive cutoff — use it as-is. + if (/^\d{4}-\d{2}-\d{2}$/.test(endStr)) { + const endExclusive = new Date(end.getTime()); + endExclusive.setUTCDate(endExclusive.getUTCDate() + 1); // next midnight + return { start, end: endExclusive }; + } - return { start, end: endInclusive }; + return { start, end }; } -/** Parse "YYYY-MM-DD" as UTC midnight, or null if format is invalid */ +/** + * Parse a date or datetime string to a Date, or null if invalid. + * Both branches produce UTC dates so server and client agree (no hydration mismatch). + * - "YYYY-MM-DD" → UTC midnight + * - "YYYY-MM-DDTHH:mm" → UTC at the given hour/minute + */ function parseIsoDate(str: string): Date | null { - if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; - const d = new Date(str + "T00:00:00.000Z"); - return isNaN(d.getTime()) ? null : d; + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) { + const d = new Date(str + "T00:00:00.000Z"); + return isNaN(d.getTime()) ? null : d; + } + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(str)) { + const d = new Date(str + ":00.000Z"); + return isNaN(d.getTime()) ? null : d; + } + return null; } diff --git a/src/ui/src/lib/format-date.ts b/src/ui/src/lib/format-date.ts index 8c2b5683c..12957f0f3 100644 --- a/src/ui/src/lib/format-date.ts +++ b/src/ui/src/lib/format-date.ts @@ -33,7 +33,20 @@ // Constants // ============================================================================= -const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; +export const MONTHS_SHORT = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; // ============================================================================= // SSR-Safe Formatters (no locale dependency)