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