Merge "Normalize timestamp formatting throughout UI"
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
index 71dc5bf..767b078 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
@@ -1 +1 @@
-24a0c7c0bd17908474dda832f51aca06226c44f93043b6b6ff8b698d013818ea
\ No newline at end of file
+b918b41bdb245964e3b3616ef35e29b3deb04e7b256305b28546bde9c7c04d81
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
index 2e2f6fd..e0abae4 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
@@ -1 +1 @@
-01576322a83634df3f5d4fee6c7dac5c3835f926b7de752b99e827e5688fec2d
\ No newline at end of file
+a185bd58eb4cceaad5dd3e8d69d2625ee7ac50953703c9a592925b72c00717e7
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
index 58c3975..9cb399f 100644
--- a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
@@ -1 +1 @@
-f618d3e05c466f49a4e130b02b1ab597dc8e0d51285c801fa9af3c6e9df505a5
\ No newline at end of file
+c7fb612c14f41ec904126417c4deceac68d547d1faf674014b645a8812d5f5cc
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
index 9d7b91c..48da846 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
@@ -1 +1 @@
-86d0bc258ef11ba82a78f4b28a1463fb4b732545b95f2ee8e8a4d6271370a030
\ No newline at end of file
+c249df2a8cdf3ced5894a2ddee0860222f46ed7af39c5d626244373e388fd73e
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
index 312b01f..230d996 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
@@ -1 +1 @@
-c8e073f0030da0c6d2dd514eed99a8e0eed79ec75c18e177812de73bf0440cf2
\ No newline at end of file
+17c9c7d4b0018f1843ecbcdb217fd3f822494f56242b5f1b7eb4a6baf05182f4
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
index 02705ed..bb93f7a 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
@@ -1 +1 @@
-c29b529b5bbd318e0df531f9118a78c8cce94004cc77a887cd394b3093d135c0
\ No newline at end of file
+b3f0d174fea5cb10bf1ba5237fc5128c4166206158cc1f9ddb8c47b66752a16d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
index 8e93988..ce23972 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
@@ -1 +1 @@
-56ec21cedc480d6b1b2a894bc70411c89424386a7910d62a09a6a5f1a5921c15
\ No newline at end of file
+b90310189240334201a45b3e7131d7c4890ca211709d6998e947cfdd1c3e30a2
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256 b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
index df63f69..cac11c2 100644
--- a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
@@ -1 +1 @@
-10931936be5a35ed461fce47ede47225ffc750a85a13462a614366f26160ca11
\ No newline at end of file
+dd7344276b4bbd204f2cd3b1ab351ab924c3c2e7120fb78ff7074d2b6251beb6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
index 6272735..85172fe 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
@@ -1 +1 @@
-c421aafc09b9ce506fd6eaa1dc358855182cd18704715491f83b7d49828c5826
\ No newline at end of file
+331b08c85045c5642e832933e7acd939bbbcd80a6af433e19959289081b8300d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
index 8e93988..ce23972 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
@@ -1 +1 @@
-56ec21cedc480d6b1b2a894bc70411c89424386a7910d62a09a6a5f1a5921c15
\ No newline at end of file
+b90310189240334201a45b3e7131d7c4890ca211709d6998e947cfdd1c3e30a2
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
index 6272735..85172fe 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
@@ -1 +1 @@
-c421aafc09b9ce506fd6eaa1dc358855182cd18704715491f83b7d49828c5826
\ No newline at end of file
+331b08c85045c5642e832933e7acd939bbbcd80a6af433e19959289081b8300d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
index 6272735..85172fe 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
@@ -1 +1 @@
-c421aafc09b9ce506fd6eaa1dc358855182cd18704715491f83b7d49828c5826
\ No newline at end of file
+331b08c85045c5642e832933e7acd939bbbcd80a6af433e19959289081b8300d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
index cbb5609..9a193b7 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
@@ -1 +1 @@
-7b0fb778fef603ab28c5bc8459db420d2c9d6c6a7a3e5b4e6547b3a2fb067249
\ No newline at end of file
+3b2014254174c0180c91117e9f9c291cad30a7fafe306d76aa016e787af4a43a
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
index 8e93988..ce23972 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
@@ -1 +1 @@
-56ec21cedc480d6b1b2a894bc70411c89424386a7910d62a09a6a5f1a5921c15
\ No newline at end of file
+b90310189240334201a45b3e7131d7c4890ca211709d6998e947cfdd1c3e30a2
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
index 6272735..85172fe 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
@@ -1 +1 @@
-c421aafc09b9ce506fd6eaa1dc358855182cd18704715491f83b7d49828c5826
\ No newline at end of file
+331b08c85045c5642e832933e7acd939bbbcd80a6af433e19959289081b8300d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
index 6272735..85172fe 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
@@ -1 +1 @@
-c421aafc09b9ce506fd6eaa1dc358855182cd18704715491f83b7d49828c5826
\ No newline at end of file
+331b08c85045c5642e832933e7acd939bbbcd80a6af433e19959289081b8300d
\ No newline at end of file
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index a83a9f6..08c2ac6 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -527,7 +527,7 @@
 }
 
 .time-axis-panel {
-  height: 12px;
+  height: 22px;
 }
 
 .tickbar {
diff --git a/ui/src/assets/details.scss b/ui/src/assets/details.scss
index 9a1fd7f..70702dc 100644
--- a/ui/src/assets/details.scss
+++ b/ui/src/assets/details.scss
@@ -363,17 +363,6 @@
     }
   }
 
