diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index c51e2c38c..0f9f25bb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { useParams } from 'next/navigation' import { @@ -9,11 +9,13 @@ import { type ComboboxOption, Loader, Popover, + PopoverAnchor, PopoverContent, PopoverItem, PopoverScrollArea, PopoverTrigger, } from '@/components/emcn' +import { DatePicker } from '@/components/emcn/components/date-picker/date-picker' import { cn } from '@/lib/core/utils/cn' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' @@ -35,8 +37,31 @@ const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'Past 7 days', label: 'Past 7 days' }, { value: 'Past 14 days', label: 'Past 14 days' }, { value: 'Past 30 days', label: 'Past 30 days' }, + { value: 'Custom range', label: 'Custom range' }, ] as const +/** + * Formats a date string (YYYY-MM-DD) for display. + */ +function formatDateShort(dateStr: string): string { + const date = new Date(dateStr) + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ] + return `${months[date.getMonth()]} ${date.getDate()}` +} + type ViewMode = 'logs' | 'dashboard' interface LogsToolbarProps { @@ -153,7 +178,14 @@ export function LogsToolbar({ setTriggers, timeRange, setTimeRange, + startDate, + endDate, + setDateRange, + clearDateRange, } = useFilterStore() + + const [datePickerOpen, setDatePickerOpen] = useState(false) + const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) const folders = useFolderStore((state) => state.folders) const allWorkflows = useWorkflowRegistry((state) => state.workflows) @@ -269,8 +301,50 @@ export function LogsToolbar({ const timeDisplayLabel = useMemo(() => { if (timeRange === 'All time') return 'Time' + if (timeRange === 'Custom range' && startDate && endDate) { + return `${formatDateShort(startDate)} - ${formatDateShort(endDate)}` + } + if (timeRange === 'Custom range') return 'Custom range' return timeRange - }, [timeRange]) + }, [timeRange, startDate, endDate]) + + /** + * Handles time range selection from combobox. + * Opens date picker when "Custom range" is selected. + */ + const handleTimeRangeChange = useCallback( + (val: string) => { + if (val === 'Custom range') { + setPreviousTimeRange(timeRange) + setDatePickerOpen(true) + } else { + clearDateRange() + setTimeRange(val as typeof timeRange) + } + }, + [timeRange, setTimeRange, clearDateRange] + ) + + /** + * Handles date range selection from DatePicker. + */ + const handleDateRangeApply = useCallback( + (start: string, end: string) => { + setDateRange(start, end) + setDatePickerOpen(false) + }, + [setDateRange] + ) + + /** + * Handles date picker cancel. + */ + const handleDatePickerCancel = useCallback(() => { + if (timeRange === 'Custom range' && !startDate) { + setTimeRange(previousTimeRange) + } + setDatePickerOpen(false) + }, [timeRange, startDate, previousTimeRange, setTimeRange]) const hasActiveFilters = useMemo(() => { return ( @@ -287,8 +361,8 @@ export function LogsToolbar({ setWorkflowIds([]) setFolderIds([]) setTriggers([]) - setTimeRange('All time') - }, [setLevel, setWorkflowIds, setFolderIds, setTriggers, setTimeRange]) + clearDateRange() + }, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange]) return (
@@ -528,7 +602,7 @@ export function LogsToolbar({ setTimeRange(val as typeof timeRange)} + onChange={handleTimeRangeChange} placeholder='All time' overlayContent={ @@ -636,18 +710,42 @@ export function LogsToolbar({ /> {/* Timeline Filter */} - setTimeRange(val as typeof timeRange)} - placeholder='Time' - overlayContent={ - {timeDisplayLabel} - } - size='sm' - align='end' - className='h-[32px] w-[120px] rounded-[6px]' - /> + + +
+ + {timeDisplayLabel} + + } + size='sm' + align='end' + className='h-[32px] w-[120px] rounded-[6px]' + /> +
+
+ + + +
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 2e08dbcc6..ae13ebf76 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' import { useParams } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' -import { getStartDateFromTimeRange } from '@/lib/logs/filters' +import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { useFolders } from '@/hooks/queries/folders' import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs' @@ -30,6 +30,8 @@ export default function Logs() { setWorkspaceId, initializeFromURL, timeRange, + startDate, + endDate, level, workflowIds, folderIds, @@ -72,6 +74,8 @@ export default function Logs() { const logFilters = useMemo( () => ({ timeRange, + startDate, + endDate, level, workflowIds, folderIds, @@ -79,7 +83,7 @@ export default function Logs() { searchQuery: debouncedSearchQuery, limit: LOGS_PER_PAGE, }), - [timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery] + [timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery] ) const logsQuery = useLogsList(workspaceId, logFilters, { @@ -90,13 +94,15 @@ export default function Logs() { const dashboardFilters = useMemo( () => ({ timeRange, + startDate, + endDate, level, workflowIds, folderIds, triggers, searchQuery: debouncedSearchQuery, }), - [timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery] + [timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery] ) const dashboardLogsQuery = useDashboardLogs(workspaceId, dashboardFilters, { @@ -261,9 +267,14 @@ export default function Logs() { if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(',')) if (folderIds.length > 0) params.set('folderIds', folderIds.join(',')) - const startDate = getStartDateFromTimeRange(timeRange) - if (startDate) { - params.set('startDate', startDate.toISOString()) + const computedStartDate = getStartDateFromTimeRange(timeRange, startDate) + if (computedStartDate) { + params.set('startDate', computedStartDate.toISOString()) + } + + const computedEndDate = getEndDateFromTimeRange(timeRange, endDate) + if (computedEndDate) { + params.set('endDate', computedEndDate.toISOString()) } const parsed = parseQuery(debouncedSearchQuery) diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx index dde4b3452..3196852bd 100644 --- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -4,12 +4,21 @@ * * @example * ```tsx - * // Basic date picker + * // Basic single date picker * setDate(dateString)} * placeholder="Select date" * /> + * + * // Range date picker + * handleRange(start, end)} + * placeholder="Select date range" + * /> * ``` */ @@ -49,21 +58,68 @@ const datePickerVariants = cva( } ) -export interface DatePickerProps +/** Base props shared by both single and range modes */ +interface DatePickerBaseProps extends Omit, 'onChange'>, VariantProps { - /** Current selected date value (YYYY-MM-DD string or Date) */ - value?: string | Date - /** Callback when date changes, returns YYYY-MM-DD format */ - onChange?: (value: string) => void /** Placeholder text when no value is selected */ placeholder?: string /** Whether the picker is disabled */ disabled?: boolean /** Size variant */ size?: 'default' | 'sm' + /** Whether to show the trigger button (set to false for inline/controlled usage) */ + showTrigger?: boolean + /** Controlled open state */ + open?: boolean + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void + /** Render calendar inline without popover (for use inside modals) */ + inline?: boolean } +/** Props for single date mode */ +interface DatePickerSingleProps extends DatePickerBaseProps { + /** Selection mode */ + mode?: 'single' + /** Current selected date value (YYYY-MM-DD string or Date) */ + value?: string | Date + /** Callback when date changes, returns YYYY-MM-DD format */ + onChange?: (value: string) => void + /** Not used in single mode */ + startDate?: never + /** Not used in single mode */ + endDate?: never + /** Not used in single mode */ + onRangeChange?: never + /** Not used in single mode */ + onCancel?: never + /** Not used in single mode */ + onClear?: never +} + +/** Props for range date mode */ +interface DatePickerRangeProps extends DatePickerBaseProps { + /** Selection mode */ + mode: 'range' + /** Start date for range mode (YYYY-MM-DD string or Date) */ + startDate?: string | Date + /** End date for range mode (YYYY-MM-DD string or Date) */ + endDate?: string | Date + /** Callback when date range is applied */ + onRangeChange?: (startDate: string, endDate: string) => void + /** Callback when range selection is cancelled */ + onCancel?: () => void + /** Callback when range is cleared */ + onClear?: () => void + /** Not used in range mode */ + value?: never + /** Not used in range mode */ + onChange?: never +} + +export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps + /** * Month names for calendar display. */ @@ -101,6 +157,24 @@ function getFirstDayOfMonth(year: number, month: number): number { return new Date(year, month, 1).getDay() } +/** + * Short month names for display. + */ +const MONTHS_SHORT = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + /** * Formats a date for display in the trigger button. */ @@ -113,6 +187,46 @@ function formatDateForDisplay(date: Date | null): string { }) } +/** + * Formats a date range for display. + */ +function formatDateRangeForDisplay(start: Date | null, end: Date | null): string { + if (!start && !end) return '' + if (start && !end) return formatDateForDisplay(start) + if (!start && end) return formatDateForDisplay(end) + if (start && end) { + const startStr = `${MONTHS_SHORT[start.getMonth()]} ${start.getDate()}` + const endStr = + start.getFullYear() === end.getFullYear() + ? `${MONTHS_SHORT[end.getMonth()]} ${end.getDate()}` + : `${MONTHS_SHORT[end.getMonth()]} ${end.getDate()}, ${end.getFullYear()}` + return `${startStr} - ${endStr}${start.getFullYear() !== end.getFullYear() ? '' : `, ${start.getFullYear()}`}` + } + return '' +} + +/** + * Checks if a date is between two dates (inclusive). + */ +function isDateInRange(date: Date, start: Date | null, end: Date | null): boolean { + if (!start || !end) return false + const time = date.getTime() + const startTime = Math.min(start.getTime(), end.getTime()) + const endTime = Math.max(start.getTime(), end.getTime()) + return time >= startTime && time <= endTime +} + +/** + * Checks if two dates are the same day. + */ +function isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ) +} + /** * Formats a date as YYYY-MM-DD string. */ @@ -135,21 +249,17 @@ function parseDate(value: string | Date | undefined): Date | null { } try { - // Handle YYYY-MM-DD format (treat as local date) if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { const [year, month, day] = value.split('-').map(Number) return new Date(year, month - 1, day) } - // Handle ISO strings with timezone (extract date part as local) if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) { const date = new Date(value) if (Number.isNaN(date.getTime())) return null - // Use UTC date components to prevent timezone shift return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) } - // Fallback: try parsing as-is const date = new Date(value) return Number.isNaN(date.getTime()) ? null : date } catch { @@ -157,252 +267,604 @@ function parseDate(value: string | Date | undefined): Date | null { } } +/** + * Calendar component for rendering a single month. + */ +interface CalendarMonthProps { + viewMonth: number + viewYear: number + selectedDate?: Date | null + rangeStart?: Date | null + rangeEnd?: Date | null + hoverDate?: Date | null + isRangeMode?: boolean + onSelectDate: (day: number) => void + onHoverDate?: (day: number | null) => void + onPrevMonth: () => void + onNextMonth: () => void + showNavigation?: 'left' | 'right' | 'both' +} + +function CalendarMonth({ + viewMonth, + viewYear, + selectedDate, + rangeStart, + rangeEnd, + hoverDate, + isRangeMode, + onSelectDate, + onHoverDate, + onPrevMonth, + onNextMonth, + showNavigation = 'both', +}: CalendarMonthProps) { + const daysInMonth = getDaysInMonth(viewYear, viewMonth) + const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth) + + const calendarDays = React.useMemo(() => { + const days: (number | null)[] = [] + for (let i = 0; i < firstDayOfMonth; i++) { + days.push(null) + } + for (let day = 1; day <= daysInMonth; day++) { + days.push(day) + } + return days + }, [firstDayOfMonth, daysInMonth]) + + const isToday = React.useCallback( + (day: number) => { + const today = new Date() + return ( + today.getDate() === day && + today.getMonth() === viewMonth && + today.getFullYear() === viewYear + ) + }, + [viewMonth, viewYear] + ) + + const isSelected = React.useCallback( + (day: number) => { + if (!selectedDate) return false + return ( + selectedDate.getDate() === day && + selectedDate.getMonth() === viewMonth && + selectedDate.getFullYear() === viewYear + ) + }, + [selectedDate, viewMonth, viewYear] + ) + + const isRangeStart = React.useCallback( + (day: number) => { + if (!rangeStart) return false + return ( + rangeStart.getDate() === day && + rangeStart.getMonth() === viewMonth && + rangeStart.getFullYear() === viewYear + ) + }, + [rangeStart, viewMonth, viewYear] + ) + + const isRangeEnd = React.useCallback( + (day: number) => { + if (!rangeEnd) return false + return ( + rangeEnd.getDate() === day && + rangeEnd.getMonth() === viewMonth && + rangeEnd.getFullYear() === viewYear + ) + }, + [rangeEnd, viewMonth, viewYear] + ) + + const isInRange = React.useCallback( + (day: number) => { + if (!isRangeMode) return false + const date = new Date(viewYear, viewMonth, day) + // Only show range highlight when both start and end are selected + if (rangeStart && rangeEnd) { + return ( + isDateInRange(date, rangeStart, rangeEnd) && + !isSameDay(date, rangeStart) && + !isSameDay(date, rangeEnd) + ) + } + return false + }, + [isRangeMode, rangeStart, rangeEnd, viewMonth, viewYear] + ) + + return ( +
+ {/* Calendar Header */} +
+ {showNavigation === 'left' || showNavigation === 'both' ? ( + + ) : ( +
+ )} + + {MONTHS[viewMonth]} {viewYear} + + {showNavigation === 'right' || showNavigation === 'both' ? ( + + ) : ( +
+ )} +
+ + {/* Day Headers */} +
+ {DAYS.map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {calendarDays.map((day, index) => { + const inRange = day !== null && isInRange(day) + const isStart = day !== null && isRangeStart(day) + const isEnd = day !== null && isRangeEnd(day) + const hasRangeHighlight = inRange || isStart || isEnd + + return ( +
+ {day !== null && ( + + )} +
+ ) + })} +
+
+ ) +} + /** * DatePicker component matching emcn design patterns. * Provides a calendar dropdown for date selection. + * Supports both single date and date range modes. */ -const DatePicker = React.forwardRef( - ( - { className, variant, size, value, onChange, placeholder = 'Select date', disabled, ...props }, - ref - ) => { - const [open, setOpen] = React.useState(false) - const selectedDate = parseDate(value) +const DatePicker = React.forwardRef((props, ref) => { + const { + className, + variant, + size, + placeholder = props.mode === 'range' ? 'Select date range' : 'Select date', + disabled, + showTrigger = true, + open: controlledOpen, + onOpenChange, + inline = false, + mode: _mode, + ...rest + } = props - const [viewMonth, setViewMonth] = React.useState(() => { - const d = selectedDate || new Date() - return d.getMonth() - }) - const [viewYear, setViewYear] = React.useState(() => { - const d = selectedDate || new Date() - return d.getFullYear() - }) + const { + value: _value, + onChange: _onChange, + startDate: _startDate, + endDate: _endDate, + onRangeChange: _onRangeChange, + onCancel: _onCancel, + onClear: _onClear, + ...htmlProps + } = rest as any - // Update view when value changes externally - React.useEffect(() => { - if (selectedDate) { - setViewMonth(selectedDate.getMonth()) - setViewYear(selectedDate.getFullYear()) + const isRangeMode = props.mode === 'range' + + const isControlled = controlledOpen !== undefined + const [internalOpen, setInternalOpen] = React.useState(false) + const open = isControlled ? controlledOpen : internalOpen + + const setOpen = React.useCallback( + (value: boolean) => { + if (!isControlled) { + setInternalOpen(value) } - }, [value]) + onOpenChange?.(value) + }, + [isControlled, onOpenChange] + ) - /** - * Handles selection of a specific day in the calendar. - */ - const handleSelectDate = React.useCallback( - (day: number) => { - onChange?.(formatDateAsString(viewYear, viewMonth, day)) + const selectedDate = !isRangeMode ? parseDate(props.value) : null + + const initialStart = isRangeMode ? parseDate(props.startDate) : null + const initialEnd = isRangeMode ? parseDate(props.endDate) : null + const [rangeStart, setRangeStart] = React.useState(initialStart) + const [rangeEnd, setRangeEnd] = React.useState(initialEnd) + const [hoverDate, setHoverDate] = React.useState(null) + const [selectingEnd, setSelectingEnd] = React.useState(false) + + const [viewMonth, setViewMonth] = React.useState(() => { + const d = selectedDate || initialStart || new Date() + return d.getMonth() + }) + const [viewYear, setViewYear] = React.useState(() => { + const d = selectedDate || initialStart || new Date() + return d.getFullYear() + }) + + const rightViewMonth = viewMonth === 11 ? 0 : viewMonth + 1 + const rightViewYear = viewMonth === 11 ? viewYear + 1 : viewYear + + React.useEffect(() => { + if (open && isRangeMode) { + setRangeStart(initialStart) + setRangeEnd(initialEnd) + setSelectingEnd(false) + if (initialStart) { + setViewMonth(initialStart.getMonth()) + setViewYear(initialStart.getFullYear()) + } else { + const now = new Date() + setViewMonth(now.getMonth()) + setViewYear(now.getFullYear()) + } + } + }, [open, isRangeMode, initialStart, initialEnd]) + + React.useEffect(() => { + if (!isRangeMode && selectedDate) { + setViewMonth(selectedDate.getMonth()) + setViewYear(selectedDate.getFullYear()) + } + }, [isRangeMode, selectedDate]) + + /** + * Handles selection of a specific day in single mode. + */ + const handleSelectDateSingle = React.useCallback( + (day: number) => { + if (!isRangeMode && props.onChange) { + props.onChange(formatDateAsString(viewYear, viewMonth, day)) setOpen(false) - }, - [viewYear, viewMonth, onChange] - ) - - /** - * Navigates to the previous month. - */ - const goToPrevMonth = React.useCallback(() => { - if (viewMonth === 0) { - setViewMonth(11) - setViewYear((prev) => prev - 1) - } else { - setViewMonth((prev) => prev - 1) } - }, [viewMonth]) + }, + [isRangeMode, viewYear, viewMonth, props.onChange, setOpen] + ) - /** - * Navigates to the next month. - */ - const goToNextMonth = React.useCallback(() => { - if (viewMonth === 11) { - setViewMonth(0) - setViewYear((prev) => prev + 1) + /** + * Handles selection of a day in range mode. + */ + const handleSelectDateRange = React.useCallback( + (year: number, month: number, day: number) => { + const date = new Date(year, month, day) + + if (!selectingEnd || !rangeStart) { + setRangeStart(date) + setRangeEnd(null) + setSelectingEnd(true) } else { - setViewMonth((prev) => prev + 1) + if (date < rangeStart) { + setRangeEnd(rangeStart) + setRangeStart(date) + } else { + setRangeEnd(date) + } + setSelectingEnd(false) } - }, [viewMonth]) + }, + [selectingEnd, rangeStart] + ) - /** - * Selects today's date and closes the picker. - */ - const handleSelectToday = React.useCallback(() => { + /** + * Handles hover for range preview. + */ + const handleHoverDate = React.useCallback((year: number, month: number, day: number | null) => { + if (day === null) { + setHoverDate(null) + } else { + setHoverDate(new Date(year, month, day)) + } + }, []) + + /** + * Navigates to the previous month. + */ + const goToPrevMonth = React.useCallback(() => { + if (viewMonth === 0) { + setViewMonth(11) + setViewYear((prev) => prev - 1) + } else { + setViewMonth((prev) => prev - 1) + } + }, [viewMonth]) + + /** + * Navigates to the next month. + */ + const goToNextMonth = React.useCallback(() => { + if (viewMonth === 11) { + setViewMonth(0) + setViewYear((prev) => prev + 1) + } else { + setViewMonth((prev) => prev + 1) + } + }, [viewMonth]) + + /** + * Selects today's date (single mode only). + */ + const handleSelectToday = React.useCallback(() => { + if (!isRangeMode && props.onChange) { const now = new Date() setViewMonth(now.getMonth()) setViewYear(now.getFullYear()) - onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate())) + props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate())) setOpen(false) - }, [onChange]) + } + }, [isRangeMode, props.onChange, setOpen]) - const daysInMonth = getDaysInMonth(viewYear, viewMonth) - const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth) + /** + * Applies the selected range (range mode only). + */ + const handleApplyRange = React.useCallback(() => { + if (isRangeMode && props.onRangeChange && rangeStart) { + const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart + const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : rangeEnd || rangeStart + props.onRangeChange( + formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate()), + formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate()) + ) + setOpen(false) + } + }, [isRangeMode, props.onRangeChange, rangeStart, rangeEnd, setOpen]) - /** - * Checks if a day is today's date. - */ - const isToday = React.useCallback( - (day: number) => { - const today = new Date() - return ( - today.getDate() === day && - today.getMonth() === viewMonth && - today.getFullYear() === viewYear - ) - }, - [viewMonth, viewYear] - ) + /** + * Cancels range selection. + */ + const handleCancelRange = React.useCallback(() => { + if (isRangeMode && props.onCancel) { + props.onCancel() + } + setOpen(false) + }, [isRangeMode, props.onCancel, setOpen]) - /** - * Checks if a day is the currently selected date. - */ - const isSelected = React.useCallback( - (day: number) => { - return ( - selectedDate && - selectedDate.getDate() === day && - selectedDate.getMonth() === viewMonth && - selectedDate.getFullYear() === viewYear - ) - }, - [selectedDate, viewMonth, viewYear] - ) + /** + * Clears the selected range. + */ + const handleClearRange = React.useCallback(() => { + setRangeStart(null) + setRangeEnd(null) + setSelectingEnd(false) + if (isRangeMode && props.onClear) { + props.onClear() + } + }, [isRangeMode, props.onClear]) - // Build calendar grid - const calendarDays = React.useMemo(() => { - const days: (number | null)[] = [] - for (let i = 0; i < firstDayOfMonth; i++) { - days.push(null) - } - for (let day = 1; day <= daysInMonth; day++) { - days.push(day) - } - return days - }, [firstDayOfMonth, daysInMonth]) - - /** - * Handles keyboard events on the trigger. - */ - const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - if (!disabled && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault() - setOpen(!open) - } - }, - [disabled, open] - ) - - /** - * Handles click on the trigger. - */ - const handleTriggerClick = React.useCallback(() => { - if (!disabled) { + /** + * Handles keyboard events on the trigger. + */ + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!disabled && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() setOpen(!open) } - }, [disabled, open]) + }, + [disabled, open, setOpen] + ) + /** + * Handles click on the trigger. + */ + const handleTriggerClick = React.useCallback(() => { + if (!disabled) { + setOpen(!open) + } + }, [disabled, open, setOpen]) + + const displayValue = isRangeMode + ? formatDateRangeForDisplay(initialStart, initialEnd) + : formatDateForDisplay(selectedDate) + + const calendarContent = isRangeMode ? ( + <> +
+ {/* Left Calendar */} + handleSelectDateRange(viewYear, viewMonth, day)} + onHoverDate={(day) => handleHoverDate(viewYear, viewMonth, day)} + onPrevMonth={goToPrevMonth} + onNextMonth={goToNextMonth} + showNavigation='left' + /> + + {/* Divider */} +
+ + {/* Right Calendar */} + handleSelectDateRange(rightViewYear, rightViewMonth, day)} + onHoverDate={(day) => handleHoverDate(rightViewYear, rightViewMonth, day)} + onPrevMonth={goToPrevMonth} + onNextMonth={goToNextMonth} + showNavigation='right' + /> +
+ + {/* Actions */} +
+ +
+ + +
+
+ + ) : ( + <> + + + {/* Today Button */} +
+ +
+ + ) + + const popoverContent = ( + + {calendarContent} + + ) + + if (inline) { + return ( +
+ {calendarContent} +
+ ) + } + + if (!showTrigger) { return ( -
+
-
- - {selectedDate ? formatDateForDisplay(selectedDate) : placeholder} - - -
+
- - - {/* Calendar Header */} -
- - - {MONTHS[viewMonth]} {viewYear} - - -
- - {/* Day Headers */} -
- {DAYS.map((day) => ( -
- {day} -
- ))} -
- - {/* Calendar Grid */} -
- {calendarDays.map((day, index) => ( -
- {day !== null && ( - - )} -
- ))} -
- - {/* Today Button */} -
- -
-
+ {popoverContent}
) } -) + + return ( + +
+ +
+ + {displayValue || placeholder} + + +
+
+ {popoverContent} +
+
+ ) +}) DatePicker.displayName = 'DatePicker' diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 862b2fb8b..c9db54a36 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -1,5 +1,5 @@ import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' -import { getStartDateFromTimeRange } from '@/lib/logs/filters' +import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import type { LogsResponse, TimeRange, WorkflowLog } from '@/stores/logs/filters/types' @@ -16,6 +16,8 @@ export const logKeys = { interface LogFilters { timeRange: TimeRange + startDate?: string + endDate?: string level: string workflowIds: string[] folderIds: string[] @@ -45,11 +47,16 @@ function applyFilterParams(params: URLSearchParams, filters: Omit /** * Calculates start date from a time range string. - * Returns null for 'All time' to indicate no date filtering. + * Returns null for 'All time' or 'Custom range' to indicate the dates + * should be handled separately. * @param timeRange - The time range option selected by the user + * @param startDate - Optional start date (YYYY-MM-DD) for custom range * @returns Date object for the start of the range, or null for 'All time' */ -export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null { +export function getStartDateFromTimeRange(timeRange: TimeRange, startDate?: string): Date | null { if (timeRange === 'All time') return null + if (timeRange === 'Custom range') { + if (startDate) { + const date = new Date(startDate) + date.setHours(0, 0, 0, 0) + return date + } + return null + } + const now = new Date() switch (timeRange) { @@ -62,6 +73,26 @@ export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null { } } +/** + * Gets the end date for a time range. + * Returns null for preset ranges (uses current time as implicit end). + * Returns end of day for custom ranges. + * @param timeRange - The time range option selected by the user + * @param endDate - Optional end date (YYYY-MM-DD) for custom range + * @returns Date object for the end of the range, or null for preset ranges + */ +export function getEndDateFromTimeRange(timeRange: TimeRange, endDate?: string): Date | null { + if (timeRange !== 'Custom range') return null + + if (endDate) { + const date = new Date(endDate) + date.setHours(23, 59, 59, 999) + return date + } + + return null +} + type ComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!=' function buildWorkflowIdsCondition(workflowIds: string): SQL | undefined { diff --git a/apps/sim/lib/logs/query-parser.test.ts b/apps/sim/lib/logs/query-parser.test.ts index bcf5fc3f5..89d70927d 100644 --- a/apps/sim/lib/logs/query-parser.test.ts +++ b/apps/sim/lib/logs/query-parser.test.ts @@ -1,16 +1,22 @@ -import { describe, expect, test } from 'vitest' +/** + * Tests for query language parser for logs search + * + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' describe('parseQuery', () => { describe('empty and whitespace input', () => { - test('should handle empty string', () => { + it.concurrent('should handle empty string', () => { const result = parseQuery('') expect(result.filters).toHaveLength(0) expect(result.textSearch).toBe('') }) - test('should handle whitespace only', () => { + it.concurrent('should handle whitespace only', () => { const result = parseQuery(' ') expect(result.filters).toHaveLength(0) @@ -19,14 +25,14 @@ describe('parseQuery', () => { }) describe('simple text search', () => { - test('should parse plain text as textSearch', () => { + it.concurrent('should parse plain text as textSearch', () => { const result = parseQuery('hello world') expect(result.filters).toHaveLength(0) expect(result.textSearch).toBe('hello world') }) - test('should preserve text case', () => { + it.concurrent('should preserve text case', () => { const result = parseQuery('Hello World') expect(result.textSearch).toBe('Hello World') @@ -34,7 +40,7 @@ describe('parseQuery', () => { }) describe('level filter', () => { - test('should parse level:error filter', () => { + it.concurrent('should parse level:error filter', () => { const result = parseQuery('level:error') expect(result.filters).toHaveLength(1) @@ -43,7 +49,7 @@ describe('parseQuery', () => { expect(result.filters[0].operator).toBe('=') }) - test('should parse level:info filter', () => { + it.concurrent('should parse level:info filter', () => { const result = parseQuery('level:info') expect(result.filters).toHaveLength(1) @@ -53,7 +59,7 @@ describe('parseQuery', () => { }) describe('status filter (alias for level)', () => { - test('should parse status:error filter', () => { + it.concurrent('should parse status:error filter', () => { const result = parseQuery('status:error') expect(result.filters).toHaveLength(1) @@ -63,7 +69,7 @@ describe('parseQuery', () => { }) describe('workflow filter', () => { - test('should parse workflow filter with quoted value', () => { + it.concurrent('should parse workflow filter with quoted value', () => { const result = parseQuery('workflow:"my-workflow"') expect(result.filters).toHaveLength(1) @@ -71,7 +77,7 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe('my-workflow') }) - test('should parse workflow filter with unquoted value', () => { + it.concurrent('should parse workflow filter with unquoted value', () => { const result = parseQuery('workflow:test-workflow') expect(result.filters).toHaveLength(1) @@ -81,7 +87,7 @@ describe('parseQuery', () => { }) describe('trigger filter', () => { - test('should parse trigger:api filter', () => { + it.concurrent('should parse trigger:api filter', () => { const result = parseQuery('trigger:api') expect(result.filters).toHaveLength(1) @@ -89,25 +95,25 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe('api') }) - test('should parse trigger:webhook filter', () => { + it.concurrent('should parse trigger:webhook filter', () => { const result = parseQuery('trigger:webhook') expect(result.filters[0].value).toBe('webhook') }) - test('should parse trigger:schedule filter', () => { + it.concurrent('should parse trigger:schedule filter', () => { const result = parseQuery('trigger:schedule') expect(result.filters[0].value).toBe('schedule') }) - test('should parse trigger:manual filter', () => { + it.concurrent('should parse trigger:manual filter', () => { const result = parseQuery('trigger:manual') expect(result.filters[0].value).toBe('manual') }) - test('should parse trigger:chat filter', () => { + it.concurrent('should parse trigger:chat filter', () => { const result = parseQuery('trigger:chat') expect(result.filters[0].value).toBe('chat') @@ -115,7 +121,7 @@ describe('parseQuery', () => { }) describe('cost filter with operators', () => { - test('should parse cost:>0.01 filter', () => { + it.concurrent('should parse cost:>0.01 filter', () => { const result = parseQuery('cost:>0.01') expect(result.filters).toHaveLength(1) @@ -124,35 +130,35 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe(0.01) }) - test('should parse cost:<0.005 filter', () => { + it.concurrent('should parse cost:<0.005 filter', () => { const result = parseQuery('cost:<0.005') expect(result.filters[0].operator).toBe('<') expect(result.filters[0].value).toBe(0.005) }) - test('should parse cost:>=0.05 filter', () => { + it.concurrent('should parse cost:>=0.05 filter', () => { const result = parseQuery('cost:>=0.05') expect(result.filters[0].operator).toBe('>=') expect(result.filters[0].value).toBe(0.05) }) - test('should parse cost:<=0.1 filter', () => { + it.concurrent('should parse cost:<=0.1 filter', () => { const result = parseQuery('cost:<=0.1') expect(result.filters[0].operator).toBe('<=') expect(result.filters[0].value).toBe(0.1) }) - test('should parse cost:!=0 filter', () => { + it.concurrent('should parse cost:!=0 filter', () => { const result = parseQuery('cost:!=0') expect(result.filters[0].operator).toBe('!=') expect(result.filters[0].value).toBe(0) }) - test('should parse cost:=0 filter', () => { + it.concurrent('should parse cost:=0 filter', () => { const result = parseQuery('cost:=0') expect(result.filters[0].operator).toBe('=') @@ -161,7 +167,7 @@ describe('parseQuery', () => { }) describe('duration filter', () => { - test('should parse duration:>5000 (ms) filter', () => { + it.concurrent('should parse duration:>5000 (ms) filter', () => { const result = parseQuery('duration:>5000') expect(result.filters[0].field).toBe('duration') @@ -169,19 +175,19 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe(5000) }) - test('should parse duration with ms suffix', () => { + it.concurrent('should parse duration with ms suffix', () => { const result = parseQuery('duration:>500ms') expect(result.filters[0].value).toBe(500) }) - test('should parse duration with s suffix (converts to ms)', () => { + it.concurrent('should parse duration with s suffix (converts to ms)', () => { const result = parseQuery('duration:>5s') expect(result.filters[0].value).toBe(5000) }) - test('should parse duration:<1s filter', () => { + it.concurrent('should parse duration:<1s filter', () => { const result = parseQuery('duration:<1s') expect(result.filters[0].operator).toBe('<') @@ -190,7 +196,7 @@ describe('parseQuery', () => { }) describe('date filter', () => { - test('should parse date:today filter', () => { + it.concurrent('should parse date:today filter', () => { const result = parseQuery('date:today') expect(result.filters).toHaveLength(1) @@ -198,15 +204,67 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe('today') }) - test('should parse date:yesterday filter', () => { + it.concurrent('should parse date:yesterday filter', () => { const result = parseQuery('date:yesterday') expect(result.filters[0].value).toBe('yesterday') }) + + it.concurrent('should parse date:this-week filter', () => { + const result = parseQuery('date:this-week') + + expect(result.filters).toHaveLength(1) + expect(result.filters[0].field).toBe('date') + expect(result.filters[0].value).toBe('this-week') + }) + + it.concurrent('should parse date:last-week filter', () => { + const result = parseQuery('date:last-week') + + expect(result.filters[0].value).toBe('last-week') + }) + + it.concurrent('should parse date:this-month filter', () => { + const result = parseQuery('date:this-month') + + expect(result.filters[0].value).toBe('this-month') + }) + + it.concurrent('should parse year-only format (YYYY)', () => { + const result = parseQuery('date:2024') + + expect(result.filters).toHaveLength(1) + expect(result.filters[0].field).toBe('date') + expect(result.filters[0].value).toBe('2024') + }) + + it.concurrent('should parse month-only format (YYYY-MM)', () => { + const result = parseQuery('date:2024-12') + + expect(result.filters).toHaveLength(1) + expect(result.filters[0].field).toBe('date') + expect(result.filters[0].value).toBe('2024-12') + }) + + it.concurrent('should parse full date format (YYYY-MM-DD)', () => { + const result = parseQuery('date:2024-12-25') + + expect(result.filters).toHaveLength(1) + expect(result.filters[0].field).toBe('date') + expect(result.filters[0].value).toBe('2024-12-25') + }) + + it.concurrent('should parse date range format (YYYY-MM-DD..YYYY-MM-DD)', () => { + const result = parseQuery('date:2024-01-01..2024-01-15') + + expect(result.filters).toHaveLength(1) + expect(result.filters[0].field).toBe('date') + expect(result.filters[0].value).toBe('2024-01-01..2024-01-15') + }) }) describe('folder filter', () => { - test('should parse folder filter with quoted value', () => { + it.concurrent('should parse folder filter with quoted value', () => { const result = parseQuery('folder:"My Folder"') expect(result.filters).toHaveLength(1) @@ -216,7 +274,7 @@ describe('parseQuery', () => { }) describe('ID filters', () => { - test('should parse executionId filter', () => { + it.concurrent('should parse executionId filter', () => { const result = parseQuery('executionId:exec-123-abc') expect(result.filters).toHaveLength(1) @@ -224,7 +282,7 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe('exec-123-abc') }) - test('should parse workflowId filter', () => { + it.concurrent('should parse workflowId filter', () => { const result = parseQuery('workflowId:wf-456-def') expect(result.filters).toHaveLength(1) @@ -232,7 +290,7 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe('wf-456-def') }) - test('should parse execution filter (alias)', () => { + it.concurrent('should parse execution filter (alias)', () => { const result = parseQuery('execution:exec-789') expect(result.filters).toHaveLength(1) @@ -240,7 +298,7 @@ describe('parseQuery', () => { expect(result.filters[0].value).toBe('exec-789') }) - test('should parse id filter', () => { + it.concurrent('should parse id filter', () => { const result = parseQuery('id:some-id-123') expect(result.filters).toHaveLength(1) @@ -249,7 +307,7 @@ describe('parseQuery', () => { }) describe('combined filters and text', () => { - test('should parse multiple filters', () => { + it.concurrent('should parse multiple filters', () => { const result = parseQuery('level:error trigger:api') expect(result.filters).toHaveLength(2) @@ -258,7 +316,7 @@ describe('parseQuery', () => { expect(result.textSearch).toBe('') }) - test('should parse filters with text search', () => { + it.concurrent('should parse filters with text search', () => { const result = parseQuery('level:error some search text') expect(result.filters).toHaveLength(1) @@ -266,14 +324,14 @@ describe('parseQuery', () => { expect(result.textSearch).toBe('some search text') }) - test('should parse text before and after filters', () => { + it.concurrent('should parse text before and after filters', () => { const result = parseQuery('before level:error after') expect(result.filters).toHaveLength(1) expect(result.textSearch).toBe('before after') }) - test('should parse complex query with multiple filters and text', () => { + it.concurrent('should parse complex query with multiple filters and text', () => { const result = parseQuery( 'level:error trigger:api cost:>0.01 workflow:"my-workflow" search text' ) @@ -284,21 +342,21 @@ describe('parseQuery', () => { }) describe('invalid filters', () => { - test('should treat unknown field as text', () => { + it.concurrent('should treat unknown field as text', () => { const result = parseQuery('unknownfield:value') expect(result.filters).toHaveLength(0) expect(result.textSearch).toBe('unknownfield:value') }) - test('should handle invalid number for cost', () => { + it.concurrent('should handle invalid number for cost', () => { const result = parseQuery('cost:>abc') expect(result.filters).toHaveLength(0) expect(result.textSearch).toBe('cost:>abc') }) - test('should handle invalid number for duration', () => { + it.concurrent('should handle invalid number for duration', () => { const result = parseQuery('duration:>notanumber') expect(result.filters).toHaveLength(0) @@ -307,77 +365,77 @@ describe('parseQuery', () => { }) describe('queryToApiParams', () => { - test('should return empty object for empty query', () => { + it.concurrent('should return empty object for empty query', () => { const parsed = parseQuery('') const params = queryToApiParams(parsed) expect(Object.keys(params)).toHaveLength(0) }) - test('should set search param for text search', () => { + it.concurrent('should set search param for text search', () => { const parsed = parseQuery('hello world') const params = queryToApiParams(parsed) expect(params.search).toBe('hello world') }) - test('should set level param for level filter', () => { + it.concurrent('should set level param for level filter', () => { const parsed = parseQuery('level:error') const params = queryToApiParams(parsed) expect(params.level).toBe('error') }) - test('should combine multiple level filters with comma', () => { + it.concurrent('should combine multiple level filters with comma', () => { const parsed = parseQuery('level:error level:info') const params = queryToApiParams(parsed) expect(params.level).toBe('error,info') }) - test('should set triggers param for trigger filter', () => { + it.concurrent('should set triggers param for trigger filter', () => { const parsed = parseQuery('trigger:api') const params = queryToApiParams(parsed) expect(params.triggers).toBe('api') }) - test('should combine multiple trigger filters', () => { + it.concurrent('should combine multiple trigger filters', () => { const parsed = parseQuery('trigger:api trigger:webhook') const params = queryToApiParams(parsed) expect(params.triggers).toBe('api,webhook') }) - test('should set workflowName param for workflow filter', () => { + it.concurrent('should set workflowName param for workflow filter', () => { const parsed = parseQuery('workflow:"my-workflow"') const params = queryToApiParams(parsed) expect(params.workflowName).toBe('my-workflow') }) - test('should set folderName param for folder filter', () => { + it.concurrent('should set folderName param for folder filter', () => { const parsed = parseQuery('folder:"My Folder"') const params = queryToApiParams(parsed) expect(params.folderName).toBe('My Folder') }) - test('should set workflowIds param for workflowId filter', () => { + it.concurrent('should set workflowIds param for workflowId filter', () => { const parsed = parseQuery('workflowId:wf-123') const params = queryToApiParams(parsed) expect(params.workflowIds).toBe('wf-123') }) - test('should set executionId param for executionId filter', () => { + it.concurrent('should set executionId param for executionId filter', () => { const parsed = parseQuery('executionId:exec-456') const params = queryToApiParams(parsed) expect(params.executionId).toBe('exec-456') }) - test('should set cost params with operator', () => { + it.concurrent('should set cost params with operator', () => { const parsed = parseQuery('cost:>0.01') const params = queryToApiParams(parsed) @@ -385,7 +443,7 @@ describe('queryToApiParams', () => { expect(params.costValue).toBe('0.01') }) - test('should set duration params with operator', () => { + it.concurrent('should set duration params with operator', () => { const parsed = parseQuery('duration:>5s') const params = queryToApiParams(parsed) @@ -393,7 +451,7 @@ describe('queryToApiParams', () => { expect(params.durationValue).toBe('5000') }) - test('should set startDate for date:today', () => { + it('should set startDate for date:today', () => { const parsed = parseQuery('date:today') const params = queryToApiParams(parsed) @@ -404,7 +462,7 @@ describe('queryToApiParams', () => { expect(startDate.getTime()).toBe(today.getTime()) }) - test('should set startDate and endDate for date:yesterday', () => { + it('should set startDate and endDate for date:yesterday', () => { const parsed = parseQuery('date:yesterday') const params = queryToApiParams(parsed) @@ -412,7 +470,112 @@ describe('queryToApiParams', () => { expect(params.endDate).toBeDefined() }) - test('should combine execution filter with text search', () => { + it('should set startDate for date:this-week', () => { + const parsed = parseQuery('date:this-week') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + const startDate = new Date(params.startDate) + expect(startDate.getDay()).toBe(0) + }) + + it('should set startDate and endDate for date:last-week', () => { + const parsed = parseQuery('date:last-week') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + expect(params.endDate).toBeDefined() + }) + + it('should set startDate for date:this-month', () => { + const parsed = parseQuery('date:this-month') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + const startDate = new Date(params.startDate) + expect(startDate.getDate()).toBe(1) + }) + + it.concurrent('should set startDate and endDate for year-only (date:2024)', () => { + const parsed = parseQuery('date:2024') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + expect(params.endDate).toBeDefined() + + const startDate = new Date(params.startDate) + const endDate = new Date(params.endDate) + + expect(startDate.getFullYear()).toBe(2024) + expect(startDate.getMonth()).toBe(0) + expect(startDate.getDate()).toBe(1) + + expect(endDate.getFullYear()).toBe(2024) + expect(endDate.getMonth()).toBe(11) + expect(endDate.getDate()).toBe(31) + }) + + it.concurrent('should set startDate and endDate for month-only (date:2024-12)', () => { + const parsed = parseQuery('date:2024-12') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + expect(params.endDate).toBeDefined() + + const startDate = new Date(params.startDate) + const endDate = new Date(params.endDate) + + expect(startDate.getFullYear()).toBe(2024) + expect(startDate.getMonth()).toBe(11) + expect(startDate.getDate()).toBe(1) + + expect(endDate.getFullYear()).toBe(2024) + expect(endDate.getMonth()).toBe(11) + expect(endDate.getDate()).toBe(31) + }) + + it.concurrent('should set startDate and endDate for full date (date:2024-12-25)', () => { + const parsed = parseQuery('date:2024-12-25') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + expect(params.endDate).toBeDefined() + + const startDate = new Date(params.startDate) + const endDate = new Date(params.endDate) + + expect(startDate.getFullYear()).toBe(2024) + expect(startDate.getMonth()).toBe(11) + expect(startDate.getDate()).toBe(25) + + expect(endDate.getFullYear()).toBe(2024) + expect(endDate.getMonth()).toBe(11) + expect(endDate.getDate()).toBe(25) + }) + + it.concurrent( + 'should set startDate and endDate for date range (date:2024-01-01..2024-01-15)', + () => { + const parsed = parseQuery('date:2024-01-01..2024-01-15') + const params = queryToApiParams(parsed) + + expect(params.startDate).toBeDefined() + expect(params.endDate).toBeDefined() + + const startDate = new Date(params.startDate) + const endDate = new Date(params.endDate) + + expect(startDate.getFullYear()).toBe(2024) + expect(startDate.getMonth()).toBe(0) + expect(startDate.getDate()).toBe(1) + + expect(endDate.getFullYear()).toBe(2024) + expect(endDate.getMonth()).toBe(0) + expect(endDate.getDate()).toBe(15) + } + ) + + it.concurrent('should combine execution filter with text search', () => { const parsed = { filters: [ { @@ -429,7 +592,7 @@ describe('queryToApiParams', () => { expect(params.search).toBe('some text exec-123') }) - test('should handle complex query with all params', () => { + it.concurrent('should handle complex query with all params', () => { const parsed = parseQuery('level:error trigger:api cost:>0.01 workflow:"test"') const params = queryToApiParams(parsed) diff --git a/apps/sim/lib/logs/query-parser.ts b/apps/sim/lib/logs/query-parser.ts index 64f996b42..772a7ad47 100644 --- a/apps/sim/lib/logs/query-parser.ts +++ b/apps/sim/lib/logs/query-parser.ts @@ -200,19 +200,90 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record { - test('should have level filter definition', () => { + it.concurrent('should have level filter definition', () => { const levelFilter = FILTER_DEFINITIONS.find((f) => f.key === 'level') expect(levelFilter).toBeDefined() @@ -18,7 +23,7 @@ describe('FILTER_DEFINITIONS', () => { expect(levelFilter?.options.map((o) => o.value)).toContain('info') }) - test('should have cost filter definition with multiple options', () => { + it.concurrent('should have cost filter definition with multiple options', () => { const costFilter = FILTER_DEFINITIONS.find((f) => f.key === 'cost') expect(costFilter).toBeDefined() @@ -28,7 +33,7 @@ describe('FILTER_DEFINITIONS', () => { expect(costFilter?.options.map((o) => o.value)).toContain('<0.005') }) - test('should have date filter definition', () => { + it.concurrent('should have date filter definition', () => { const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date') expect(dateFilter).toBeDefined() @@ -37,7 +42,39 @@ describe('FILTER_DEFINITIONS', () => { expect(dateFilter?.options.map((o) => o.value)).toContain('yesterday') }) - test('should have duration filter definition', () => { + it.concurrent('should have date filter with all keyword options', () => { + const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date') + const values = dateFilter?.options.map((o) => o.value) || [] + + expect(values).toContain('today') + expect(values).toContain('yesterday') + expect(values).toContain('this-week') + expect(values).toContain('last-week') + expect(values).toContain('this-month') + }) + + it.concurrent('should have dynamic date examples in date filter', () => { + const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date') + const options = dateFilter?.options || [] + + const specificDate = options.find((o) => o.label === 'Specific date') + expect(specificDate).toBeDefined() + expect(specificDate?.value).toMatch(/^\d{4}-\d{2}-\d{2}$/) + + const specificMonth = options.find((o) => o.label === 'Specific month') + expect(specificMonth).toBeDefined() + expect(specificMonth?.value).toMatch(/^\d{4}-\d{2}$/) + + const specificYear = options.find((o) => o.label === 'Specific year') + expect(specificYear).toBeDefined() + expect(specificYear?.value).toMatch(/^\d{4}$/) + + const dateRange = options.find((o) => o.label === 'Date range') + expect(dateRange).toBeDefined() + expect(dateRange?.value).toMatch(/^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/) + }) + + it.concurrent('should have duration filter definition', () => { const durationFilter = FILTER_DEFINITIONS.find((f) => f.key === 'duration') expect(durationFilter).toBeDefined() @@ -69,19 +106,19 @@ describe('SearchSuggestions', () => { ] describe('constructor', () => { - test('should create instance with empty data', () => { + it.concurrent('should create instance with empty data', () => { const suggestions = new SearchSuggestions() expect(suggestions).toBeDefined() }) - test('should create instance with provided data', () => { + it.concurrent('should create instance with provided data', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) expect(suggestions).toBeDefined() }) }) describe('updateData', () => { - test('should update internal data', () => { + it.concurrent('should update internal data', () => { const suggestions = new SearchSuggestions() suggestions.updateData(mockWorkflows, mockFolders, mockTriggers) @@ -92,7 +129,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - empty input', () => { - test('should return filter keys list for empty input', () => { + it.concurrent('should return filter keys list for empty input', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('') @@ -101,7 +138,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.length).toBeGreaterThan(0) }) - test('should include core filter keys', () => { + it.concurrent('should include core filter keys', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('') @@ -113,7 +150,7 @@ describe('SearchSuggestions', () => { expect(filterValues).toContain('trigger:') }) - test('should include workflow filter when workflows exist', () => { + it.concurrent('should include workflow filter when workflows exist', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('') @@ -121,7 +158,7 @@ describe('SearchSuggestions', () => { expect(filterValues).toContain('workflow:') }) - test('should include folder filter when folders exist', () => { + it.concurrent('should include folder filter when folders exist', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('') @@ -129,7 +166,7 @@ describe('SearchSuggestions', () => { expect(filterValues).toContain('folder:') }) - test('should not include workflow filter when no workflows', () => { + it.concurrent('should not include workflow filter when no workflows', () => { const suggestions = new SearchSuggestions([], mockFolders, mockTriggers) const result = suggestions.getSuggestions('') @@ -139,7 +176,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - filter values (ending with colon)', () => { - test('should return level filter values', () => { + it.concurrent('should return level filter values', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('level:') @@ -149,7 +186,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(true) }) - test('should return cost filter values', () => { + it.concurrent('should return cost filter values', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('cost:') @@ -158,7 +195,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.value === 'cost:>0.01')).toBe(true) }) - test('should return trigger filter values', () => { + it.concurrent('should return trigger filter values', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('trigger:') @@ -168,7 +205,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.value === 'trigger:manual')).toBe(true) }) - test('should return workflow filter values', () => { + it.concurrent('should return workflow filter values', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('workflow:') @@ -177,7 +214,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true) }) - test('should return folder filter values', () => { + it.concurrent('should return folder filter values', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('folder:') @@ -186,7 +223,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true) }) - test('should return null for unknown filter key', () => { + it.concurrent('should return null for unknown filter key', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('unknown:') @@ -195,7 +232,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - partial filter values', () => { - test('should filter level values by partial input', () => { + it.concurrent('should filter level values by partial input', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('level:err') @@ -204,7 +241,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(false) }) - test('should filter workflow values by partial input', () => { + it.concurrent('should filter workflow values by partial input', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('workflow:test') @@ -213,7 +250,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(false) }) - test('should filter trigger values by partial input', () => { + it.concurrent('should filter trigger values by partial input', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('trigger:sch') @@ -221,7 +258,7 @@ describe('SearchSuggestions', () => { expect(result?.suggestions.some((s) => s.value === 'trigger:schedule')).toBe(true) }) - test('should return null when no matches found', () => { + it.concurrent('should return null when no matches found', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('level:xyz') @@ -230,7 +267,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - plain text search (multi-section)', () => { - test('should return multi-section results for plain text', () => { + it.concurrent('should return multi-section results for plain text', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('test') @@ -238,49 +275,49 @@ describe('SearchSuggestions', () => { expect(result?.type).toBe('multi-section') }) - test('should include show-all suggestion', () => { + it.concurrent('should include show-all suggestion', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('test') expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true) }) - test('should match workflows by name', () => { + it.concurrent('should match workflows by name', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('production') expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(true) }) - test('should match workflows by description', () => { + it.concurrent('should match workflows by description', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('API requests') expect(result?.suggestions.some((s) => s.label === 'API Handler')).toBe(true) }) - test('should match folders by name', () => { + it.concurrent('should match folders by name', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('development') expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true) }) - test('should match triggers by label', () => { + it.concurrent('should match triggers by label', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('slack') expect(result?.suggestions.some((s) => s.value === 'trigger:slack')).toBe(true) }) - test('should match filter values', () => { + it.concurrent('should match filter values', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('error') expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true) }) - test('should show suggested filters when no matches found', () => { + it.concurrent('should show suggested filters when no matches found', () => { const suggestions = new SearchSuggestions([], [], []) const result = suggestions.getSuggestions('xyz123') @@ -290,7 +327,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - case insensitivity', () => { - test('should match regardless of case', () => { + it.concurrent('should match regardless of case', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const lowerResult = suggestions.getSuggestions('test') @@ -304,7 +341,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - sorting', () => { - test('should sort exact matches first', () => { + it.concurrent('should sort exact matches first', () => { const workflows: WorkflowData[] = [ { id: '1', name: 'API Handler' }, { id: '2', name: 'API' }, @@ -317,7 +354,7 @@ describe('SearchSuggestions', () => { expect(workflowSuggestions?.[0]?.label).toBe('API') }) - test('should sort prefix matches before substring matches', () => { + it.concurrent('should sort prefix matches before substring matches', () => { const workflows: WorkflowData[] = [ { id: '1', name: 'Contains Test Inside' }, { id: '2', name: 'Test First' }, @@ -331,7 +368,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - result limits', () => { - test('should limit workflow results to 8', () => { + it.concurrent('should limit workflow results to 8', () => { const manyWorkflows = Array.from({ length: 20 }, (_, i) => ({ id: `wf-${i}`, name: `Test Workflow ${i}`, @@ -343,9 +380,9 @@ describe('SearchSuggestions', () => { expect(workflowSuggestions?.length).toBeLessThanOrEqual(8) }) - test('should limit filter value results to 5', () => { + it.concurrent('should limit filter value results to 5', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) - const result = suggestions.getSuggestions('o') // Matches multiple filter values + const result = suggestions.getSuggestions('o') const filterSuggestions = result?.suggestions.filter( (s) => @@ -359,7 +396,7 @@ describe('SearchSuggestions', () => { }) describe('getSuggestions - suggestion structure', () => { - test('should include correct properties for filter key suggestions', () => { + it.concurrent('should include correct properties for filter key suggestions', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('') @@ -370,7 +407,7 @@ describe('SearchSuggestions', () => { expect(suggestion).toHaveProperty('category') }) - test('should include color for trigger suggestions', () => { + it.concurrent('should include color for trigger suggestions', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('trigger:') @@ -378,7 +415,7 @@ describe('SearchSuggestions', () => { expect(triggerSuggestion?.color).toBeDefined() }) - test('should quote workflow names in value', () => { + it.concurrent('should quote workflow names in value', () => { const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const result = suggestions.getSuggestions('workflow:') @@ -386,4 +423,73 @@ describe('SearchSuggestions', () => { expect(workflowSuggestion?.value).toBe('workflow:"Test Workflow"') }) }) + + describe('getSuggestions - date filter values', () => { + it.concurrent('should return date filter keyword options', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:') + + expect(result).not.toBeNull() + expect(result?.type).toBe('filter-values') + expect(result?.suggestions.some((s) => s.value === 'date:today')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:last-week')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true) + }) + + it.concurrent('should suggest year format when typing a year', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:2024') + + expect(result).not.toBeNull() + expect(result?.suggestions.some((s) => s.value === 'date:2024')).toBe(true) + expect(result?.suggestions.some((s) => s.label === 'Year 2024')).toBe(true) + }) + + it.concurrent('should suggest month format when typing YYYY-MM', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:2024-12') + + expect(result).not.toBeNull() + expect(result?.suggestions.some((s) => s.value === 'date:2024-12')).toBe(true) + expect(result?.suggestions.some((s) => s.label === 'Dec 2024')).toBe(true) + }) + + it.concurrent('should suggest single date and range start when typing full date', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:2024-12-25') + + expect(result).not.toBeNull() + expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25..')).toBe(true) + }) + + it.concurrent('should suggest completing range when typing date..', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:2024-01-01..') + + expect(result).not.toBeNull() + expect(result?.suggestions.some((s) => s.description?.includes('Type end date'))).toBe(true) + }) + + it.concurrent('should suggest complete range when both dates provided', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:2024-01-01..2024-01-15') + + expect(result).not.toBeNull() + expect(result?.suggestions.some((s) => s.value === 'date:2024-01-01..2024-01-15')).toBe(true) + expect(result?.suggestions.some((s) => s.description === 'Custom date range')).toBe(true) + }) + + it.concurrent('should filter date options by partial keyword match', () => { + const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) + const result = suggestions.getSuggestions('date:this') + + expect(result).not.toBeNull() + expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true) + expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(false) + }) + }) }) diff --git a/apps/sim/lib/logs/search-suggestions.ts b/apps/sim/lib/logs/search-suggestions.ts index b7c05a717..907ac4c1d 100644 --- a/apps/sim/lib/logs/search-suggestions.ts +++ b/apps/sim/lib/logs/search-suggestions.ts @@ -9,6 +9,8 @@ export interface FilterDefinition { label: string description?: string }> + acceptsCustomValue?: boolean + customValueHint?: string } export interface WorkflowData { @@ -28,6 +30,20 @@ export interface TriggerData { color: string } +/** + * Generates current date examples for the date filter options. + */ +function getDateExamples() { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + const firstOfMonth = `${year}-${month}-01` + const today = `${year}-${month}-${day}` + const yearMonth = `${year}-${month}` + return { today, firstOfMonth, year: String(year), yearMonth } +} + export const FILTER_DEFINITIONS: FilterDefinition[] = [ { key: 'level', @@ -58,13 +74,24 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [ key: 'date', label: 'Date', description: 'Filter by date range', - options: [ - { value: 'today', label: 'Today', description: "Today's logs" }, - { value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" }, - { value: 'this-week', label: 'This week', description: "This week's logs" }, - { value: 'last-week', label: 'Last week', description: "Last week's logs" }, - { value: 'this-month', label: 'This month', description: "This month's logs" }, - ], + options: (() => { + const { today, firstOfMonth, year, yearMonth } = getDateExamples() + return [ + { value: 'today', label: 'Today', description: "Today's logs" }, + { value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" }, + { value: 'this-week', label: 'This week', description: "This week's logs" }, + { value: 'last-week', label: 'Last week', description: "Last week's logs" }, + { value: 'this-month', label: 'This month', description: "This month's logs" }, + { value: today, label: 'Specific date', description: 'YYYY-MM-DD' }, + { value: yearMonth, label: 'Specific month', description: 'YYYY-MM' }, + { value: year, label: 'Specific year', description: 'YYYY' }, + { + value: `${firstOfMonth}..${today}`, + label: 'Date range', + description: 'YYYY-MM-DD..YYYY-MM-DD', + }, + ] + })(), }, { key: 'duration', @@ -208,7 +235,7 @@ export class SearchSuggestions { const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key) if (filterDef) { - const suggestions = filterDef.options + const suggestions: Suggestion[] = filterDef.options .filter( (opt) => !partial || @@ -220,9 +247,17 @@ export class SearchSuggestions { value: `${key}:${opt.value}`, label: opt.label, description: opt.description, - category: key as any, + category: key as Suggestion['category'], })) + // Handle custom date input + if (key === 'date' && partial) { + const dateSuggestions = this.getDateSuggestions(partial) + if (dateSuggestions.length > 0) { + suggestions.unshift(...dateSuggestions) + } + } + return suggestions.length > 0 ? { type: 'filter-values', @@ -372,6 +407,140 @@ export class SearchSuggestions { : null } + /** + * Get suggestions for custom date input + */ + private getDateSuggestions(partial: string): Suggestion[] { + const suggestions: Suggestion[] = [] + + // Pattern for year only: YYYY + const yearPattern = /^\d{4}$/ + // Pattern for month only: YYYY-MM + const monthPattern = /^\d{4}-\d{2}$/ + // Pattern for full date: YYYY-MM-DD + const fullDatePattern = /^\d{4}-\d{2}-\d{2}$/ + // Pattern for partial date being typed + const partialDatePattern = /^\d{4}(-\d{0,2})?(-\d{0,2})?$/ + // Pattern for date range: YYYY-MM-DD..YYYY-MM-DD (complete or partial) + const rangePattern = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/ + const partialRangePattern = /^(\d{4}-\d{2}-\d{2})\.\.?$/ + + // Check if it's a complete date range + if (rangePattern.test(partial)) { + const [startDate, endDate] = partial.split('..') + suggestions.push({ + id: `date-range-${partial}`, + value: `date:${partial}`, + label: `${this.formatDateLabel(startDate)} to ${this.formatDateLabel(endDate)}`, + description: 'Custom date range', + category: 'date' as any, + }) + return suggestions + } + + // Check if it's a partial date range (has ..) + if (partialRangePattern.test(partial)) { + const startDate = partial.replace(/\.+$/, '') + suggestions.push({ + id: `date-range-hint-${partial}`, + value: `date:${startDate}..`, + label: `${this.formatDateLabel(startDate)} to ...`, + description: 'Type end date (YYYY-MM-DD)', + category: 'date' as any, + }) + return suggestions + } + + // Check if it's a year only (YYYY) + if (yearPattern.test(partial)) { + suggestions.push({ + id: `date-year-${partial}`, + value: `date:${partial}`, + label: `Year ${partial}`, + description: 'All logs from this year', + category: 'date' as any, + }) + return suggestions + } + + // Check if it's a month only (YYYY-MM) + if (monthPattern.test(partial)) { + const [year, month] = partial.split('-') + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ] + const monthName = monthNames[Number.parseInt(month, 10) - 1] || month + suggestions.push({ + id: `date-month-${partial}`, + value: `date:${partial}`, + label: `${monthName} ${year}`, + description: 'All logs from this month', + category: 'date' as any, + }) + return suggestions + } + + // Check if it's a complete single date + if (fullDatePattern.test(partial)) { + const date = new Date(partial) + if (!Number.isNaN(date.getTime())) { + suggestions.push({ + id: `date-single-${partial}`, + value: `date:${partial}`, + label: this.formatDateLabel(partial), + description: 'Single date', + category: 'date' as any, + }) + // Also suggest starting a range + suggestions.push({ + id: `date-range-start-${partial}`, + value: `date:${partial}..`, + label: `${this.formatDateLabel(partial)} to ...`, + description: 'Start a date range', + category: 'date' as any, + }) + } + return suggestions + } + + // Check if user is typing a date pattern + if (partialDatePattern.test(partial) && partial.length >= 4) { + suggestions.push({ + id: 'date-custom-hint', + value: `date:${partial}`, + label: partial, + description: 'Continue typing: YYYY, YYYY-MM, or YYYY-MM-DD', + category: 'date' as any, + }) + } + + return suggestions + } + + /** + * Format a date string for display + */ + private formatDateLabel(dateStr: string): string { + const date = new Date(dateStr) + if (Number.isNaN(date.getTime())) return dateStr + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } + /** * Match filter values across all definitions */ diff --git a/apps/sim/stores/logs/filters/store.ts b/apps/sim/stores/logs/filters/store.ts index 6afadfc79..7f9f8ee09 100644 --- a/apps/sim/stores/logs/filters/store.ts +++ b/apps/sim/stores/logs/filters/store.ts @@ -37,6 +37,8 @@ const parseTimeRangeFromURL = (value: string | null): TimeRange => { return 'Past 14 days' case 'past-30-days': return 'Past 30 days' + case 'custom': + return 'Custom range' default: return DEFAULT_TIME_RANGE } @@ -85,6 +87,8 @@ const timeRangeToURL = (timeRange: TimeRange): string => { return 'past-14-days' case 'Past 30 days': return 'past-30-days' + case 'Custom range': + return 'custom' default: return 'all-time' } @@ -94,6 +98,8 @@ export const useFilterStore = create((set, get) => ({ workspaceId: '', viewMode: 'logs', timeRange: DEFAULT_TIME_RANGE, + startDate: undefined, + endDate: undefined, level: 'all', workflowIds: [], folderIds: [], @@ -112,6 +118,28 @@ export const useFilterStore = create((set, get) => ({ } }, + setDateRange: (start, end) => { + set({ + timeRange: 'Custom range', + startDate: start, + endDate: end, + }) + if (!get().isInitializing) { + get().syncWithURL() + } + }, + + clearDateRange: () => { + set({ + timeRange: DEFAULT_TIME_RANGE, + startDate: undefined, + endDate: undefined, + }) + if (!get().isInitializing) { + get().syncWithURL() + } + }, + setLevel: (level) => { set({ level }) if (!get().isInitializing) { @@ -205,9 +233,13 @@ export const useFilterStore = create((set, get) => ({ const folderIds = parseStringArrayFromURL(params.get('folderIds')) const triggers = parseTriggerArrayFromURL(params.get('triggers')) const searchQuery = params.get('search') || '' + const startDate = params.get('startDate') || undefined + const endDate = params.get('endDate') || undefined set({ timeRange, + startDate, + endDate, level, workflowIds, folderIds, @@ -218,13 +250,23 @@ export const useFilterStore = create((set, get) => ({ }, syncWithURL: () => { - const { timeRange, level, workflowIds, folderIds, triggers, searchQuery } = get() + const { timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery } = + get() const params = new URLSearchParams() if (timeRange !== DEFAULT_TIME_RANGE) { params.set('timeRange', timeRangeToURL(timeRange)) } + if (timeRange === 'Custom range') { + if (startDate) { + params.set('startDate', startDate) + } + if (endDate) { + params.set('endDate', endDate) + } + } + if (level !== 'all') { params.set('level', level) } diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index 0df25ccec..569b1e001 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -170,6 +170,7 @@ export type TimeRange = | 'Past 14 days' | 'Past 30 days' | 'All time' + | 'Custom range' export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {}) export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string @@ -179,6 +180,8 @@ export interface FilterState { workspaceId: string viewMode: 'logs' | 'dashboard' timeRange: TimeRange + startDate?: string + endDate?: string level: LogLevel workflowIds: string[] folderIds: string[] @@ -189,6 +192,8 @@ export interface FilterState { setWorkspaceId: (workspaceId: string) => void setViewMode: (viewMode: 'logs' | 'dashboard') => void setTimeRange: (timeRange: TimeRange) => void + setDateRange: (startDate: string | undefined, endDate: string | undefined) => void + clearDateRange: () => void setLevel: (level: LogLevel) => void setWorkflowIds: (workflowIds: string[]) => void toggleWorkflowId: (workflowId: string) => void