Repository: temporalio/ui
Pull request #411 (2022-04-11 16:42:05)

Format timestamps on history events and pending activities
Standardizes timestamps with the rest of the UI on models.

cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>82fd9fc</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://b862ad2d.ui-c3x.pages.dev'>https://b862ad2d.ui-c3x.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/temporal-pizza/b862ad2d-1ad1-4c4f-b141-d70617237729)

cypress[bot]: 
<p align="center">
  <br />
  <img src="https://assets.cypress.io/github-pr-comment-icons/cypress-logo.png" height="32" />
</p>

---

### Test summary
<a href="https://dashboard.cypress.io/projects/g7xfxw/runs/94/overview?utm_source=github"><img src="https://assets.cypress.io/github-pr-comment-icons/green-check-mark.png" width="14" height="14" title="Passed" /></a> <b title="Passed">28</b>  &bull; <a href="https://dashboard.cypress.io/projects/g7xfxw/runs/94/test-results?utm_source=github&statuses=%5B%7B%22value%22%3A%22FAILED%22%2C%22label%22%3A%22FAILED%22%7D%5D"><img src="https://assets.cypress.io/github-pr-comment-icons/red-x.png" width="14" height="14" title="Failed" /></a> <b title="Failed">0</b>  &bull; <img src="https://assets.cypress.io/github-pr-comment-icons/gray-circle.png" width="14" height="14" title="Tests that did not run due to a developer annotating a test with .skip" /> <b title="Pending">0</b> &bull; <img src="https://assets.cypress.io/github-pr-comment-icons/yellow-triangle.png" width="14" height="14" title="Tests that did not run due to a failure in a mocha hook" /> <b title="Skipped">0</b> 

---

### Run details
| | |
| --- | --- |
| **Project** | Temporal UI |
| **Status** | Passed |
| **Commit** | f13fdc39a0 <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span> |
| **Started** | Apr 11, 2022 4:43 PM |
| **Ended** | Apr 11, 2022 4:44 PM |
| **Duration** | 01:09 <a href="https://on.cypress.io/parallelization?utm_source=github" title="Enable parallelization to reduce run duration">💡</a> |
| **OS** | Linux Ubuntu - 20.04 |
| **Browser** | Chrome 100 <img src="https://cdnjs.cloudflare.com/ajax/libs/browser-logos/48.0.4/chrome/chrome_32x32.png" width="16" height="16" /> |