-  button {
-    background: #262f3c;
-    color: white;
-    border-radius: 10px;
-    font-size: 10px;
-    height: 22px;
-    line-height: 18px;
-    min-width: 7em;
-    margin: auto 0 auto 1rem;
-  }
-
   input[type="text"] {
     flex-grow: 1;
     border-radius: 4px;
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 8e5afe6..640b30e 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -42,3 +42,4 @@
 @import "widgets/details_shell";
 @import "widgets/grid_layout";
 @import "widgets/section";
+@import "widgets/timestamp";
diff --git a/ui/src/assets/widgets/button.scss b/ui/src/assets/widgets/button.scss
index 61eeb50..a73c5b2 100644
--- a/ui/src/assets/widgets/button.scss
+++ b/ui/src/assets/widgets/button.scss
@@ -98,7 +98,7 @@
 
   // Reduce padding when we are icon-only
   &.pf-icon-only {
-    & > i {
+    & > .pf-left-icon {
       margin: 0;
     }
 
diff --git a/ui/src/assets/widgets/timestamp.scss b/ui/src/assets/widgets/timestamp.scss
new file mode 100644
index 0000000..f361ff4
--- /dev/null
+++ b/ui/src/assets/widgets/timestamp.scss
@@ -0,0 +1,38 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@import "theme";
+
+// Make millis, micros, & nanos slightly smaller than hh:mm:ss for readability.
+$subsec-font-size: 0.9em;
+
+.pf-timecode {
+  // Spacing the sub sections using CSS rather than spaces makes the spaces
+  // disappear when copying.
+  .pf-timecode-millis {
+    margin-left: 1px;
+    font-size: $subsec-font-size;
+  }
+  .pf-timecode-micros {
+    margin-left: 2px;
+    font-size: $subsec-font-size;
+  }
+  .pf-timecode-nanos {
+    margin-left: 2px;
+    font-size: $subsec-font-size;
+  }
+  .pf-button {
+    margin-left: 2px;
+  }
+}
diff --git a/ui/src/base/bigint_math.ts b/ui/src/base/bigint_math.ts
index 45fab7b..1e2897c 100644
--- a/ui/src/base/bigint_math.ts
+++ b/ui/src/base/bigint_math.ts
@@ -102,4 +102,9 @@
   static ratio(dividend: bigint, divisor: bigint): number {
     return Number(dividend) / Number(divisor);
   }
+
+  // Calculates the absolute value of a n.
+  static abs(n: bigint) {
+    return n < 0n ? -1n * n : n;
+  }
 }
diff --git a/ui/src/base/bigint_math_unittest.ts b/ui/src/base/bigint_math_unittest.ts
index 33e7179..2aa6b19 100644
--- a/ui/src/base/bigint_math_unittest.ts
+++ b/ui/src/base/bigint_math_unittest.ts
@@ -221,4 +221,21 @@
           .toBeCloseTo(0.125, 3);
     });
   });
+
+  describe('abs', () => {
+    test('should return the absolute value of a positive BigInt', () => {
+      const result = BIM.abs(12345678901234567890n);
+      expect(result).toEqual(12345678901234567890n);
+    });
+
+    test('should return the absolute value of a negative BigInt', () => {
+      const result = BIM.abs(-12345678901234567890n);
+      expect(result).toEqual(12345678901234567890n);
+    });
+
+    test('should return the absolute value of zero', () => {
+      const result = BIM.abs(0n);
+      expect(result).toEqual(0n);
+    });
+  });
 });
diff --git a/ui/src/common/time.ts b/ui/src/common/time.ts
index d9157df..b823dbf 100644
--- a/ui/src/common/time.ts
+++ b/ui/src/common/time.ts
@@ -12,13 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../base/bigint_math';
 import {assertTrue} from '../base/logging';
 import {asTPTimestamp, toTraceTime} from '../frontend/sql_types';
 
 import {ColumnType} from './query_result';
 
-// TODO(hjd): Combine with timeToCode.
-export function tpTimeToString(time: TPTime) {
+// Print a duration using a handful of significant figures.
+// Use this when readability is more desireable than precision.
+// Examples: 1234 -> 1.23ns
+//           123456789 -> 123ms
+//           123,123,123,123,123 -> 34h 12m
+//           1,000,000,023 -> 1 s
+//           1,230,000,023 -> 1.2 s
+export function formatDuration(time: TPTime) {
   const sec = tpTimeToSeconds(time);
   const units = ['s', 'ms', 'us', 'ns'];
   const sign = Math.sign(sec);
@@ -28,65 +35,73 @@
     n *= 1000;
     u++;
   }
-  return `${sign < 0 ? '-' : ''}${Math.round(n * 10) / 10} ${units[u]}`;
+  return `${sign < 0 ? '-' : ''}${Math.round(n * 10) / 10}${units[u]}`;
 }
 
