diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 3907662..215520e 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -192,6 +192,149 @@ } } +.export-date-range-time-select { + position: relative; + width: 100%; + + &.open .export-date-range-time-trigger { + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + color: var(--primary); + } +} + +.export-date-range-time-trigger { + width: 100%; + min-width: 0; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + height: 30px; + padding: 0 9px; + font-size: 12px; + font-family: inherit; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } +} + +.export-date-range-time-trigger-value { + flex: 1; + min-width: 0; + text-align: left; +} + +.export-date-range-time-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 24; + border: 1px solid var(--border-color); + border-radius: 12px; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + box-shadow: var(--shadow-md); + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.export-date-range-time-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + span { + font-size: 11px; + color: var(--text-secondary); + } + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.export-date-range-time-quick-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.export-date-range-time-quick-item, +.export-date-range-time-option { + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + border-color: rgba(var(--primary-rgb), 0.28); + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } +} + +.export-date-range-time-quick-item { + min-width: 52px; + height: 28px; + padding: 0 10px; + font-size: 11px; +} + +.export-date-range-time-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.export-date-range-time-column { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.export-date-range-time-column-label { + font-size: 11px; + color: var(--text-secondary); +} + +.export-date-range-time-column-list { + max-height: 168px; + overflow-y: auto; + padding-right: 2px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; +} + +.export-date-range-time-option { + min-height: 28px; + padding: 0 8px; + font-size: 11px; +} + .export-date-range-calendar-nav { display: inline-flex; align-items: center; diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index 8a49fdd..d2cbabf 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' +import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, @@ -10,7 +10,6 @@ import { createDateRangeByPreset, createDefaultDateRange, formatCalendarMonthTitle, - formatDateInputValue, isSameDay, parseDateInputValue, startOfDay, @@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { panelMonth: Date } +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0')) +const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0')) +const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59'] + const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null @@ -57,16 +60,42 @@ const clampSelectionToBounds = ( const bounds = resolveBounds(minDate, maxDate) if (!bounds) return cloneExportDateRangeSelection(value) - const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start) - const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end) - const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) - const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) - const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate - const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime() + // For custom selections, only ensure end >= start, preserve time precision + if (value.preset === 'custom' && !value.useAllTime) { + const { start, end } = value.dateRange + if (end.getTime() < start.getTime()) { + return { + ...value, + dateRange: { start, end: start } + } + } + return cloneExportDateRangeSelection(value) + } + + // For useAllTime, use bounds directly + if (value.useAllTime) { + return { + preset: value.preset, + useAllTime: true, + dateRange: { + start: bounds.minDate, + end: bounds.maxDate + } + } + } + + // For preset selections (not custom), clamp dates to bounds and use default times + const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate + + // Set default times: start at 00:00:00, end at 23:59:59 + nextStart.setHours(0, 0, 0, 0) + nextEnd.setHours(23, 59, 59, 999) return { - preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset), - useAllTime: value.useAllTime, + preset: value.preset, + useAllTime: false, dateRange: { start: nextStart, end: nextEnd @@ -95,62 +124,129 @@ export function ExportDateRangeDialog({ onClose, onConfirm }: ExportDateRangeDialogProps) { + // Helper: Format date only (YYYY-MM-DD) for the date input field + const formatDateOnly = (date: Date): string => { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` + } + + // Helper: Format time only (HH:mm) for the time input field + const formatTimeOnly = (date: Date): string => { + const h = `${date.getHours()}`.padStart(2, '0') + const m = `${date.getMinutes()}`.padStart(2, '0') + return `${h}:${m}` + } + const [draft, setDraft] = useState(() => buildDialogDraft(value, minDate, maxDate)) const [activeBoundary, setActiveBoundary] = useState('start') const [dateInput, setDateInput] = useState({ - start: formatDateInputValue(value.dateRange.start), - end: formatDateInputValue(value.dateRange.end) + start: formatDateOnly(value.dateRange.start), + end: formatDateOnly(value.dateRange.end) }) const [dateInputError, setDateInputError] = useState({ start: false, end: false }) + // Default times: start at 00:00, end at 23:59 + const [timeInput, setTimeInput] = useState({ + start: '00:00', + end: '23:59' + }) + const [openTimeDropdown, setOpenTimeDropdown] = useState(null) + const startTimeSelectRef = useRef(null) + const endTimeSelectRef = useRef(null) + useEffect(() => { if (!open) return const nextDraft = buildDialogDraft(value, minDate, maxDate) setDraft(nextDraft) setActiveBoundary('start') setDateInput({ - start: formatDateInputValue(nextDraft.dateRange.start), - end: formatDateInputValue(nextDraft.dateRange.end) + start: formatDateOnly(nextDraft.dateRange.start), + end: formatDateOnly(nextDraft.dateRange.end) }) + // For preset-based selections (not custom), use default times 00:00 and 23:59 + // For custom selections, preserve the time from value.dateRange + if (nextDraft.useAllTime || nextDraft.preset !== 'custom') { + setTimeInput({ + start: '00:00', + end: '23:59' + }) + } else { + setTimeInput({ + start: formatTimeOnly(nextDraft.dateRange.start), + end: formatTimeOnly(nextDraft.dateRange.end) + }) + } + setOpenTimeDropdown(null) setDateInputError({ start: false, end: false }) }, [maxDate, minDate, open, value]) useEffect(() => { if (!open) return setDateInput({ - start: formatDateInputValue(draft.dateRange.start), - end: formatDateInputValue(draft.dateRange.end) + start: formatDateOnly(draft.dateRange.start), + end: formatDateOnly(draft.dateRange.end) }) + // Don't sync timeInput here - it's controlled by the time picker setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) + useEffect(() => { + if (!openTimeDropdown) return + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + const activeContainer = openTimeDropdown === 'start' + ? startTimeSelectRef.current + : endTimeSelectRef.current + if (!activeContainer?.contains(target)) { + setOpenTimeDropdown(null) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenTimeDropdown(null) + } + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleEscape) + } + }, [openTimeDropdown]) + const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) const clampStartDate = useCallback((targetDate: Date) => { - const start = startOfDay(targetDate) - if (!bounds) return start - if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate - if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate) - return start + if (!bounds) return targetDate + const min = bounds.minDate + const max = bounds.maxDate + if (targetDate.getTime() < min.getTime()) return min + if (targetDate.getTime() > max.getTime()) return max + return targetDate }, [bounds]) const clampEndDate = useCallback((targetDate: Date) => { - const end = endOfDay(targetDate) - if (!bounds) return end - if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate) - if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate - return end + if (!bounds) return targetDate + const min = bounds.minDate + const max = bounds.maxDate + if (targetDate.getTime() < min.getTime()) return min + if (targetDate.getTime() > max.getTime()) return max + return targetDate }, [bounds]) const setRangeStart = useCallback((targetDate: Date) => { const start = clampStartDate(targetDate) setDraft(prev => { - const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end return { ...prev, preset: 'custom', useAllTime: false, dateRange: { start, - end: nextEnd + end: prev.dateRange.end }, panelMonth: toMonthStart(start) } @@ -161,14 +257,13 @@ export function ExportDateRangeDialog({ const end = clampEndDate(targetDate) setDraft(prev => { const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start - const nextEnd = end < nextStart ? endOfDay(nextStart) : end return { ...prev, preset: 'custom', useAllTime: false, dateRange: { start: nextStart, - end: nextEnd + end: end }, panelMonth: toMonthStart(targetDate) } @@ -180,6 +275,11 @@ export function ExportDateRangeDialog({ const previewRange = bounds ? { start: bounds.minDate, end: bounds.maxDate } : createDefaultDateRange() + setTimeInput({ + start: '00:00', + end: '23:59' + }) + setOpenTimeDropdown(null) setDraft(prev => ({ ...prev, preset, @@ -196,6 +296,11 @@ export function ExportDateRangeDialog({ useAllTime: false, dateRange: createDateRangeByPreset(preset) }, minDate, maxDate).dateRange + setTimeInput({ + start: '00:00', + end: '23:59' + }) + setOpenTimeDropdown(null) setDraft(prev => ({ ...prev, preset, @@ -206,25 +311,149 @@ export function ExportDateRangeDialog({ setActiveBoundary('start') }, [bounds, maxDate, minDate]) + const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => { + const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim()) + if (!matched) return null + const hours = Number(matched[1]) + const minutes = Number(matched[2]) + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null + return { hours, minutes } + } + + const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => { + setTimeInput(prev => ({ ...prev, [boundary]: timeStr })) + + const parsedTime = parseTimeValue(timeStr) + if (!parsedTime) return + + setDraft(prev => { + const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end + const newDate = new Date(dateObj) + newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + ...prev.dateRange, + [boundary]: newDate + } + } + }) + }, []) + + const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => { + setActiveBoundary(boundary) + setOpenTimeDropdown(prev => (prev === boundary ? null : boundary)) + }, []) + + const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => { + const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? { + hours: boundary === 'start' ? 0 : 23, + minutes: boundary === 'start' ? 0 : 59 + } + const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours + const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes + updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`) + }, [timeInput, updateBoundaryTime]) + + const renderTimeDropdown = (boundary: ActiveBoundary) => { + const currentTime = timeInput[boundary] + const parsedCurrent = parseTimeValue(currentTime) ?? { + hours: boundary === 'start' ? 0 : 23, + minutes: boundary === 'start' ? 0 : 59 + } + + return ( +
event.stopPropagation()}> +
+ {boundary === 'start' ? '开始时间' : '结束时间'} + {currentTime} +
+
+ {QUICK_TIME_OPTIONS.map(option => ( + + ))} +
+
+
+ 小时 +
+ {HOUR_OPTIONS.map(option => ( + + ))} +
+
+
+ 分钟 +
+ {MINUTE_OPTIONS.map(option => ( + + ))} +
+
+
+
+ ) + } + + // Check if date input string contains time (YYYY-MM-DD HH:mm format) + const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim()) + const commitStartFromInput = useCallback(() => { - const parsed = parseDateInputValue(dateInput.start) - if (!parsed) { + const parsedDate = parseDateInputValue(dateInput.start) + if (!parsedDate) { setDateInputError(prev => ({ ...prev, start: true })) return } + // Only apply time picker value if date input doesn't contain time + if (!dateInputHasTime(dateInput.start)) { + const parsedTime = parseTimeValue(timeInput.start) + if (parsedTime) { + parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) + } + } setDateInputError(prev => ({ ...prev, start: false })) - setRangeStart(parsed) - }, [dateInput.start, setRangeStart]) + setRangeStart(parsedDate) + }, [dateInput.start, timeInput.start, setRangeStart]) const commitEndFromInput = useCallback(() => { - const parsed = parseDateInputValue(dateInput.end) - if (!parsed) { + const parsedDate = parseDateInputValue(dateInput.end) + if (!parsedDate) { setDateInputError(prev => ({ ...prev, end: true })) return } + // Only apply time picker value if date input doesn't contain time + if (!dateInputHasTime(dateInput.end)) { + const parsedTime = parseTimeValue(timeInput.end) + if (parsedTime) { + parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) + } + } setDateInputError(prev => ({ ...prev, end: false })) - setRangeEnd(parsed) - }, [dateInput.end, setRangeEnd]) + setRangeEnd(parsedDate) + }, [dateInput.end, timeInput.end, setRangeEnd]) const shiftPanelMonth = useCallback((delta: number) => { setDraft(prev => ({ @@ -234,30 +463,50 @@ export function ExportDateRangeDialog({ }, []) const handleCalendarSelect = useCallback((targetDate: Date) => { + // Use time from timeInput state (which is updated by the time picker) + const parseTime = (timeStr: string): { hours: number; minutes: number } => { + const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim()) + if (!matched) return { hours: 0, minutes: 0 } + return { hours: Number(matched[1]), minutes: Number(matched[2]) } + } + if (activeBoundary === 'start') { - setRangeStart(targetDate) + const newStart = new Date(targetDate) + const time = parseTime(timeInput.start) + newStart.setHours(time.hours, time.minutes, 0, 0) + setRangeStart(newStart) setActiveBoundary('end') + setOpenTimeDropdown(null) return } - setDraft(prev => { - const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start - const pickedStart = startOfDay(targetDate) - const nextStart = pickedStart <= start ? pickedStart : start - const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate) - return { - ...prev, - preset: 'custom', - useAllTime: false, - dateRange: { - start: nextStart, - end: nextEnd - }, - panelMonth: toMonthStart(targetDate) - } - }) + const pickedStart = startOfDay(targetDate) + const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start + const nextStart = pickedStart <= start ? pickedStart : start + + const newEnd = new Date(targetDate) + const time = parseTime(timeInput.end) + // If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput + if (pickedStart <= start) { + newEnd.setHours(23, 59, 59, 999) + setTimeInput(prev => ({ ...prev, end: '23:59' })) + } else { + newEnd.setHours(time.hours, time.minutes, 59, 999) + } + + setDraft(prev => ({ + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: newEnd + }, + panelMonth: toMonthStart(targetDate) + })) setActiveBoundary('start') - }, [activeBoundary, setRangeEnd, setRangeStart]) + setOpenTimeDropdown(null) + }, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart]) const isRangeModeActive = !draft.useAllTime const modeText = isRangeModeActive @@ -364,6 +613,23 @@ export function ExportDateRangeDialog({ }} onBlur={commitStartFromInput} /> +
event.stopPropagation()} + > + + {openTimeDropdown === 'start' && renderTimeDropdown('start')} +
+
event.stopPropagation()} + > + + {openTimeDropdown === 'end' && renderTimeDropdown('end')} +
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1f95d36..20390a4 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = ( ): ExportDateRangeSelection => { if (!bounds) return cloneExportDateRangeSelection(selection) - const boundedStart = startOfDay(bounds.minDate) - const boundedEnd = endOfDay(bounds.maxDate) - const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start) - const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end) - const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime())) - const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime())) - const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate - const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime() + // For custom selections, only ensure end >= start, preserve time precision + if (selection.preset === 'custom' && !selection.useAllTime) { + const { start, end } = selection.dateRange + if (end.getTime() < start.getTime()) { + return { + ...selection, + dateRange: { start, end: start } + } + } + return cloneExportDateRangeSelection(selection) + } + // For useAllTime, use bounds directly + if (selection.useAllTime) { + return { + preset: selection.preset, + useAllTime: true, + dateRange: { + start: bounds.minDate, + end: bounds.maxDate + } + } + } + + // For preset selections (not custom), clamp dates to bounds and use default times + const boundedStart = new Date(Math.min(Math.max(selection.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const boundedEnd = new Date(Math.min(Math.max(selection.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + // Use default times: start at 00:00, end at 23:59:59 + boundedStart.setHours(0, 0, 0, 0) + boundedEnd.setHours(23, 59, 59, 999) return { - preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset), - useAllTime: selection.useAllTime, + preset: selection.preset, + useAllTime: false, dateRange: { - start: nextStart, - end: nextEnd + start: boundedStart, + end: boundedEnd } } } diff --git a/src/utils/exportDateRange.ts b/src/utils/exportDateRange.ts index e1f2def..8c019e8 100644 --- a/src/utils/exportDateRange.ts +++ b/src/utils/exportDateRange.ts @@ -138,19 +138,24 @@ export const formatDateInputValue = (date: Date): string => { const y = date.getFullYear() const m = `${date.getMonth() + 1}`.padStart(2, '0') const d = `${date.getDate()}`.padStart(2, '0') - return `${y}-${m}-${d}` + const h = `${date.getHours()}`.padStart(2, '0') + const min = `${date.getMinutes()}`.padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` } export const parseDateInputValue = (raw: string): Date | null => { const text = String(raw || '').trim() - const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + const matched = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?$/.exec(text) if (!matched) return null const year = Number(matched[1]) const month = Number(matched[2]) const day = Number(matched[3]) + const hour = matched[4] !== undefined ? Number(matched[4]) : 0 + const minute = matched[5] !== undefined ? Number(matched[5]) : 0 if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null if (month < 1 || month > 12 || day < 1 || day > 31) return null - const parsed = new Date(year, month - 1, day) + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null + const parsed = new Date(year, month - 1, day, hour, minute, 0, 0) if ( parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || @@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = ( const parsedStart = parseStoredDate(raw.start) const parsedEnd = parseStoredDate(raw.end) if (parsedStart && parsedEnd) { - const start = startOfDay(parsedStart) - const end = endOfDay(parsedEnd) + const start = parsedStart + const end = parsedEnd return { preset: 'custom', useAllTime: false, dateRange: { start, - end: end < start ? endOfDay(start) : end + end: end < start ? start : end } } }