Repository: temporalio/ui
Pull request #225 (2022-01-12 21:59:35)

Use the new List Workflows API
Refactors to use the new `ListWorkflows` API and adds some utility methods.


Patch:
diff --git a/src/events.d.ts b/src/events.d.ts
index a9fde83b..4b6be0de 100644
--- a/src/events.d.ts
+++ b/src/events.d.ts
@@ -317,13 +317,8 @@ interface PendingActivity extends PendingActivityInfo {
 }
 
 type EventType =
-  | 'ActivityTaskCanceled'
-  | 'ActivityTaskCancelRequested'
-  | 'ActivityTaskCompleted'
-  | 'ActivityTaskFailed'
-  | 'ActivityTaskScheduled'
-  | 'ActivityTaskStarted'
-  | 'ActivityTaskTimedOut'
+  | ActivityType
+  | TimerType
   | 'ChildWorkflowExecutionCanceled'
   | 'ChildWorkflowExecutionCompleted'
   | 'ChildWorkflowExecutionFailed'
@@ -334,9 +329,6 @@ type EventType =
   | 'StartChildWorkflowExecutionInitiated'
   | 'SignalExternalWorkflowExecutionFailed'
   | 'SignalExternalWorkflowExecutionInitiated'
-  | 'TimerCanceled'
-  | 'TimerFired'
-  | 'TimerStarted'
   | 'WorkflowExecutionCanceled'
   | 'WorkflowExecutionCancelRequested'
   | 'WorkflowExecutionCompleted'
@@ -367,6 +359,8 @@ type ActivityType =
   | 'ActivityTaskStarted'
   | 'ActivityTaskTimedOut';
 
+type TimerType = 'TimerCanceled' | 'TimerFired' | 'TimerStarted';
+
 type EventTypeCategory =
   | 'activity'
   | 'child-workflow'
@@ -375,8 +369,4 @@ type EventTypeCategory =
   | 'workflow'
   | 'command';
 
-type ActivityEvent = HistoryEventWithId & {
-  eventType: ActivityType;
-};
-
 type EventualHistoryEvents = PromiseLike<HistoryEventWithId[]>;