-// 1000000023ns -> "1.000 000 023"
-export function formatTPTime(time: TPTime) {
-  const strTime = time.toString().padStart(10, '0');
+// This class takes a time and converts it to a set of strings representing a
+// time code where each string represents a group of time units formatted with
+// an appropriate number of leading zeros.
+export class Timecode {
+  public readonly sign: string;
+  public readonly days: string;
+  public readonly hours: string;
+  public readonly minutes: string;
+  public readonly seconds: string;
+  public readonly millis: string;
+  public readonly micros: string;
+  public readonly nanos: string;
 
-  const nanos = strTime.slice(-3);
-  const micros = strTime.slice(-6, -3);
-  const millis = strTime.slice(-9, -6);
-  const seconds = strTime.slice(0, -9);
+  constructor(time: TPTime) {
+    this.sign = time < 0 ? '-' : '';
 
-  return `${seconds}.${millis} ${micros} ${nanos}`;
+    const absTime = BigintMath.abs(time);
+
+    const days = (absTime / 86_400_000_000_000n);
+    const hours = (absTime / 3_600_000_000_000n) % 24n;
+    const minutes = (absTime / 60_000_000_000n) % 60n;
+    const seconds = (absTime / 1_000_000_000n) % 60n;
+    const millis = (absTime / 1_000_000n) % 1_000n;
+    const micros = (absTime / 1_000n) % 1_000n;
+    const nanos = absTime % 1_000n;
+
+    this.days = days.toString();
+    this.hours = hours.toString().padStart(2, '0');
+    this.minutes = minutes.toString().padStart(2, '0');
+    this.seconds = seconds.toString().padStart(2, '0');
+    this.millis = millis.toString().padStart(3, '0');
+    this.micros = micros.toString().padStart(3, '0');
+    this.nanos = nanos.toString().padStart(3, '0');
+  }
+
+  // Get the upper part of the timecode formatted as: [-]DdHH:MM:SS.
+  get dhhmmss(): string {
+    const days = this.days === '0' ? '' : `${this.days}d`;
+    return `${this.sign}${days}${this.hours}:${this.minutes}:${this.seconds}`;
+  }
+
+  // Get the subsecond part of the timecode formatted as: mmm uuu nnn.
+  // The "space" char is configurable but defaults to a normal space.
+  subsec(spaceChar: string = ' '): string {
+    return `${this.millis}${spaceChar}${this.micros}${spaceChar}${this.nanos}`;
+  }
+
+  // Formats the entire timecode to a string.
+  toString(spaceChar: string = ' '): string {
+    return `${this.dhhmmss}.${this.subsec(spaceChar)}`;
+  }
 }
 
-// TODO(hjd): Rename to formatTimestampWithUnits
-// 1000000023ns -> "1s 23ns"
-export function tpTimeToCode(time: TPTime): string {
-  let result = '';
-  if (time < 1) return '0s';
-  const unitAndValue: [string, bigint][] = [
-    ['m', 60000000000n],
-    ['s', 1000000000n],
-    ['ms', 1000000n],
-    ['us', 1000n],
-    ['ns', 1n],
-  ];
-  unitAndValue.forEach(([unit, unitSize]) => {
-    if (time >= unitSize) {
-      const unitCount = time / unitSize;
-      result += unitCount.toLocaleString() + unit + ' ';
-      time %= unitSize;
-    }
-  });
-  return result.slice(0, -1);
+// Single entry point where timestamps can be converted to the globally
+// configured domain.
+// In the future this will be configurable.
+export function toDomainTime(time: TPTime): TPTime {
+  return toTraceTime(asTPTimestamp(time));
 }
 
 export function toNs(seconds: number) {
   return Math.round(seconds * 1e9);
 }
 
-// Given an absolute time in TP units, print the time from the start of the
-// trace as a string.
-// Going forward this shall be the universal timestamp printing function
-// superseding all others, with options to customise formatting and the domain.
-// If minimal is true, the time will be printed without any units and in a
-// minimal but still readable format, otherwise the time will be printed with
-// units on each group of digits. Use minimal in places like tables and
-// timelines where there are likely to be multiple timestamps in one place, and
-// use the normal formatting in places that have one-off timestamps.
-export function formatTime(time: TPTime, minimal: boolean = false): string {
-  const relTime = toTraceTime(asTPTimestamp(time));
-  if (minimal) {
-    return formatTPTime(relTime);
-  } else {
-    return tpTimeToCode(relTime);
-  }
-}
-
 export function currentDateHourAndMinute(): string {
   const date = new Date();
   return `${date.toISOString().substr(0, 10)}-${date.getHours()}-${
diff --git a/ui/src/common/time_unittest.ts b/ui/src/common/time_unittest.ts
index 47395ec..fdf22d6 100644
--- a/ui/src/common/time_unittest.ts
+++ b/ui/src/common/time_unittest.ts
@@ -12,71 +12,53 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {globals} from '../frontend/globals';
+import {createEmptyState} from './empty_state';
 import {
-  formatTPTime,
+  formatDuration,
+  Timecode,
   TPTime,
   TPTimeSpan,
-  tpTimeToCode,
-  tpTimeToString,
 } from './time';
 
-test('tpTimeToCode', () => {
-  expect(tpTimeToCode(0n)).toEqual('0s');
-  expect(tpTimeToCode(3_000_000_000n)).toEqual('3s');
-  expect(tpTimeToCode(60_000_000_000n)).toEqual('1m');
-  expect(tpTimeToCode(63_000_000_000n)).toEqual('1m 3s');
-  expect(tpTimeToCode(63_200_000_000n)).toEqual('1m 3s 200ms');
-  expect(tpTimeToCode(63_222_100_000n)).toEqual('1m 3s 222ms 100us');
-  expect(tpTimeToCode(63_222_111_100n)).toEqual('1m 3s 222ms 111us 100ns');
-  expect(tpTimeToCode(222_111_100n)).toEqual('222ms 111us 100ns');
-  expect(tpTimeToCode(1_000n)).toEqual('1us');
-  expect(tpTimeToCode(3_000n)).toEqual('3us');
-  expect(tpTimeToCode(1_000_001_000n)).toEqual('1s 1us');
-  expect(tpTimeToCode(200_000_000_030n)).toEqual('3m 20s 30ns');
-  expect(tpTimeToCode(3_600_000_000_000n)).toEqual('60m');
-  expect(tpTimeToCode(3_600_000_000_001n)).toEqual('60m 1ns');
-  expect(tpTimeToCode(86_400_000_000_000n)).toEqual('1,440m');
-  expect(tpTimeToCode(86_400_000_000_001n)).toEqual('1,440m 1ns');
-  expect(tpTimeToCode(31_536_000_000_000_000n)).toEqual('525,600m');
-  expect(tpTimeToCode(31_536_000_000_000_001n)).toEqual('525,600m 1ns');
+beforeAll(() => {
+  globals.state = createEmptyState();
+  globals.state.traceTime.start = 0n;
 });
 
-test('formatTPTime', () => {
-  expect(formatTPTime(0n)).toEqual('0.000 000 000');
-  expect(formatTPTime(3_000_000_000n)).toEqual('3.000 000 000');
-  expect(formatTPTime(60_000_000_000n)).toEqual('60.000 000 000');
-  expect(formatTPTime(63_000_000_000n)).toEqual('63.000 000 000');
-  expect(formatTPTime(63_200_000_000n)).toEqual('63.200 000 000');
-  expect(formatTPTime(63_222_100_000n)).toEqual('63.222 100 000');
-  expect(formatTPTime(63_222_111_100n)).toEqual('63.222 111 100');
-  expect(formatTPTime(222_111_100n)).toEqual('0.222 111 100');
-  expect(formatTPTime(1_000n)).toEqual('0.000 001 000');
-  expect(formatTPTime(3_000n)).toEqual('0.000 003 000');
-  expect(formatTPTime(1_000_001_000n)).toEqual('1.000 001 000');
-  expect(formatTPTime(200_000_000_030n)).toEqual('200.000 000 030');
-  expect(formatTPTime(3_600_000_000_000n)).toEqual('3600.000 000 000');
-  expect(formatTPTime(86_400_000_000_000n)).toEqual('86400.000 000 000');
-  expect(formatTPTime(86_400_000_000_001n)).toEqual('86400.000 000 001');
-  expect(formatTPTime(31_536_000_000_000_000n)).toEqual('31536000.000 000 000');
-  expect(formatTPTime(31_536_000_000_000_001n)).toEqual('31536000.000 000 001');
+test('formatDuration', () => {
+  expect(formatDuration(0n)).toEqual('0s');
+  expect(formatDuration(123n)).toEqual('123ns');
+  expect(formatDuration(1_234n)).toEqual('1.2us');
+  expect(formatDuration(12_345n)).toEqual('12.3us');
+  expect(formatDuration(3_000_000_000n)).toEqual('3s');
+  expect(formatDuration(60_000_000_000n)).toEqual('60s');
+  expect(formatDuration(63_000_000_000n)).toEqual('63s');
+  expect(formatDuration(63_200_000_000n)).toEqual('63.2s');
+  expect(formatDuration(63_222_100_000n)).toEqual('63.2s');
+  expect(formatDuration(63_222_111_100n)).toEqual('63.2s');
+  expect(formatDuration(222_111_100n)).toEqual('222.1ms');
+  expect(formatDuration(1_000n)).toEqual('1us');
+  expect(formatDuration(3_000n)).toEqual('3us');
+  expect(formatDuration(1_000_001_000n)).toEqual('1s');
+  expect(formatDuration(200_000_000_030n)).toEqual('200s');
+  expect(formatDuration(3_600_000_000_000n)).toEqual('3600s');
+  expect(formatDuration(86_400_000_000_000n)).toEqual('86400s');
+  expect(formatDuration(31_536_000_000_000_000n)).toEqual('31536000s');
 });
 
-test('tpTimeToString', () => {
-  expect(tpTimeToString(0n)).toEqual('0 s');
-  expect(tpTimeToString(3_000_000_000n)).toEqual('3 s');
-  expect(tpTimeToString(60_000_000_000n)).toEqual('60 s');
-  expect(tpTimeToString(63_000_000_000n)).toEqual('63 s');
-  expect(tpTimeToString(63_200_000_000n)).toEqual('63.2 s');
-  expect(tpTimeToString(63_222_100_000n)).toEqual('63.2 s');
-  expect(tpTimeToString(63_222_111_100n)).toEqual('63.2 s');
-  expect(tpTimeToString(222_111_100n)).toEqual('222.1 ms');
-  expect(tpTimeToString(1_000n)).toEqual('1 us');
-  expect(tpTimeToString(3_000n)).toEqual('3 us');
-  expect(tpTimeToString(1_000_001_000n)).toEqual('1 s');
-  expect(tpTimeToString(200_000_000_030n)).toEqual('200 s');
-  expect(tpTimeToString(3_600_000_000_000n)).toEqual('3600 s');
-  expect(tpTimeToString(86_400_000_000_000n)).toEqual('86400 s');
-  expect(tpTimeToString(31_536_000_000_000_000n)).toEqual('31536000 s');
+test('timecode', () => {
+  expect(new Timecode(0n).toString(' ')).toEqual('00:00:00.000 000 000');
+  expect(new Timecode(123n).toString(' ')).toEqual('00:00:00.000 000 123');
+  expect(new Timecode(60_000_000_000n).toString(' '))
+      .toEqual('00:01:00.000 000 000');
+  expect(new Timecode(12_345_678_910n).toString(' '))
+      .toEqual('00:00:12.345 678 910');
+  expect(new Timecode(86_400_000_000_000n).toString(' '))
+      .toEqual('1d00:00:00.000 000 000');
+  expect(new Timecode(31_536_000_000_000_000n).toString(' '))
+      .toEqual('365d00:00:00.000 000 000');
+  expect(new Timecode(-123n).toString(' ')).toEqual('-00:00:00.000 000 123');
 });
 
 function mkSpan(start: TPTime, end: TPTime) {
diff --git a/ui/src/frontend/chrome_slice_panel.ts b/ui/src/frontend/chrome_slice_panel.ts
index 1102482..472ccc2 100644
--- a/ui/src/frontend/chrome_slice_panel.ts
+++ b/ui/src/frontend/chrome_slice_panel.ts
@@ -20,10 +20,10 @@
 import {EngineProxy} from '../common/engine';
 import {runQuery} from '../common/queries';
 import {
+  formatDuration,
   TPDuration,
   tpDurationToSeconds,
   TPTime,
-  tpTimeToCode,
 } from '../common/time';
 import {Argument, convertArgsToTree, Key} from '../controller/args_parser';
 
@@ -35,6 +35,7 @@
 import {asTPTimestamp} from './sql_types';
 import {Button} from './widgets/button';
 import {DetailsShell} from './widgets/details_shell';
+import {Duration} from './widgets/duration';
 import {Column, GridLayout} from './widgets/grid_layout';
 import {MenuItem, PopupMenu2} from './widgets/menu';
 import {Section} from './widgets/section';
@@ -316,9 +317,13 @@
   }
 }
 
-function computeDuration(ts: TPTime, dur: TPDuration): string {
-  return dur === -1n ? `${globals.state.traceTime.end - ts} (Did not end)` :
-                       tpTimeToCode(dur);
+function computeDuration(ts: TPTime, dur: TPDuration): m.Children {
+  if (dur === -1n) {
+    const minDuration = globals.state.traceTime.end - ts;
+    return `${formatDuration(minDuration)} (Did not end)`;
+  } else {
+    return m(Duration, {dur});
+  }
 }
 
 export class ChromeSliceDetailsPanel implements m.ClassComponent {
@@ -420,8 +425,10 @@
           sliceInfo.threadDur === -1n ? '' : ` (${(ratio * 100).toFixed(2)}%)`;
       return m(TreeNode, {
         left: 'Thread duration',
-        right: computeDuration(sliceInfo.threadTs, sliceInfo.threadDur) +
-            threadDurFractionSuffix,
+        right: [
+          computeDuration(sliceInfo.threadTs, sliceInfo.threadDur),
+          threadDurFractionSuffix,
+        ],
       });
     } else {
       return undefined;
@@ -486,7 +493,7 @@
         TreeNode,
         {left: 'Flow'},
         m(TreeNode, {left: 'Slice', right: sliceLink}),
-        m(TreeNode, {left: 'Delay', right: tpTimeToCode(dur)}),
+        m(TreeNode, {left: 'Delay', right: m(Duration, {dur})}),
         m(TreeNode, {left: 'Thread', right: threadName}),
     );
   }
diff --git a/ui/src/frontend/counter_panel.ts b/ui/src/frontend/counter_panel.ts
index e1710a8..b9d0979 100644
--- a/ui/src/frontend/counter_panel.ts
+++ b/ui/src/frontend/counter_panel.ts
@@ -14,11 +14,10 @@
 
 import m from 'mithril';
 
-import {tpTimeToCode} from '../common/time';
-
 import {globals} from './globals';
 import {asTPTimestamp} from './sql_types';
 import {DetailsShell} from './widgets/details_shell';
+import {Duration} from './widgets/duration';
 import {GridLayout} from './widgets/grid_layout';
 import {Section} from './widgets/section';
 import {Timestamp} from './widgets/timestamp';
@@ -56,7 +55,7 @@
                     }),
                     m(TreeNode, {
                       left: 'Duration',
-                      right: `${tpTimeToCode(counterInfo.duration)}`,
+                      right: m(Duration, {dur: counterInfo.duration}),
                     }),
                     ),
                 )),
diff --git a/ui/src/frontend/flamegraph_panel.ts b/ui/src/frontend/flamegraph_panel.ts
index 0ac5f79..30a1d45 100644
--- a/ui/src/frontend/flamegraph_panel.ts
+++ b/ui/src/frontend/flamegraph_panel.ts
@@ -28,7 +28,6 @@
   FlamegraphStateViewingOption,
   ProfileType,
 } from '../common/state';
-import {tpTimeToCode} from '../common/time';
 import {profileType} from '../controller/flamegraph_controller';
 
 import {Flamegraph, NodeRendering} from './flamegraph';
@@ -40,6 +39,7 @@
 import {getCurrentTrace} from './sidebar';
 import {convertTraceToPprofAndDownload} from './trace_converter';
 import {Button} from './widgets/button';
+import {Duration} from './widgets/duration';
 import {findRef} from './widgets/utils';
 
 interface FlamegraphDetailsPanelAttrs {}
@@ -108,7 +108,8 @@
                         toSelectedCallsite(
                             flamegraphDetails.expandedCallsite)}`),
                   m('div.time',
-                    `Snapshot time: ${tpTimeToCode(flamegraphDetails.dur)}`),
+                    `Snapshot time: `,
+                    m(Duration, {dur: flamegraphDetails.dur})),
                   m('input[type=text][placeholder=Focus]', {
                     oninput: (e: Event) => {
                       const target = (e.target as HTMLInputElement);
diff --git a/ui/src/frontend/flow_events_panel.ts b/ui/src/frontend/flow_events_panel.ts
index ee2f146..961043c 100644
--- a/ui/src/frontend/flow_events_panel.ts
+++ b/ui/src/frontend/flow_events_panel.ts
@@ -15,11 +15,11 @@
 import m from 'mithril';
 
 import {Actions} from '../common/actions';
-import {tpTimeToCode} from '../common/time';
 
 import {Flow, globals} from './globals';
 import {BLANK_CHECKBOX, CHECKBOX} from './icons';
 import {Panel, PanelSize} from './panel';
+import {Duration} from './widgets/duration';
 
 export const ALL_CATEGORIES = '_all_';
 
@@ -95,7 +95,7 @@
 
       const data = [
         m('td.flow-link', args, outgoing ? 'Outgoing' : 'Incoming'),
-        m('td.flow-link', args, tpTimeToCode(flow.dur)),
+        m('td.flow-link', args, m(Duration, {dur: flow.dur})),
         m('td.flow-link', args, otherEnd.sliceId.toString()),
         m('td.flow-link', args, otherEnd.sliceName),
         m('td.flow-link', args, flow.begin.threadName),
diff --git a/ui/src/frontend/ftrace_panel.ts b/ui/src/frontend/ftrace_panel.ts
index 02b3d12..84337a9 100644
--- a/ui/src/frontend/ftrace_panel.ts
+++ b/ui/src/frontend/ftrace_panel.ts
@@ -189,7 +189,7 @@
       for (let i = 0; i < events.length; i++) {
         const {ts, name, cpu, process, args} = events[i];
 
-        const timestamp = m(Timestamp, {ts: asTPTimestamp(ts), minimal: true});
+        const timestamp = m(Timestamp, {ts: asTPTimestamp(ts)});
 
         const rank = i + offset;
 
diff --git a/ui/src/frontend/gridline_helper.ts b/ui/src/frontend/gridline_helper.ts
index 6581c81..00045a1 100644
--- a/ui/src/frontend/gridline_helper.ts
+++ b/ui/src/frontend/gridline_helper.ts
@@ -147,7 +147,7 @@
   time: TPTime;
 }
 
-const MIN_PX_PER_STEP = 80;
+export const MIN_PX_PER_STEP = 120;
 export function getMaxMajorTicks(width: number) {
   return Math.max(1, Math.floor(width / MIN_PX_PER_STEP));
 }
diff --git a/ui/src/frontend/logs_panel.ts b/ui/src/frontend/logs_panel.ts
index 030763e..eb9a5c2 100644
--- a/ui/src/frontend/logs_panel.ts
+++ b/ui/src/frontend/logs_panel.ts
@@ -156,7 +156,7 @@
                 'onmouseover': this.onRowOver.bind(this, ts),
                 'onmouseout': this.onRowOut.bind(this),
               },
-              m('.cell', m(Timestamp, {ts: asTPTimestamp(ts), minimal: true})),
+              m('.cell', m(Timestamp, {ts: asTPTimestamp(ts)})),
               m('.cell', priorityLetter || '?'),
               m('.cell', tags[i]),
               hasProcessNames ? m('.cell.with-process', processNames[i]) :
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index 27d9ec8..d656fa6 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -17,9 +17,6 @@
 import {Actions} from '../common/actions';
 import {randomColor} from '../common/colorizer';
 import {AreaNote, Note} from '../common/state';
-import {
-  tpTimeToString,
-} from '../common/time';
 
 import {
   BottomTab,
@@ -36,7 +33,11 @@
   timeScaleForVisibleWindow,
 } from './gridline_helper';
 import {Panel, PanelSize} from './panel';
+import {Icons} from './semantic_icons';
 import {isTraceLoaded} from './sidebar';
+import {asTPTimestamp} from './sql_types';
+import {Button} from './widgets/button';
+import {Timestamp} from './widgets/timestamp';
 
 const FLAG_WIDTH = 16;
 const AREA_TRIANGLE_WIDTH = 10;
@@ -325,12 +326,13 @@
     if (note === undefined) {
       return m('.', `No Note with id ${this.config.id}`);
     }
-    const startTime = getStartTimestamp(note) - globals.state.traceTime.start;
+    const startTime = getStartTimestamp(note);
     return m(
         '.notes-editor-panel',
         m('.notes-editor-panel-heading-bar',
           m('.notes-editor-panel-heading',
-            `Annotation at ${tpTimeToString(startTime)}`),
+            `Annotation at `,
+            m(Timestamp, {ts: asTPTimestamp(startTime)})),
           m('input[type=text]', {
             onkeydown: (e: Event) => {
               e.stopImmediatePropagation();
@@ -354,15 +356,16 @@
                 }));
               },
             })),
-          m('button',
-            {
-              onclick: () => {
-                globals.dispatch(Actions.removeNote({id: this.config.id}));
-                globals.dispatch(Actions.setCurrentTab({tab: undefined}));
-                globals.rafScheduler.scheduleFullRedraw();
-              },
+          m(Button, {
+            label: 'Remove',
+            icon: Icons.Delete,
+            minimal: true,
+            onclick: () => {
+              globals.dispatch(Actions.removeNote({id: this.config.id}));
+              globals.dispatch(Actions.setCurrentTab({tab: undefined}));
+              globals.rafScheduler.scheduleFullRedraw();
             },
-            'Remove')),
+          })),
     );
   }
 }
