From 96b62306514e1a91b3796ae2d1a85699274dc304 Mon Sep 17 00:00:00 2001 From: Laura Whitaker Date: Fri, 8 May 2026 11:05:00 -0700 Subject: [PATCH 1/6] Add event time filter --- .../lines-and-dots/end-time-interval.svelte | 5 +- .../lines-and-dots/event-time-filter.svelte | 235 ++++++++++++++++++ .../svg/timeline-graph-row.svelte | 6 +- .../lines-and-dots/svg/timeline-graph.svelte | 9 +- .../layouts/workflow-timeline-layout.svelte | 60 ++++- src/lib/stores/event-view.ts | 12 + src/lib/stores/events.ts | 39 ++- src/lib/utilities/event-filter-params.ts | 20 ++ 8 files changed, 369 insertions(+), 17 deletions(-) create mode 100644 src/lib/components/lines-and-dots/event-time-filter.svelte diff --git a/src/lib/components/lines-and-dots/end-time-interval.svelte b/src/lib/components/lines-and-dots/end-time-interval.svelte index 6f92d452ea..cac1696549 100644 --- a/src/lib/components/lines-and-dots/end-time-interval.svelte +++ b/src/lib/components/lines-and-dots/end-time-interval.svelte @@ -8,6 +8,7 @@ export let workflow: WorkflowExecution; export let startTime: string | Timestamp; + export let overrideEndTime: string | undefined = undefined; let currentTime = Date.now(); @@ -16,7 +17,7 @@ return currentTime + 1000; }; - $: endTime = workflow?.endTime || rightNow(); + $: endTime = overrideEndTime || workflow?.endTime || rightNow(); $: duration = getMillisecondDuration({ start: startTime, end: endTime, @@ -33,7 +34,7 @@ }; const startStopInterval = (pauseLiveUpdates) => { - if (pauseLiveUpdates) { + if (pauseLiveUpdates || overrideEndTime) { clearInterval(endTimeInterval); endTimeInterval = null; } else if (!endTimeInterval && (workflow.isRunning || workflow.isPaused)) { diff --git a/src/lib/components/lines-and-dots/event-time-filter.svelte b/src/lib/components/lines-and-dots/event-time-filter.svelte new file mode 100644 index 0000000000..ed53a8e6de --- /dev/null +++ b/src/lib/components/lines-and-dots/event-time-filter.svelte @@ -0,0 +1,235 @@ + + + + + {#snippet leading()} +
+ +
+ {/snippet} + +
+ + +
+ + {translate('common.start')} + + + +
+
+ + +
+ + {translate('common.end')} + + + +
+
+ +

+

+
+ + +
+
+
diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte index 625b3c9ce6..ec4cf98534 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte @@ -86,6 +86,8 @@ onlyUnderSecond: false, }); + const clampRatio = (r: number) => Math.max(0, Math.min(1, r)); + const points = group.eventList.map((event) => { const distance = getMillisecondDuration({ start: startTime, @@ -93,7 +95,7 @@ onlyUnderSecond: false, }); - const ratio = distance / workflowDistance; + const ratio = clampRatio(distance / workflowDistance); return Math.round(ratio * timelineWidth) + gutter; }); @@ -104,7 +106,7 @@ onlyUnderSecond: false, }); - const ratio = distance / workflowDistance; + const ratio = clampRatio(distance / workflowDistance); const pausePoint = Math.round(ratio * timelineWidth) + gutter; points.push(pausePoint); } diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index 3d4c132aae..beafdfd951 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -25,6 +25,8 @@ viewportHeight: number | undefined; readOnly?: boolean; error?: boolean; + overrideStartTime?: string; + overrideEndTime?: string; } let { @@ -35,6 +37,8 @@ viewportHeight, readOnly = false, error = false, + overrideStartTime, + overrideEndTime, }: Props = $props(); const { height, gutter, radius } = TimelineConfig; @@ -59,7 +63,9 @@ }); const startTime = $derived( - (!isWorkflowDelayed(workflow) && firstStartTime) || workflow.startTime, + overrideStartTime || + (!isWorkflowDelayed(workflow) && firstStartTime) || + workflow.startTime, ); const timelineHeight = $derived( Math.max(height * (filteredGroups.length + 2), 120) + expandedGroupHeight, @@ -93,6 +99,7 @@ { $eventFilterSort = urlParams.sort; $pauseLiveUpdates = urlParams.refresh_off; + $eventTimeFilter = { + startTime: urlParams.timeStart ? new Date(urlParams.timeStart) : null, + endTime: urlParams.timeEnd ? new Date(urlParams.timeEnd) : null, + }; }); const reverseSort = $derived($eventFilterSort === 'descending'); + const firstEventTime = $derived.by(() => { + const first = $currentEventHistory[0]; + return first?.eventTime ? new Date(first.eventTime as string) : null; + }); + + const lastEventTime = $derived.by(() => { + const last = $currentEventHistory[$currentEventHistory.length - 1]; + return last?.eventTime ? new Date(last.eventTime as string) : null; + }); + + const defaultStart = $derived( + firstEventTime ?? + (workflow?.startTime ? new Date(workflow.startTime as string) : null), + ); + + const workflowCompleted = $derived( + workflow && !workflow?.isRunning && !workflow?.isPaused, + ); + const defaultEnd = $derived( + workflowCompleted + ? (lastEventTime ?? + (workflow?.endTime ? new Date(workflow.endTime as string) : null)) + : null, + ); + const ascendingGroups = $derived( groupEvents( - $filteredEventHistory, + $typeFilteredEventHistory, 'ascending', pendingActivities, pendingNexusOperations, ), ); + const groupsInWindow = $derived.by(() => { + const startMs = $eventTimeFilter.startTime?.getTime() ?? null; + const endMs = $eventTimeFilter.endTime?.getTime() ?? null; + if (startMs === null && endMs === null) return ascendingGroups; + return ascendingGroups.filter((group) => + group.eventList.some((event) => { + if (!event.eventTime) return false; + const evMs = new Date(event.eventTime as string).getTime(); + if (startMs !== null && evMs < startMs) return false; + if (endMs !== null && evMs > endMs) return false; + return true; + }), + ); + }); + const groups = $derived( orderGroupsByPending( - reverseSort ? [...ascendingGroups].reverse() : ascendingGroups, + reverseSort ? [...groupsInWindow].reverse() : groupsInWindow, reverseSort, ), ); @@ -116,11 +161,14 @@
+ {reverseSort ? 'Descending' : 'Ascending'}{reverseSort ? 'Descending' : 'Ascending'}
{/if} diff --git a/src/lib/stores/event-view.ts b/src/lib/stores/event-view.ts index 25cff2a468..28af94b208 100644 --- a/src/lib/stores/event-view.ts +++ b/src/lib/stores/event-view.ts @@ -1,7 +1,19 @@ +import { writable } from 'svelte/store'; + import { persistStore } from '$lib/stores/persist-store'; import type { EventView } from '$lib/types/events'; import type { BooleanString } from '$lib/types/global'; +export type EventTimeFilter = { + startTime: Date | null; + endTime: Date | null; +}; + +export const eventTimeFilter = writable({ + startTime: null, + endTime: null, +}); + export type EventSortOrder = 'ascending' | 'descending'; export type EventSortOrderOptions = { label: string; diff --git a/src/lib/stores/events.ts b/src/lib/stores/events.ts index 67ae74e7bb..b1c015d795 100644 --- a/src/lib/stores/events.ts +++ b/src/lib/stores/events.ts @@ -12,7 +12,7 @@ import { isWorkflowTaskCompletedEvent, } from '$lib/utilities/is-event-type'; -import { eventFilterSort } from './event-view'; +import { eventFilterSort, eventTimeFilter } from './event-view'; import { eventTypeFilter } from './filters'; import { persistStore } from './persist-store'; @@ -55,14 +55,39 @@ export const fullEventHistory = writable([]); export const pauseLiveUpdates = writable(false); export const currentEventHistory = writable([]); -export const filteredEventHistory = derived( +const ISO_SECOND_LENGTH = 19; +const toIsoSecond = (d: Date) => d.toISOString().slice(0, ISO_SECOND_LENGTH); + +const matchesType = ( + event: WorkflowEvents[number], + types: string[], +): boolean => { + if (isLocalActivityMarkerEvent(event)) + return types.includes('local-activity'); + return types.includes(event.category); +}; + +export const typeFilteredEventHistory = derived( [currentEventHistory, eventTypeFilter], - ([$history, $types]) => { + ([$history, $types]) => + $history.filter((event) => matchesType(event, $types)), +); + +export const filteredEventHistory = derived( + [currentEventHistory, eventTypeFilter, eventTimeFilter], + ([$history, $types, $timeRange]) => { + const startIso = $timeRange.startTime + ? toIsoSecond($timeRange.startTime) + : null; + const endIso = $timeRange.endTime ? toIsoSecond($timeRange.endTime) : null; return $history.filter((event) => { - if (isLocalActivityMarkerEvent(event)) { - return $types.includes('local-activity'); - } - return $types.includes(event.category); + if (!matchesType(event, $types)) return false; + if (startIso === null && endIso === null) return true; + if (!event.eventTime) return true; + const eventIso = (event.eventTime as string).slice(0, ISO_SECOND_LENGTH); + if (startIso !== null && eventIso < startIso) return false; + if (endIso !== null && eventIso > endIso) return false; + return true; }); }, ); diff --git a/src/lib/utilities/event-filter-params.ts b/src/lib/utilities/event-filter-params.ts index aff1998a18..4b0fdd4da6 100644 --- a/src/lib/utilities/event-filter-params.ts +++ b/src/lib/utilities/event-filter-params.ts @@ -10,6 +10,8 @@ export const SHARED_FILTER_PARAMS = [ 'category', 'status', 'refresh_off', + 'time_start', + 'time_end', ] as const; export function getSharedFilterParams(url: URL): Record { @@ -36,6 +38,8 @@ export function parseEventFilterParams(url: URL) { : null, statusFilter: url.searchParams.get('status') === 'pending', refresh_off: url.searchParams.get('refresh_off') === 'true', + timeStart: url.searchParams.get('time_start'), + timeEnd: url.searchParams.get('time_end'), }; } @@ -44,6 +48,8 @@ type FilterUpdate = { categories?: EventTypeCategory[] | null; statusFilter?: boolean; refresh_off?: boolean; + timeStart?: string | null; + timeEnd?: string | null; }; export function updateEventFilterParams( @@ -84,6 +90,20 @@ export function updateEventFilterParams( }); } + if (filters.timeStart !== undefined) { + parameters.push({ + parameter: 'time_start', + value: filters.timeStart ?? undefined, + }); + } + + if (filters.timeEnd !== undefined) { + parameters.push({ + parameter: 'time_end', + value: filters.timeEnd ?? undefined, + }); + } + return updateMultipleQueryParameters({ parameters, url, From 348a7d9c450d4db56c4ebb23d256a67b3597d9be Mon Sep 17 00:00:00 2001 From: Laura Whitaker Date: Fri, 8 May 2026 11:05:31 -0700 Subject: [PATCH 2/6] Add toggle for end time filter --- .../lines-and-dots/event-time-filter.svelte | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/lib/components/lines-and-dots/event-time-filter.svelte b/src/lib/components/lines-and-dots/event-time-filter.svelte index ed53a8e6de..1e6e6b94b3 100644 --- a/src/lib/components/lines-and-dots/event-time-filter.svelte +++ b/src/lib/components/lines-and-dots/event-time-filter.svelte @@ -16,6 +16,7 @@ import MenuItem from '$lib/holocene/menu/menu-item.svelte'; import Menu from '$lib/holocene/menu/menu.svelte'; import TimePicker from '$lib/holocene/time-picker.svelte'; + import ToggleSwitch from '$lib/holocene/toggle-switch.svelte'; import { translate } from '$lib/i18n/translate'; import { eventTimeFilter } from '$lib/stores/event-view'; import { timeFormat } from '$lib/stores/time-format'; @@ -74,6 +75,7 @@ let endMinute = $state(''); let endSecond = $state(''); + let endEnabled = $state(false); const filterActive = $derived( Boolean($eventTimeFilter.startTime || $eventTimeFilter.endTime), ); @@ -106,7 +108,8 @@ const hydrateFromStore = () => { const s = $eventTimeFilter.startTime ?? defaultStart; - const e = $eventTimeFilter.endTime ?? defaultEnd ?? new Date(); + const storedEnd = $eventTimeFilter.endTime; + const e = storedEnd ?? defaultEnd ?? new Date(); startDate = toStartOfDay(s); startHour = toHour(s); startMinute = toMinute(s); @@ -115,6 +118,7 @@ endHour = toHour(e); endMinute = toMinute(e); endSecond = toSecond(e); + endEnabled = storedEnd !== null; }; const onToggle = (next: boolean) => { @@ -123,12 +127,18 @@ const onApply = () => { const start = composeDate(startDate, startHour, startMinute, startSecond); - const end = composeDate(endDate, endHour, endMinute, endSecond); - end.setUTCMilliseconds(999); + let end: Date | null = null; + if (endEnabled) { + end = composeDate(endDate, endHour, endMinute, endSecond); + end.setUTCMilliseconds(999); + } $eventTimeFilter = { startTime: start, endTime: end }; updateEventFilterParams( $page.url, - { timeStart: start.toISOString(), timeEnd: end.toISOString() }, + { + timeStart: start.toISOString(), + timeEnd: end ? end.toISOString() : null, + }, goto, ); $open = false; @@ -189,9 +199,17 @@
- - {translate('common.end')} - +
+ + {translate('common.end')} + + +
From 6c52d2e97fdb970b1ad4028159567cc42c36a332 Mon Sep 17 00:00:00 2001 From: Laura Whitaker Date: Thu, 21 May 2026 16:42:53 -0600 Subject: [PATCH 3/6] Add error if end time is before start --- .../lines-and-dots/event-time-filter.svelte | 30 +++++++++++++++++-- src/lib/i18n/locales/en/common.ts | 1 + 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/lib/components/lines-and-dots/event-time-filter.svelte b/src/lib/components/lines-and-dots/event-time-filter.svelte index 1e6e6b94b3..03c55ebd37 100644 --- a/src/lib/components/lines-and-dots/event-time-filter.svelte +++ b/src/lib/components/lines-and-dots/event-time-filter.svelte @@ -65,6 +65,11 @@ return true; }; + const isEndDateAllowed = $derived((d: Date) => { + if (!isDateAllowed(d)) return false; + return startOfDay(d).getTime() >= startDate.getTime(); + }); + let startDate = $state(toStartOfDay(null)); let startHour = $state(''); let startMinute = $state(''); @@ -80,6 +85,13 @@ Boolean($eventTimeFilter.startTime || $eventTimeFilter.endTime), ); + const endBeforeStart = $derived.by(() => { + if (!endEnabled) return false; + const start = composeDate(startDate, startHour, startMinute, startSecond); + const end = composeDate(endDate, endHour, endMinute, endSecond); + return end.getTime() < start.getTime(); + }); + const composeDate = ( date: Date, hour: string, @@ -100,6 +112,9 @@ const onStartDateChange = (e: CustomEvent) => { startDate = startOfDay(e.detail); + if (endDate.getTime() < startDate.getTime()) { + endDate = startDate; + } }; const onEndDateChange = (e: CustomEvent) => { @@ -215,7 +230,7 @@ labelHidden on:datechange={onEndDateChange} selected={endDate} - isAllowed={isDateAllowed} + isAllowed={isEndDateAllowed} todayLabel={translate('common.today')} closeLabel={translate('common.close')} clearLabel={translate('common.clear-input-button-label')} @@ -228,6 +243,12 @@ twelveHourClock={false} disabled={!endEnabled} /> + {#if endBeforeStart} +

+

+ {/if} @@ -247,7 +268,12 @@ > {translate('common.clear-all')} - diff --git a/src/lib/i18n/locales/en/common.ts b/src/lib/i18n/locales/en/common.ts index 655ed255ac..e42ed859b1 100644 --- a/src/lib/i18n/locales/en/common.ts +++ b/src/lib/i18n/locales/en/common.ts @@ -15,6 +15,7 @@ export const Strings = { custom: 'Custom', 'start-time': 'Start Time', 'end-time': 'End Time', + 'end-must-be-after-start': 'End time must be on or after start time', 'close-time': 'Close Time', relative: 'Relative', utc: 'UTC', From 42265f980d1babd21b82694168b49fcccd41fd84 Mon Sep 17 00:00:00 2001 From: Laura Whitaker Date: Thu, 21 May 2026 16:56:45 -0600 Subject: [PATCH 4/6] Disable auto refresh if end time filter applied --- src/lib/i18n/locales/en/workflows.ts | 2 + .../layouts/workflow-timeline-layout.svelte | 47 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/lib/i18n/locales/en/workflows.ts b/src/lib/i18n/locales/en/workflows.ts index 8b427a67cd..ffe87da9a0 100644 --- a/src/lib/i18n/locales/en/workflows.ts +++ b/src/lib/i18n/locales/en/workflows.ts @@ -345,6 +345,8 @@ export const Strings = { 'scheduled-by': 'Scheduled By', 'auto-refresh-on': 'Auto Refresh On', 'auto-refresh-off': 'Auto Refresh Off', + 'auto-refresh-disabled-end-time': + 'Auto refresh is disabled while an end time filter is set', minimized: 'Minimized', expanded: 'Expanded', 'timeline-minimized': diff --git a/src/lib/layouts/workflow-timeline-layout.svelte b/src/lib/layouts/workflow-timeline-layout.svelte index 9c80c57835..5198dc6051 100644 --- a/src/lib/layouts/workflow-timeline-layout.svelte +++ b/src/lib/layouts/workflow-timeline-layout.svelte @@ -12,6 +12,7 @@ import WorkflowCallbacks from '$lib/components/workflow/workflow-callbacks.svelte'; import ToggleButton from '$lib/holocene/toggle-button/toggle-button.svelte'; import ToggleButtons from '$lib/holocene/toggle-button/toggle-buttons.svelte'; + import Tooltip from '$lib/holocene/tooltip.svelte'; import { translate } from '$lib/i18n/translate'; import { groupEvents } from '$lib/models/event-groups'; import { clearActives } from '$lib/stores/active-events'; @@ -111,6 +112,10 @@ Boolean(workflow && !workflow?.isRunning && !workflow?.isPaused), ); + const autoRefreshDisabledByEndTime = $derived( + !isNotPending && $eventTimeFilter.endTime !== null, + ); + beforeNavigate(() => { clearActives(); }); @@ -121,6 +126,12 @@ } }); + $effect(() => { + if (autoRefreshDisabledByEndTime && !$pauseLiveUpdates) { + updateEventFilterParams(page.url, { refresh_off: true }, goto); + } + }); + let showDownloadPrompt = $state(false); const onSort = () => { @@ -171,22 +182,28 @@ >{reverseSort ? 'Descending' : 'Ascending'} - - - {$pauseLiveUpdates || isNotPending - ? translate('workflows.auto-refresh-off') - : translate('workflows.auto-refresh-on')} - + + + {$pauseLiveUpdates || isNotPending + ? translate('workflows.auto-refresh-off') + : translate('workflows.auto-refresh-on')} + + Date: Fri, 22 May 2026 12:49:09 -0600 Subject: [PATCH 5/6] Add tests --- .../event-time-filter.svelte | 108 ++++--- .../event-time-filter.test.ts | 283 ++++++++++++++++++ .../event-time-filter/event-time-filter.ts | 96 ++++++ .../layouts/workflow-timeline-layout.svelte | 2 +- tests/integration/workflow-timeline.spec.ts | 113 +++++++ 5 files changed, 553 insertions(+), 49 deletions(-) rename src/lib/components/{lines-and-dots => workflow/event-time-filter}/event-time-filter.svelte (80%) create mode 100644 src/lib/components/workflow/event-time-filter/event-time-filter.test.ts create mode 100644 src/lib/components/workflow/event-time-filter/event-time-filter.ts create mode 100644 tests/integration/workflow-timeline.spec.ts diff --git a/src/lib/components/lines-and-dots/event-time-filter.svelte b/src/lib/components/workflow/event-time-filter/event-time-filter.svelte similarity index 80% rename from src/lib/components/lines-and-dots/event-time-filter.svelte rename to src/lib/components/workflow/event-time-filter/event-time-filter.svelte index 03c55ebd37..b093c17fc5 100644 --- a/src/lib/components/lines-and-dots/event-time-filter.svelte +++ b/src/lib/components/workflow/event-time-filter/event-time-filter.svelte @@ -2,7 +2,7 @@ import { writable } from 'svelte/store'; import { startOfDay } from 'date-fns'; - import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; + import { utcToZonedTime } from 'date-fns-tz'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; @@ -24,6 +24,14 @@ import { getSelectedTimezone } from '$lib/utilities/format-date'; import { getTimezone } from '$lib/utilities/timezone'; + import { + composeDate, + endBeforeStart as endBeforeStartCalc, + isDateAllowed as isDateAllowedCalc, + isEndDateAllowed as isEndDateAllowedCalc, + toStartOfDayInTz, + } from './event-time-filter'; + type Props = { defaultStart: Date | null; defaultEnd: Date | null; @@ -33,6 +41,9 @@ const open = writable(false); + let innerWidth = $state(0); + const menuPosition = $derived(innerWidth < 1134 ? 'left' : 'right'); + const timezone = $derived(getTimezone($timeFormat ?? 'UTC')); const selectedTime = $derived(getSelectedTimezone($timeFormat ?? 'UTC')); @@ -50,24 +61,14 @@ const z = toZoned(d); return z ? pad(z.getSeconds()) : ''; }; - const toStartOfDay = (d: Date | null) => { - const z = toZoned(d) ?? new Date(); - return startOfDay(new Date(z.getFullYear(), z.getMonth(), z.getDate())); - }; + const toStartOfDay = (d: Date | null) => toStartOfDayInTz(d, timezone); - const isDateAllowed = (d: Date) => { - const cellDay = startOfDay(d).getTime(); - if (defaultStart && cellDay < toStartOfDay(defaultStart).getTime()) { - return false; - } - const upperBound = defaultEnd ?? new Date(); - if (cellDay > toStartOfDay(upperBound).getTime()) return false; - return true; - }; + const isDateAllowed = (d: Date) => + isDateAllowedCalc(d, { defaultStart, defaultEnd, timezone }); - const isEndDateAllowed = $derived((d: Date) => { - if (!isDateAllowed(d)) return false; - return startOfDay(d).getTime() >= startDate.getTime(); + const isEndDateAllowed = $derived.by(() => { + const bounds = { defaultStart, defaultEnd, timezone, startDate }; + return (d: Date) => isEndDateAllowedCalc(d, bounds); }); let startDate = $state(toStartOfDay(null)); @@ -85,30 +86,20 @@ Boolean($eventTimeFilter.startTime || $eventTimeFilter.endTime), ); - const endBeforeStart = $derived.by(() => { - if (!endEnabled) return false; - const start = composeDate(startDate, startHour, startMinute, startSecond); - const end = composeDate(endDate, endHour, endMinute, endSecond); - return end.getTime() < start.getTime(); - }); - - const composeDate = ( - date: Date, - hour: string, - minute: string, - second: string, - ): Date => { - const wallClock = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - parseInt(hour) || 0, - parseInt(minute) || 0, - parseInt(second) || 0, - 0, - ); - return zonedTimeToUtc(wallClock, timezone); - }; + const endBeforeStart = $derived( + endBeforeStartCalc({ + endEnabled, + startDate, + startHour, + startMinute, + startSecond, + endDate, + endHour, + endMinute, + endSecond, + timezone, + }), + ); const onStartDateChange = (e: CustomEvent) => { startDate = startOfDay(e.detail); @@ -141,10 +132,16 @@ }; const onApply = () => { - const start = composeDate(startDate, startHour, startMinute, startSecond); + const start = composeDate( + startDate, + startHour, + startMinute, + startSecond, + timezone, + ); let end: Date | null = null; if (endEnabled) { - end = composeDate(endDate, endHour, endMinute, endSecond); + end = composeDate(endDate, endHour, endMinute, endSecond, timezone); end.setUTCMilliseconds(999); } $eventTimeFilter = { startTime: start, endTime: end }; @@ -170,8 +167,15 @@ }; + + - + {#snippet leading()}
-
+
{translate('common.start')} @@ -213,7 +220,10 @@ -
+
{translate('common.end')} @@ -265,6 +275,7 @@ variant="ghost" on:click={onClear} disabled={!filterActive} + data-testid="event-time-filter-clear-button" > {translate('common.clear-all')} @@ -273,6 +284,7 @@ class="grow" on:click={onApply} disabled={endBeforeStart} + data-testid="event-time-filter-apply-button" > {translate('common.apply')} diff --git a/src/lib/components/workflow/event-time-filter/event-time-filter.test.ts b/src/lib/components/workflow/event-time-filter/event-time-filter.test.ts new file mode 100644 index 0000000000..8c54c53181 --- /dev/null +++ b/src/lib/components/workflow/event-time-filter/event-time-filter.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest'; + +import { + composeDate, + endBeforeStart, + isDateAllowed, + isEndDateAllowed, + toStartOfDayInTz, +} from './event-time-filter'; + +const UTC = 'UTC'; + +const d = (iso: string) => new Date(iso); + +describe('event-time-filter helpers', () => { + describe('toStartOfDayInTz', () => { + it('returns midnight for a UTC date in UTC', () => { + const result = toStartOfDayInTz(d('2026-05-20T14:30:00Z'), UTC); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(4); + expect(result.getDate()).toBe(20); + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + expect(result.getSeconds()).toBe(0); + }); + + it('falls back to now() when input is null', () => { + const fakeNow = d('2026-03-15T09:00:00Z'); + const result = toStartOfDayInTz(null, UTC, fakeNow); + expect(result.getDate()).toBe(15); + expect(result.getMonth()).toBe(2); + expect(result.getFullYear()).toBe(2026); + }); + + it('treats the same instant as a different day in a different tz', () => { + // 2026-05-20T02:00:00Z is still May 19 in America/Los_Angeles + const inUtc = toStartOfDayInTz(d('2026-05-20T02:00:00Z'), UTC); + const inPt = toStartOfDayInTz( + d('2026-05-20T02:00:00Z'), + 'America/Los_Angeles', + ); + expect(inUtc.getDate()).toBe(20); + expect(inPt.getDate()).toBe(19); + }); + }); + + describe('composeDate', () => { + const baseDate = new Date(2026, 4, 20); // May 20 2026, local wall clock + + it('parses hour / minute / second strings', () => { + const result = composeDate(baseDate, '14', '30', '45', UTC); + expect(result.toISOString()).toBe('2026-05-20T14:30:45.000Z'); + }); + + it('treats empty strings as zero', () => { + const result = composeDate(baseDate, '', '', '', UTC); + expect(result.toISOString()).toBe('2026-05-20T00:00:00.000Z'); + }); + + it('treats non-numeric strings as zero', () => { + const result = composeDate(baseDate, 'abc', '7', 'xx', UTC); + expect(result.toISOString()).toBe('2026-05-20T00:07:00.000Z'); + }); + + it('respects timezone — PT 14:30 → UTC 21:30 or 22:30 depending on DST', () => { + // May is PDT (UTC-7) + const result = composeDate( + baseDate, + '14', + '30', + '00', + 'America/Los_Angeles', + ); + expect(result.toISOString()).toBe('2026-05-20T21:30:00.000Z'); + }); + }); + + describe('isDateAllowed', () => { + const fakeNow = d('2026-05-20T12:00:00Z'); + const defaultStart = d('2026-05-01T00:00:00Z'); + const defaultEnd = d('2026-05-25T00:00:00Z'); + + it('returns true for a date inside [defaultStart, defaultEnd]', () => { + expect( + isDateAllowed(d('2026-05-10T00:00:00Z'), { + defaultStart, + defaultEnd, + timezone: UTC, + now: fakeNow, + }), + ).toBe(true); + }); + + it('returns false for a date before defaultStart', () => { + expect( + isDateAllowed(d('2026-04-30T00:00:00Z'), { + defaultStart, + defaultEnd, + timezone: UTC, + now: fakeNow, + }), + ).toBe(false); + }); + + it('returns false for a date after defaultEnd', () => { + expect( + isDateAllowed(d('2026-05-26T00:00:00Z'), { + defaultStart, + defaultEnd, + timezone: UTC, + now: fakeNow, + }), + ).toBe(false); + }); + + it('allows any past date when defaultStart is null', () => { + expect( + isDateAllowed(d('2020-01-01T00:00:00Z'), { + defaultStart: null, + defaultEnd, + timezone: UTC, + now: fakeNow, + }), + ).toBe(true); + }); + + it('falls back to "now" as the upper bound when defaultEnd is null', () => { + // 2026-05-21 is one day past the injected "now" (2026-05-20) + expect( + isDateAllowed(d('2026-05-21T00:00:00Z'), { + defaultStart, + defaultEnd: null, + timezone: UTC, + now: fakeNow, + }), + ).toBe(false); + // 2026-05-20 (today) is allowed + expect( + isDateAllowed(d('2026-05-20T00:00:00Z'), { + defaultStart, + defaultEnd: null, + timezone: UTC, + now: fakeNow, + }), + ).toBe(true); + }); + + it('allows the boundary days themselves (inclusive)', () => { + expect( + isDateAllowed(defaultStart, { + defaultStart, + defaultEnd, + timezone: UTC, + now: fakeNow, + }), + ).toBe(true); + expect( + isDateAllowed(defaultEnd, { + defaultStart, + defaultEnd, + timezone: UTC, + now: fakeNow, + }), + ).toBe(true); + }); + }); + + describe('isEndDateAllowed', () => { + const fakeNow = d('2026-05-20T12:00:00Z'); + const defaultStart = d('2026-05-01T00:00:00Z'); + const defaultEnd = d('2026-05-25T00:00:00Z'); + const startDate = new Date(2026, 4, 10); // May 10 — user's start selection + + const bounds = { + defaultStart, + defaultEnd, + timezone: UTC, + now: fakeNow, + startDate, + }; + + it('returns true for a date after the user-selected start date', () => { + expect(isEndDateAllowed(d('2026-05-15T00:00:00Z'), bounds)).toBe(true); + }); + + it('returns true for the exact user-selected start date (on-or-after)', () => { + expect(isEndDateAllowed(startDate, bounds)).toBe(true); + }); + + it('returns false for a date before the user-selected start date', () => { + expect(isEndDateAllowed(d('2026-05-09T00:00:00Z'), bounds)).toBe(false); + }); + + it('still rejects dates outside the outer [defaultStart, defaultEnd] window', () => { + expect(isEndDateAllowed(d('2026-05-26T00:00:00Z'), bounds)).toBe(false); + }); + + it('still rejects dates before defaultStart even if after user startDate', () => { + const earlyStart = new Date(2026, 3, 20); // April 20 — outside defaultStart + expect( + isEndDateAllowed(d('2026-04-25T00:00:00Z'), { + ...bounds, + startDate: earlyStart, + }), + ).toBe(false); + }); + }); + + describe('endBeforeStart', () => { + const sameDay = new Date(2026, 4, 20); + const laterDay = new Date(2026, 4, 21); + const earlierDay = new Date(2026, 4, 19); + + const base = { + endEnabled: true, + startDate: sameDay, + startHour: '10', + startMinute: '00', + startSecond: '00', + endDate: sameDay, + endHour: '12', + endMinute: '00', + endSecond: '00', + timezone: UTC, + }; + + it('returns false when endEnabled is false, regardless of times', () => { + expect( + endBeforeStart({ + ...base, + endEnabled: false, + endHour: '01', // would otherwise be invalid + }), + ).toBe(false); + }); + + it('returns false when end is later on the same day', () => { + expect(endBeforeStart(base)).toBe(false); + }); + + it('returns true when end hour is before start hour on the same day', () => { + expect(endBeforeStart({ ...base, endHour: '09' })).toBe(true); + }); + + it('returns true when end is on an earlier day', () => { + expect(endBeforeStart({ ...base, endDate: earlierDay })).toBe(true); + }); + + it('returns false when end is on a later day, even if end hour is earlier', () => { + expect( + endBeforeStart({ ...base, endDate: laterDay, endHour: '01' }), + ).toBe(false); + }); + + it('returns false when start and end are identical', () => { + expect( + endBeforeStart({ + ...base, + endHour: base.startHour, + endMinute: base.startMinute, + endSecond: base.startSecond, + }), + ).toBe(false); + }); + + it('compares at second precision', () => { + expect( + endBeforeStart({ + ...base, + endHour: base.startHour, + endMinute: base.startMinute, + startSecond: '30', + endSecond: '29', + }), + ).toBe(true); + }); + + it('respects timezone — same wall-clock time in different tz is not "before"', () => { + // 10:00 UTC === 10:00 UTC, so end equals start, not before + expect(endBeforeStart(base)).toBe(false); + }); + }); +}); diff --git a/src/lib/components/workflow/event-time-filter/event-time-filter.ts b/src/lib/components/workflow/event-time-filter/event-time-filter.ts new file mode 100644 index 0000000000..3b05303077 --- /dev/null +++ b/src/lib/components/workflow/event-time-filter/event-time-filter.ts @@ -0,0 +1,96 @@ +import { startOfDay } from 'date-fns'; +import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; + +const toZoned = (d: Date | null, timezone: string) => + d ? utcToZonedTime(d, timezone) : null; + +export const toStartOfDayInTz = ( + d: Date | null, + timezone: string, + now: Date = new Date(), +): Date => { + const z = toZoned(d, timezone) ?? now; + return startOfDay(new Date(z.getFullYear(), z.getMonth(), z.getDate())); +}; + +export const composeDate = ( + date: Date, + hour: string, + minute: string, + second: string, + timezone: string, +): Date => { + const wallClock = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + parseInt(hour) || 0, + parseInt(minute) || 0, + parseInt(second) || 0, + 0, + ); + return zonedTimeToUtc(wallClock, timezone); +}; + +export type DateRangeBounds = { + defaultStart: Date | null; + defaultEnd: Date | null; + timezone: string; + now?: Date; +}; + +export const isDateAllowed = (d: Date, bounds: DateRangeBounds): boolean => { + const cellDay = startOfDay(d).getTime(); + const { defaultStart, defaultEnd, timezone, now } = bounds; + if ( + defaultStart && + cellDay < toStartOfDayInTz(defaultStart, timezone, now).getTime() + ) { + return false; + } + const upperBound = defaultEnd ?? now ?? new Date(); + if (cellDay > toStartOfDayInTz(upperBound, timezone, now).getTime()) { + return false; + } + return true; +}; + +export const isEndDateAllowed = ( + d: Date, + bounds: DateRangeBounds & { startDate: Date }, +): boolean => { + if (!isDateAllowed(d, bounds)) return false; + return startOfDay(d).getTime() >= bounds.startDate.getTime(); +}; + +export type EndBeforeStartArgs = { + endEnabled: boolean; + startDate: Date; + startHour: string; + startMinute: string; + startSecond: string; + endDate: Date; + endHour: string; + endMinute: string; + endSecond: string; + timezone: string; +}; + +export const endBeforeStart = (args: EndBeforeStartArgs): boolean => { + if (!args.endEnabled) return false; + const start = composeDate( + args.startDate, + args.startHour, + args.startMinute, + args.startSecond, + args.timezone, + ); + const end = composeDate( + args.endDate, + args.endHour, + args.endMinute, + args.endSecond, + args.timezone, + ); + return end.getTime() < start.getTime(); +}; diff --git a/src/lib/layouts/workflow-timeline-layout.svelte b/src/lib/layouts/workflow-timeline-layout.svelte index 5198dc6051..a96c93af63 100644 --- a/src/lib/layouts/workflow-timeline-layout.svelte +++ b/src/lib/layouts/workflow-timeline-layout.svelte @@ -3,11 +3,11 @@ import { page } from '$app/state'; import EventHistoryLegend from '$lib/components/lines-and-dots/event-history-legend.svelte'; - import EventTimeFilter from '$lib/components/lines-and-dots/event-time-filter.svelte'; import EventTypeFilter from '$lib/components/lines-and-dots/event-type-filter.svelte'; import TimelineGraph from '$lib/components/lines-and-dots/svg/timeline-graph.svelte'; import WorkflowError from '$lib/components/lines-and-dots/workflow-error.svelte'; import DownloadEventHistoryModal from '$lib/components/workflow/download-event-history-modal.svelte'; + import EventTimeFilter from '$lib/components/workflow/event-time-filter/event-time-filter.svelte'; import InputAndResults from '$lib/components/workflow/input-and-results.svelte'; import WorkflowCallbacks from '$lib/components/workflow/workflow-callbacks.svelte'; import ToggleButton from '$lib/holocene/toggle-button/toggle-button.svelte'; diff --git a/tests/integration/workflow-timeline.spec.ts b/tests/integration/workflow-timeline.spec.ts new file mode 100644 index 0000000000..7093ba3eea --- /dev/null +++ b/tests/integration/workflow-timeline.spec.ts @@ -0,0 +1,113 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { mockWorkflowApis } from '~/test-utilities/mock-apis'; +import { mockWorkflow } from '~/test-utilities/mocks/workflow'; + +const timelineUrl = `/namespaces/default/workflows/${mockWorkflow.workflowExecutionInfo.execution.workflowId}/${mockWorkflow.workflowExecutionInfo.execution.runId}/timeline`; + +const openTimeFilter = async (page: Page) => { + await page.getByTestId('event-time-filter').click(); + await expect( + page.getByTestId('event-time-filter-start-section'), + ).toBeVisible(); +}; + +test.describe('Workflow Timeline — Event Time Filter', () => { + test.beforeEach(async ({ page }) => { + await mockWorkflowApis(page); + await page.goto(timelineUrl); + await expect(page.getByTestId('timeline-tab')).toBeVisible(); + }); + + test('renders the time filter trigger inside the timeline toolbar', async ({ + page, + }) => { + await expect(page.getByTestId('event-time-filter')).toBeVisible(); + }); + + test('opens the menu with both start and end sections', async ({ page }) => { + await openTimeFilter(page); + await expect( + page.getByTestId('event-time-filter-start-section'), + ).toBeVisible(); + await expect( + page.getByTestId('event-time-filter-end-section'), + ).toBeVisible(); + }); + + test('end section is disabled by default and the toggle enables it', async ({ + page, + }) => { + await openTimeFilter(page); + const endSection = page.getByTestId('event-time-filter-end-section'); + const endToggle = endSection.locator('#event-time-filter-end-enabled'); + + await expect(endToggle).not.toBeChecked(); + await expect(endSection.locator('input#hour')).toBeDisabled(); + + await endToggle.evaluate((el: HTMLInputElement) => el.click()); + await expect(endToggle).toBeChecked(); + await expect(endSection.locator('input#hour')).toBeEnabled(); + }); + + test('shows the validation error and disables Apply when end is before start', async ({ + page, + }) => { + await openTimeFilter(page); + const startSection = page.getByTestId('event-time-filter-start-section'); + const endSection = page.getByTestId('event-time-filter-end-section'); + + // Pin both dates to the same day so the hour comparison is the deciding + // factor (defaultStart is 2022 and defaultEnd is "now" for a running mock, + // so days otherwise differ). DatePicker requires exactly 8 chars MM/DD/YY. + const sameDay = '01/15/25'; + await startSection.locator('input#datepicker').fill(sameDay); + await endSection + .locator('#event-time-filter-end-enabled') + .evaluate((el: HTMLInputElement) => el.click()); + await endSection.locator('input#datepicker').fill(sameDay); + + await startSection.locator('input#hour').fill('10'); + await endSection.locator('input#hour').fill('09'); + await endSection.locator('input#hour').blur(); + + await expect( + page.getByText('End time must be on or after start time'), + ).toBeVisible(); + await expect( + page.getByTestId('event-time-filter-apply-button'), + ).toBeDisabled(); + }); + + test('applying a start time writes time_start to the URL', async ({ + page, + }) => { + await openTimeFilter(page); + const startSection = page.getByTestId('event-time-filter-start-section'); + + await startSection.locator('input#hour').fill('08'); + await startSection.locator('input#minute').fill('30'); + await startSection.locator('input#second').fill('00'); + + await page.getByTestId('event-time-filter-apply-button').click(); + + await expect(page).toHaveURL(/time_start=/); + }); + + test('Clear all removes time filter params from the URL', async ({ + page, + }) => { + await openTimeFilter(page); + await page + .getByTestId('event-time-filter-start-section') + .locator('input#hour') + .fill('08'); + await page.getByTestId('event-time-filter-apply-button').click(); + await expect(page).toHaveURL(/time_start=/); + + await page.getByTestId('event-time-filter').click(); + await page.getByTestId('event-time-filter-clear-button').click(); + + await expect(page).not.toHaveURL(/time_start=/); + }); +}); From f565e9cbbce2386de8c487865c596e3d7bb82ce0 Mon Sep 17 00:00:00 2001 From: Laura Whitaker Date: Fri, 22 May 2026 13:11:05 -0600 Subject: [PATCH 6/6] Fix strict errors --- .../components/lines-and-dots/end-time-interval.svelte | 10 +++++----- .../lines-and-dots/svg/timeline-graph-row.svelte | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/components/lines-and-dots/end-time-interval.svelte b/src/lib/components/lines-and-dots/end-time-interval.svelte index cac1696549..0691b1dfd9 100644 --- a/src/lib/components/lines-and-dots/end-time-interval.svelte +++ b/src/lib/components/lines-and-dots/end-time-interval.svelte @@ -24,18 +24,18 @@ onlyUnderSecond: false, }); - let endTimeInterval; + let endTimeInterval: ReturnType | null = null; const clearEndTimeInterval = (endTime: string) => { if (endTime) { - clearInterval(endTimeInterval); + if (endTimeInterval) clearInterval(endTimeInterval); endTimeInterval = null; } }; - const startStopInterval = (pauseLiveUpdates) => { + const startStopInterval = (pauseLiveUpdates: boolean) => { if (pauseLiveUpdates || overrideEndTime) { - clearInterval(endTimeInterval); + if (endTimeInterval) clearInterval(endTimeInterval); endTimeInterval = null; } else if (!endTimeInterval && (workflow.isRunning || workflow.isPaused)) { endTimeInterval = setInterval(() => { @@ -48,7 +48,7 @@ $: startStopInterval($pauseLiveUpdates); onDestroy(() => { - clearInterval(endTimeInterval); + if (endTimeInterval) clearInterval(endTimeInterval); endTimeInterval = null; $pauseLiveUpdates = false; }); diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte index ec4cf98534..7320aef20e 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte @@ -95,7 +95,7 @@ onlyUnderSecond: false, }); - const ratio = clampRatio(distance / workflowDistance); + const ratio = clampRatio((distance ?? 0) / (workflowDistance ?? 1)); return Math.round(ratio * timelineWidth) + gutter; }); @@ -106,7 +106,7 @@ onlyUnderSecond: false, }); - const ratio = clampRatio(distance / workflowDistance); + const ratio = clampRatio((distance ?? 0) / (workflowDistance ?? 1)); const pausePoint = Math.round(ratio * timelineWidth) + gutter; points.push(pausePoint); } @@ -241,7 +241,7 @@ startPoint={[x, y]} endPoint={[canvasWidth - gutter, y]} category={pendingActivity - ? pendingActivity.attempt > 1 + ? (pendingActivity.attempt ?? 0) > 1 ? 'retry' : 'pending' : group.category}