diff --git a/src/global.d.ts b/src/global.d.ts
index c54b7128..d5474fc2 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -54,31 +54,10 @@ type WorkflowExecutionFilters = {
 
 type TimeFormat = 'UTC' | 'relative' | 'local';
 
-type ActivityStatus =
-  | 'Started'
-  | 'Scheduled'
-  | 'Completed'
-  | 'Failed'
-  | 'TimedOut'
-  | 'CancelRequested'
-  | 'Canceled';
-
-type Activity = {
-  id?: string;
-  status?: ActivityStatus;
-  activityTaskStartedEvent?: ActivityTaskStartedEvent;
-  activityTaskScheduledEvent?: ActivityTaskScheduledEvent;
-  activityTaskCompletedEvent?: ActivityTaskCompletedEvent;
-  activityTaskFailedEvent?: ActivityTaskFailedEvent;
-  activityTaskTimedOutEvent?: ActivityTaskTimedOutEvent;
-  activityTaskCancelRequestedEvent?: ActivityTaskCancelRequestedEvent;
-  activityTaskCanceledEvent?: ActivityTaskCanceledEvent;
-};
-
 type FilterParameters = {
   workflowId?: string;
   workflowType?: string;
-  status?: WorkflowStatus;
+  executionStatus?: WorkflowStatus;
   timeRange?: Duration | string;
 };
 
diff --git a/src/lib/components/event-table.svelte b/src/lib/components/event-table.svelte
index 46820d62..642cea9e 100644
--- a/src/lib/components/event-table.svelte
+++ b/src/lib/components/event-table.svelte
@@ -1,10 +1,10 @@
 <script lang="ts">
   import VirtualList from '@sveltejs/svelte-virtual-list';
 
-  import type { Activities } from '$lib/models/activity';
+  import type { EventGroups } from '$lib/models/events-group';
   import Event from './event.svelte';
 
-  export let events: (HistoryEventWithId | PendingActivity)[] | Activities;
+  export let events: (HistoryEventWithId | PendingActivity)[] | EventGroups;
 </script>
 
 <section
diff --git a/src/lib/components/event.svelte b/src/lib/components/event.svelte
index 27bd086b..1770b66a 100644
--- a/src/lib/components/event.svelte
+++ b/src/lib/components/event.svelte
@@ -3,14 +3,14 @@
   import { faCalendar } from '@fortawesome/free-solid-svg-icons';
   import { page } from '$app/stores';
 
-  import type { Activity } from '$lib/models/activity';
+  import type { EventsGroup } from '$lib/models/events-group';
 
   import { formatEvent } from '$lib/utilities/get-event-classification';
   import { formatDate } from '$lib/utilities/format-date';
 
   import EventLabel from '$lib/components/event-label.svelte';
 
-  export let event: HistoryEventWithId | PendingActivity | Activity;
+  export let event: HistoryEventWithId | PendingActivity | EventsGroup;
 
   let { routeFor, pending, timeStamp, name, tag, classification, id } =
     formatEvent(event);
diff --git a/src/lib/models/activity.ts b/src/lib/models/events-group.ts
similarity index 56%
rename from src/lib/models/activity.ts
rename to src/lib/models/events-group.ts
index 15bb8d31..65fa1c31 100644
--- a/src/lib/models/activity.ts
+++ b/src/lib/models/events-group.ts
@@ -12,8 +12,18 @@ const activityTypes = [
   'ActivityTaskTimedOut',
 ];
 
-const isActivity = (event: HistoryEventWithId): event is ActivityEvent => {
-  if (activityTypes.includes(event.eventType)) return true;
+const timerTypes = ['TimerStarted', 'TimerCanceled', 'TimerFired'];
+
+type CompactEvent = HistoryEventWithId & {
+  eventType: ActivityType | TimerType;
+};
+
+const isCompactEvent = (event: HistoryEventWithId): event is CompactEvent => {
+  if (
+    activityTypes.includes(event.eventType) ||
+    timerTypes.includes(event.eventType)
+  )
+    return true;
   return false;
 };
 
@@ -23,23 +33,39 @@ const isActivityScheduledEvent = (
   return event.eventType === 'ActivityTaskScheduled';
 };
 
-export class Activity {
+const isTimerStartedEvent = (
+  event: HistoryEventWithId,
+): event is ScheduledActivityEvent => {
+  return event.eventType === 'TimerStarted';
+};
+
+type CompactEventType = ActivityType | TimerType;
+
+export class EventsGroup {
   id: string;
   name: string;
-  private _events: Map<ActivityType, HistoryEventWithId> = new Map();
+  private _events: Map<CompactEventType, HistoryEventWithId> = new Map();
+
+  constructor(event: CompactEvent) {
+    if (isActivityScheduledEvent) {
+      this.id = event?.activityTaskScheduledEventAttributes?.activityId;
+      this.name =
+        event?.activityTaskScheduledEventAttributes?.activityType?.name;
+    }
 
-  constructor(event: ScheduledActivityEvent) {
-    this.id = event?.activityTaskScheduledEventAttributes?.activityId;
-    this.name = event?.activityTaskScheduledEventAttributes?.activityType?.name;
+    if (isTimerStartedEvent(event)) {
+      this.id = event?.timerStartedEventAttributes?.timerId;
+      this.name = `Timer ${event?.timerStartedEventAttributes?.timerId}`;
+    }
 
     this.set(event.eventType, event);
   }
 
-  set(type: ActivityType, event: HistoryEventWithId): void {
+  set(type: CompactEventType, event: HistoryEventWithId): void {
     this._events.set(type, event);
   }
 
-  get(type: ActivityType): HistoryEventWithId {
+  get(type: CompactEventType): HistoryEventWithId {
     return this._events.get(type);
   }
 
@@ -70,41 +96,41 @@ export class Activity {
   }
 }
 
-export class Activities {
-  private _activities: Map<string, Activity> = new Map();
+export class EventGroups {
+  private _groups: Map<string, EventsGroup> = new Map();
 
   static async fromPromise(
     events: PromiseLike<HistoryEventWithId[]>,
-  ): Promise<Activities> {
-    return Activities.from(await events);
+  ): Promise<EventGroups> {
+    return EventGroups.from(await events);
   }
 
   static from = (
     events: HistoryEventWithId[],
-    activities = new Activities(),
-  ): Activities => {
+    groups = new EventGroups(),
+  ): EventGroups => {
     for (const event of events) {
-      if (isActivity(event)) {
-        activities.add(event);
+      if (isCompactEvent(event)) {
+        groups.add(event);
       }
     }
 
-    return activities;
+    return groups;
   };
 
-  constructor(event?: ActivityEvent | HistoryEventWithId[]) {
-    if (Array.isArray(event)) return Activities.from(event, this);
+  constructor(event?: CompactEvent | HistoryEventWithId[]) {
+    if (Array.isArray(event)) return EventGroups.from(event, this);
     if (event) this.add(event);
   }
 
-  get(id: string | number | Long): Activity {
-    return this._activities.get(String(id));
+  get(id: string | number | Long): EventsGroup {
+    return this._groups.get(String(id));
   }
 
-  add(event: ActivityEvent): void {
+  add(event: CompactEvent): void {
     if (isActivityScheduledEvent(event)) {
       const id = String(event.id);
-      this._activities.set(id, new Activity(event));
+      this._groups.set(id, new EventsGroup(event));
     }
 
     if (event.eventType === 'ActivityTaskStarted') {
@@ -137,17 +163,22 @@ export class Activities {
       const { scheduledEventId } = event.activityTaskCompletedEventAttributes;
       this.get(scheduledEventId).set(event.eventType, event);
     }
+
+    if (isTimerStartedEvent(event)) {
+      const id = String(event.id);
+      this._groups.set(id, new EventsGroup(event));
+    }
   }
 
   get length(): number {
-    return this._activities.size;
+    return this._groups.size;
   }
 
-  slice(...args: Parameters<typeof Array.prototype.slice>): Activity[] {
+  slice(...args: Parameters<typeof Array.prototype.slice>): EventsGroup[] {
     return [...this].slice(...args);
   }
 
-  [Symbol.iterator](): IterableIterator<Activity> {
-    return this._activities.values();
+  [Symbol.iterator](): IterableIterator<EventsGroup> {
+    return this._groups.values();
   }
 }
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index 1b825066..22c52728 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -1,4 +1,3 @@
-import { fetchWorkflow } from '$lib/services/workflow-service';
 import type {
   WorkflowExecutionStatus,
   DescribeWorkflowExecutionResponse,
diff --git a/src/lib/services/workflow-service.ts b/src/lib/services/workflow-service.ts
index 6e94af3d..d69cae96 100644
--- a/src/lib/services/workflow-service.ts
+++ b/src/lib/services/workflow-service.ts
@@ -9,7 +9,7 @@ import {
 } from '$lib/models/workflow-execution';
 
 import { routeForApi } from '$lib/utilities/route-for-api';
-import { getWorkflowFilterParameters } from '$lib/utilities/get-workflow-filter-parameters';
+import { toListWorkflowQuery } from '$lib/utilities/list-workflow-query';
 
 export type GetWorkflowExecutionRequest = NamespaceScopedRequest & {
   executionId: string;
@@ -18,60 +18,7 @@ export type GetWorkflowExecutionRequest = NamespaceScopedRequest & {
 
 type CombinedWorkflowExecutionsResponse = {
   workflows: WorkflowExecution[];
-  nextPageTokens: NextPageTokens;
-};
-
-const emptyWorkflowRequest = (): Promise<ListWorkflowExecutionsResponse> => {
-  return Promise.resolve({
-    executions: [],
-  });
-};
-
-const checkForStatus =
-  (workflowStatus: WorkflowStatus, value: boolean) =>
-  ({ status }: FilterParameters): boolean => {
-    if (!status) return false;
-    if (status === workflowStatus) return value;
-    return !value;
-  };
-
-const filterIsSetToRunning = checkForStatus('Running', true);
-const filterIsNotSetToRunning = checkForStatus('Running', false);
-
-export const fetchOpenWorkflows = (
-  namespace: string,
-  parameters: FilterParameters,
-  request = fetch,
-): Promise<ListWorkflowExecutionsResponse> => {
-  if (filterIsNotSetToRunning(parameters)) return emptyWorkflowRequest();
-
-  const params = getWorkflowFilterParameters(parameters);
-
-  return requestFromAPI<ListWorkflowExecutionsResponse>(
-    routeForApi('workflows.open', { namespace }),
-    {
-      params,
-      request,
-    },
-  );
-};
-
-export const fetchClosedWorkflows = (
-  namespace: string,
-  parameters: FilterParameters,
-  request = fetch,
-): Promise<ListWorkflowExecutionsResponse> => {
-  if (filterIsSetToRunning(parameters)) return emptyWorkflowRequest();
-
-  const params = getWorkflowFilterParameters(parameters);
-
-  return requestFromAPI<ListWorkflowExecutionsResponse>(
-    routeForApi('workflows.closed', { namespace }),
-    {
-      params,
-      request,
-    },
-  );
+  nextPageToken: string;
 };
 
 export const fetchAllWorkflows = async (
@@ -79,19 +26,20 @@ export const fetchAllWorkflows = async (
   parameters: FilterParameters,
   request = fetch,
 ): Promise<CombinedWorkflowExecutionsResponse> => {
-  const [open, closed] = await Promise.all([
-    fetchOpenWorkflows(namespace, parameters, request),
-    fetchClosedWorkflows(namespace, parameters, request),
-  ]);
+  const query = toListWorkflowQuery(parameters);
 
-  const executions = [...open?.executions, ...closed?.executions];
+  const { executions, nextPageToken } =
+    await requestFromAPI<ListWorkflowExecutionsResponse>(
+      routeForApi('workflows', { namespace }),
+      {
+        params: { query },
+        request,
+      },
+    );
 
   return {
     workflows: toWorkflowExecutions({ executions }),
-    nextPageTokens: {
-      open: open.nextPageToken,
-      closed: closed.nextPageToken,
-    },
+    nextPageToken: String(nextPageToken),
   };
 };
 
diff --git a/src/lib/utilities/get-event-classification.ts b/src/lib/utilities/get-event-classification.ts
index 33904aa7..40ff9e1f 100644
--- a/src/lib/utilities/get-event-classification.ts
+++ b/src/lib/utilities/get-event-classification.ts
@@ -1,4 +1,4 @@
-import { Activity } from '../models/activity';
+import { EventsGroup } from '../models/events-group';
 import { format } from './format-camel-case';
 import { routeFor } from './route-for';
 
@@ -6,7 +6,7 @@ import type { WorkflowParameters } from './route-for';
 import type { Timestamp } from '$types';
 
 export type EventClassification = typeof eventClassifications[number];
-type EventOrActivity = HistoryEventWithId | PendingActivity | Activity;
+type EventOrGroup = HistoryEventWithId | PendingActivity | EventsGroup;
 
 type EventSummary = {
   id: string;
@@ -51,7 +51,7 @@ export const isEvent = (event: unknown): event is HistoryEventWithId => {
 };
 
 export const isPendingActivity = (
-  event: EventOrActivity,
+  event: EventOrGroup,
 ): event is PendingActivity => {
   if (event === null) return false;
   if (typeof event !== 'object') return false;
@@ -60,15 +60,17 @@ export const isPendingActivity = (
   return false;
 };
 
-export const isActivity = (event: EventOrActivity): event is Activity => {
-  return event instanceof Activity;
+export const isEventsGroup = (event: EventOrGroup): event is EventsGroup => {
+  return event instanceof EventsGroup;
 };
 
 export const getEventClassification = (
-  event: EventOrActivity,
+  event: EventOrGroup,
 ): EventClassification => {
   if (isPendingActivity(event)) return event.state;
-  if (isActivity(event)) event = event.get('ActivityTaskScheduled');
+  if (isEventsGroup(event)) {
+    event = event.get('ActivityTaskScheduled') ?? event.get('TimerStarted');
+  }
 
   const eventType = event.eventType;
 
@@ -78,39 +80,39 @@ export const getEventClassification = (
   }
 };
 
-const getName = (event: EventOrActivity): string => {
+const getName = (event: EventOrGroup): string => {
   if (isEvent(event)) return String(event.eventType);
   if (isPendingActivity(event))
     return `${event.activityType.name}:${event.state}`;
-  if (isActivity(event)) return event.name;
+  if (isEventsGroup(event)) return event.name;
 };
 
-const getTime = (event: EventOrActivity): string => {
+const getTime = (event: EventOrGroup): string => {
   let ts: Timestamp;
 
   if (isEvent(event)) ts = event.eventTime;
   if (isPendingActivity(event)) ts = event.lastStartedTime;
-  if (isActivity(event)) ts = event.last.eventTime;
+  if (isEventsGroup(event)) ts = event.last.eventTime;
 
   return ts ? String(ts) : null;
 };
 
-const getId = (event: EventOrActivity): string => {
+const getId = (event: EventOrGroup): string => {
   if (isEvent(event)) return String(event.eventId);
   if (isPendingActivity(event)) return String(event.activityId);
-  if (isActivity(event)) return String(event.id);
+  if (isEventsGroup(event)) return String(event.id);
 };
 
 const getType = (
-  event: EventOrActivity,
+  event: EventOrGroup,
 ): 'event' | 'pending-activity' | 'activity' => {
   if (isEvent(event)) return 'event';
   if (isPendingActivity(event)) return 'pending-activity';
-  if (isActivity(event)) return 'activity';
+  if (isEventsGroup(event)) return 'activity';
 };
 
 export const getHref = (
-  event: EventOrActivity | PendingActivity,
+  event: EventOrGroup | PendingActivity,
   parameters: WorkflowParameters,
 ): string => {
   if (isEvent(event)) {
@@ -127,7 +129,7 @@ export const getHref = (
     });
   }
 
-  if (isActivity(event)) {
+  if (isEventsGroup(event)) {
     return routeFor('workflow.events.compact.activity', {
       ...parameters,
       eventId: String(event.id),
@@ -136,7 +138,7 @@ export const getHref = (
 };
 
 export const formatEvent = (
-  event: EventOrActivity | Activity,
+  event: EventOrGroup | EventsGroup,
 ): EventSummary => {
   return {
     id: getId(event),
@@ -146,7 +148,7 @@ export const formatEvent = (
     tag: getName(event),
     type: getType(event),
     pending: isPendingActivity(event),
-    activity: isActivity(event),
+    activity: isEventsGroup(event),
     routeFor: (parameters: WorkflowParameters) => getHref(event, parameters),
   };
 };
diff --git a/src/lib/utilities/get-workflow-filter-parameters.ts b/src/lib/utilities/get-workflow-filter-parameters.ts
deleted file mode 100644
index 7fe0e70a..00000000
--- a/src/lib/utilities/get-workflow-filter-parameters.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { formatISO, sub } from 'date-fns';
-
-import { toDuration } from './to-duration';
-import { getStatusFilterCode } from './get-workflow-status-filter-code';
-
-const createDate = (timeRange: Duration | string) => {
-  const duration =
-    typeof timeRange === 'string' ? toDuration(timeRange) : timeRange;
-  return formatISO(sub(new Date(), duration));
-};
-
-export const getWorkflowFilterParameters = ({
-  workflowId,
-  workflowType,
-  timeRange,
-  status,
-}: FilterParameters = {}): Record<string, string> => {
-  const params: Record<string, string> = {};
-  const statusFilter = getStatusFilterCode(status);
-
-  params['start_time_filter.earliest_time'] = createDate(
-    timeRange || { hours: 24 },
-  );
-
-  if (workflowId) params['execution_filter.workflow_id'] = workflowId;
-  if (workflowType) params['type_filter.name'] = workflowType;
-
-  if (statusFilter) params['status_filter.status'] = statusFilter;
-
-  return params;
-};
diff --git a/src/lib/utilities/list-workflow-query.ts b/src/lib/utilities/list-workflow-query.ts
new file mode 100644
index 00000000..7ded76b5
--- /dev/null
+++ b/src/lib/utilities/list-workflow-query.ts
@@ -0,0 +1,72 @@
+import { isDuration, isDurationString, toDate } from './to-duration';
+
+type QueryKey =
+  | 'WorkflowId'
+  | 'WorkflowType'
+  | 'StartTime'
+  | 'CloseTime'
+  | 'ExecutionTime'
+  | 'ExecutionStatus';
+
+type FilterKey =
+  | 'workflowId'
+  | 'workflowType'
+  | 'timeRange'
+  | 'executionStatus';
+
+type FilterValue = string | Duration;
+
+const queryKeys: Readonly<Record<string, QueryKey>> = {
+  workflowId: 'WorkflowId',
+  workflowType: 'WorkflowType',
+  timeRange: 'StartTime',
+  executionStatus: 'ExecutionStatus',
+} as const;
+
+const filterKeys: Readonly<FilterKey[]> = [
+  'workflowId',
+  'workflowType',
+  'timeRange',
+  'executionStatus',
+] as const;
+
+const isValid = (value: unknown): boolean => {
+  if (value === null) return false;
+  if (value === undefined) return false;
+  if (typeof value === 'string' && value === 'undefined') return false;
+
+  return true;
+};
+
+const isFilterKey = (key: unknown): key is FilterKey => {
+  if (typeof key !== 'string') return false;
+
+  for (const filterKey of filterKeys) {
+    if (filterKey === key) return true;
+  }
+
+  return false;
+};
+
+const toQueryStatement = (key: FilterKey, value: FilterValue): string => {
+  const queryKey = queryKeys[key];
+
+  if (isDuration(value) || isDurationString(value)) {
+    return `${queryKey} > "${toDate(value)}"`;
+  }
+
+  return `${queryKey}="${value}"`;
+};
+
+const toQueryStatements = (parameters: FilterParameters): string[] => {
+  return Object.entries(parameters)
+    .map(([key, value]) => {
+      if (isFilterKey(key) && isValid(value))
+        return toQueryStatement(key, value);
+    })
+    .filter(Boolean);
+};
+
+export const toListWorkflowQuery = (parameters: FilterParameters): string => {
+  return toQueryStatements(parameters).join(' and ');
+};
diff --git a/src/lib/utilities/route-for-api.ts b/src/lib/utilities/route-for-api.ts
index b64fa61a..7ad482b8 100644
--- a/src/lib/utilities/route-for-api.ts
+++ b/src/lib/utilities/route-for-api.ts
@@ -1,4 +1,7 @@
-type WorkflowsAPIRoutePath = 'workflows.open' | 'workflows.closed';
+type WorkflowsAPIRoutePath =
+  | 'workflows'
+  | 'workflows.open'
+  | 'workflows.closed';
 type WorkflowAPIRoutePath =
   | 'workflow'
   | 'workflow.terminate'
@@ -67,6 +70,7 @@ export function routeForApi(
     user: '/me',
     namespaces: '/namespaces',
     'task-queue': `/namespaces/${parameters?.namespace}/task-queues/${parameters?.queue}`,
+    workflows: `/namespaces/${parameters?.namespace}/workflows`,
     'workflows.open': `/namespaces/${parameters?.namespace}/workflows/open`,
     'workflows.closed': `/namespaces/${parameters?.namespace}/workflows/closed`,
     workflow: `/namespaces/${parameters?.namespace}/workflows/${parameters?.executionId}/executions/${parameters?.runId}`,
diff --git a/src/lib/utilities/to-duration.ts b/src/lib/utilities/to-duration.ts
index 518d10d0..bc26fe73 100644
--- a/src/lib/utilities/to-duration.ts
+++ b/src/lib/utilities/to-duration.ts
@@ -1,5 +1,69 @@
-const durationPattern =
-  /(\d+)\s(years|months|weeks|days|hours|minutes|seconds)/;
+import { formatISO, sub } from 'date-fns';
+
+type DurationKey = typeof durationKeys;
+
+const millisecond = 1;
+const second = millisecond * 1000;
+const minute = second * 60;
+const hour = minute * 60;
+const day = hour * 24;
+const year = day * 365;
+
+const durationKeys = [
+  'years',
+  'months',
+  'weeks',
+  'days',
+  'hours',
+  'minutes',
+  'seconds',
+] as const;
+
+export const durations = [
+  '10 minutes',
+  '60 minutes',
+  '3 hours',
+  '24 hours',
+  '3 days',
+  '7 days',
+  '30 days',
+  '90 days',
+];
+
+const durationPattern = new RegExp(`(\\d+)\\s(${durationKeys.join('|')})`);
+
+const isDurationKey = (key: unknown): key is DurationKey => {
+  if (typeof key !== 'string') return false;
+
+  for (const durationKey of durationKeys) {
+    if (durationKey === key) return true;
+  }
+
+  return false;
+};
+
+export const isDuration = (value: unknown): value is Duration => {
+  if (value === null) return false;
+  if (typeof value !== 'object') return false;
+
+  for (const key of Object.keys(value)) {
+    if (!isDurationKey(key)) return false;
+  }
+
+  return true;
+};
+
+export const isDurationString = (value: unknown): value is string => {
+  if (value === null) return false;
+  if (typeof value !== 'string') return false;
+
+  const match = value.match(durationPattern);
+
+  if (!match) return false;
+
+  const [, amount, units] = match;
+  return !!amount && !!units;
+};
 
 export const toDuration = (value: string): Duration => {
   const [, amount, units] = value.match(durationPattern);
@@ -11,3 +75,29 @@ export const toString = (duration: Duration): string => {
   const amount: number = duration[units];
   return `${amount} ${units}`;
 };
+
+export const toDate = (timeRange: Duration | string): string => {
+  const duration =
+    typeof timeRange === 'string' ? toDuration(timeRange) : timeRange;
+
+  return formatISO(sub(new Date(), duration));
+};
+
+export const fromDate = (targetDate: string | Date): Duration => {
+  if (typeof targetDate === 'string') targetDate = new Date(targetDate);
+
+  const currentDate = new Date();
+  const difference = Number(currentDate) - Number(targetDate);
+
+  const years = Math.floor(difference / year);
+  const days = Math.floor(difference / day);
+  const hours = Math.floor(difference / hour);
+  const minutes = Math.floor(difference / minute);
+
+  if (years >= 1) return { years };
+  if (days >= 1) return { days };
+  if (hours >= 1) return { hours };
+  if (minutes >= 1) return { minutes };
+
+  return { seconds: difference / 1000 };
+};
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/__layout.svelte
index c102b577..5b1dc588 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/__layout.svelte
@@ -1,12 +1,12 @@
 <script lang="ts">
   import { getContext } from 'svelte';
-  import { Activities } from '$lib/models/activity';
+  import { EventGroups } from '$lib/models/events-group';
   import EventTable from '$lib/components/event-table.svelte';
 
   export let events = getContext<EventualHistoryEvents>('events');
 </script>
 
-{#await Activities.fromPromise(events) then activities}
+{#await EventGroups.fromPromise(events) then activities}
   <EventTable events={activities}>
     <div slot="details" class="w-full h-full py-4">
       <slot />
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/activity-[eventId]/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/activity-[eventId]/__layout.svelte
index 8bd507a9..c9005981 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/activity-[eventId]/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/compact/activity-[eventId]/__layout.svelte
@@ -26,30 +26,30 @@
 <script lang="ts">
   import { getContext } from 'svelte';
 
-  import { Activities } from '$lib/models/activity';
+  import { EventGroups, EventsGroup } from '$lib/models/events-group';
   import { routeFor } from '$lib/utilities/route-for';
   import { page } from '$app/stores';
 
   let events = getContext<EventualHistoryEvents>('events');
   export let params: EventParameter;
 
-  const getActivity = async (
+  const getEventsGroup = async (
     events: EventualHistoryEvents,
     id: string,
-  ): Promise<{ activity: Activity; events: HistoryEventWithId[] }> => {
-    const activities = await Activities.fromPromise(events);
-    const activity = activities.get(id);
+  ): Promise<{ group: EventsGroup; events: HistoryEventWithId[] }> => {
+    const groups = await EventGroups.fromPromise(events);
+    const group = groups.get(id);
 
     return {
-      activity,
-      events: activity.events,
+      group,
+      events: group.events,
     };
   };
 
-  const getHref = (activity: Activity, event: HistoryEventWithId): string =>
+  const getHref = (group: EventsGroup, event: HistoryEventWithId): string =>
     routeFor('workflow.events.compact.activity.event', {
       ...params,
-      activityId: activity.id,
+      activityId: group.id,
       eventId: event.id,
     });
 </script>
@@ -57,12 +57,12 @@
 <div class="flex flex-col w-full h-full">
   <nav class="mb-4">
     <ul class="flex gap-4 w-full items-start">
-      {#await getActivity(events, params.eventId) then { activity, events }}
+      {#await getEventsGroup(events, params.eventId) then { group, events }}
         {#each events as event}
           <li>
             <a
-              href={getHref(activity, event)}
-              class:active={$page.path === getHref(activity, event)}
+              href={getHref(group, event)}
+              class:active={$page.path === getHref(group, event)}
             >
               {event.eventType}
             </a>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index d7aa588c..89831026 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -1,4 +1,6 @@
 <script lang="ts">
+  import { durations } from '$lib/utilities/to-duration';
+
   import Select from '$lib/components/select/select.svelte';
   import FilterSelect from '$lib/components/select/filter-select.svelte';
   import Option from '$lib/components/select/option.svelte';
@@ -9,17 +11,6 @@
   let workflowIdFilter = '';
   let workflowTypeFilter = '';
 
-  const durations = [
-    '10 minutes',
-    '60 minutes',
-    '3 hours',
-    '24 hours',
-    '3 days',
-    '7 days',
-    '30 days',
-    '90 days',
-  ];
-
   const statuses = {
     All: null,
     Running: 'Running',
diff --git a/src/routes/namespaces/[namespace]/workflows/index.svelte b/src/routes/namespaces/[namespace]/workflows/index.svelte
index 655f5762..d6d66e47 100644
--- a/src/routes/namespaces/[namespace]/workflows/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/index.svelte
@@ -8,22 +8,24 @@
     const workflowId = page.query.get('workflow-id');
     const workflowType = page.query.get('workflow-type');
     const timeRange = page.query.get('time-range');
-    const status = page.query.get('status') as WorkflowStatus;
+    const executionStatus = page.query.get('status') as WorkflowStatus;
 
-    const initialData = await fetchAllWorkflows(
-      namespace,
-      { workflowId, workflowType, timeRange, status },
-      fetch,
-    );
+    const parameters: FilterParameters = {
+      workflowId,
+      workflowType,
+      timeRange,
+      executionStatus,
+    };
+    const initialData = await fetchAllWorkflows(namespace, parameters, fetch);
 
     return {
-      props: { initialData, namespace },
+      props: { initialData, namespace, parameters },
     };
   }
 </script>
 
 <script lang="ts">
-  import { page } from '$app/stores';
+  import VirtualList from '@sveltejs/svelte-virtual-list';
 
   import { fetchAllWorkflows } from '$lib/services/workflow-service';
   import { refreshable } from '$lib/stores/refreshable';
@@ -33,17 +35,15 @@
   import WorkflowFilters from './_workflow-filters.svelte';
   import EmptyState from '$lib/components/empty-state.svelte';
   import WorkflowsLoadingState from './_workflows-loading.svelte';
-  import VirtualList from '@sveltejs/svelte-virtual-list';
 
   export let namespace: string;
   export let initialData: ReturnType<typeof fetchAllWorkflows>;
-
-  $: timeRange = $page.query.get('time-range');
+  export let parameters: FilterParameters;
 
   let timeFormat: TimeFormat = 'UTC';
 
   $: data = refreshable(
-    () => fetchAllWorkflows(namespace, { timeRange }),
+    () => fetchAllWorkflows(namespace, parameters),
     initialData,
   );
 </script>


Test patch:
diff --git a/src/lib/models/activity.test.ts b/src/lib/models/activity.test.ts
deleted file mode 100644
index 32129333..00000000
--- a/src/lib/models/activity.test.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { Activity, Activities } from './activity';
-
-const scheduledEvent = {
-  id: '5',
-  eventId: '5',
-  eventTime: '2021-12-27T15:23:21.806965200Z',
-  eventType: 'ActivityTaskScheduled',
-  version: '0',
-  taskId: '1049493',
-  activityTaskScheduledEventAttributes: {
-    activityId: '5',
-    activityType: {
-      name: 'CompletedActivity',
-    },
-    namespace: '',
-    taskQueue: {
-      name: 'rainbow-statuses',
-      kind: 'Normal',
-    },
-    input: null,
-  },
-};
-
-const anotherScheduledEvent = {
-  id: '99',
-  eventId: '99',
-  eventTime: '2021-12-27T15:23:21.806965200Z',
-  eventType: 'ActivityTaskScheduled',
-  version: '0',
-  taskId: '1049494',
-  activityTaskScheduledEventAttributes: {
-    activityId: '5',
-    activityType: {
-      name: 'FicticiousEvent',
-    },
-    namespace: '',
-    taskQueue: {
-      name: 'test-events',
-      kind: 'Normal',
-    },
-    input: null,
-  },
-};
-
-const startedEvent = {
-  id: '6',
-  eventId: '6',
-  eventTime: '2021-12-27T15:23:21.824600700Z',
-  eventType: 'ActivityTaskStarted',
-  version: '0',
-  taskId: '1049497',
-  activityTaskStartedEventAttributes: {
-    scheduledEventId: '5',
-    identity: '21665@temporal@',
-    requestId: '0202572f-485c-426a-a3ee-02ea46d3cad7',
-    attempt: 1,
-    lastFailure: null,
-  },
-};
-
-const completedEvent = {
-  id: '7',
-  eventId: '7',
-  eventTime: '2021-12-27T15:23:21.841178500Z',
-  eventType: 'ActivityTaskCompleted',
-  version: '0',
-  taskId: '1049498',
-  activityTaskCompletedEventAttributes: {
-    result: null,
-    scheduledEventId: '5',
-    startedEventId: '6',
-    identity: '21665@temporal@',
-  },
-};
-
-const eventHistory = [
-  scheduledEvent,
-  startedEvent,
-  anotherScheduledEvent,
-  completedEvent,
-];
-
-describe(Activity, () => {
-  it('should grab the name of the ActivityTaskScheduledEvent', () => {
-    const activity = new Activity(scheduledEvent);
-
-    expect(activity.name).toBe('CompletedActivity');
-  });
-
-  it('should grab the id of the ActivityTaskScheduledEvent', () => {
-    const activity = new Activity(scheduledEvent);
-
-    expect(activity.id).toBe('5');
-  });
-
-  it('should store the ActivityTaskScheduled', () => {
-    const activity = new Activity(scheduledEvent);
-
-    expect(activity.get('ActivityTaskScheduled')).toBe(scheduledEvent);
-  });
-
-  it('should be able to add a started event', () => {
-    const activity = new Activity(scheduledEvent);
-    activity.set(completedEvent.eventType, completedEvent);
-
-    expect(activity.length).toBe(2);
-    expect(activity.get('ActivityTaskCompleted')).toBe(completedEvent);
-  });
-});
-
-describe(Activity, () => {
-  it('should create a new entry when given an ActivityTaskScheduled event', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-
-    const activity = activities.get(scheduledEvent.id);
-
-    expect(activity.get('ActivityTaskScheduled')).toBe(scheduledEvent);
-  });
-
-  it('should correctly set the name of the activity', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-
-    const activity = activities.get(scheduledEvent.id);
-
-    expect(activity.name).toBe('CompletedActivity');
-  });
-
-  it('should correctly set the id of the activity', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-
-    const activity = activities.get(scheduledEvent.id);
-
-    expect(activity.id).toBe('5');
-  });
-
-  it('should correctly set the name of the activity', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-
-    const activity = activities.get(scheduledEvent.id);
-
-    expect(activity.id).toBe('5');
-  });
-
-  it('should be able to store multiple activities', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-    activities.add(anotherScheduledEvent);
-
-    expect(activities.length).toBe(2);
-  });
-
-  it('should add a completed event to the correct activity', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-    activities.add(completedEvent);
-
-    const activity = activities.get(scheduledEvent.id);
-
-    expect(activity.length).toBe(2);
-    expect(activity.get('ActivityTaskCompleted')).toBe(completedEvent);
-  });
-
-  it('should be able to add multiple activities and their associated events', () => {
-    const activities = new Activities();
-
-    activities.add(scheduledEvent);
-    activities.add(startedEvent);
-    activities.add(completedEvent);
-    activities.add(anotherScheduledEvent);
-
-    const [first, second] = activities;
-
-    expect(activities.length).toBe(2);
-    expect(first.length).toBe(3);
-    expect(second.length).toBe(1);
-  });
-
-  describe('from event history', () => {
-    it('should create a collection with the correct number of activities', () => {
-      const activities = Activities.from(eventHistory);
-
-      const [first, second] = activities;
-
-      expect(activities.length).toBe(2);
-      expect(first.length).toBe(3);
-      expect(second.length).toBe(1);
-    });
-
-    it('should create a collection with the correct number of activities using the constructor', () => {
-      const activities = new Activities(eventHistory);
-
-      const [first, second] = activities;
-
-      expect(activities.length).toBe(2);
-      expect(first.length).toBe(3);
-      expect(second.length).toBe(1);
-    });
-
-    it('should instantiate correctly when given a single event', () => {
-      const activities = new Activities(scheduledEvent);
-
-      const [activity] = activities;
-
-      expect(activities.length).toBe(1);
-      expect(activity.length).toBe(1);
-    });
-  });
-});
diff --git a/src/lib/models/events-group.test.ts b/src/lib/models/events-group.test.ts
new file mode 100644
index 00000000..939c50e2
--- /dev/null
+++ b/src/lib/models/events-group.test.ts
@@ -0,0 +1,218 @@
+import { EventsGroup, EventGroups } from './events-group';
+
+const scheduledEvent = {
+  id: '5',
+  eventId: '5',
+  eventTime: '2021-12-27T15:23:21.806965200Z',
+  eventType: 'ActivityTaskScheduled',
+  version: '0',
+  taskId: '1049493',
+  activityTaskScheduledEventAttributes: {
+    activityId: '5',
+    activityType: {
+      name: 'CompletedActivity',
+    },
+    namespace: '',
+    taskQueue: {
+      name: 'rainbow-statuses',
+      kind: 'Normal',
+    },
+    input: null,
+  },
+};
+
+const anotherScheduledEvent = {
+  id: '99',
+  eventId: '99',
+  eventTime: '2021-12-27T15:23:21.806965200Z',
+  eventType: 'ActivityTaskScheduled',
+  version: '0',
+  taskId: '1049494',
+  activityTaskScheduledEventAttributes: {
+    activityId: '5',
+    activityType: {
+      name: 'FicticiousEvent',
+    },
+    namespace: '',
+    taskQueue: {
+      name: 'test-events',
+      kind: 'Normal',
+    },
+    input: null,
+  },
+};
+
+const startedEvent = {
+  id: '6',
+  eventId: '6',
+  eventTime: '2021-12-27T15:23:21.824600700Z',
+  eventType: 'ActivityTaskStarted',
+  version: '0',
+  taskId: '1049497',
+  activityTaskStartedEventAttributes: {
+    scheduledEventId: '5',
+    identity: '21665@temporal@',
+    requestId: '0202572f-485c-426a-a3ee-02ea46d3cad7',
+    attempt: 1,
+    lastFailure: null,
+  },
+};
+
+const completedEvent = {
+  id: '7',
+  eventId: '7',
+  eventTime: '2021-12-27T15:23:21.841178500Z',
+  eventType: 'ActivityTaskCompleted',
+  version: '0',
+  taskId: '1049498',
+  activityTaskCompletedEventAttributes: {
+    result: null,
+    scheduledEventId: '5',
+    startedEventId: '6',
+    identity: '21665@temporal@',
+  },
+};
+
+const eventHistory = [
+  scheduledEvent,
+  startedEvent,
+  anotherScheduledEvent,
+  completedEvent,
+];
+
+describe(EventsGroup, () => {
+  it('should grab the name of the groupTaskScheduledEvent', () => {
+    const group = new EventsGroup(scheduledEvent);
+
+    expect(group.name).toBe('CompletedActivity');
+  });
+
+  it('should grab the id of the groupTaskScheduledEvent', () => {
+    const group = new EventsGroup(scheduledEvent);
+
+    expect(group.id).toBe('5');
+  });
+
+  it('should store the groupTaskScheduled', () => {
+    const group = new EventsGroup(scheduledEvent);
+
+    expect(group.get('ActivityTaskScheduled')).toBe(scheduledEvent);
+  });
+
+  it('should be able to add a started event', () => {
+    const group = new EventsGroup(scheduledEvent);
+    group.set(completedEvent.eventType, completedEvent);
+
+    expect(group.length).toBe(2);
+    expect(group.get('ActivityTaskCompleted')).toBe(completedEvent);
+  });
+});
+
+describe(EventsGroup, () => {
+  it('should create a new entry when given an groupTaskScheduled event', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+
+    const group = groups.get(scheduledEvent.id);
+
+    expect(group.get('ActivityTaskScheduled')).toBe(scheduledEvent);
+  });
+
+  it('should correctly set the name of the events group', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+
+    const group = groups.get(scheduledEvent.id);
+
+    expect(group.name).toBe('CompletedActivity');
+  });
+
+  it('should correctly set the id of the group', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+
+    const group = groups.get(scheduledEvent.id);
+
+    expect(group.id).toBe('5');
+  });
+
+  it('should correctly set the name of the group', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+
+    const group = groups.get(scheduledEvent.id);
+
+    expect(group.id).toBe('5');
+  });
+
+  it('should be able to store multiple event groups', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+    groups.add(anotherScheduledEvent);
+
+    expect(groups.length).toBe(2);
+  });
+
+  it('should add a completed event to the correct group', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+    groups.add(completedEvent);
+
+    const group = groups.get(scheduledEvent.id);
+
+    expect(group.length).toBe(2);
+    expect(group.get('ActivityTaskCompleted')).toBe(completedEvent);
+  });
+
+  it('should be able to add multiple event groups and their associated events', () => {
+    const groups = new EventGroups();
+
+    groups.add(scheduledEvent);
+    groups.add(startedEvent);
+    groups.add(completedEvent);
+    groups.add(anotherScheduledEvent);
+
+    const [first, second] = groups;
+
+    expect(groups.length).toBe(2);
+    expect(first.length).toBe(3);
+    expect(second.length).toBe(1);
+  });
+
+  describe('from event history', () => {
+    it('should create a collection with the correct number of event groups', () => {
+      const groups = EventGroups.from(eventHistory);
+
+      const [first, second] = groups;
+
+      expect(groups.length).toBe(2);
+      expect(first.length).toBe(3);
+      expect(second.length).toBe(1);
+    });
+
+    it('should create a collection with the correct number of event groups using the constructor', () => {
+      const groups = new EventGroups(eventHistory);
+
+      const [first, second] = groups;
+
+      expect(groups.length).toBe(2);
+      expect(first.length).toBe(3);
+      expect(second.length).toBe(1);
+    });
+
+    it('should instantiate correctly when given a single event', () => {
+      const groups = new EventGroups(scheduledEvent);
+
+      const [group] = groups;
+
+      expect(groups.length).toBe(1);
+      expect(group.length).toBe(1);
+    });
+  });
+});
diff --git a/src/lib/utilities/get-workflow-filter-parameters.test.ts b/src/lib/utilities/get-workflow-filter-parameters.test.ts
deleted file mode 100644
index 1fcc8329..00000000
--- a/src/lib/utilities/get-workflow-filter-parameters.test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { getWorkflowFilterParameters } from './get-workflow-filter-parameters';
-
-describe(getWorkflowFilterParameters, () => {
-  afterEach(() => {
-    jest.useRealTimers();
-  });
-
-  it('should always have an earliest_time set', () => {
-    const result = getWorkflowFilterParameters();
-    expect(result['start_time_filter.earliest_time']).toBeDefined();
-  });
-
-  it('should always have a default earliest_time of 24 hours', () => {
-    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
-    const OneDayEarlier = '2019-12-31T00:00:00Z';
-
-    const result = getWorkflowFilterParameters();
-    expect(result['start_time_filter.earliest_time']).toBe(OneDayEarlier);
-  });
-
-  it('should adjust the start time based on a duration', () => {
-    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
-    const NinetyDaysEarlier = '2019-10-03T00:00:00Z';
-
-    const result = getWorkflowFilterParameters({ timeRange: { days: 90 } });
-    expect(result['start_time_filter.earliest_time']).toBe(NinetyDaysEarlier);
-  });
-
-  it('should pass through a workflow ID', () => {
-    const result = getWorkflowFilterParameters({ workflowId: 'string value' });
-    expect(result['execution_filter.workflow_id']).toBe('string value');
-  });
-
-  it('should pass through a workflow type', () => {
-    const result = getWorkflowFilterParameters({
-      workflowType: 'string value',
-    });
-    expect(result['type_filter.name']).toBe('string value');
-  });
-
-  it('should not include a workflow ID filter if none is provided', () => {
-    const result = getWorkflowFilterParameters();
-    expect(result['execution_filter.workflow_id']).toBeUndefined();
-  });
-
-  it('should not include a workflow ID filter if null is provided', () => {
-    const result = getWorkflowFilterParameters({ workflowId: null });
-    expect(result['execution_filter.workflow_id']).toBeUndefined();
-  });
-
-  it('should not include a workflow ID filter if an empty string is provided', () => {
-    const result = getWorkflowFilterParameters({ workflowId: '' });
-    expect(result['execution_filter.workflow_id']).toBeUndefined();
-  });
-
-  it('should not include a workflow type filter if none is provided', () => {
-    const result = getWorkflowFilterParameters();
-    expect(result['type_filter.name']).toBeUndefined();
-  });
-
-  it('should not include a workflow type filter if null is provided', () => {
-    const result = getWorkflowFilterParameters({ workflowType: null });
-    expect(result['type_filter.name']).toBeUndefined();
-  });
-
-  it('should not include a workflow type filter if an empty string is provided', () => {
-    const result = getWorkflowFilterParameters({ workflowType: '' });
-    expect(result['type_filter.name']).toBeUndefined();
-  });
-
-  it('should return a status filter if one is provided', () => {
-    const result = getWorkflowFilterParameters({ status: 'Running' });
-    expect(result['status_filter.status']).toBe('1');
-  });
-});
diff --git a/src/lib/utilities/list-workflow-query.test.ts b/src/lib/utilities/list-workflow-query.test.ts
new file mode 100644
index 00000000..24b87a99
--- /dev/null
+++ b/src/lib/utilities/list-workflow-query.test.ts
@@ -0,0 +1,62 @@
+import { toListWorkflowQuery } from './list-workflow-query';
+
+describe(toListWorkflowQuery, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should convert an executionStatus', () => {
+    const query = toListWorkflowQuery({ executionStatus: 'Running' });
+    expect(query).toBe('ExecutionStatus="Running"');
+  });
+
+  it('should convert an timeRange with a Duration as a value', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const twentyFourHoursEarlier = '2019-12-31T00:00:00Z';
+
+    const query = toListWorkflowQuery({ timeRange: { hours: 24 } });
+
+    expect(query).toBe(`StartTime > "${twentyFourHoursEarlier}"`);
+  });
+
+  it('should convert an two values using an "and"', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+    });
+
+    expect(query).toBe('ExecutionStatus="Running" and WorkflowId="abcdef123"');
+  });
+
+  it('should convert an three values using an "and"', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+      workflowType: 'ImportantBusinessTransaction',
+    });
+
+    expect(query).toBe(
+      'ExecutionStatus="Running" and WorkflowId="abcdef123" and WorkflowType="ImportantBusinessTransaction"',
+    );
+  });
+
+  it('should drop null values', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+      workflowType: null,
+    });
+
+    expect(query).toBe('ExecutionStatus="Running" and WorkflowId="abcdef123"');
+  });
+
+  it('should drop "undefined" which can be returned from inputs', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+      workflowType: 'undefined',
+    });
+
+    expect(query).toBe('ExecutionStatus="Running" and WorkflowId="abcdef123"');
+  });
+});
diff --git a/src/lib/utilities/to-duration.test.ts b/src/lib/utilities/to-duration.test.ts
index bafedf32..ea9b8fd7 100644
--- a/src/lib/utilities/to-duration.test.ts
+++ b/src/lib/utilities/to-duration.test.ts
@@ -1,4 +1,4 @@
-import { toDuration } from './to-duration';
+import { toDuration, toDate, fromDate, isDurationString } from './to-duration';
 
 describe(toDuration, () => {
   it('should correctly parse "Last 10 minutes"', () => {
@@ -65,3 +65,65 @@ describe(toDuration, () => {
     expect(toDuration('3 months')).toEqual({ months: 3 });
   });
 });
+
+describe(toDate, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should produce a date based on a duration', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = toDate({ days: 90 });
+    expect(result).toBe(ninetyDaysEarlier);
+  });
+
+  it('should produce a date based on a duration string', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = toDate('90 days');
+    expect(result).toBe(ninetyDaysEarlier);
+  });
+});
+
+describe(fromDate, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should produce a duration based on a 90 days in the past', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = fromDate(ninetyDaysEarlier);
+    expect(result).toEqual({ days: 90 });
+  });
+
+  it('should produce a duration based on a 60 days in the past', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const sixtyDaysEarlier = '2019-11-02T00:00:00Z';
+
+    const result = fromDate(sixtyDaysEarlier);
+    expect(result).toEqual({ days: 60 });
+  });
+});
+
+describe(isDurationString, () => {
+  it('should return true for "24 hours"', () => {
+    expect(isDurationString('24 hours')).toBe(true);
+  });
+
+  it('should return false for null', () => {
+    expect(isDurationString(null)).toBe(false);
+  });
+
+  it('should return false for undefined', () => {
+    expect(isDurationString(undefined)).toBe(false);
+  });
+
+  it('should return false for "undefined"', () => {
+    expect(isDurationString('undefined')).toBe(false);
+  });
+});