diff --git a/ui/src/frontend/overview_timeline_panel.ts b/ui/src/frontend/overview_timeline_panel.ts
index fbc275f..4c3b1db 100644
--- a/ui/src/frontend/overview_timeline_panel.ts
+++ b/ui/src/frontend/overview_timeline_panel.ts
@@ -17,8 +17,9 @@
 import {hueForCpu} from '../common/colorizer';
 import {
   Span,
+  Timecode,
+  toDomainTime,
   TPTime,
-  tpTimeToSeconds,
 } from '../common/time';
 
 import {
@@ -32,7 +33,12 @@
 import {OuterDragStrategy} from './drag/outer_drag_strategy';
 import {DragGestureHandler} from './drag_gesture_handler';
 import {globals} from './globals';
-import {getMaxMajorTicks, TickGenerator, TickType} from './gridline_helper';
+import {
+  getMaxMajorTicks,
+  MIN_PX_PER_STEP,
+  TickGenerator,
+  TickType,
+} from './gridline_helper';
 import {Panel, PanelSize} from './panel';
 import {PxSpan, TimeScale} from './time_scale';
 
@@ -103,8 +109,9 @@
         if (xPos > this.width) break;
         if (type === TickType.MAJOR) {
           ctx.fillRect(xPos - 1, 0, 1, headerHeight - 5);
-          const sec = tpTimeToSeconds(time - globals.state.traceTime.start);
-          ctx.fillText(sec.toFixed(tickGen.digits) + ' s', xPos + 5, 18);
+          const relTime = toDomainTime(time);
+          const timecode = new Timecode(relTime);
+          ctx.fillText(timecode.dhhmmss, xPos + 5, 18, MIN_PX_PER_STEP);
         } else if (type == TickType.MEDIUM) {
           ctx.fillRect(xPos - 1, 0, 1, 8);
         } else if (type == TickType.MINOR) {
diff --git a/ui/src/frontend/pivot_table.ts b/ui/src/frontend/pivot_table.ts
index bf56ad2..e8a8156 100644
--- a/ui/src/frontend/pivot_table.ts
+++ b/ui/src/frontend/pivot_table.ts
@@ -27,7 +27,6 @@
   PivotTableResult,
   SortDirection,
 } from '../common/state';
-import {tpTimeToCode} from '../common/time';
 
 import {globals} from './globals';
 import {Panel} from './panel';
@@ -49,6 +48,7 @@
 import {runQueryInNewTab} from './query_result_tab';
 import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
 import {AttributeModalHolder} from './tables/attribute_modal_holder';
+import {Duration} from './widgets/duration';
 
 
 interface PathItem {
@@ -195,11 +195,11 @@
     return m('tr', renderedCells);
   }
 
-  renderCell(column: TableColumn, value: ColumnType): string {
+  renderCell(column: TableColumn, value: ColumnType): m.Children {
     if (column.kind === 'regular' &&
         (column.column === 'dur' || column.column === 'thread_dur')) {
       if (typeof value === 'bigint') {
-        return tpTimeToCode(value);
+        return m(Duration, {dur: value});
       }
     }
     return `${value}`;
diff --git a/ui/src/frontend/semantic_icons.ts b/ui/src/frontend/semantic_icons.ts
index e30eb99..a80acca 100644
--- a/ui/src/frontend/semantic_icons.ts
+++ b/ui/src/frontend/semantic_icons.ts
@@ -18,4 +18,5 @@
   static readonly ChangeViewport = 'query_stats';   // Could be 'search'
   static readonly ContextMenu = 'arrow_drop_down';  // Could be 'more_vert'
   static readonly Copy = 'content_copy';
+  static readonly Delete = 'delete';
 }
diff --git a/ui/src/frontend/slice_details_panel.ts b/ui/src/frontend/slice_details_panel.ts
index eaab6ad..e1adb23 100644
--- a/ui/src/frontend/slice_details_panel.ts
+++ b/ui/src/frontend/slice_details_panel.ts
@@ -16,7 +16,6 @@
 
 import {Actions} from '../common/actions';
 import {translateState} from '../common/thread_state';
-import {formatTime, tpTimeToCode} from '../common/time';
 
 import {Anchor} from './anchor';
 import {globals, SliceDetails, ThreadDesc} from './globals';
@@ -24,6 +23,7 @@
 import {SlicePanel} from './slice_panel';
 import {asTPTimestamp} from './sql_types';
 import {DetailsShell} from './widgets/details_shell';
+import {Duration} from './widgets/duration';
 import {GridLayout} from './widgets/grid_layout';
 import {Section} from './widgets/section';
 import {SqlRef} from './widgets/sql_ref';
@@ -84,10 +84,13 @@
     if (!threadInfo) {
       return null;
     }
-    const timestamp = formatTime(sliceInfo.wakeupTs!);
+    const ts = asTPTimestamp(sliceInfo.wakeupTs!);
     return m(
         '.slice-details-wakeup-text',
-        m('', `Wakeup @ ${timestamp} on CPU ${sliceInfo.wakerCpu} by`),
+        m('',
+          `Wakeup @ `,
+          m(Timestamp, {ts}),
+          ` on CPU ${sliceInfo.wakerCpu} by`),
         m('', `P: ${threadInfo.procName} [${threadInfo.pid}]`),
         m('', `T: ${threadInfo.threadName} [${threadInfo.tid}]`),
     );
@@ -98,10 +101,10 @@
       return null;
     }
 
-    const latency = tpTimeToCode(sliceInfo.ts - sliceInfo.wakeupTs);
+    const latency = sliceInfo.ts - sliceInfo.wakeupTs;
     return m(
         '.slice-details-latency-text',
-        m('', `Scheduling latency: ${latency}`),
+        m('', `Scheduling latency: `, m(Duration, {dur: latency})),
         m('.text-detail',
           `This is the interval from when the task became eligible to run
         (e.g. because of notifying a wait queue it was suspended on) to
diff --git a/ui/src/frontend/slice_panel.ts b/ui/src/frontend/slice_panel.ts
index 9d6f542..b4adda0 100644
--- a/ui/src/frontend/slice_panel.ts
+++ b/ui/src/frontend/slice_panel.ts
@@ -12,10 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {TPDuration, TPTime, tpTimeToCode} from '../common/time';
+import m from 'mithril';
+
+import {formatDuration, TPDuration, TPTime} from '../common/time';
 
 import {globals, SliceDetails} from './globals';
 import {Panel} from './panel';
+import {Duration} from './widgets/duration';
 
 // To display process or thread, we want to concatenate their name with ID, but
 // either can be undefined and all the cases need to be considered carefully to
@@ -34,9 +37,13 @@
 }
 
 export abstract class SlicePanel extends Panel {
-  protected computeDuration(ts: TPTime, dur: TPDuration): string {
-    return dur === -1n ? `${globals.state.traceTime.end - ts} (Did not end)` :
-                         tpTimeToCode(dur);
+  protected computeDuration(ts: TPTime, dur: TPDuration): m.Children {
+    if (dur === -1n) {
+      const minDuration = globals.state.traceTime.end - ts;
+      return `${formatDuration(minDuration)} (Did not end)`;
+    } else {
+      return m(Duration, {dur});
+    }
   }
 
   protected getProcessThreadDetails(sliceInfo: SliceDetails) {
diff --git a/ui/src/frontend/thread_state_tab.ts b/ui/src/frontend/thread_state_tab.ts
index b086104..c4a7295 100644
--- a/ui/src/frontend/thread_state_tab.ts
+++ b/ui/src/frontend/thread_state_tab.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {TPTime, tpTimeToCode} from '../common/time';
+import {TPTime} from '../common/time';
 
 import {Anchor} from './anchor';
 import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
@@ -27,6 +27,7 @@
 } from './thread_and_process_info';
 import {getThreadState, goToSchedSlice, ThreadState} from './thread_state';
 import {DetailsShell} from './widgets/details_shell';
+import {Duration} from './widgets/duration';
 import {GridLayout} from './widgets/grid_layout';
 import {Section} from './widgets/section';
 import {SqlRef} from './widgets/sql_ref';
@@ -100,7 +101,7 @@
         }),
         m(TreeNode, {
           left: 'Duration',
-          right: tpTimeToCode(state.dur),
+          right: m(Duration, {dur: state.dur}),
         }),
         m(TreeNode, {
           left: 'State',
diff --git a/ui/src/frontend/time_axis_panel.ts b/ui/src/frontend/time_axis_panel.ts
index 4819d54..5d7cf83 100644
--- a/ui/src/frontend/time_axis_panel.ts
+++ b/ui/src/frontend/time_axis_panel.ts
@@ -14,15 +14,13 @@
 
 import m from 'mithril';
 
-import {
-  tpTimeToSeconds,
-  tpTimeToString,
-} from '../common/time';
+import {Timecode, toDomainTime, TPTime} from '../common/time';
 
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {globals} from './globals';
 import {
   getMaxMajorTicks,
+  MIN_PX_PER_STEP,
   TickGenerator,
   TickType,
   timeScaleForVisibleWindow,
@@ -36,11 +34,12 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
     ctx.fillStyle = '#999';
-    ctx.font = '10px Roboto Condensed';
     ctx.textAlign = 'left';
+    ctx.font = '11px Roboto Condensed';
 
-    const startTime = tpTimeToString(globals.state.traceTime.start);
-    ctx.fillText(startTime + ' +', 6, 11);
+    const traceStartTime = globals.state.traceTime.start;
+    const width = renderTimecode(ctx, traceStartTime, 6, 10);
+    ctx.fillText('+', 6 + width, 15, 6);
 
     ctx.save();
     ctx.beginPath();
@@ -52,20 +51,39 @@
     if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) {
       const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH);
       const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
-      const tickGen =
-          new TickGenerator(span, maxMajorTicks, globals.state.traceTime.start);
+      const tickGen = new TickGenerator(span, maxMajorTicks, traceStartTime);
       for (const {type, time} of tickGen) {
-        const position = Math.floor(map.tpTimeToPx(time));
-        const sec = tpTimeToSeconds(time - globals.state.traceTime.start);
         if (type === TickType.MAJOR) {
+          const position = Math.floor(map.tpTimeToPx(time));
           ctx.fillRect(position, 0, 1, size.height);
-          ctx.fillText(sec.toFixed(tickGen.digits) + ' s', position + 5, 10);
+          const relTime = toDomainTime(time);
+          renderTimecode(ctx, relTime, position + 5, 10);
         }
       }
     }
-
     ctx.restore();
-
     ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
   }
 }
+
+// Print a timecode over 2 lines with this formatting:
+// DdHH:MM:SS
+// mmm uuu nnn
+// Returns the resultant width of the timecode.
+function renderTimecode(
+    ctx: CanvasRenderingContext2D, time: TPTime, x: number, y: number): number {
+  const timecode = new Timecode(time);
+  ctx.font = '11px Roboto Condensed';
+
+  const {dhhmmss} = timecode;
+  const thinSpace = '\u2009';
+  const subsec = timecode.subsec(thinSpace);
+  ctx.fillText(dhhmmss, x, y, MIN_PX_PER_STEP);
+  const {width: firstRowWidth} = ctx.measureText(subsec);
+
+  ctx.font = '10.5px Roboto Condensed';
+  ctx.fillText(subsec, x, y + 10, MIN_PX_PER_STEP);
+  const {width: secondRowWidth} = ctx.measureText(subsec);
+
+  return Math.max(firstRowWidth, secondRowWidth);
+}
diff --git a/ui/src/frontend/time_selection_panel.ts b/ui/src/frontend/time_selection_panel.ts
index 5711e6a..0c19fed 100644
--- a/ui/src/frontend/time_selection_panel.ts
+++ b/ui/src/frontend/time_selection_panel.ts
@@ -13,9 +13,9 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {BigintMath} from '../base/bigint_math';
 
-import {Span, tpTimeToString} from '../common/time';
+import {BigintMath} from '../base/bigint_math';
+import {formatDuration, Span, Timecode, toDomainTime} from '../common/time';
 import {
   TPTime,
   TPTimeSpan,
@@ -188,9 +188,8 @@
     const {visibleTimeScale} = globals.frontendLocalState;
     const xPos =
         TRACK_SHELL_WIDTH + Math.floor(visibleTimeScale.tpTimeToPx(ts));
-    const offsetTime = tpTimeToString(ts - globals.state.traceTime.start);
-    const timeFromStart = tpTimeToString(ts);
-    const label = `${offsetTime} (${timeFromStart})`;
+    const domainTime = toDomainTime(ts);
+    const label = new Timecode(domainTime).dhhmmss;
     drawIBar(ctx, xPos, this.bounds(size), label);
   }
 
@@ -199,7 +198,7 @@
     const {visibleTimeScale} = globals.frontendLocalState;
     const xLeft = visibleTimeScale.tpTimeToPx(span.start);
     const xRight = visibleTimeScale.tpTimeToPx(span.end);
-    const label = tpTimeToString(span.duration);
+    const label = formatDuration(span.duration);
     drawHBar(
         ctx,
         {
diff --git a/ui/src/frontend/widgets/duration.ts b/ui/src/frontend/widgets/duration.ts
index cd18e02..10f61bd 100644
--- a/ui/src/frontend/widgets/duration.ts
+++ b/ui/src/frontend/widgets/duration.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {TPDuration, tpTimeToCode} from '../../common/time';
+import {formatDuration, TPDuration} from '../../common/time';
 
 interface DurationAttrs {
   dur: TPDuration;
@@ -22,6 +22,6 @@
 
 export class Duration implements m.ClassComponent<DurationAttrs> {
   view(vnode: m.Vnode<DurationAttrs>) {
-    return tpTimeToCode(vnode.attrs.dur);
+    return formatDuration(vnode.attrs.dur);
   }
 }
diff --git a/ui/src/frontend/widgets/timestamp.ts b/ui/src/frontend/widgets/timestamp.ts
index 374f8e3..b945489 100644
--- a/ui/src/frontend/widgets/timestamp.ts
+++ b/ui/src/frontend/widgets/timestamp.ts
@@ -14,37 +14,57 @@
 
 import m from 'mithril';
 
-import {formatTime} from '../../common/time';
-import {Anchor} from '../anchor';
+import {Timecode, toDomainTime} from '../../common/time';
 import {copyToClipboard} from '../clipboard';
 import {Icons} from '../semantic_icons';
 import {TPTimestamp} from '../sql_types';
 
+import {Button} from './button';
 import {MenuItem, PopupMenu2} from './menu';
 
+// import {MenuItem, PopupMenu2} from './menu';
+
 interface TimestampAttrs {
   // The timestamp to print, this should be the absolute, raw timestamp as
   // found in trace processor.
   ts: TPTimestamp;
-  minimal?: boolean;
 }
 
 export class Timestamp implements m.ClassComponent<TimestampAttrs> {
   view({attrs}: m.Vnode<TimestampAttrs>) {
-    const {ts, minimal = false} = attrs;
+    const {ts} = attrs;
     return m(
-        PopupMenu2,
-        {
-          trigger:
-              m(Anchor, {icon: Icons.ContextMenu}, formatTime(ts, minimal)),
-        },
-        m(MenuItem, {
-          icon: Icons.Copy,
-          label: 'Copy raw timestamp',
-          onclick: () => {
-            copyToClipboard(ts.toString());
-          },
-        }),
+        'span.pf-timecode',
+        renderTimecode(ts),
+        m(
+            PopupMenu2,
+            {
+              trigger: m(Button, {
+                icon: Icons.ContextMenu,
+                compact: true,
+                minimal: true,
+              }),
+            },
+            m(MenuItem, {
+              icon: Icons.Copy,
+              label: `Copy raw value`,
+              onclick: () => {
+                copyToClipboard(ts.toString());
+              },
+            }),
+            ),
     );
   }
 }
+
+function renderTimecode(ts: TPTimestamp): m.Children {
+  const relTime = toDomainTime(ts);
+  const {dhhmmss, millis, micros, nanos} = new Timecode(relTime);
+  return [
+    m('span.pf-timecode-hms', dhhmmss),
+    '.',
+    m('span.pf-timecode-millis', millis),
+    m('span.pf-timecode-micros', micros),
+    m('span.pf-timecode-nanos', nanos),
+  ];
+}
diff --git a/ui/src/frontend/widgets/utils.ts b/ui/src/frontend/widgets/utils.ts
index 6521162..859a5cb 100644
--- a/ui/src/frontend/widgets/utils.ts
+++ b/ui/src/frontend/widgets/utils.ts
@@ -42,7 +42,7 @@
 // Allows doing the following
 //   exists(val) && m('div', val)
 // Even if val is a non-nullish falsey value like 0 or ''
-export function exists<T>(value: T): value is Exclude<T, null|undefined> {
+export function exists<T>(value: T): value is NonNullable<T> {
   return value !== undefined && value !== null;
 }
 
diff --git a/ui/src/tracks/cpu_slices/index.ts b/ui/src/tracks/cpu_slices/index.ts
index 2b2d03d..c3c9a25 100644
--- a/ui/src/tracks/cpu_slices/index.ts
+++ b/ui/src/tracks/cpu_slices/index.ts
@@ -24,11 +24,7 @@
 import {colorForThread} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {LONG, NUM} from '../../common/query_result';
-import {
-  TPDuration,
-  TPTime,
-  tpTimeToString,
-} from '../../common/time';
+import {formatDuration, TPDuration, TPTime} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -366,7 +362,7 @@
               latencyWidth >= 20);
           // Latency time with a white semi-transparent background.
           const latency = tStart - details.wakeupTs;
-          const displayText = tpTimeToString(latency);
+          const displayText = formatDuration(latency);
           const measured = ctx.measureText(displayText);
           if (latencyWidth >= measured.width + 2) {
             ctx.fillStyle = 'rgba(255,255,255,0.7)';