[View run in Cypress Dashboard ➡️](https://dashboard.cypress.io/projects/g7xfxw/runs/94/overview?utm_source=github)





---

This comment has been generated by **cypress-bot** as a result of this project's GitHub integration settings. You can manage this integration in [this project's settings in the Cypress Dashboard](https://dashboard.cypress.io/projects/g7xfxw/settings?utm_source=github)



Patch:
diff --git a/src/lib/models/event-history/simplify-attributes.ts b/src/lib/models/event-history/simplify-attributes.ts
index f21b56a3..173c5f7e 100644
--- a/src/lib/models/event-history/simplify-attributes.ts
+++ b/src/lib/models/event-history/simplify-attributes.ts
@@ -1,5 +1,61 @@
+import { formatDate, formatDuration } from '$lib/utilities/format-date';
 import type { PendingActivityInfo } from '$types';
 
+const keysToBeFormattedAsTime = [
+  'closeTime',
+  'createTime',
+  'currentAttemptScheduledTime',
+  'earliestTime',
+  'eventTime',
+  'executionTime',
+  'expirationTime',
+  'expireTime',
+  'lastAccessTime',
+  'lastHeartbeatTime',
+  'lastStartedTime',
+  'lastUpdateTime',
+  'latestTime',
+  'releaseTime',
+  'scheduledTime',
+  'startedTime',
+  'startTime',
+  'workflowExecutionExpirationTime',
+] as const;
+
+const keysToBeFormattedAsDuration = [
+  'backoffStartInterval',
+  'defaultWorkflowTaskTimeout',
+  'firstWorkflowTaskBackoff',
+  'heartbeatTimeout',
+  'initialInterval',
+  'maximumInterval',
+  'scheduleToCloseTimeout',
+  'scheduleToStartTimeout',
+  'startToCloseTimeout',
+  'startToFireTimeout',
+  'workflowExecutionRetentionPeriod',
+  'workflowExecutionRetentionTtl',
+  'workflowExecutionTimeout',
+  'workflowRunTimeout',
+  'workflowTaskTimeout',
+] as const;
+
+const isTime = (key: string): key is typeof keysToBeFormattedAsTime[number] => {
+  for (const timeKey of keysToBeFormattedAsTime) {
+    if (timeKey === key) return true;
+  }
+  return false;
+};
+
+const isDuration = (
+  key: string,
+): key is typeof keysToBeFormattedAsDuration[number] => {
+  for (const timeKey of keysToBeFormattedAsDuration) {
+    if (timeKey === key) return true;
+  }
+  return false;
+};
+
 const canBeSimplified = (value: unknown): value is Record<string, string> => {
   if (value === null) return false;
   if (value === undefined) return false;
@@ -26,13 +82,21 @@ export function simplifyAttributes(
 export function simplifyAttributes(
   attributes: PendingActivityInfo,
 ): PendingActivityInfo;
-export function simplifyAttributes(
-  attributes: EventAttributesWithType | PendingActivityInfo,
-): EventAttributesWithType | PendingActivityInfo {
+export function simplifyAttributes<
+  T = EventAttributesWithType | PendingActivityInfo,
+>(attributes: T): T {
   for (const [key, value] of Object.entries(attributes)) {
     if (canBeSimplified(value)) {
       attributes[key] = getValueForFirstKey(value);
     }
+
+    if (isTime(key)) {
+      attributes[key] = formatDate(value);
+    }
+
+    if (isDuration(key)) {
+      attributes[key] = formatDuration(value);
+    }
   }
 
   return attributes;
diff --git a/src/lib/utilities/format-date.ts b/src/lib/utilities/format-date.ts
index 6bd62e3f..d02a8f5d 100644
--- a/src/lib/utilities/format-date.ts
+++ b/src/lib/utilities/format-date.ts
@@ -1,7 +1,12 @@
-import { formatDistanceToNow, parseJSON } from 'date-fns';
+import {
+  formatDistanceToNow,
+  parseJSON,
+  formatDuration as durationToString,
+} from 'date-fns';
 import * as dateTz from 'date-fns-tz'; // `build` script fails on importing some of named CommonJS modules
 
 import type { Timestamp } from '$types';
+import { fromSeconds } from './to-duration';
 
 type ValidTime = Parameters<typeof parseJSON>[0] | Timestamp;
 
@@ -47,3 +52,8 @@ function isTimestamp(arg: unknown): arg is Timestamp {
   }
   return false;
 }
+
+export function formatDuration(duration: Duration | string): string {
+  if (typeof duration === 'string') duration = fromSeconds(duration);
+  return durationToString(duration, { delimiter: ', ' });
+}
diff --git a/src/lib/utilities/to-duration.ts b/src/lib/utilities/to-duration.ts
index 7ada0018..f7b7989f 100644
--- a/src/lib/utilities/to-duration.ts
+++ b/src/lib/utilities/to-duration.ts
@@ -1,4 +1,4 @@
-import { formatISO, sub, add } from 'date-fns';
+import { formatISO, sub, add, intervalToDuration } from 'date-fns';
 
 type DurationKey = typeof durationKeys;
 
@@ -65,6 +65,10 @@ export const isDurationString = (value: unknown): value is string => {
   return !!amount && !!units;
 };
 
+export const tomorrow = (): string => {
+  return formatISO(add(new Date(), { hours: 24 }));
+};
+
 export const toDuration = (value: string): Duration => {
   const [, amount, units] = value.match(durationPattern);
   return { [units]: parseInt(amount, 10) };
@@ -88,23 +92,14 @@ export const toDate = (timeRange: Duration | string): string => {
 
 export const fromDate = (targetDate: string | Date): Duration => {
   if (typeof targetDate === 'string') targetDate = new Date(targetDate);
+  return intervalToDuration({ start: new Date(), end: 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 };
+export const fromSeconds = (seconds: string): Duration => {
+  const milliseconds = parseInt(seconds) * 1000;
 
-  return { seconds: difference / 1000 };
-};
+  if (!seconds.endsWith('s')) return undefined;
+  if (isNaN(milliseconds)) return undefined;
 
-export const tomorrow = (): string => {
-  return formatISO(add(new Date(), { hours: 24 }));
+  return intervalToDuration({ start: 0, end: milliseconds });
 };


Test patch:
diff --git a/src/lib/utilities/to-duration.test.ts b/src/lib/utilities/to-duration.test.ts
index c7879bd3..1364c299 100644
--- a/src/lib/utilities/to-duration.test.ts
+++ b/src/lib/utilities/to-duration.test.ts
@@ -6,6 +6,7 @@ import {
   isDurationKey,
   toString,
   isDuration,
+  fromSeconds,
 } from './to-duration';
 
 describe(toDuration, () => {
@@ -106,7 +107,7 @@ describe(fromDate, () => {
     const twoYearsEarlier = '2018-01-01T00:00:00Z';
 
     const result = fromDate(twoYearsEarlier);
-    expect(result).toEqual({ years: 2 });
+    expect(result).toEqual(expect.objectContaining({ years: 2 }));
   });
 
   it('should produce a duration based on a 90 days in the past', () => {
@@ -114,7 +115,7 @@ describe(fromDate, () => {
     const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
 
     const result = fromDate(ninetyDaysEarlier);
-    expect(result).toEqual({ days: 90 });
+    expect(result).toEqual(expect.objectContaining({ months: 2, days: 29 }));
   });
 
   it('should produce a duration based on a 60 days in the past', () => {
@@ -122,7 +123,7 @@ describe(fromDate, () => {
     const sixtyDaysEarlier = '2019-11-02T00:00:00Z';
 
     const result = fromDate(sixtyDaysEarlier);
-    expect(result).toEqual({ days: 60 });
+    expect(result).toEqual(expect.objectContaining({ months: 1, days: 29 }));
   });
 
   it('should produce a duration based on 23 hours in the past', () => {
@@ -130,7 +131,7 @@ describe(fromDate, () => {
     const twentyThreeHoursEarlier = '2019-12-31T01:00:00Z';
 
     const result = fromDate(twentyThreeHoursEarlier);
-    expect(result).toEqual({ hours: 23 });
+    expect(result).toEqual(expect.objectContaining({ hours: 23 }));
   });
 
   it('should produce a duration based on 30 minutes in the past', () => {
@@ -138,7 +139,7 @@ describe(fromDate, () => {
     const thirtyMinutesEarlier = '2019-12-31T23:30:00Z';
 
     const result = fromDate(thirtyMinutesEarlier);
-    expect(result).toEqual({ minutes: 30 });
+    expect(result).toEqual(expect.objectContaining({ minutes: 30 }));
   });
 
   it('should produce a duration based on 10 seconds in the past', () => {
@@ -146,7 +147,7 @@ describe(fromDate, () => {
     const tenSecondsEarlier = '2019-12-31T23:59:50Z';
 
     const result = fromDate(tenSecondsEarlier);
-    expect(result).toEqual({ seconds: 10 });
+    expect(result).toEqual(expect.objectContaining({ seconds: 10 }));
   });
 });
 
@@ -289,3 +290,27 @@ describe(toString, () => {
     expect(toString({ seconds: 1 })).toBe('1 second');
   });
 });
+
+describe(fromSeconds, () => {
+  it('should return undefined if given invalid input', () => {
+    expect(fromSeconds('lolol')).toBeUndefined();
+  });
+
+  it('should return undefined given a string that does not end in seconds', () => {
+    expect(fromSeconds('3600m')).toBeUndefined();
+  });
+
+  it('should correctly parse minutes', () => {
+    expect(fromSeconds('60s')).toEqual(expect.objectContaining({ minutes: 1 }));
+  });
+
+  it('should correctly parse hours', () => {
+    expect(fromSeconds('3600s')).toEqual(expect.objectContaining({ hours: 1 }));
+  });
+
+  it('should correctly parse minutes', () => {
+    expect(fromSeconds('3660s')).toEqual(
+      expect.objectContaining({ hours: 1, minutes: 1 }),
+    );
+  });
+});