refactor(emcn): consolidate date pickers onto the chip Calendar (range support + retire legacy DatePicker) (#5222)

* improvement(knowledge): use canonical ChipDatePicker in document tags modal

The document tags modal used the legacy DatePicker primitive for date-typed
tag values while the rest of the knowledge base date inputs use the canonical
ChipDatePicker. Swap both usages (edit + create) to ChipDatePicker (same
YYYY-MM-DD value contract, full-width to match sibling fields) so the chrome
matches the chip design system, and align a tag-row value label to the caption
text size used by its sibling.

* feat(emcn): add range mode to ChipDatePicker via the canonical Calendar

ChipDatePicker was single-date only, so date-range surfaces had to fall back
to the legacy DatePicker primitive. Add range support as a discriminated
mode on the existing canonical Calendar (not a parallel component): a
start/end selection staged behind Clear/Cancel/Apply with optional
time-of-day inputs, built from the chip family (CalendarDayCell, chipVariants,
ChipTimePicker) and fully tokenized. ChipDatePicker gains a matching
mode='range' that renders it behind the same chip trigger.

Extract the range-bounds serialization into a pure, unit-tested helper, and
remove the dead logs-toolbar directory (LogsToolbar/AutocompleteSearch had no
consumers; the logs page renders its toolbar inline).

* refactor(emcn): retire legacy DatePicker; migrate all consumers to the chip calendar

Removes the parallel DatePicker component (its own dual-month CalendarMonth,
hardcoded colors, non-chip chrome) so the chip Calendar is the single date
surface. Migrates every consumer:

- tables row modal -> ChipDatePicker (single)
- tables inline grid editor, logs filter, ee audit-logs filter -> canonical
  Calendar inside an emcn Popover (the same headless/anchored pattern
  DatePicker rendered internally; single for the grid cell, range+time for the
  log filters)
- playground -> ChipDatePicker showcase (single, range, range+time)

The range bounds keep the exact YYYY-MM-DD / YYYY-MM-DDTHH:mm wire format, so
the log filters' parsing is unchanged. Calendar is imported via its component
path (the emcn barrel already exports a Calendar icon).

* fix(emcn): resync range calendar draft state when bound props change

Cursor Bugbot: RangeCalendarView seeded its staged selection from props only
once. Closing the popover unmounts it (so a fresh open re-seeds), but if the
bound startDate/endDate change while it stays mounted the grid could linger on
a stale draft. Add a render-phase reset keyed on the bound props.

* refactor(emcn): reset range calendar via key instead of render-phase resync

Replaces the imperative render-phase state reset with React's idiomatic
key-based reset: the range view is keyed on its committed bounds in the
Calendar dispatcher, so a newly applied range remounts it with fresh draft
state. Cleaner and fully encapsulated — consumers need no special handling.
Move the rationale into TSDoc (no inline comment).

* fix(logs): keep the date-range popover anchor from blocking the time-range select

The custom-range Calendar is anchored with an always-rendered PopoverAnchor
overlaying the time-range combobox (absolute inset-0). Without
pointer-events-none it would intercept clicks meant for the select, leaving it
unclickable. The anchor is only a positioning reference, so disable its pointer
events on both the logs and audit-logs filters.
This commit is contained in:
Waleed
2026-06-26 11:16:39 -07:00
committed by GitHub
parent 365d8be02b
commit 02d254e267
15 changed files with 607 additions and 2175 deletions
+28 -14
View File
@@ -16,11 +16,11 @@ import {
Card as CardIcon,
Checkbox,
ChevronDown,
ChipDatePicker,
Code,
Combobox,
Connections,
Cursor,
DatePicker,
DocumentAttachment,
Download,
Duplicate,
@@ -772,24 +772,24 @@ export default function PlaygroundPage() {
</VariantRow>
</Section>
{/* DatePicker */}
<Section title='DatePicker'>
{/* ChipDatePicker */}
<Section title='ChipDatePicker'>
<VariantRow label='single date'>
<div className='w-56'>
<DatePicker value={dateValue} onChange={setDateValue} placeholder='Select date' />
<ChipDatePicker
value={dateValue}
onChange={setDateValue}
placeholder='Select date'
fullWidth
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>
{dateValue || 'No date'}
</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-56'>
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />
</div>
</VariantRow>
<VariantRow label='range mode'>
<div className='w-72'>
<DatePicker
<ChipDatePicker
mode='range'
startDate={dateRangeStart}
endDate={dateRangeEnd}
@@ -798,17 +798,31 @@ export default function PlaygroundPage() {
setDateRangeEnd(end)
}}
placeholder='Select date range'
fullWidth
/>
</div>
</VariantRow>
<VariantRow label='range mode (with time)'>
<div className='w-72'>
<ChipDatePicker
mode='range'
showTime
startDate={dateRangeStart}
endDate={dateRangeEnd}
onRangeChange={(start, end) => {
setDateRangeStart(start)
setDateRangeEnd(end)
}}
placeholder='Select date range'
fullWidth
/>
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-56'>
<DatePicker value='2025-01-15' disabled />
<ChipDatePicker value='2025-01-15' disabled fullWidth />
</div>
</VariantRow>
<VariantRow label='inline'>
<DatePicker inline value={dateValue} onChange={setDateValue} />
</VariantRow>
</Section>
{/* Tooltip */}
@@ -6,13 +6,13 @@ import {
Badge,
Button,
ChipCombobox,
ChipDatePicker,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
DatePicker,
Label,
Trash,
} from '@/components/emcn'
@@ -411,7 +411,7 @@ export function DocumentTagsModal({
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[var(--border-1)]' />
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-xs'>
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'>
{formatValueForDisplay(tag.value, tag.fieldType)}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
@@ -524,10 +524,11 @@ export function DocumentTagsModal({
}}
/>
) : editTagForm.fieldType === 'date' ? (
<DatePicker
<ChipDatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
fullWidth
/>
) : (
<ChipInput
@@ -676,10 +677,11 @@ export function DocumentTagsModal({
}}
/>
) : editTagForm.fieldType === 'date' ? (
<DatePicker
<ChipDatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
fullWidth
/>
) : (
<ChipInput
@@ -1 +0,0 @@
export { AutocompleteSearch } from './search'
@@ -1,371 +0,0 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge, Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser'
import {
type FolderData,
SearchSuggestions,
type TriggerData,
type WorkflowData,
} from '@/lib/logs/search-suggestions'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
function truncateFilterValue(field: string, value: string): string {
if ((field === 'executionId' || field === 'workflowId') && value.length > 12) {
return `...${value.slice(-6)}`
}
if (value.length > 20) {
return `${value.slice(0, 17)}...`
}
return value
}
interface AutocompleteSearchProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
onOpenChange?: (open: boolean) => void
}
export function AutocompleteSearch({
value,
onChange,
placeholder = 'Search',
className,
onOpenChange,
}: AutocompleteSearchProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowList = [] } = useWorkflows(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const workflowsData = useMemo<WorkflowData[]>(() => {
return workflowList.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
}))
}, [workflowList])
const foldersData = useMemo<FolderData[]>(() => {
return Object.values(folders).map((f) => ({
id: f.id,
name: f.name,
}))
}, [folders])
const triggersData = useMemo<TriggerData[]>(() => {
return getTriggerOptions().map((t) => ({
value: t.value,
label: t.label,
color: t.color,
}))
}, [])
const suggestionEngine = useMemo(() => {
return new SearchSuggestions(workflowsData, foldersData, triggersData)
}, [workflowsData, foldersData, triggersData])
const handleFiltersChange = (filters: ParsedFilter[], textSearch: string) => {
const filterStrings = filters.map(
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
)
const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ')
onChange(fullQuery)
}
const {
appliedFilters,
currentInput,
textSearch,
isOpen,
suggestions,
sections,
highlightedIndex,
highlightedBadgeIndex,
inputRef,
dropdownRef,
handleInputChange,
handleSuggestionSelect,
handleKeyDown,
handleFocus,
handleBlur,
removeBadge,
clearAll,
setHighlightedIndex,
initializeFromQuery,
} = useSearchState({
onFiltersChange: handleFiltersChange,
getSuggestions: (input) => suggestionEngine.getSuggestions(input),
initialQuery: value,
})
const lastExternalValue = useRef(value)
useEffect(() => {
if (value !== lastExternalValue.current) {
lastExternalValue.current = value
const parsed = parseQuery(value)
initializeFromQuery(parsed.textSearch, parsed.filters)
}
}, [value, initializeFromQuery])
const [dropdownWidth, setDropdownWidth] = useState(400)
useEffect(() => {
const measure = () => {
if (inputRef.current) {
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 400)
}
}
measure()
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [])
useEffect(() => {
onOpenChange?.(isOpen)
}, [isOpen, onOpenChange])
useEffect(() => {
if (!isOpen || highlightedIndex < 0) return
const container = dropdownRef.current
const optionEl = container?.querySelector(`[data-index="${highlightedIndex}"]`)
if (container && optionEl) {
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [isOpen, highlightedIndex])
const hasFilters = appliedFilters.length > 0
const hasTextSearch = textSearch.length > 0
const suggestionType =
sections.length > 0 ? 'multi-section' : suggestions.length > 0 ? suggestions[0]?.category : null
return (
<div className={cn('relative', className)}>
{/* Search Input with Inline Badges */}
<DropdownMenuPrimitive.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setHighlightedIndex(-1)
}
}}
modal={false}
>
<DropdownMenuPrimitive.Trigger asChild>
<div className='relative flex h-[32px] w-[400px] items-center rounded-lg bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
{/* Search Icon */}
<Search className='mr-1.5 ml-2 size-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
{/* Scrollable container for badges */}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pr-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{/* Applied Filter Badges */}
{appliedFilters.map((filter, index) => (
<Badge
key={`${filter.field}-${filter.value}`}
variant='outline'
role='button'
tabIndex={0}
className={cn(
'h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-xs',
highlightedBadgeIndex === index &&
'ring-1 ring-[var(--text-subtle)] ring-offset-1 ring-offset-[var(--surface-3)] dark:ring-offset-[var(--surface-5)]'
)}
onClick={() => removeBadge(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
removeBadge(index)
}
}}
>
<span className='text-[var(--text-muted)]'>{filter.field}:</span>
<span className='text-[var(--text-primary)]'>
{filter.operator !== '=' && filter.operator}
{truncateFilterValue(filter.field, filter.originalValue)}
</span>
<X className='size-3 shrink-0' />
</Badge>
))}
{/* Text Search Badge (if present) */}
{hasTextSearch && (
<Badge
variant='outline'
role='button'
tabIndex={0}
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-xs'
onClick={() => handleFiltersChange(appliedFilters, '')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleFiltersChange(appliedFilters, '')
}
}}
>
<span className='max-w-[150px] truncate text-[var(--text-primary)]'>
"{textSearch}"
</span>
<X className='size-3 shrink-0' />
</Badge>
)}
{/* Input - only current typing */}
<input
ref={inputRef}
type='text'
placeholder={hasFilters || hasTextSearch ? '' : placeholder}
value={currentInput}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
className='min-w-[80px] flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none outline-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-sm'
/>
</div>
{/* Clear All Button */}
{(hasFilters || hasTextSearch) && (
<Button
type='button'
variant='ghost'
className='mr-2 ml-1.5 size-[14px] flex-shrink-0 p-0 text-[var(--text-subtle)] hover-hover:text-[var(--text-secondary)]'
onClick={clearAll}
>
<X className='size-[14px]' />
</Button>
)}
</div>
</DropdownMenuPrimitive.Trigger>
{/* Dropdown */}
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={dropdownRef}
className='z-[70] rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
style={{ width: dropdownWidth }}
align='start'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className='max-h-96 overflow-y-auto px-1'>
{sections.length > 0 ? (
<div className='py-1'>
{/* Show all results (no header) */}
{suggestions[0]?.category === 'show-all' && (
<Button
key={suggestions[0].id}
variant='ghost'
data-index={0}
className={cn(
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--text-subtle)]',
'hover-hover:bg-[var(--surface-5)]',
highlightedIndex === 0 && 'bg-[var(--surface-5)]'
)}
onMouseEnter={() => setHighlightedIndex(0)}
onMouseDown={(e) => {
e.preventDefault()
handleSuggestionSelect(suggestions[0])
}}
>
<div className='text-small'>{suggestions[0].label}</div>
</Button>
)}
{sections.map((section) => (
<div key={section.title}>
<div className='px-3 py-1.5 font-medium text-[var(--text-tertiary)] text-caption uppercase tracking-wide'>
{section.title}
</div>
{section.suggestions.map((suggestion) => {
if (suggestion.category === 'show-all') return null
const index = suggestions.indexOf(suggestion)
const isHighlighted = index === highlightedIndex
return (
<Button
key={suggestion.id}
variant='ghost'
data-index={index}
className={cn(
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--text-subtle)]',
'hover-hover:bg-[var(--surface-5)]',
isHighlighted && 'bg-[var(--surface-5)]'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault()
handleSuggestionSelect(suggestion)
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1 truncate text-small'>
{suggestion.label}
</div>
{suggestion.value !== suggestion.label && (
<div className='shrink-0 font-mono text-[var(--text-muted)] text-xs'>
{suggestion.category === 'workflow' ||
suggestion.category === 'folder'
? `${suggestion.category}:`
: ''}
</div>
)}
</div>
</Button>
)
})}
</div>
))}
</div>
) : (
// Single section layout
<div className='py-1'>
{suggestionType === 'filters' && (
<div className='px-3 py-1.5 font-medium text-[var(--text-tertiary)] text-caption uppercase tracking-wide'>
SUGGESTED FILTERS
</div>
)}
{suggestions.map((suggestion, index) => (
<Button
key={suggestion.id}
variant='ghost'
data-index={index}
className={cn(
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--text-subtle)]',
'hover-hover:bg-[var(--surface-5)]',
index === highlightedIndex && 'bg-[var(--surface-5)]'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault()
handleSuggestionSelect(suggestion)
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1 text-small'>{suggestion.label}</div>
{suggestion.description && (
<div className='shrink-0 text-[var(--text-muted)] text-xs'>
{suggestion.value}
</div>
)}
</div>
</Button>
))}
</div>
)}
</div>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
</div>
)
}
@@ -1,2 +0,0 @@
export { AutocompleteSearch } from './components/search'
export { LogsToolbar } from './logs-toolbar'
@@ -1,756 +0,0 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { ArrowUp, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
Button,
ChipCombobox,
type ComboboxOption,
DatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Loader,
} from '@/components/emcn'
import { Workflow } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { captureEvent } from '@/lib/posthog/client'
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
import {
formatDateShort,
type LogStatus,
STATUS_CONFIG,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { AutocompleteSearch } from './components/search'
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'All time', label: 'All time' },
{ value: 'Past 30 minutes', label: 'Past 30 minutes' },
{ value: 'Past hour', label: 'Past hour' },
{ value: 'Past 6 hours', label: 'Past 6 hours' },
{ value: 'Past 12 hours', label: 'Past 12 hours' },
{ value: 'Past 24 hours', label: 'Past 24 hours' },
{ value: 'Past 3 days', label: 'Past 3 days' },
{ 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
type ViewMode = 'logs' | 'dashboard'
interface LogsToolbarProps {
/** Current view mode */
viewMode: ViewMode
/** Callback when view mode changes */
onViewModeChange: (mode: ViewMode) => void
/** Whether the refresh spinner is visible */
isRefreshing: boolean
/** Callback when refresh button is clicked */
onRefresh: () => void
/** Whether live mode is enabled */
isLive: boolean
/** Callback when live toggle is clicked */
onToggleLive: () => void
/** Whether export is in progress */
isExporting: boolean
/** Callback when export is triggered */
onExport: () => void
/** Whether user can edit (for export permissions) */
canEdit: boolean
/** Whether there are logs to export */
hasLogs: boolean
/** Search query value */
searchQuery: string
/** Callback when search query changes */
onSearchQueryChange: (query: string) => void
/** Callback when search open state changes */
onSearchOpenChange: (open: boolean) => void
}
/** Cache for color icon components to ensure stable references across renders */
const colorIconCache = new Map<string, React.ComponentType<{ className?: string }>>()
/**
* Returns a memoized icon component for a given color.
* Uses a cache to ensure the same color always returns the same component reference,
* which prevents unnecessary React reconciliation.
* @param color - CSS color value for the icon background
* @returns A React component that renders a colored square icon
*/
function getColorIcon(color: string): React.ComponentType<{ className?: string }> {
const cached = colorIconCache.get(color)
if (cached) return cached
const ColorIcon = ({ className }: { className?: string }) => (
<div
className={cn(className, 'flex-shrink-0 rounded-[3px]')}
style={{
backgroundColor: color,
width: 10,
height: 10,
}}
/>
)
ColorIcon.displayName = `ColorIcon(${color})`
colorIconCache.set(color, ColorIcon)
return ColorIcon
}
/**
* Renders the workflow skeleton icon used as the workflow filter indicator.
* @param props - Optional className passthrough
* @returns The workflow skeleton icon
*/
function WorkflowOptionIcon({ className }: { className?: string }) {
return <Workflow className={cn(className, 'flex-shrink-0 text-[var(--text-icon)]')} />
}
/**
* Returns a memoized trigger icon component for integration blocks.
* Core trigger types (manual, api, schedule, chat, webhook) return undefined.
* @param triggerType - The trigger type identifier
* @returns A React component that renders the trigger icon, or undefined for core types
*/
function getTriggerIcon(
triggerType: string
): React.ComponentType<{ className?: string }> | undefined {
if ((CORE_TRIGGER_TYPES as readonly string[]).includes(triggerType)) return undefined
const block = getBlock(triggerType)
if (!block?.icon) return undefined
const BlockIcon = block.icon
const TriggerIcon = ({ className }: { className?: string }) => (
<BlockIcon className={cn(className, 'flex-shrink-0')} style={{ width: 12, height: 12 }} />
)
TriggerIcon.displayName = `TriggerIcon(${triggerType})`
return TriggerIcon
}
/**
* Consolidated logs toolbar component that combines header, search, and filters.
* Contains title, icon, view mode toggle, refresh/live controls, search bar, and filter controls.
* @param props - The component props
* @returns The complete logs toolbar
*/
export const LogsToolbar = memo(function LogsToolbar({
viewMode,
onViewModeChange,
isRefreshing,
onRefresh,
isLive,
onToggleLive,
isExporting,
onExport,
canEdit,
hasLogs,
searchQuery,
onSearchQueryChange,
onSearchOpenChange,
}: LogsToolbarProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const posthog = usePostHog()
const posthogRef = useRef(posthog)
posthogRef.current = posthog
const {
level,
setLevel,
workflowIds,
setWorkflowIds,
folderIds,
setFolderIds,
triggers,
setTriggers,
timeRange,
setTimeRange,
startDate,
endDate,
setDateRange,
clearDateRange,
resetFilters,
} = useLogFilters()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
const dateRangeAppliedRef = useRef(false)
const { data: folders = {} } = useFolderMap(workspaceId)
const { data: allWorkflowList = [] } = useWorkflows(workspaceId)
const workflows = useMemo(() => {
return allWorkflowList.map((w) => ({
id: w.id,
name: w.name,
}))
}, [allWorkflowList])
const folderList = useMemo(() => {
return Object.values(folders).filter((f) => f.workspaceId === workspaceId)
}, [folders, workspaceId])
const isDashboardView = viewMode === 'dashboard'
const selectedStatuses = useMemo((): string[] => {
if (level === 'all' || !level) return []
return level.split(',').filter(Boolean)
}, [level])
const statusOptions: ComboboxOption[] = useMemo(
() =>
(Object.keys(STATUS_CONFIG) as LogStatus[])
.filter((status) => STATUS_CONFIG[status].filterable)
.map((status) => ({
value: status,
label: STATUS_CONFIG[status].label,
icon: getColorIcon(STATUS_CONFIG[status].color),
})),
[]
)
const handleStatusChange = useCallback(
(values: string[]) => {
if (values.length === 0) {
setLevel('all')
} else {
setLevel(values.join(','))
}
captureEvent(posthogRef.current, 'logs_filter_applied', {
filter_type: 'status',
workspace_id: workspaceId,
})
},
[setLevel, workspaceId]
)
const handleWorkflowFilterChange = useCallback(
(values: string[]) => {
setWorkflowIds(values)
captureEvent(posthogRef.current, 'logs_filter_applied', {
filter_type: 'workflow',
workspace_id: workspaceId,
})
},
[setWorkflowIds, workspaceId]
)
const handleFolderFilterChange = useCallback(
(values: string[]) => {
setFolderIds(values)
captureEvent(posthogRef.current, 'logs_filter_applied', {
filter_type: 'folder',
workspace_id: workspaceId,
})
},
[setFolderIds, workspaceId]
)
const handleTriggerFilterChange = useCallback(
(values: string[]) => {
setTriggers(values)
captureEvent(posthogRef.current, 'logs_filter_applied', {
filter_type: 'trigger',
workspace_id: workspaceId,
})
},
[setTriggers, workspaceId]
)
const statusDisplayLabel =
selectedStatuses.length === 0
? 'Status'
: selectedStatuses.length === 1
? (statusOptions.find((s) => s.value === selectedStatuses[0])?.label ?? '1 selected')
: `${selectedStatuses.length} selected`
const selectedStatusColor =
selectedStatuses.length === 1
? (STATUS_CONFIG[selectedStatuses[0] as LogStatus]?.color ?? null)
: null
const workflowOptions: ComboboxOption[] = useMemo(
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: WorkflowOptionIcon })),
[workflows]
)
const workflowDisplayLabel =
workflowIds.length === 0
? 'Workflow'
: workflowIds.length === 1
? (workflows.find((w) => w.id === workflowIds[0])?.name ?? '1 selected')
: `${workflowIds.length} workflows`
const selectedWorkflow =
workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null
const folderOptions: ComboboxOption[] = useMemo(
() => folderList.map((f) => ({ value: f.id, label: f.name })),
[folderList]
)
const folderDisplayLabel =
folderIds.length === 0
? 'Folder'
: folderIds.length === 1
? (folderList.find((f) => f.id === folderIds[0])?.name ?? '1 selected')
: `${folderIds.length} folders`
const triggerOptions: ComboboxOption[] = useMemo(
() =>
getTriggerOptions().map((t) => ({
value: t.value,
label: t.label,
icon: getTriggerIcon(t.value),
})),
[]
)
const triggerDisplayLabel =
triggers.length === 0
? 'Trigger'
: triggers.length === 1
? (triggerOptions.find((t) => t.value === triggers[0])?.label ?? '1 selected')
: `${triggers.length} triggers`
const timeDisplayLabel =
timeRange === 'All time'
? 'Time'
: timeRange === 'Custom range' && startDate && endDate
? `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`
: timeRange === 'Custom range'
? 'Custom range'
: timeRange
/**
* 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)
captureEvent(posthogRef.current, 'logs_filter_applied', {
filter_type: 'time',
workspace_id: workspaceId,
})
}
},
[timeRange, setTimeRange, clearDateRange, workspaceId]
)
/**
* Handles date range selection from DatePicker.
*/
function handleDateRangeApply(start: string, end: string) {
dateRangeAppliedRef.current = true
setDateRange(start, end)
setDatePickerOpen(false)
captureEvent(posthogRef.current, 'logs_filter_applied', {
filter_type: 'time',
workspace_id: workspaceId,
})
}
/**
* Handles date picker cancel.
*/
function handleDatePickerCancel() {
if (timeRange === 'Custom range' && !startDate) {
setTimeRange(previousTimeRange)
}
setDatePickerOpen(false)
}
const filtersActive = useMemo(
() =>
hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery,
}),
[timeRange, level, workflowIds, folderIds, triggers, searchQuery]
)
function handleClearFilters() {
resetFilters()
onSearchQueryChange('')
}
return (
<div className='flex flex-col gap-[19px]'>
{/* Header Section */}
<div className='flex items-start justify-between'>
<div className='flex items-start gap-3'>
<div className='flex size-[26px] items-center justify-center rounded-md border border-[#D4A843] bg-[#FDF6E3] dark:border-[#7A5F11] dark:bg-[#514215]'>
<Library className='size-[14px] text-[#D4A843] dark:text-[#FBBC04]' />
</div>
<h1 className='font-medium text-lg'>Logs</h1>
</div>
<div className='flex items-center gap-2'>
{/* More options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='default' className='size-[32px] rounded-md p-0'>
<MoreHorizontal className='size-[14px]' />
<span className='sr-only'>More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' sideOffset={4}>
<DropdownMenuItem onSelect={onExport} disabled={!canEdit || isExporting || !hasLogs}>
<ArrowUp className='size-3' />
<span>Export as CSV</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Refresh button */}
<Button
variant='default'
className='h-[32px] rounded-md px-2.5'
onClick={isRefreshing ? undefined : onRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader className='size-[14px]' animate />
) : (
<RefreshCw className='size-[14px]' />
)}
</Button>
{/* Live button */}
<Button
variant={isLive ? 'tertiary' : 'default'}
onClick={onToggleLive}
className={cn(
'h-[32px] rounded-md px-2.5',
isLive && 'border border-[var(--brand-accent)]'
)}
>
Live
</Button>
{/* View mode toggle */}
<div className='flex h-[32px] items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-0.5'>
<Button
variant={!isDashboardView ? 'active' : 'ghost'}
className={cn(
'h-[26px] rounded-sm px-2.5',
isDashboardView && 'border border-transparent'
)}
onClick={() => onViewModeChange('logs')}
>
Logs
</Button>
<Button
variant={isDashboardView ? 'active' : 'ghost'}
className={cn(
'h-[26px] rounded-sm px-2.5',
!isDashboardView && 'border border-transparent'
)}
onClick={() => onViewModeChange('dashboard')}
>
Dashboard
</Button>
</div>
</div>
</div>
{/* Filter Bar Section */}
<div className='flex w-full items-center gap-3'>
<div className='min-w-[200px] max-w-[400px] flex-1'>
<AutocompleteSearch
value={searchQuery}
onChange={onSearchQueryChange}
placeholder='Search'
onOpenChange={onSearchOpenChange}
/>
</div>
<div className='ml-auto flex items-center gap-2'>
{/* Clear Filters Button */}
{filtersActive && (
<Button
variant='active'
onClick={handleClearFilters}
className='h-[32px] rounded-md px-2.5'
>
<span>Clear</span>
</Button>
)}
{/* Filters dropdown - Small screens only */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='active' className='h-[32px] gap-1.5 rounded-md px-2.5 xl:hidden'>
<span>Filters</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' sideOffset={4} className='w-[280px] p-3'>
<div className='flex flex-col gap-3'>
{/* Status Filter */}
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Status
</span>
<ChipCombobox
options={statusOptions}
multiSelect
multiSelectValues={selectedStatuses}
onMultiSelectChange={handleStatusChange}
placeholder='All statuses'
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
{selectedStatusColor && (
<div
className='flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
/>
)}
<span className='truncate'>{statusDisplayLabel}</span>
</span>
}
showAllOption
allOptionLabel='All statuses'
className='w-full'
/>
</div>
{/* Workflow Filter */}
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Workflow
</span>
<ChipCombobox
options={workflowOptions}
multiSelect
multiSelectValues={workflowIds}
onMultiSelectChange={handleWorkflowFilterChange}
placeholder='All workflows'
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
{selectedWorkflow && (
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
)}
<span className='truncate'>{workflowDisplayLabel}</span>
</span>
}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All workflows'
className='w-full'
/>
</div>
{/* Folder Filter */}
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Folder
</span>
<ChipCombobox
options={folderOptions}
multiSelect
multiSelectValues={folderIds}
onMultiSelectChange={handleFolderFilterChange}
placeholder='All folders'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{folderDisplayLabel}
</span>
}
searchable
searchPlaceholder='Search folders...'
showAllOption
allOptionLabel='All folders'
className='w-full'
/>
</div>
{/* Trigger Filter */}
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Trigger
</span>
<ChipCombobox
options={triggerOptions}
multiSelect
multiSelectValues={triggers}
onMultiSelectChange={handleTriggerFilterChange}
placeholder='All triggers'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{triggerDisplayLabel}
</span>
}
searchable
searchPlaceholder='Search triggers...'
showAllOption
allOptionLabel='All triggers'
className='w-full'
/>
</div>
{/* Time Filter */}
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Time Range
</span>
<ChipCombobox
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{timeDisplayLabel}
</span>
}
className='w-full'
/>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Inline Filters - Large screens only */}
<div className='hidden items-center gap-2 xl:flex'>
{/* Status Filter */}
<ChipCombobox
options={statusOptions}
multiSelect
multiSelectValues={selectedStatuses}
onMultiSelectChange={handleStatusChange}
placeholder='Status'
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
{selectedStatusColor && (
<div
className='flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
/>
)}
<span className='truncate'>{statusDisplayLabel}</span>
</span>
}
showAllOption
allOptionLabel='All statuses'
align='end'
className='w-[120px]'
/>
{/* Workflow Filter */}
<ChipCombobox
options={workflowOptions}
multiSelect
multiSelectValues={workflowIds}
onMultiSelectChange={handleWorkflowFilterChange}
placeholder='Workflow'
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
{selectedWorkflow && (
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
)}
<span className='truncate'>{workflowDisplayLabel}</span>
</span>
}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All workflows'
align='end'
className='w-[120px]'
/>
{/* Folder Filter */}
<ChipCombobox
options={folderOptions}
multiSelect
multiSelectValues={folderIds}
onMultiSelectChange={handleFolderFilterChange}
placeholder='Folder'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
}
searchable
searchPlaceholder='Search folders...'
showAllOption
allOptionLabel='All folders'
align='end'
className='w-[120px]'
/>
{/* Trigger Filter */}
<ChipCombobox
options={triggerOptions}
multiSelect
multiSelectValues={triggers}
onMultiSelectChange={handleTriggerFilterChange}
placeholder='Trigger'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search triggers...'
showAllOption
allOptionLabel='All triggers'
align='end'
className='w-[120px]'
/>
{/* Timeline Filter */}
<div className='relative'>
<ChipCombobox
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='Time'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
}
align='end'
className='w-[160px]'
maxHeight={320}
/>
<DatePicker
mode='range'
showTrigger={false}
showTime
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
if (dateRangeAppliedRef.current) {
dateRangeAppliedRef.current = false
} else {
handleDatePickerCancel()
}
}
}}
startDate={startDate}
endDate={endDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</div>
</div>
</div>
</div>
</div>
)
})
@@ -17,11 +17,14 @@ import {
Button,
ChipCombobox,
type ComboboxOption,
DatePicker,
Library,
Popover,
PopoverAnchor,
PopoverContent,
RefreshCw,
toast,
} from '@/components/emcn'
import { Calendar } from '@/components/emcn/components/calendar/calendar'
import { Download, Workflow } from '@/components/emcn/icons'
import type {
WorkflowLogDetail,
@@ -1425,10 +1428,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
className='w-full'
maxHeight={320}
/>
<DatePicker
mode='range'
showTrigger={false}
showTime
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
@@ -1439,11 +1439,19 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
}
}
}}
startDate={startDate}
endDate={endDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
<Calendar
mode='range'
showTime
startDate={startDate}
endDate={endDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</PopoverContent>
</Popover>
</div>
</div>
@@ -7,13 +7,13 @@ import { useParams } from 'next/navigation'
import {
Checkbox,
ChipConfirmModal,
ChipDatePicker,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
DatePicker,
Label,
} from '@/components/emcn'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
@@ -238,11 +238,11 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
if (column.type === 'date') {
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<DatePicker
mode='single'
<ChipDatePicker
value={formatValueForInput(value, column.type) || undefined}
onChange={(dateStr) => onChange(dateStr)}
onChange={onChange}
placeholder='Select date'
fullWidth
/>
</ChipModalField>
)
@@ -1,7 +1,8 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DatePicker } from '@/components/emcn'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
import { Calendar } from '@/components/emcn/components/calendar/calendar'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
import type { SaveReason } from '../../../types'
@@ -20,7 +21,7 @@ interface InlineEditorProps {
onCancel: () => void
}
/** Inline editor for `date` columns — text input + popover DatePicker. */
/** Inline editor for `date` columns — text input + popover calendar. */
function InlineDateEditor({
value,
column,
@@ -116,17 +117,12 @@ function InlineDateEditor({
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
)}
/>
<div className='absolute top-full left-0 size-0'>
<DatePicker
mode='single'
value={pickerValue}
onChange={handlePickerChange}
open={true}
onOpenChange={handlePickerOpenChange}
showTrigger={false}
size='sm'
/>
</div>
<Popover open onOpenChange={handlePickerOpenChange}>
<PopoverAnchor className='absolute top-full left-0 size-0' />
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
<Calendar value={pickerValue} onChange={handlePickerChange} />
</PopoverContent>
</Popover>
</>
)
}
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildRangeBounds,
formatDateRangeLabel,
parseDateValue,
} from '@/components/emcn/components/calendar/calendar'
describe('parseDateValue', () => {
it('parses a YYYY-MM-DD string as a local day', () => {
const date = parseDateValue('2026-04-21')
expect(date?.getFullYear()).toBe(2026)
expect(date?.getMonth()).toBe(3)
expect(date?.getDate()).toBe(21)
})
it('parses the day from a YYYY-MM-DDTHH:mm string', () => {
const date = parseDateValue('2026-04-21T09:30')
expect(date?.getMonth()).toBe(3)
expect(date?.getDate()).toBe(21)
})
it('returns null for empty or invalid input', () => {
expect(parseDateValue(undefined)).toBeNull()
expect(parseDateValue('not-a-date')).toBeNull()
})
})
describe('buildRangeBounds', () => {
it('serializes bare days when time is off', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 30), {
showTime: false,
startTime: '00:00',
endTime: '23:59',
})
expect(bounds).toEqual({ start: '2026-04-01', end: '2026-04-30' })
})
it('orders inverted bounds', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 30), new Date(2026, 3, 1), {
showTime: false,
startTime: '00:00',
endTime: '23:59',
})
expect(bounds).toEqual({ start: '2026-04-01', end: '2026-04-30' })
})
it('appends start time and closes the end at :59 when time is on', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 2), {
showTime: true,
startTime: '09:00',
endTime: '17:30',
})
expect(bounds).toEqual({ start: '2026-04-01T09:00', end: '2026-04-02T17:30:59' })
})
it('swaps inverted times on a single day', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 1), {
showTime: true,
startTime: '18:00',
endTime: '09:00',
})
expect(bounds).toEqual({ start: '2026-04-01T09:00', end: '2026-04-01T18:00:59' })
})
})
describe('formatDateRangeLabel', () => {
it('renders a same-year range compactly', () => {
expect(formatDateRangeLabel('2026-04-08', '2026-04-12')).toBe('Apr 8 - Apr 12, 2026')
})
it('shows both years when they differ', () => {
expect(formatDateRangeLabel('2025-12-30', '2026-01-02')).toBe('Dec 30, 2025 - Jan 2, 2026')
})
it('falls back to a single label when only one bound is set', () => {
expect(formatDateRangeLabel('2026-04-08', undefined)).toBe('Apr 8, 2026')
expect(formatDateRangeLabel(undefined, undefined)).toBe('')
})
})
@@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
import { CalendarDayCell } from '@/components/emcn/components/calendar/calendar-day-cell'
import { Chip, chipVariants } from '@/components/emcn/components/chip/chip'
import { chipContentLabelClass } from '@/components/emcn/components/chip/chip-chrome'
import { ChipTimePicker } from '@/components/emcn/components/chip-time-picker/chip-time-picker'
import { cn } from '@/lib/core/utils/cn'
const MONTHS = [
@@ -24,6 +25,9 @@ const MONTHS = [
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] as const
const DEFAULT_RANGE_START_TIME = '00:00'
const DEFAULT_RANGE_END_TIME = '23:59'
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
@@ -52,8 +56,8 @@ function toDateString(year: number, month: number, day: number): string {
export function parseDateValue(value: string | Date | undefined): Date | null {
if (!value) return null
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number)
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
return new Date(year, month - 1, day)
}
const parsed = new Date(value)
@@ -70,6 +74,35 @@ export function formatDateLabel(value: string | Date | undefined): string {
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* Compact label for a date range (e.g. `May 8 - May 12, 2026`). Mirrors
* {@link formatDateLabel} for the empty/partial cases so triggers can fall back
* to a placeholder.
*/
export function formatDateRangeLabel(
start: string | Date | undefined,
end: string | Date | undefined
): string {
const startDate = parseDateValue(start)
const endDate = parseDateValue(end)
if (!startDate && !endDate) return ''
if (startDate && !endDate) return formatDateLabel(startDate)
if (!startDate && endDate) return formatDateLabel(endDate)
if (!startDate || !endDate) return ''
const sameYear = startDate.getFullYear() === endDate.getFullYear()
const startLabel = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' }),
})
const endLabel = endDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
return `${startLabel} - ${endLabel}`
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
@@ -78,49 +111,121 @@ function isSameDay(a: Date, b: Date): boolean {
)
}
export interface CalendarProps {
/** Selected date as a `YYYY-MM-DD` string or `Date`. */
value?: string | Date
/** Called with the picked date in `YYYY-MM-DD` format. */
onChange?: (value: string) => void
function isStrictlyWithin(date: Date, start: Date, end: Date): boolean {
const time = date.getTime()
const lo = Math.min(start.getTime(), end.getTime())
const hi = Math.max(start.getTime(), end.getTime())
return time > lo && time < hi
}
/** Reads the `HH:mm` slice from a `YYYY-MM-DDTHH:mm` value, or a fallback. */
function extractTime(value: string | Date | undefined, fallback: string): string {
return typeof value === 'string' && value.includes('T') ? value.slice(11, 16) : fallback
}
/**
* Orders a start/end pair and serializes the range bounds to the wire format.
* Without `showTime` the bounds are bare `YYYY-MM-DD` days; with it, the start
* gains its `HH:mm` and the end is closed at `:59` seconds. On a single day the
* times are swapped when inverted so the range never ends before it starts.
*/
export function buildRangeBounds(
start: Date,
end: Date,
options: { showTime: boolean; startTime: string; endTime: string }
): { start: string; end: string } {
const ordered = end < start ? { start: end, end: start } : { start, end }
const startStr = toDateString(
ordered.start.getFullYear(),
ordered.start.getMonth(),
ordered.start.getDate()
)
const endStr = toDateString(
ordered.end.getFullYear(),
ordered.end.getMonth(),
ordered.end.getDate()
)
if (!options.showTime) return { start: startStr, end: endStr }
let startTime = options.startTime
let endTime = options.endTime
if (startStr === endStr && startTime > endTime) {
startTime = options.endTime
endTime = options.startTime
}
return { start: `${startStr}T${startTime}`, end: `${endStr}T${endTime}:59` }
}
interface CalendarBaseProps {
/** Forwarded to the root grid container. */
className?: string
}
interface CalendarSingleProps extends CalendarBaseProps {
mode?: 'single'
/** Selected date as a `YYYY-MM-DD` string or `Date`. */
value?: string | Date
/** Called with the picked date in `YYYY-MM-DD` format. */
onChange?: (value: string) => void
}
interface CalendarRangeProps extends CalendarBaseProps {
mode: 'range'
/** Range start as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string or `Date`. */
startDate?: string | Date
/** Range end as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string or `Date`. */
endDate?: string | Date
/** Adds start/end time-of-day inputs; emits `YYYY-MM-DDTHH:mm` bounds. */
showTime?: boolean
/** Called on Apply with the ordered range bounds. */
onRangeChange: (start: string, end: string) => void
/** Called when the Cancel action is pressed. */
onCancel?: () => void
/** Called when the Clear action is pressed. */
onClear?: () => void
}
export type CalendarProps = CalendarSingleProps | CalendarRangeProps
/**
* Single-month date grid composed from the chip family — icon-only `Chip`
* chevrons for month navigation, day cells built on `chipVariants` (`primary`
* fill when selected, the `border` shadow-ring on today), and a `filled`-chip
* Today action. Pair it with {@link ChipDatePicker} for a chip trigger, or
* embed it directly.
* Date grid composed from the chip family — icon-only `Chip` chevrons for month
* navigation and day cells built on `chipVariants` (`primary` fill when
* selected, the `border` shadow-ring on today). Pair it with
* {@link ChipDatePicker} for a chip trigger, or embed it directly.
*
* `mode='single'` (default) commits on day click via `onChange`. `mode='range'`
* stages a start/end selection behind Clear/Cancel/Apply actions (with optional
* time-of-day inputs) and commits via `onRangeChange`. The range view is keyed
* on its committed bounds so a newly applied range remounts it with fresh draft
* state (React's "reset all state with a key") — the staged start/end never
* lingers when the source range changes.
*
* @example
* <Calendar value={value} onChange={setValue} />
*
* @example
* <Calendar mode='range' startDate={from} endDate={to} showTime onRangeChange={apply} />
*/
export function Calendar({ value, onChange, className }: CalendarProps) {
const selected = useMemo(() => parseDateValue(value), [value])
const today = useMemo(() => new Date(), [])
export function Calendar(props: CalendarProps) {
if (props.mode === 'range') {
return (
<RangeCalendarView
key={`${String(props.startDate ?? '')}|${String(props.endDate ?? '')}`}
{...props}
/>
)
}
return <SingleCalendarView {...props} />
}
function useCalendarView(seed: Date | null) {
const today = useMemo(() => new Date(), [])
const [view, setView] = useState(() => {
const base = selected ?? today
const base = seed ?? today
return { month: base.getMonth(), year: base.getFullYear() }
})
useEffect(() => {
if (selected) {
setView({ month: selected.getMonth(), year: selected.getFullYear() })
}
}, [selected])
const cells = useMemo<(number | null)[]>(() => {
const leading = getFirstDayOfMonth(view.year, view.month)
const total = getDaysInMonth(view.year, view.month)
const result: (number | null)[] = []
for (let i = 0; i < leading; i++) result.push(null)
for (let day = 1; day <= total; day++) result.push(day)
return result
}, [view])
const goToPrevMonth = () =>
setView((prev) =>
prev.month === 0
@@ -135,6 +240,63 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
: { month: prev.month + 1, year: prev.year }
)
const cells = useMemo<(number | null)[]>(() => {
const leading = getFirstDayOfMonth(view.year, view.month)
const total = getDaysInMonth(view.year, view.month)
const result: (number | null)[] = []
for (let i = 0; i < leading; i++) result.push(null)
for (let day = 1; day <= total; day++) result.push(day)
return result
}, [view])
return { today, view, setView, goToPrevMonth, goToNextMonth, cells }
}
function CalendarHeader({
month,
year,
onPrev,
onNext,
}: {
month: number
year: number
onPrev: () => void
onNext: () => void
}) {
return (
<div className='flex items-center justify-between pb-1'>
<Chip leftIcon={ChevronLeft} aria-label='Previous month' onClick={onPrev} />
<span className={chipContentLabelClass}>
{MONTHS[month]} {year}
</span>
<Chip rightIcon={ChevronRight} aria-label='Next month' onClick={onNext} />
</div>
)
}
function WeekdayRow() {
return (
<div className='grid grid-cols-7'>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-caption'
>
{weekday}
</div>
))}
</div>
)
}
function SingleCalendarView({ value, onChange, className }: CalendarSingleProps) {
const selected = useMemo(() => parseDateValue(value), [value])
const { today, view, setView, goToPrevMonth, goToNextMonth, cells } = useCalendarView(selected)
useEffect(() => {
if (selected) setView({ month: selected.getMonth(), year: selected.getFullYear() })
}, [selected, setView])
const selectDay = (day: number) => onChange?.(toDateString(view.year, view.month, day))
const goToToday = () => {
@@ -144,24 +306,13 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
return (
<div className={cn('flex w-[256px] flex-col p-2', className)}>
<div className='flex items-center justify-between pb-1'>
<Chip leftIcon={ChevronLeft} aria-label='Previous month' onClick={goToPrevMonth} />
<span className={chipContentLabelClass}>
{MONTHS[view.month]} {view.year}
</span>
<Chip rightIcon={ChevronRight} aria-label='Next month' onClick={goToNextMonth} />
</div>
<div className='grid grid-cols-7'>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-caption'
>
{weekday}
</div>
))}
</div>
<CalendarHeader
month={view.month}
year={view.year}
onPrev={goToPrevMonth}
onNext={goToNextMonth}
/>
<WeekdayRow />
<div className='grid grid-cols-7'>
{cells.map((day, index) => {
@@ -194,3 +345,125 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
</div>
)
}
function RangeCalendarView({
startDate,
endDate,
showTime = false,
onRangeChange,
onCancel,
onClear,
className,
}: CalendarRangeProps) {
const seededStart = useMemo(() => parseDateValue(startDate), [startDate])
const { today, view, goToPrevMonth, goToNextMonth, cells } = useCalendarView(seededStart)
const [rangeStart, setRangeStart] = useState<Date | null>(seededStart)
const [rangeEnd, setRangeEnd] = useState<Date | null>(() => parseDateValue(endDate))
const [selectingEnd, setSelectingEnd] = useState(false)
const [startTime, setStartTime] = useState(() => extractTime(startDate, DEFAULT_RANGE_START_TIME))
const [endTime, setEndTime] = useState(() => extractTime(endDate, DEFAULT_RANGE_END_TIME))
const pickDay = (day: number) => {
const date = new Date(view.year, view.month, day)
if (!selectingEnd || !rangeStart) {
setRangeStart(date)
setRangeEnd(null)
setSelectingEnd(true)
return
}
if (date < rangeStart) {
setRangeEnd(rangeStart)
setRangeStart(date)
} else {
setRangeEnd(date)
}
setSelectingEnd(false)
}
const clear = () => {
setRangeStart(null)
setRangeEnd(null)
setSelectingEnd(false)
onClear?.()
}
const apply = () => {
if (!rangeStart) return
const bounds = buildRangeBounds(rangeStart, rangeEnd ?? rangeStart, {
showTime,
startTime,
endTime,
})
onRangeChange(bounds.start, bounds.end)
}
return (
<div className={cn('flex w-[256px] flex-col p-2', className)}>
<CalendarHeader
month={view.month}
year={view.year}
onPrev={goToPrevMonth}
onNext={goToNextMonth}
/>
<WeekdayRow />
<div className='grid grid-cols-7'>
{cells.map((day, index) => {
if (day === null) return <div key={`empty-${index}`} className='h-[34px]' />
const cellDate = new Date(view.year, view.month, day)
const isStart = rangeStart ? isSameDay(cellDate, rangeStart) : false
const isEnd = rangeEnd ? isSameDay(cellDate, rangeEnd) : false
const within =
rangeStart && rangeEnd ? isStrictlyWithin(cellDate, rangeStart, rangeEnd) : false
const hasBand = (within || isStart || isEnd) && Boolean(rangeStart && rangeEnd)
return (
<div
key={day}
className={cn(
'relative flex h-[34px] items-center justify-center',
hasBand &&
'before:absolute before:inset-y-[2px] before:right-0 before:left-0 before:bg-[var(--surface-active)]',
hasBand && isStart && 'before:left-[3px] before:rounded-l-[8px]',
hasBand && isEnd && 'before:right-[3px] before:rounded-r-[8px]'
)}
>
<CalendarDayCell
selected={isStart || isEnd}
today={isSameDay(cellDate, today)}
className='relative z-[1]'
onClick={() => pickDay(day)}
>
{day}
</CalendarDayCell>
</div>
)
})}
</div>
{showTime && (
<div className='mt-1 flex items-center gap-2'>
<ChipTimePicker value={startTime} onChange={setStartTime} fullWidth flush />
<span className='shrink-0 text-[var(--text-muted)] text-caption'>to</span>
<ChipTimePicker value={endTime} onChange={setEndTime} fullWidth flush />
</div>
)}
<div className='mt-1 flex items-center justify-between gap-2'>
<Chip onClick={clear} disabled={!rangeStart && !rangeEnd}>
Clear
</Chip>
<div className='flex items-center gap-2'>
<Chip variant='border' onClick={() => onCancel?.()}>
Cancel
</Chip>
<Chip variant='primary' onClick={apply} disabled={!rangeStart}>
Apply
</Chip>
</div>
</div>
</div>
)
}
@@ -2,18 +2,18 @@
import { forwardRef, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { Calendar, formatDateLabel } from '@/components/emcn/components/calendar/calendar'
import {
Calendar,
formatDateLabel,
formatDateRangeLabel,
} from '@/components/emcn/components/calendar/calendar'
import { chipVariants, TRIGGER_BORDER_CLASS } from '@/components/emcn/components/chip/chip'
import { chipContentLabelClass } from '@/components/emcn/components/chip/chip-chrome'
import { POPOVER_ANIMATION_CLASSES } from '@/components/emcn/components/popover/popover-animation'
import { ChevronDown } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
export interface ChipDatePickerProps {
/** Selected date as a `YYYY-MM-DD` string. */
value?: string
/** Called with the picked date in `YYYY-MM-DD` format. */
onChange?: (value: string) => void
interface ChipDatePickerBaseProps {
/**
* Trigger chrome. `filled` (default) is the bordered field chip with the owned
* chevron; `ghost` is the bare toolbar pill — no border, no chevron — for
@@ -21,11 +21,11 @@ export interface ChipDatePickerProps {
*/
variant?: 'filled' | 'ghost'
/**
* Overrides the formatted date as the trigger text, e.g. a calendar period
* label ("June 2026") whose granularity differs from the selected day.
* Overrides the trigger text, e.g. a calendar period label ("June 2026") whose
* granularity differs from the selected value.
*/
label?: string
/** Shown in the trigger when no date is selected. */
/** Shown in the trigger when nothing is selected. */
placeholder?: string
/** Aligns the calendar popover relative to the trigger. */
align?: 'start' | 'center' | 'end'
@@ -39,92 +39,135 @@ export interface ChipDatePickerProps {
className?: string
}
interface ChipDatePickerSingleProps extends ChipDatePickerBaseProps {
mode?: 'single'
/** Selected date as a `YYYY-MM-DD` string. */
value?: string
/** Called with the picked date in `YYYY-MM-DD` format. */
onChange?: (value: string) => void
}
interface ChipDatePickerRangeProps extends ChipDatePickerBaseProps {
mode: 'range'
/** Range start as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string. */
startDate?: string
/** Range end as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string. */
endDate?: string
/** Adds start/end time-of-day inputs; emits `YYYY-MM-DDTHH:mm` bounds. */
showTime?: boolean
/** Called on Apply with the ordered range bounds. */
onRangeChange: (start: string, end: string) => void
}
export type ChipDatePickerProps = ChipDatePickerSingleProps | ChipDatePickerRangeProps
/**
* Date counterpart to {@link ChipDropdown} — a chip-styled trigger that opens a
* {@link Calendar} in a popover. The default `filled` trigger reuses
* `chipVariants` (filled + border) and the owned chevron for visual parity with
* the other chip field controls; `ghost` renders the bare toolbar pill instead.
*
* `mode='single'` (default) commits on day click. `mode='range'` opens the
* range calendar — start/end staged behind Clear/Cancel/Apply, with optional
* time-of-day inputs — and commits via `onRangeChange`.
*
* @example
* <ChipDatePicker value={value} onChange={setValue} placeholder='Select date' fullWidth />
*
* @example
* // Toolbar period label that opens the calendar
* <ChipDatePicker variant='ghost' label='June 2026' value={anchor} onChange={goToDate} />
* <ChipDatePicker mode='range' startDate={from} endDate={to} showTime onRangeChange={apply} />
*/
const ChipDatePicker = forwardRef<HTMLButtonElement, ChipDatePickerProps>(function ChipDatePicker(
{
value,
onChange,
variant = 'filled',
label,
placeholder = 'Select date',
align = 'start',
disabled,
fullWidth,
flush,
className,
},
ref
) {
const [open, setOpen] = useState(false)
const triggerText = label ?? formatDateLabel(value)
const ChipDatePicker = forwardRef<HTMLButtonElement, ChipDatePickerProps>(
function ChipDatePicker(props, ref) {
const {
variant = 'filled',
label,
placeholder = props.mode === 'range' ? 'Select date range' : 'Select date',
align = 'start',
disabled,
fullWidth,
flush,
className,
} = props
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(
variant === 'ghost'
? chipVariants({ fullWidth, flush })
: cn(chipVariants({ variant: 'filled', fullWidth, flush }), TRIGGER_BORDER_CLASS),
className
)}
>
<span
const [open, setOpen] = useState(false)
const triggerText =
label ??
(props.mode === 'range'
? formatDateRangeLabel(props.startDate, props.endDate)
: formatDateLabel(props.value))
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(
chipContentLabelClass,
'flex-1',
!triggerText && 'text-[var(--text-muted)]'
variant === 'ghost'
? chipVariants({ fullWidth, flush })
: cn(chipVariants({ variant: 'filled', fullWidth, flush }), TRIGGER_BORDER_CLASS),
className
)}
>
{triggerText || placeholder}
</span>
{variant === 'filled' && (
<span
aria-hidden
className='inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'
className={cn(
chipContentLabelClass,
'flex-1',
!triggerText && 'text-[var(--text-muted)]'
)}
>
<ChevronDown className='h-[6px] w-[10px]' />
{triggerText || placeholder}
</span>
)}
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={6}
collisionPadding={8}
className={cn(
POPOVER_ANIMATION_CLASSES,
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm'
)}
>
<Calendar
value={value}
onChange={(next) => {
onChange?.(next)
setOpen(false)
}}
/>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)
})
{variant === 'filled' && (
<span
aria-hidden
className='inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'
>
<ChevronDown className='h-[6px] w-[10px]' />
</span>
)}
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={6}
collisionPadding={8}
className={cn(
POPOVER_ANIMATION_CLASSES,
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm'
)}
>
{props.mode === 'range' ? (
<Calendar
mode='range'
startDate={props.startDate}
endDate={props.endDate}
showTime={props.showTime}
onRangeChange={(start, end) => {
props.onRangeChange(start, end)
setOpen(false)
}}
onCancel={() => setOpen(false)}
/>
) : (
<Calendar
value={props.value}
onChange={(next) => {
props.onChange?.(next)
setOpen(false)
}}
/>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)
}
)
ChipDatePicker.displayName = 'ChipDatePicker'
@@ -1,863 +0,0 @@
/**
* DatePicker component with calendar dropdown for date selection.
* Uses Radix UI Popover primitives for positioning and accessibility.
*
* @example
* ```tsx
* // Basic single date picker
* <DatePicker
* value={date}
* onChange={(dateString) => setDate(dateString)}
* placeholder="Select date"
* />
*
* // Range date picker
* <DatePicker
* mode="range"
* startDate={startDate}
* endDate={endDate}
* onRangeChange={(start, end) => handleRange(start, end)}
* placeholder="Select date range"
* />
* ```
*/
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
Popover,
PopoverAnchor,
PopoverContent,
} from '@/components/emcn/components/popover/popover'
import { TimePicker } from '@/components/emcn/components/time-picker/time-picker'
import { cn } from '@/lib/core/utils/cn'
/**
* Variant styles for the date picker trigger button.
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default: '',
},
size: {
default: 'py-1.5 text-sm',
sm: 'py-[5px] text-caption',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
/** Base props shared by both single and range modes */
interface DatePickerBaseProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof datePickerVariants> {
/** 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
/** Not used in single mode */
showTime?: never
}
/** Props for range date mode */
interface DatePickerRangeProps extends DatePickerBaseProps {
/** Selection mode */
mode: 'range'
/** Start date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */
startDate?: string | Date
/** End date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */
endDate?: string | Date
/** Callback when date range is applied — returns YYYY-MM-DD or YYYY-MM-DDTHH:mm depending on showTime */
onRangeChange?: (startDate: string, endDate: string) => void
/** Callback when range selection is cancelled */
onCancel?: () => void
/** Callback when range is cleared */
onClear?: () => void
/** Whether to show time inputs for precise range selection */
showTime?: boolean
/** Not used in range mode */
value?: never
/** Not used in range mode */
onChange?: never
}
export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps
/**
* Flattened props type for safe destructuring.
* The discriminated union prevents direct destructuring of mode-specific props,
* so we cast to this merged shape after the forwardRef boundary.
*/
type FlatDatePickerProps = DatePickerBaseProps & {
mode?: 'single' | 'range'
value?: string | Date
onChange?: (value: string) => void
startDate?: string | Date
endDate?: string | Date
onRangeChange?: (startDate: string, endDate: string) => void
onCancel?: () => void
onClear?: () => void
showTime?: boolean
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
const MONTHS_SHORT = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay()
}
function formatDateForDisplay(date: Date | null): string {
if (!date) return ''
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
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 ''
}
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
}
function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
function formatDateAsString(year: number, month: number, day: number): string {
const m = (month + 1).toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
return `${year}-${m}-${d}`
}
/**
* Parses a string or Date value into a Date object.
* YYYY-MM-DD strings are parsed as local time to avoid UTC offset shifts.
*/
function parseDate(value: string | Date | undefined): Date | null {
if (!value) return null
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return null
return value
}
try {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day)
}
if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
} catch {
return null
}
}
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: _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 || !rangeStart || !rangeEnd) return false
const date = new Date(viewYear, viewMonth, day)
return (
isDateInRange(date, rangeStart, rangeEnd) &&
!isSameDay(date, rangeStart) &&
!isSameDay(date, rangeEnd)
)
},
[isRangeMode, rangeStart, rangeEnd, viewMonth, viewYear]
)
return (
<div className='flex flex-col'>
{/* Calendar Header */}
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-3 py-2.5'>
{showNavigation === 'left' || showNavigation === 'both' ? (
<button
type='button'
className='flex size-[24px] items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
onClick={onPrevMonth}
>
<ChevronLeft className='size-4' />
</button>
) : (
<div className='size-[24px]' />
)}
<span className='font-medium text-[var(--text-primary)] text-small'>
{MONTHS[viewMonth]} {viewYear}
</span>
{showNavigation === 'right' || showNavigation === 'both' ? (
<button
type='button'
className='flex size-[24px] items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
onClick={onNextMonth}
>
<ChevronRight className='size-4' />
</button>
) : (
<div className='size-[24px]' />
)}
</div>
{/* Day Headers */}
<div className='grid grid-cols-7 px-2 pt-2'>
{DAYS.map((day) => (
<div
key={day}
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-xs'
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className='grid grid-cols-7 px-2 pb-2'>
{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 (
<div
key={index}
className={cn(
'relative flex h-[32px] items-center justify-center',
isRangeMode &&
hasRangeHighlight &&
'before:absolute before:inset-y-[2px] before:right-0 before:left-0 before:bg-[#60a5fa]/25',
isRangeMode && isStart && 'before:left-[2px] before:rounded-l-[4px]',
isRangeMode && isEnd && 'before:right-[2px] before:rounded-r-[4px]',
isRangeMode && isStart && !rangeEnd && 'before:right-[2px] before:rounded-r-[4px]',
isRangeMode && isStart && isEnd && 'before:rounded-sm'
)}
>
{day !== null && (
<button
type='button'
className={cn(
'relative z-10 flex h-[28px] w-[28px] items-center justify-center rounded-sm text-caption transition-colors',
isRangeMode
? isStart || isEnd
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: inRange
? 'text-[var(--text-primary)] hover-hover:bg-[#60a5fa]/40'
: 'text-[var(--text-primary)] hover-hover:bg-[var(--surface-5)]'
: isSelected(day)
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: isToday(day)
? 'bg-[var(--surface-5)] text-[var(--text-primary)]'
: 'text-[var(--text-primary)] hover-hover:bg-[var(--surface-5)]'
)}
onClick={() => onSelectDate(day)}
onMouseEnter={() => onHoverDate?.(day)}
onMouseLeave={() => onHoverDate?.(null)}
>
{day}
</button>
)}
</div>
)
})}
</div>
</div>
)
}
/**
* 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<HTMLDivElement, DatePickerProps>((props, ref) => {
const {
className,
variant,
size,
placeholder: placeholderProp,
disabled,
showTrigger = true,
open: controlledOpen,
onOpenChange,
inline = false,
mode,
value,
onChange,
startDate,
endDate,
onRangeChange,
onCancel,
onClear,
showTime = false,
...htmlProps
} = props as FlatDatePickerProps
const isRangeMode = mode === 'range'
const placeholder = placeholderProp ?? (isRangeMode ? 'Select date range' : 'Select date')
const isControlled = controlledOpen !== undefined
const [internalOpen, setInternalOpen] = React.useState(false)
const open = isControlled ? controlledOpen : internalOpen
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange]
)
const selectedDate = !isRangeMode ? parseDate(value) : null
const [rangeStart, setRangeStart] = React.useState<Date | null>(() =>
isRangeMode ? parseDate(startDate) : null
)
const [rangeEnd, setRangeEnd] = React.useState<Date | null>(() =>
isRangeMode ? parseDate(endDate) : null
)
const [hoverDate, setHoverDate] = React.useState<Date | null>(null)
const [selectingEnd, setSelectingEnd] = React.useState(false)
const [startTime, setStartTime] = React.useState('00:00')
const [endTime, setEndTime] = React.useState('23:59')
const [viewMonth, setViewMonth] = React.useState(() => {
const d = selectedDate ?? (isRangeMode ? parseDate(startDate) : null) ?? new Date()
return d.getMonth()
})
const [viewYear, setViewYear] = React.useState(() => {
const d = selectedDate ?? (isRangeMode ? parseDate(startDate) : null) ?? new Date()
return d.getFullYear()
})
const rightViewMonth = viewMonth === 11 ? 0 : viewMonth + 1
const rightViewYear = viewMonth === 11 ? viewYear + 1 : viewYear
// Sync range state when the popover opens with the current prop values.
// Deps are the raw string/Date props — NOT derived Date objects — to avoid
// an infinite re-render loop: Object.is(new Date(), new Date()) === false,
// so derived Date objects in deps cause the effect to fire every render.
React.useEffect(() => {
if (!open || !isRangeMode) return
const start = parseDate(startDate)
const end = parseDate(endDate)
setRangeStart(start)
setRangeEnd(end)
setSelectingEnd(false)
if (showTime) {
setStartTime(
typeof startDate === 'string' && startDate.includes('T') ? startDate.slice(11, 16) : '00:00'
)
setEndTime(
typeof endDate === 'string' && endDate.includes('T') ? endDate.slice(11, 16) : '23:59'
)
}
if (start) {
setViewMonth(start.getMonth())
setViewYear(start.getFullYear())
} else {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
}
}, [open, isRangeMode, startDate, endDate, showTime])
// Sync the calendar view when the external single-date value changes.
// This is a render-phase state update (derived state pattern): safe because
// it only triggers when singleValueKey — a primitive timestamp — actually changes.
const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined
const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey)
if (singleValueKey !== prevSingleValueKey) {
setPrevSingleValueKey(singleValueKey)
if (selectedDate) {
setViewMonth(selectedDate.getMonth())
setViewYear(selectedDate.getFullYear())
}
}
const handleSelectDateSingle = React.useCallback(
(day: number) => {
if (!isRangeMode) {
onChange?.(formatDateAsString(viewYear, viewMonth, day))
setOpen(false)
}
},
[isRangeMode, onChange, viewYear, viewMonth, setOpen]
)
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 {
if (date < rangeStart) {
setRangeEnd(rangeStart)
setRangeStart(date)
} else {
setRangeEnd(date)
}
setSelectingEnd(false)
}
},
[selectingEnd, rangeStart]
)
const handleHoverDate = React.useCallback((year: number, month: number, day: number | null) => {
setHoverDate(day === null ? null : new Date(year, month, day))
}, [])
const goToPrevMonth = React.useCallback(() => {
if (viewMonth === 0) {
setViewMonth(11)
setViewYear((y) => y - 1)
} else {
setViewMonth((m) => m - 1)
}
}, [viewMonth])
const goToNextMonth = React.useCallback(() => {
if (viewMonth === 11) {
setViewMonth(0)
setViewYear((y) => y + 1)
} else {
setViewMonth((m) => m + 1)
}
}, [viewMonth])
const handleSelectToday = React.useCallback(() => {
if (!isRangeMode) {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
setOpen(false)
}
}, [isRangeMode, onChange, setOpen])
const handleApplyRange = React.useCallback(() => {
if (!isRangeMode || !onRangeChange || !rangeStart) return
const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart
const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : (rangeEnd ?? rangeStart)
const startStr = formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate())
const endStr = formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate())
let effectiveStartTime = startTime
let effectiveEndTime = endTime
if (showTime && startStr === endStr && startTime > endTime) {
effectiveStartTime = endTime
effectiveEndTime = startTime
}
onRangeChange(
showTime ? `${startStr}T${effectiveStartTime}` : startStr,
showTime ? `${endStr}T${effectiveEndTime}:59` : endStr
)
setOpen(false)
}, [isRangeMode, onRangeChange, rangeStart, rangeEnd, showTime, startTime, endTime, setOpen])
const handleCancelRange = React.useCallback(() => {
if (isRangeMode) onCancel?.()
setOpen(false)
}, [isRangeMode, onCancel, setOpen])
const handleClearRange = React.useCallback(() => {
setRangeStart(null)
setRangeEnd(null)
setSelectingEnd(false)
if (isRangeMode) onClear?.()
}, [isRangeMode, onClear])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
setOpen(!open)
}
},
[disabled, open, setOpen]
)
const handleTriggerClick = React.useCallback(() => {
if (!disabled) setOpen(!open)
}, [disabled, open, setOpen])
const displayValue = isRangeMode
? formatDateRangeForDisplay(parseDate(startDate), parseDate(endDate))
: formatDateForDisplay(selectedDate)
const calendarContent = isRangeMode ? (
<>
<div className='flex'>
{/* Left Calendar */}
<CalendarMonth
viewMonth={viewMonth}
viewYear={viewYear}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
hoverDate={hoverDate}
isRangeMode
onSelectDate={(day) => handleSelectDateRange(viewYear, viewMonth, day)}
onHoverDate={(day) => handleHoverDate(viewYear, viewMonth, day)}
onPrevMonth={goToPrevMonth}
onNextMonth={goToNextMonth}
showNavigation='left'
/>
{/* Divider */}
<div className='w-[1px] bg-[var(--border-1)]' />
{/* Right Calendar */}
<CalendarMonth
viewMonth={rightViewMonth}
viewYear={rightViewYear}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
hoverDate={hoverDate}
isRangeMode
onSelectDate={(day) => handleSelectDateRange(rightViewYear, rightViewMonth, day)}
onHoverDate={(day) => handleHoverDate(rightViewYear, rightViewMonth, day)}
onPrevMonth={goToPrevMonth}
onNextMonth={goToNextMonth}
showNavigation='right'
/>
</div>
{/* Time inputs */}
{showTime && (
<div className='flex border-[var(--border-1)] border-t'>
<div className='flex flex-1 items-center justify-between gap-2 px-3 py-2'>
<span className='shrink-0 text-[12px] text-[var(--text-muted)]'>Start</span>
<TimePicker size='sm' value={startTime} onChange={setStartTime} />
</div>
<div className='w-[1px] bg-[var(--border-1)]' />
<div className='flex flex-1 items-center justify-between gap-2 px-3 py-2'>
<span className='shrink-0 text-[12px] text-[var(--text-muted)]'>End</span>
<TimePicker size='sm' value={endTime} onChange={setEndTime} />
</div>
</div>
)}
{/* Actions */}
<div className='flex items-center justify-between border-[var(--border-1)] border-t px-3 py-2'>
<Button
variant='ghost'
size='sm'
onClick={handleClearRange}
disabled={!rangeStart && !rangeEnd}
className='text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]'
>
Clear
</Button>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' onClick={handleCancelRange}>
Cancel
</Button>
<Button variant='active' size='sm' onClick={handleApplyRange} disabled={!rangeStart}>
Apply
</Button>
</div>
</div>
</>
) : (
<>
<CalendarMonth
viewMonth={viewMonth}
viewYear={viewYear}
selectedDate={selectedDate}
onSelectDate={handleSelectDateSingle}
onPrevMonth={goToPrevMonth}
onNextMonth={goToNextMonth}
/>
{/* Today Button */}
<div className='border-[var(--border-1)] border-t p-2'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
</>
)
const popoverContent = (
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
collisionPadding={16}
className={cn(
'rounded-md border border-[var(--border-1)] p-0',
isRangeMode ? 'w-auto' : 'w-[280px]'
)}
>
{calendarContent}
</PopoverContent>
)
if (inline) {
return (
<div
ref={ref}
className={cn(
'rounded-md border border-[var(--border-1)] bg-[var(--surface-2)]',
isRangeMode ? 'w-auto' : 'w-[280px]',
className
)}
{...htmlProps}
>
{calendarContent}
</div>
)
}
if (!showTrigger) {
return (
<Popover open={open} onOpenChange={setOpen}>
<div ref={ref} {...htmlProps}>
<PopoverAnchor asChild>
<div />
</PopoverAnchor>
{popoverContent}
</div>
</Popover>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<div ref={ref} className='relative w-full' {...htmlProps}>
<PopoverAnchor asChild>
<div
role='button'
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
className={cn(
datePickerVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
>
<span className={cn('flex-1 truncate', !displayValue && 'text-[var(--text-muted)]')}>
{displayValue || placeholder}
</span>
<ChevronDown
className={cn(
'ml-2 h-4 w-4 flex-shrink-0 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
</div>
</PopoverAnchor>
{popoverContent}
</div>
</Popover>
)
})
DatePicker.displayName = 'DatePicker'
export { DatePicker, datePickerVariants }
@@ -85,7 +85,6 @@ export {
type ComboboxOption,
type ComboboxOptionGroup,
} from './combobox/combobox'
export { DatePicker, datePickerVariants } from './date-picker/date-picker'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -11,10 +11,13 @@ import {
ChipInput,
ChipSelect,
type ComboboxOption,
DatePicker,
Popover,
PopoverAnchor,
PopoverContent,
RefreshCw,
Search,
} from '@/components/emcn'
import { Calendar } from '@/components/emcn/components/calendar/calendar'
import { cn } from '@/lib/core/utils/cn'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format'
@@ -391,10 +394,7 @@ export function AuditLogs() {
maxHeight={320}
align='start'
/>
<DatePicker
mode='range'
showTrigger={false}
showTime
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
@@ -405,11 +405,19 @@ export function AuditLogs() {
}
}
}}
startDate={customStartDate}
endDate={customEndDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
<Calendar
mode='range'
showTime
startDate={customStartDate}
endDate={customEndDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</PopoverContent>
</Popover>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isVisuallyRefreshing}>
<RefreshCw animate={isVisuallyRefreshing} className='size-[14px]' />