This is an automated email from the ASF dual-hosted git repository.

vogievetsky pushed a commit to branch segment_timeline2
in repository https://gitbox.apache.org/repos/asf/druid.git

commit 5c430de66ce982acef7bb87c5158b6ee81fd18d0
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Tue Oct 29 16:16:42 2024 -0700

    progress
---
 licenses.yaml                                      |  76 ++--
 web-console/package-lock.json                      | 119 ++---
 web-console/package.json                           |   2 +-
 web-console/script/licenses                        |   1 +
 web-console/src/druid-models/index.ts              |   1 +
 .../segment/segment.ts}                            |  35 +-
 .../date-floor-shift-ceil-utc.spec.ts              | 169 +++++++
 .../date-floor-shift-ceil.spec.ts                  | 181 ++++++++
 .../date-floor-shift-ceil/date-floor-shift-ceil.ts | 296 ++++++++++++
 web-console/src/utils/duration/duration.spec.ts    | 505 +++++++++++++++++++++
 web-console/src/utils/duration/duration.ts         | 381 ++++++++++++++++
 web-console/src/utils/index.tsx                    |   2 +
 .../views/datasources-view/datasources-view.tsx    |  10 +-
 .../modules/multi-axis-chart-module.tsx            |  12 +-
 .../explore-view/modules/time-chart-module.tsx     |  17 +-
 .../src/views/explore-view/utils/duration.ts       |  46 --
 .../explore-view/utils/filter-pattern-helpers.ts   |   8 +-
 .../explore-view/utils/get-auto-granularity.ts     |   7 +-
 web-console/src/views/explore-view/utils/index.ts  |   2 -
 .../explore-view/utils/snap-to-granularity.ts      |  57 ---
 .../src/views/explore-view/utils/table-query.ts    |   7 +-
 .../src/views/segments-view/segments-view.tsx      |  60 +--
 22 files changed, 1663 insertions(+), 331 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index 40305fc55c6..48c4ebd8390 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5224,6 +5224,16 @@ license_file_path: licenses/bin/@emotion-weak-memoize.MIT
 
 ---
 
+name: "@flatten-js/interval-tree"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Alex Bol
+version: 1.1.3
+license_file_path: licenses/bin/@flatten-js-interval-tree.MIT
+
+---
+
 name: "@fontsource/open-sans"
 license_category: binary
 module: web-console
@@ -5234,6 +5244,15 @@ license_file_path: licenses/bin/@fontsource-open-sans.OFL
 
 ---
 
+name: "@internationalized/date"
+license_category: binary
+module: web-console
+license_name: Apache License version 2.0
+copyright: Adobe
+version: 3.5.6
+
+---
+
 name: "@popperjs/core"
 license_category: binary
 module: web-console
@@ -5244,6 +5263,15 @@ license_file_path: licenses/bin/@popperjs-core.MIT
 
 ---
 
+name: "@swc/helpers"
+license_category: binary
+module: web-console
+license_name: Apache License version 2.0
+copyright: 강동윤
+version: 0.5.13
+
+---
+
 name: "@types/parse-json"
 license_category: binary
 module: web-console
@@ -5404,15 +5432,6 @@ license_file_path: licenses/bin/change-case.MIT
 
 ---
 
-name: "chronoshift"
-license_category: binary
-module: web-console
-license_name: Apache License version 2.0
-copyright: Vadim Ogievetsky
-version: 0.10.0
-
----
-
 name: "classnames"
 license_category: binary
 module: web-console
@@ -5801,16 +5820,6 @@ license_file_path: licenses/bin/has-flag.MIT
 
 ---
 
-name: "has-own-prop"
-license_category: binary
-module: web-console
-license_name: MIT License
-copyright: Sindre Sorhus
-version: 2.0.0
-license_file_path: licenses/bin/has-own-prop.MIT
-
----
-
 name: "hasown"
 license_category: binary
 module: web-console
@@ -5871,15 +5880,6 @@ license_file_path: licenses/bin/iconv-lite.MIT
 
 ---
 
-name: "immutable-class"
-license_category: binary
-module: web-console
-license_name: Apache License version 2.0
-copyright: Vadim Ogievetsky
-version: 0.11.2
-
----
-
 name: "import-fresh"
 license_category: binary
 module: web-console
@@ -6060,26 +6060,6 @@ license_file_path: licenses/bin/mime-types.MIT
 
 ---
 
-name: "moment-timezone"
-license_category: binary
-module: web-console
-license_name: MIT License
-copyright: Tim Wood
-version: 0.5.43
-license_file_path: licenses/bin/moment-timezone.MIT
-
----
-
-name: "moment"
-license_category: binary
-module: web-console
-license_name: MIT License
-copyright: Iskren Ivov Chernev
-version: 2.29.4
-license_file_path: licenses/bin/moment.MIT
-
----
-
 name: "no-case"
 license_category: binary
 module: web-console
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 4abbd266e0c..709bba0bf5b 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -17,9 +17,9 @@
         "@druid-toolkit/query": "^0.22.23",
         "@flatten-js/interval-tree": "^1.1.3",
         "@fontsource/open-sans": "^5.0.30",
+        "@internationalized/date": "^3.5.6",
         "ace-builds": "~1.5.3",
         "axios": "^1.7.7",
-        "chronoshift": "^0.10.0",
         "classnames": "^2.2.6",
         "copy-to-clipboard": "^3.3.3",
         "d3-array": "^3.2.4",
@@ -2449,6 +2449,15 @@
         "url": "https://github.com/sponsors/nzakas";
       }
     },
+    "node_modules/@internationalized/date": {
+      "version": "3.5.6",
+      "resolved": 
"https://registry.npmjs.org/@internationalized/date/-/date-3.5.6.tgz";,
+      "integrity": 
"sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@swc/helpers": "^0.5.0"
+      }
+    },
     "node_modules/@istanbuljs/load-nyc-config": {
       "version": "1.1.0",
       "resolved": 
"https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz";,
@@ -3480,6 +3489,15 @@
         "url": "https://opencollective.com/eslint";
       }
     },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.13",
+      "resolved": 
"https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz";,
+      "integrity": 
"sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@testing-library/dom": {
       "version": "10.4.0",
       "resolved": 
"https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz";,
@@ -5809,16 +5827,6 @@
         "node": ">=6.0"
       }
     },
-    "node_modules/chronoshift": {
-      "version": "0.10.0",
-      "resolved": 
"https://registry.npmjs.org/chronoshift/-/chronoshift-0.10.0.tgz";,
-      "integrity": 
"sha512-dNvumPg7R6ACUOKbGo1zH6DtmTo5ut9/LNbzqaKGnpC9VdArIos8+kApHOVIZH4FCpm9M9XYh++jwlRHhc1PyA==",
-      "dependencies": {
-        "immutable-class": "^0.11.0",
-        "moment-timezone": "^0.5.26",
-        "tslib": "^2.3.1"
-      }
-    },
     "node_modules/ci-info": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz";,
@@ -8916,14 +8924,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/has-own-prop": {
-      "version": "2.0.0",
-      "resolved": 
"https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz";,
-      "integrity": 
"sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==",
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/has-property-descriptors": {
       "version": "1.0.2",
       "resolved": 
"https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz";,
@@ -9361,15 +9361,6 @@
       "integrity": 
"sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==",
       "dev": true
     },
-    "node_modules/immutable-class": {
-      "version": "0.11.2",
-      "resolved": 
"https://registry.npmjs.org/immutable-class/-/immutable-class-0.11.2.tgz";,
-      "integrity": 
"sha512-CzkVPkJXzkspt6RX+ipNgtvt16+rzEBUlA3yNPLkK5/S042c9wvuyfE4F5TfMfPJ6XF86Fp+OCwu6eeAnMICuw==",
-      "dependencies": {
-        "has-own-prop": "^2.0.0",
-        "tslib": "^2.3.1"
-      }
-    },
     "node_modules/import-fresh": {
       "version": "3.3.0",
       "resolved": 
"https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz";,
@@ -12896,25 +12887,6 @@
         "mkdirp": "bin/cmd.js"
       }
     },
-    "node_modules/moment": {
-      "version": "2.29.4",
-      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz";,
-      "integrity": 
"sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
-      "engines": {
-        "node": "*"
-      }
-    },
-    "node_modules/moment-timezone": {
-      "version": "0.5.43",
-      "resolved": 
"https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz";,
-      "integrity": 
"sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==",
-      "dependencies": {
-        "moment": "^2.29.4"
-      },
-      "engines": {
-        "node": "*"
-      }
-    },
     "node_modules/mrmime": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz";,
@@ -19827,6 +19799,14 @@
       "integrity": 
"sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
       "dev": true
     },
+    "@internationalized/date": {
+      "version": "3.5.6",
+      "resolved": 
"https://registry.npmjs.org/@internationalized/date/-/date-3.5.6.tgz";,
+      "integrity": 
"sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==",
+      "requires": {
+        "@swc/helpers": "^0.5.0"
+      }
+    },
     "@istanbuljs/load-nyc-config": {
       "version": "1.1.0",
       "resolved": 
"https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz";,
@@ -20626,6 +20606,14 @@
         }
       }
     },
+    "@swc/helpers": {
+      "version": "0.5.13",
+      "resolved": 
"https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz";,
+      "integrity": 
"sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
+      "requires": {
+        "tslib": "^2.4.0"
+      }
+    },
     "@testing-library/dom": {
       "version": "10.4.0",
       "resolved": 
"https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz";,
@@ -22423,16 +22411,6 @@
       "integrity": 
"sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
       "dev": true
     },
-    "chronoshift": {
-      "version": "0.10.0",
-      "resolved": 
"https://registry.npmjs.org/chronoshift/-/chronoshift-0.10.0.tgz";,
-      "integrity": 
"sha512-dNvumPg7R6ACUOKbGo1zH6DtmTo5ut9/LNbzqaKGnpC9VdArIos8+kApHOVIZH4FCpm9M9XYh++jwlRHhc1PyA==",
-      "requires": {
-        "immutable-class": "^0.11.0",
-        "moment-timezone": "^0.5.26",
-        "tslib": "^2.3.1"
-      }
-    },
     "ci-info": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz";,
@@ -24662,11 +24640,6 @@
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz";,
       "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
     },
-    "has-own-prop": {
-      "version": "2.0.0",
-      "resolved": 
"https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz";,
-      "integrity": 
"sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ=="
-    },
     "has-property-descriptors": {
       "version": "1.0.2",
       "resolved": 
"https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz";,
@@ -24994,15 +24967,6 @@
       "integrity": 
"sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==",
       "dev": true
     },
-    "immutable-class": {
-      "version": "0.11.2",
-      "resolved": 
"https://registry.npmjs.org/immutable-class/-/immutable-class-0.11.2.tgz";,
-      "integrity": 
"sha512-CzkVPkJXzkspt6RX+ipNgtvt16+rzEBUlA3yNPLkK5/S042c9wvuyfE4F5TfMfPJ6XF86Fp+OCwu6eeAnMICuw==",
-      "requires": {
-        "has-own-prop": "^2.0.0",
-        "tslib": "^2.3.1"
-      }
-    },
     "import-fresh": {
       "version": "3.3.0",
       "resolved": 
"https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz";,
@@ -27569,19 +27533,6 @@
         "minimist": "^1.2.5"
       }
     },
-    "moment": {
-      "version": "2.29.4",
-      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz";,
-      "integrity": 
"sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
-    },
-    "moment-timezone": {
-      "version": "0.5.43",
-      "resolved": 
"https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz";,
-      "integrity": 
"sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==",
-      "requires": {
-        "moment": "^2.29.4"
-      }
-    },
     "mrmime": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz";,
diff --git a/web-console/package.json b/web-console/package.json
index 2580151bac1..22bb09d232c 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -58,9 +58,9 @@
     "@druid-toolkit/query": "^0.22.23",
     "@flatten-js/interval-tree": "^1.1.3",
     "@fontsource/open-sans": "^5.0.30",
+    "@internationalized/date": "^3.5.6",
     "ace-builds": "~1.5.3",
     "axios": "^1.7.7",
-    "chronoshift": "^0.10.0",
     "classnames": "^2.2.6",
     "copy-to-clipboard": "^3.3.3",
     "d3-array": "^3.2.4",
diff --git a/web-console/script/licenses b/web-console/script/licenses
index 90f1420282a..f4e67862d01 100755
--- a/web-console/script/licenses
+++ b/web-console/script/licenses
@@ -193,6 +193,7 @@ checker.init(
           if (name === 'diff-match-patch') publisher = 'Google';
           if (name === 'esutils') publisher = 'Yusuke Suzuki'; // 
https://github.com/estools/esutils#license
           if (name === 'echarts') publisher = 'Apache Software Foundation';
+          if (name === '@internationalized/date') publisher = 'Adobe';
         }
 
         if (!publisher) {
diff --git a/web-console/src/druid-models/index.ts 
b/web-console/src/druid-models/index.ts
index 82f14aab4e6..3e5c7062232 100644
--- a/web-console/src/druid-models/index.ts
+++ b/web-console/src/druid-models/index.ts
@@ -37,6 +37,7 @@ export * from './lookup-spec/lookup-spec';
 export * from './metric-spec/metric-spec';
 export * from './overlord-dynamic-config/overlord-dynamic-config';
 export * from './query-context/query-context';
+export * from './segment/segment';
 export * from './stages/stages';
 export * from './supervisor-status/supervisor-status';
 export * from './task/task';
diff --git a/web-console/src/views/explore-view/utils/duration.spec.ts 
b/web-console/src/druid-models/segment/segment.ts
similarity index 54%
rename from web-console/src/views/explore-view/utils/duration.spec.ts
rename to web-console/src/druid-models/segment/segment.ts
index 0d7e0473131..3860d4f5127 100644
--- a/web-console/src/views/explore-view/utils/duration.spec.ts
+++ b/web-console/src/druid-models/segment/segment.ts
@@ -16,24 +16,25 @@
  * limitations under the License.
  */
 
-import { formatDuration } from './duration';
+import { Duration } from '../../utils';
 
-describe('formatDuration', () => {
-  it('works with 0', () => {
-    expect(formatDuration('PT0S')).toEqual('0 seconds');
-  });
+export const START_OF_TIME_DATE = '-146136543-09-08T08:23:32.096Z';
+export const END_OF_TIME_DATE = '146140482-04-24T15:36:27.903Z';
 
-  it('works with single span', () => {
-    expect(formatDuration('P1D')).toEqual('1 day');
-    expect(formatDuration('PT1M')).toEqual('1 minute');
-  });
+export function computeSegmentTimeSpan(start: string, end: string): string {
+  if (start === START_OF_TIME_DATE && end === END_OF_TIME_DATE) {
+    return 'All';
+  }
 
-  it('works with single span (compact)', () => {
-    expect(formatDuration('PT1M', true)).toEqual('minute');
-  });
+  const startDate = new Date(start);
+  if (isNaN(startDate.valueOf())) {
+    return 'Invalid start';
+  }
 
-  it('works with multiple spans', () => {
-    expect(formatDuration('PT2H30M15S')).toEqual('2 hours, 30 minutes, 15 
seconds');
-    expect(formatDuration('PT2H30M15S', true)).toEqual('2 hours, 30 minutes, 
15 seconds');
-  });
-});
+  const endDate = new Date(end);
+  if (isNaN(endDate.valueOf())) {
+    return 'Invalid end';
+  }
+
+  return Duration.fromRange(startDate, endDate, 
'Etc/UTC').getDescription(true);
+}
diff --git 
a/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts 
b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts
new file mode 100755
index 00000000000..5ba63b04468
--- /dev/null
+++ 
b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { shifters } from './date-floor-shift-ceil';
+
+function pairwise<T>(array: T[], callback: (t1: T, t2: T) => void) {
+  for (let i = 0; i < array.length - 1; i++) {
+    callback(array[i], array[i + 1]);
+  }
+}
+
+describe('floor, shift, ceil (UTC)', () => {
+  const tz = 'Etc/UTC';
+
+  it('moves seconds', () => {
+    const dates: Date[] = [
+      new Date('2012-11-04T00:00:00Z'),
+      new Date('2012-11-04T00:00:03Z'),
+      new Date('2012-11-04T00:00:06Z'),
+      new Date('2012-11-04T00:00:09Z'),
+      new Date('2012-11-04T00:00:12Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.second.shift(d1, tz, 
3)).toEqual(d2));
+  });
+
+  it('rounds minutes', () => {
+    expect(shifters.minute.round(new Date('2012-11-04T00:29:00Z'), 15, 
tz)).toEqual(
+      new Date('2012-11-04T00:15:00Z'),
+    );
+
+    expect(shifters.minute.round(new Date('2012-11-04T00:29:00Z'), 4, 
tz)).toEqual(
+      new Date('2012-11-04T00:28:00Z'),
+    );
+  });
+
+  it('moves minutes', () => {
+    const dates: Date[] = [
+      new Date('2012-11-04T00:00:00Z'),
+      new Date('2012-11-04T00:03:00Z'),
+      new Date('2012-11-04T00:06:00Z'),
+      new Date('2012-11-04T00:09:00Z'),
+      new Date('2012-11-04T00:12:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.minute.shift(d1, tz, 
3)).toEqual(d2));
+  });
+
+  it('floors hour correctly', () => {
+    expect(shifters.hour.floor(new Date('2012-11-04T00:30:00Z'), tz)).toEqual(
+      new Date('2012-11-04T00:00:00Z'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T01:30:00Z'), tz)).toEqual(
+      new Date('2012-11-04T01:00:00Z'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T01:30:00Z'), tz)).toEqual(
+      new Date('2012-11-04T01:00:00Z'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T02:30:00Z'), tz)).toEqual(
+      new Date('2012-11-04T02:00:00Z'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T03:30:00Z'), tz)).toEqual(
+      new Date('2012-11-04T03:00:00Z'),
+    );
+  });
+
+  it('moves hour', () => {
+    const dates: Date[] = [
+      new Date('2012-11-04T00:00:00Z'),
+      new Date('2012-11-04T01:00:00Z'),
+      new Date('2012-11-04T02:00:00Z'),
+      new Date('2012-11-04T03:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.hour.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('moves day', () => {
+    const dates: Date[] = [
+      new Date('2012-11-03T00:00:00Z'),
+      new Date('2012-11-04T00:00:00Z'),
+      new Date('2012-11-05T00:00:00Z'),
+      new Date('2012-11-06T00:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.day.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('ceils day', () => {
+    let d1 = new Date('2014-12-11T22:11:57.469Z');
+    let d2 = new Date('2014-12-12T00:00:00.000Z');
+    expect(shifters.day.ceil(d1, tz)).toEqual(d2);
+
+    d1 = new Date('2014-12-08T00:00:00.000Z');
+    d2 = new Date('2014-12-08T00:00:00.000Z');
+    expect(shifters.day.ceil(d1, tz)).toEqual(d2);
+  });
+
+  it('moves week', () => {
+    const dates: Date[] = [
+      new Date('2012-10-29T00:00:00Z'),
+      new Date('2012-11-05T00:00:00Z'),
+      new Date('2012-11-12T00:00:00Z'),
+      new Date('2012-11-19T00:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.week.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('floors week correctly', () => {
+    let d1 = new Date('2014-12-11T22:11:57.469Z');
+    let d2 = new Date('2014-12-08T00:00:00.000Z');
+    expect(shifters.week.floor(d1, tz)).toEqual(d2);
+
+    d1 = new Date('2014-12-07T12:11:57.469Z');
+    d2 = new Date('2014-12-01T00:00:00.000Z');
+    expect(shifters.week.floor(d1, tz)).toEqual(d2);
+  });
+
+  it('ceils week correctly', () => {
+    let d1 = new Date('2014-12-11T22:11:57.469Z');
+    let d2 = new Date('2014-12-15T00:00:00.000Z');
+    expect(shifters.week.ceil(d1, tz)).toEqual(d2);
+
+    d1 = new Date('2014-12-07T12:11:57.469Z');
+    d2 = new Date('2014-12-08T00:00:00.000Z');
+    expect(shifters.week.ceil(d1, tz)).toEqual(d2);
+  });
+
+  it('moves month', () => {
+    const dates: Date[] = [
+      new Date('2012-11-01T00:00:00Z'),
+      new Date('2012-12-01T00:00:00Z'),
+      new Date('2013-01-01T00:00:00Z'),
+      new Date('2013-02-01T00:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.month.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('shifts month on the 31st', () => {
+    const d1 = new Date('2016-03-31T00:00:00.000Z');
+    const d2 = new Date('2016-05-01T00:00:00.000Z');
+    expect(shifters.month.shift(d1, tz, 1)).toEqual(d2);
+  });
+
+  it('moves year', () => {
+    const dates: Date[] = [
+      new Date('2010-01-01T00:00:00Z'),
+      new Date('2011-01-01T00:00:00Z'),
+      new Date('2012-01-01T00:00:00Z'),
+      new Date('2013-01-01T00:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.year.shift(d1, tz, 
1)).toEqual(d2));
+  });
+});
diff --git 
a/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts 
b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts
new file mode 100755
index 00000000000..1612c9ce579
--- /dev/null
+++ b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { shifters } from './date-floor-shift-ceil';
+
+function pairwise<T>(array: T[], callback: (t1: T, t2: T) => void) {
+  for (let i = 0; i < array.length - 1; i++) {
+    callback(array[i], array[i + 1]);
+  }
+}
+
+describe('floor/shift/ceil', () => {
+  const tz = 'America/Los_Angeles';
+
+  it('shifts seconds', () => {
+    const dates: Date[] = [
+      new Date('2012-11-04T00:00:00-07:00'),
+      new Date('2012-11-04T00:00:03-07:00'),
+      new Date('2012-11-04T00:00:06-07:00'),
+      new Date('2012-11-04T00:00:09-07:00'),
+      new Date('2012-11-04T00:00:12-07:00'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.second.shift(d1, tz, 
3)).toEqual(d2));
+  });
+
+  it('shifts minutes', () => {
+    const dates: Date[] = [
+      new Date('2012-11-04T00:00:00-07:00'),
+      new Date('2012-11-04T00:03:00-07:00'),
+      new Date('2012-11-04T00:06:00-07:00'),
+      new Date('2012-11-04T00:09:00-07:00'),
+      new Date('2012-11-04T00:12:00-07:00'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.minute.shift(d1, tz, 
3)).toEqual(d2));
+  });
+
+  it('floors hour correctly', () => {
+    expect(shifters.hour.floor(new Date('2012-11-04T00:30:00-07:00'), 
tz)).toEqual(
+      new Date('2012-11-04T00:00:00-07:00'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T01:30:00-07:00'), 
tz)).toEqual(
+      new Date('2012-11-04T01:00:00-07:00'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T01:30:00-08:00'), 
tz)).toEqual(
+      new Date('2012-11-04T01:00:00-07:00'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T02:30:00-08:00'), 
tz)).toEqual(
+      new Date('2012-11-04T02:00:00-08:00'),
+    );
+
+    expect(shifters.hour.floor(new Date('2012-11-04T03:30:00-08:00'), 
tz)).toEqual(
+      new Date('2012-11-04T03:00:00-08:00'),
+    );
+  });
+
+  it('shifting 24 hours over DST is not the same as shifting a day', () => {
+    const start = new Date('2012-11-04T07:00:00Z');
+
+    const shift1Day = shifters.day.shift(start, tz, 1);
+    const shift24Hours = shifters.hour.shift(start, tz, 24);
+
+    expect(shift1Day).toEqual(new Date('2012-11-05T08:00:00Z'));
+    expect(shift24Hours).toEqual(new Date('2012-11-05T07:00:00Z'));
+  });
+
+  it('shifts hour over DST 1', () => {
+    const dates: Date[] = [
+      new Date('2012-11-04T00:00:00-07:00'),
+      new Date('2012-11-04T08:00:00Z'),
+      new Date('2012-11-04T09:00:00Z'),
+      new Date('2012-11-04T10:00:00Z'),
+      new Date('2012-11-04T11:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.hour.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('floors hour over DST 1', () => {
+    expect(shifters.hour.floor(new Date('2012-11-04T00:05:00-07:00'), 
tz)).toEqual(
+      new Date('2012-11-04T00:00:00-07:00'),
+    );
+    expect(shifters.hour.floor(new Date('2012-11-04T01:05:00-07:00'), 
tz)).toEqual(
+      new Date('2012-11-04T01:00:00-07:00'),
+    );
+    expect(shifters.hour.floor(new Date('2012-11-04T02:05:00-07:00'), 
tz)).toEqual(
+      new Date('2012-11-04T01:00:00-07:00'),
+    );
+    expect(shifters.hour.floor(new Date('2012-11-04T03:05:00-07:00'), 
tz)).toEqual(
+      new Date('2012-11-04T03:00:00-07:00'),
+    );
+  });
+
+  it('shifts hour over DST 2', () => {
+    // "2018-03-11T09:00:00Z"
+    const dates: Date[] = [
+      new Date('2018-03-11T01:00:00-07:00'),
+      new Date('2018-03-11T09:00:00Z'),
+      new Date('2018-03-11T10:00:00Z'),
+      new Date('2018-03-11T11:00:00Z'),
+      new Date('2018-03-11T12:00:00Z'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.hour.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('shifts day over DST', () => {
+    const dates: Date[] = [
+      new Date('2012-11-03T00:00:00-07:00'),
+      new Date('2012-11-04T00:00:00-07:00'),
+      new Date('2012-11-05T00:00:00-08:00'),
+      new Date('2012-11-06T00:00:00-08:00'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.day.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('shifts week over DST', () => {
+    const dates: Date[] = [
+      new Date('2012-10-29T00:00:00-07:00'),
+      new Date('2012-11-05T00:00:00-08:00'),
+      new Date('2012-11-12T00:00:00-08:00'),
+      new Date('2012-11-19T00:00:00-08:00'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.week.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('floors week correctly', () => {
+    let d1 = new Date('2014-12-11T22:11:57.469Z');
+    let d2 = new Date('2014-12-08T08:00:00.000Z');
+    expect(shifters.week.floor(d1, tz)).toEqual(d2);
+
+    d1 = new Date('2014-12-07T12:11:57.469Z');
+    d2 = new Date('2014-12-01T08:00:00.000Z');
+    expect(shifters.week.floor(d1, tz)).toEqual(d2);
+  });
+
+  it('ceils week correctly', () => {
+    let d1 = new Date('2014-12-11T22:11:57.469Z');
+    let d2 = new Date('2014-12-15T08:00:00.000Z');
+    expect(shifters.week.ceil(d1, tz)).toEqual(d2);
+
+    d1 = new Date('2014-12-07T12:11:57.469Z');
+    d2 = new Date('2014-12-08T08:00:00.000Z');
+    expect(shifters.week.ceil(d1, tz)).toEqual(d2);
+  });
+
+  it('shifts month over DST', () => {
+    const dates: Date[] = [
+      new Date('2012-11-01T00:00:00-07:00'),
+      new Date('2012-12-01T00:00:00-08:00'),
+      new Date('2013-01-01T00:00:00-08:00'),
+      new Date('2013-02-01T00:00:00-08:00'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.month.shift(d1, tz, 
1)).toEqual(d2));
+  });
+
+  it('shifts year', () => {
+    const dates: Date[] = [
+      new Date('2010-01-01T00:00:00-08:00'),
+      new Date('2011-01-01T00:00:00-08:00'),
+      new Date('2012-01-01T00:00:00-08:00'),
+      new Date('2013-01-01T00:00:00-08:00'),
+    ];
+    pairwise(dates, (d1, d2) => expect(shifters.year.shift(d1, tz, 
1)).toEqual(d2));
+  });
+});
diff --git 
a/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts 
b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts
new file mode 100755
index 00000000000..3306b05267d
--- /dev/null
+++ b/web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts
@@ -0,0 +1,296 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { fromDate, startOfWeek } from '@internationalized/date';
+
+export type AlignFn = (dt: Date, tz: string) => Date;
+
+export type ShiftFn = (dt: Date, tz: string, step: number) => Date;
+
+export type RoundFn = (dt: Date, roundTo: number, tz: string) => Date;
+
+export interface TimeShifterNoCeil {
+  canonicalLength: number;
+  siblings?: number;
+  floor: AlignFn;
+  round: RoundFn;
+  shift: ShiftFn;
+}
+
+export interface TimeShifter extends TimeShifterNoCeil {
+  ceil: AlignFn;
+}
+
+function isUTC(tz: string): boolean {
+  return tz === 'Etc/UTC';
+}
+
+function adjustDay(day: number): number {
+  return (day + 6) % 7;
+}
+
+function floorTo(n: number, roundTo: number): number {
+  return Math.floor(n / roundTo) * roundTo;
+}
+
+function timeShifterFiller(tm: TimeShifterNoCeil): TimeShifter {
+  const { floor, shift } = tm;
+  return {
+    ...tm,
+    ceil: (dt: Date, tz: string) => {
+      const floored = floor(dt, tz);
+      if (floored.valueOf() === dt.valueOf()) return dt; // Just like ceil(3) 
is 3 and not 4
+      return shift(floored, tz, 1);
+    },
+  };
+}
+
+export const second = timeShifterFiller({
+  canonicalLength: 1000,
+  siblings: 60,
+  floor: (dt, _tz) => {
+    // Seconds do not actually need a timezone because all timezones align on 
seconds... for now...
+    dt = new Date(dt.valueOf());
+    dt.setUTCMilliseconds(0);
+    return dt;
+  },
+  round: (dt, roundTo, _tz) => {
+    const cur = dt.getUTCSeconds();
+    const adj = floorTo(cur, roundTo);
+    if (cur !== adj) dt.setUTCSeconds(adj);
+    return dt;
+  },
+  shift: (dt, _tz, step) => {
+    dt = new Date(dt.valueOf());
+    dt.setUTCSeconds(dt.getUTCSeconds() + step);
+    return dt;
+  },
+});
+
+export const minute = timeShifterFiller({
+  canonicalLength: 60000,
+  siblings: 60,
+  floor: (dt, _tz) => {
+    // Minutes do not actually need a timezone because all timezones align on 
minutes... for now...
+    dt = new Date(dt.valueOf());
+    dt.setUTCSeconds(0, 0);
+    return dt;
+  },
+  round: (dt, roundTo, _tz) => {
+    const cur = dt.getUTCMinutes();
+    const adj = floorTo(cur, roundTo);
+    if (cur !== adj) dt.setUTCMinutes(adj);
+    return dt;
+  },
+  shift: (dt, _tz, step) => {
+    dt = new Date(dt.valueOf());
+    dt.setUTCMinutes(dt.getUTCMinutes() + step);
+    return dt;
+  },
+});
+
+// Movement by hour is tz independent because in every timezone an hour is 60 
min
+function hourMove(dt: Date, _tz: string, step: number) {
+  dt = new Date(dt.valueOf());
+  dt.setUTCHours(dt.getUTCHours() + step);
+  return dt;
+}
+
+export const hour = timeShifterFiller({
+  canonicalLength: 3600000,
+  siblings: 24,
+  floor: (dt, tz) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCMinutes(0, 0, 0);
+      return dt;
+    } else {
+      return fromDate(dt, tz).set({ second: 0, minute: 0, millisecond: 0 
}).toDate();
+    }
+  },
+  round: (dt, roundTo, tz) => {
+    if (isUTC(tz)) {
+      const cur = dt.getUTCHours();
+      const adj = floorTo(cur, roundTo);
+      if (cur !== adj) dt.setUTCHours(adj);
+    } else {
+      const cur = fromDate(dt, tz).hour;
+      const adj = floorTo(cur, roundTo);
+      if (cur !== adj) return hourMove(dt, tz, adj - cur);
+    }
+    return dt;
+  },
+  shift: hourMove,
+});
+
+export const day = timeShifterFiller({
+  canonicalLength: 24 * 3600000,
+  floor: (dt, tz) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCHours(0, 0, 0, 0);
+      return dt;
+    } else {
+      return fromDate(dt, tz).set({ hour: 0, second: 0, minute: 0, 
millisecond: 0 }).toDate();
+    }
+  },
+  shift: (dt, tz, step) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCDate(dt.getUTCDate() + step);
+      return dt;
+    } else {
+      return fromDate(dt, tz).add({ days: step }).toDate();
+    }
+  },
+  round: () => {
+    throw new Error('missing day round');
+  },
+});
+
+export const week = timeShifterFiller({
+  canonicalLength: 7 * 24 * 3600000,
+  floor: (dt, tz) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCHours(0, 0, 0, 0);
+      dt.setUTCDate(dt.getUTCDate() - adjustDay(dt.getUTCDay()));
+    } else {
+      const zd = fromDate(dt, tz);
+      return startOfWeek(
+        zd.set({ hour: 0, second: 0, minute: 0, millisecond: 0 }),
+        'fr-FR', // We want the week to start on Monday
+      ).toDate();
+    }
+    return dt;
+  },
+  shift: (dt, tz, step) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCDate(dt.getUTCDate() + step * 7);
+      return dt;
+    } else {
+      return fromDate(dt, tz).add({ weeks: step }).toDate();
+    }
+  },
+  round: () => {
+    throw new Error('missing week round');
+  },
+});
+
+function monthShift(dt: Date, tz: string, step: number) {
+  if (isUTC(tz)) {
+    dt = new Date(dt.valueOf());
+    dt.setUTCMonth(dt.getUTCMonth() + step);
+    return dt;
+  } else {
+    return fromDate(dt, tz).add({ months: step }).toDate();
+  }
+}
+
+export const month = timeShifterFiller({
+  canonicalLength: 30 * 24 * 3600000,
+  siblings: 12,
+  floor: (dt, tz) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCHours(0, 0, 0, 0);
+      dt.setUTCDate(1);
+      return dt;
+    } else {
+      return fromDate(dt, tz)
+        .set({ day: 1, hour: 0, second: 0, minute: 0, millisecond: 0 })
+        .toDate();
+    }
+  },
+  round: (dt, roundTo, tz) => {
+    if (isUTC(tz)) {
+      const cur = dt.getUTCMonth();
+      const adj = floorTo(cur, roundTo);
+      if (cur !== adj) dt.setUTCMonth(adj);
+    } else {
+      const cur = fromDate(dt, tz).month - 1; // Needs to be zero indexed
+      const adj = floorTo(cur, roundTo);
+      if (cur !== adj) return monthShift(dt, tz, adj - cur);
+    }
+    return dt;
+  },
+  shift: monthShift,
+});
+
+function yearShift(dt: Date, tz: string, step: number) {
+  if (isUTC(tz)) {
+    dt = new Date(dt.valueOf());
+    dt.setUTCFullYear(dt.getUTCFullYear() + step);
+    return dt;
+  } else {
+    return fromDate(dt, tz).add({ years: step }).toDate();
+  }
+}
+
+export const year = timeShifterFiller({
+  canonicalLength: 365 * 24 * 3600000,
+  siblings: 1000,
+  floor: (dt, tz) => {
+    if (isUTC(tz)) {
+      dt = new Date(dt.valueOf());
+      dt.setUTCHours(0, 0, 0, 0);
+      dt.setUTCMonth(0, 1);
+      return dt;
+    } else {
+      return fromDate(dt, tz)
+        .set({ month: 1, day: 1, hour: 0, second: 0, minute: 0, millisecond: 0 
})
+        .toDate();
+    }
+  },
+  round: (dt, roundTo, tz) => {
+    if (isUTC(tz)) {
+      const cur = dt.getUTCFullYear();
+      const adj = floorTo(cur, roundTo);
+      if (cur !== adj) dt.setUTCFullYear(adj);
+    } else {
+      const cur = fromDate(dt, tz).year;
+      const adj = floorTo(cur, roundTo);
+      if (cur !== adj) return yearShift(dt, tz, adj - cur);
+    }
+    return dt;
+  },
+  shift: yearShift,
+});
+
+export interface Shifters {
+  second: TimeShifter;
+  minute: TimeShifter;
+  hour: TimeShifter;
+  day: TimeShifter;
+  week: TimeShifter;
+  month: TimeShifter;
+  year: TimeShifter;
+
+  [key: string]: TimeShifter;
+}
+
+export const shifters: Shifters = {
+  second,
+  minute,
+  hour,
+  day,
+  week,
+  month,
+  year,
+};
diff --git a/web-console/src/utils/duration/duration.spec.ts 
b/web-console/src/utils/duration/duration.spec.ts
new file mode 100755
index 00000000000..8b20ac0a6b2
--- /dev/null
+++ b/web-console/src/utils/duration/duration.spec.ts
@@ -0,0 +1,505 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { Duration } from './duration';
+
+describe('Duration', () => {
+  const TZ_LA = 'America/Los_Angeles';
+  const TZ_JUNEAU = 'America/Juneau';
+
+  describe('errors', () => {
+    it('throws error if invalid duration', () => {
+      expect(() => new Duration('')).toThrow("Can not parse duration ''");
+
+      expect(() => new Duration('P00')).toThrow("Can not parse duration 
'P00'");
+
+      expect(() => new Duration('P')).toThrow('Duration can not be empty');
+
+      expect(() => new Duration('P0YT0H')).toThrow('Duration can not be 
empty');
+
+      expect(() => new Duration('P0W').shift(new Date(), TZ_LA)).toThrow(
+        'Duration can not have empty weeks',
+      );
+
+      expect(() => new Duration('P0Y0MT0H0M0S').shift(new Date(), 
TZ_LA)).toThrow(
+        'Duration can not be empty',
+      );
+    });
+
+    it('throws error if fromJS is not given a string', () => {
+      expect(() => new Duration(new Date() as any)).toThrow('Duration can not 
be empty');
+    });
+  });
+
+  describe('#toString', () => {
+    it('gives back the correct string', () => {
+      let durationStr: string;
+
+      durationStr = 'P3Y';
+      expect(new Duration(durationStr).toString()).toEqual(durationStr);
+
+      durationStr = 'P2W';
+      expect(new Duration(durationStr).toString()).toEqual(durationStr);
+
+      durationStr = 'PT5H';
+      expect(new Duration(durationStr).toString()).toEqual(durationStr);
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).toString()).toEqual(durationStr);
+    });
+
+    it('eliminates 0', () => {
+      expect(new Duration('P0DT15H').toString()).toEqual('PT15H');
+    });
+  });
+
+  describe('fromCanonicalLength', () => {
+    it('handles zero', () => {
+      expect(() => {
+        Duration.fromCanonicalLength(0);
+      }).toThrow('length must be positive');
+    });
+
+    it('works 1', () => {
+      expect(Duration.fromCanonicalLength(86400000).toString()).toEqual('P1D');
+    });
+
+    it('works 2', () => {
+      const len =
+        new Date('2018-03-01T00:00:00Z').valueOf() - new 
Date('2016-02-22T00:00:00Z').valueOf();
+      expect(Duration.fromCanonicalLength(len).toString()).toEqual('P2Y8D');
+    });
+
+    it('works 3', () => {
+      const len =
+        new Date('2018-09-15T00:00:00Z').valueOf() - new 
Date('2018-09-04T00:00:00Z').valueOf();
+      expect(Duration.fromCanonicalLength(len).toString()).toEqual('P11D');
+    });
+
+    it('works with months', () => {
+      
expect(Duration.fromCanonicalLength(2592000000).toString()).toEqual('P1M');
+      
expect(Duration.fromCanonicalLength(2678400000).toString()).toEqual('P1M1D');
+    });
+
+    it('works without months', () => {
+      expect(Duration.fromCanonicalLength(2592000000, 
true).toString()).toEqual('P30D');
+      expect(Duration.fromCanonicalLength(2678400000, 
true).toString()).toEqual('P31D');
+    });
+  });
+
+  describe('construct from span', () => {
+    it('parses days over DST', () => {
+      expect(
+        Duration.fromRange(
+          new Date('2012-10-29T00:00:00-07:00'),
+          new Date('2012-11-05T00:00:00-08:00'),
+          TZ_LA,
+        ).toString(),
+      ).toEqual('P7D');
+
+      expect(
+        Duration.fromRange(
+          new Date('2012-10-29T00:00:00-07:00'),
+          new Date('2012-11-12T00:00:00-08:00'),
+          TZ_LA,
+        ).toString(),
+      ).toEqual('P14D');
+    });
+
+    it('parses complex case', () => {
+      expect(
+        Duration.fromRange(
+          new Date('2012-10-29T00:00:00-07:00'),
+          new Date(new Date('2012-11-05T00:00:00-08:00').valueOf() - 1000),
+          TZ_LA,
+        ).toString(),
+      ).toEqual('P6DT24H59M59S');
+
+      expect(
+        Duration.fromRange(
+          new Date('2012-01-01T00:00:00-08:00'),
+          new Date('2013-03-04T04:05:06-08:00'),
+          TZ_LA,
+        ).toString(),
+      ).toEqual('P1Y2M3DT4H5M6S');
+    });
+  });
+
+  describe('#isFloorable', () => {
+    const floorable = 'P1Y P5Y P10Y P100Y P1M P2M P3M P4M P1D'.split(' ');
+    for (const v of floorable) {
+      it(`works on floorable ${v}`, () => {
+        expect(new Duration(v).isFloorable()).toEqual(true);
+      });
+    }
+
+    const unfloorable = 'P1Y1M P5M P2D P3D'.split(' ');
+    for (const v of unfloorable) {
+      it(`works on not floorable ${v}`, () => {
+        expect(new Duration(v).isFloorable()).toEqual(false);
+      });
+    }
+  });
+
+  describe('#floor', () => {
+    it('throws error if complex duration', () => {
+      expect(() => new Duration('P1Y2D').floor(new Date(), TZ_LA)).toThrow(
+        'Can not floor on a complex duration',
+      );
+
+      expect(() => new Duration('P3DT15H').floor(new Date(), TZ_LA)).toThrow(
+        'Can not floor on a complex duration',
+      );
+
+      expect(() => new Duration('PT5H').floor(new Date(), TZ_LA)).toThrow(
+        'Can not floor on a hour duration that does not divide into 24',
+      );
+    });
+
+    it('works for year', () => {
+      const p1y = new Duration('P1Y');
+      expect(p1y.floor(new Date('2013-09-29T01:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-01-01T00:00:00.000-08:00'),
+      );
+    });
+
+    it('works for PT2M', () => {
+      const pt2h = new Duration('PT2M');
+      expect(pt2h.floor(new Date('2013-09-29T03:03:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-09-29T03:02:00.000-07:00'),
+      );
+    });
+
+    it('works for P2H', () => {
+      const pt2h = new Duration('PT2H');
+      expect(pt2h.floor(new Date('2013-09-29T03:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-09-29T02:00:00.000-07:00'),
+      );
+    });
+
+    it('works for PT12H', () => {
+      const pt12h = new Duration('PT12H');
+      expect(pt12h.floor(new Date('2015-09-12T13:05:00-08:00'), 
TZ_JUNEAU)).toEqual(
+        new Date('2015-09-12T12:00:00-08:00'),
+      );
+    });
+
+    it('works for P1W', () => {
+      const p1w = new Duration('P1W');
+
+      expect(p1w.floor(new Date('2013-09-29T01:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-09-23T07:00:00.000Z'),
+      );
+
+      expect(p1w.floor(new Date('2013-10-03T01:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-09-30T00:00:00.000-07:00'),
+      );
+    });
+
+    it('works for P3M', () => {
+      const p3m = new Duration('P3M');
+      expect(p3m.floor(new Date('2013-09-29T03:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-07-01T00:00:00.000-07:00'),
+      );
+
+      expect(p3m.floor(new Date('2013-02-29T03:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2013-01-01T00:00:00.000-08:00'),
+      );
+    });
+
+    it('works for P4Y', () => {
+      const p4y = new Duration('P4Y');
+      expect(p4y.floor(new Date('2013-09-29T03:02:03.456-07:00'), 
TZ_LA)).toEqual(
+        new Date('2012-01-01T00:00:00.000-08:00'),
+      );
+    });
+  });
+
+  describe('#shift', () => {
+    it('works for weeks', () => {
+      let p1w = new Duration('P1W');
+      expect(p1w.shift(new Date('2012-10-29T00:00:00-07:00'), TZ_LA)).toEqual(
+        new Date('2012-11-05T00:00:00-08:00'),
+      );
+
+      p1w = new Duration('P1W');
+      expect(p1w.shift(new Date('2012-10-29T00:00:00-07:00'), TZ_LA, 
2)).toEqual(
+        new Date('2012-11-12T00:00:00-08:00'),
+      );
+
+      const p2w = new Duration('P2W');
+      expect(p2w.shift(new Date('2012-10-29T05:16:17-07:00'), TZ_LA)).toEqual(
+        new Date('2012-11-12T05:16:17-08:00'),
+      );
+    });
+
+    it('works for general complex case', () => {
+      const pComplex = new Duration('P1Y2M3DT4H5M6S');
+      expect(pComplex.shift(new Date('2012-01-01T00:00:00-08:00'), 
TZ_LA)).toEqual(
+        new Date('2013-03-04T04:05:06-08:00'),
+      );
+    });
+  });
+
+  describe('#materialize', () => {
+    it('works for weeks', () => {
+      const p1w = new Duration('P1W');
+
+      expect(
+        p1w.materialize(
+          new Date('2012-10-29T00:00:00-07:00'),
+          new Date('2012-12-01T00:00:00-08:00'),
+          TZ_LA,
+        ),
+      ).toEqual([
+        new Date('2012-10-29T07:00:00.000Z'),
+        new Date('2012-11-05T08:00:00.000Z'),
+        new Date('2012-11-12T08:00:00.000Z'),
+        new Date('2012-11-19T08:00:00.000Z'),
+        new Date('2012-11-26T08:00:00.000Z'),
+      ]);
+
+      expect(
+        p1w.materialize(
+          new Date('2012-10-29T00:00:00-07:00'),
+          new Date('2012-12-01T00:00:00-08:00'),
+          TZ_LA,
+          2,
+        ),
+      ).toEqual([
+        new Date('2012-10-29T07:00:00.000Z'),
+        new Date('2012-11-12T08:00:00.000Z'),
+        new Date('2012-11-26T08:00:00.000Z'),
+      ]);
+    });
+  });
+
+  describe('#isAligned', () => {
+    it('works for weeks', () => {
+      const p1w = new Duration('P1W');
+      expect(p1w.isAligned(new Date('2012-10-29T00:00:00-07:00'), 
TZ_LA)).toEqual(true);
+      expect(p1w.isAligned(new Date('2012-10-29T00:00:00-07:00'), 
'Etc/UTC')).toEqual(false);
+    });
+  });
+
+  describe('#dividesBy', () => {
+    const divisible = 'P5Y/P1Y P1D/P1D P1M/P1D P1W/P1D P1D/PT6H 
PT3H/PT1H'.split(' ');
+    for (const v of divisible) {
+      it(`works for ${v} (true)`, () => {
+        const p = v.split('/');
+        expect(new Duration(p[0]).dividesBy(new Duration(p[1]))).toEqual(true);
+      });
+    }
+
+    const undivisible = 'P1D/P1M PT5H/PT1H'.split(' ');
+    for (const v of undivisible) {
+      it(`works for ${v} (false)`, () => {
+        const p = v.split('/');
+        expect(new Duration(p[0]).dividesBy(new 
Duration(p[1]))).toEqual(false);
+      });
+    }
+  });
+
+  describe('#getCanonicalLength', () => {
+    it('gives back the correct canonical length', () => {
+      let durationStr: string;
+
+      durationStr = 'P3Y';
+      expect(new 
Duration(durationStr).getCanonicalLength()).toEqual(94608000000);
+
+      durationStr = 'P2W';
+      expect(new 
Duration(durationStr).getCanonicalLength()).toEqual(1209600000);
+
+      durationStr = 'PT5H';
+      expect(new Duration(durationStr).getCanonicalLength()).toEqual(18000000);
+
+      durationStr = 'P3DT15H';
+      expect(new 
Duration(durationStr).getCanonicalLength()).toEqual(313200000);
+    });
+  });
+
+  describe('#add()', () => {
+    it('works with a simple duration', () => {
+      const d1 = new Duration('P1D');
+      const d2 = new Duration('P1D');
+
+      expect(d1.add(d2).toString()).toEqual('P2D');
+    });
+
+    it('works with heterogeneous spans', () => {
+      const d1 = new Duration('P1D');
+      const d2 = new Duration('P1Y');
+
+      expect(d1.add(d2).toString()).toEqual('P1Y1D');
+    });
+
+    it('works with weeks', () => {
+      let d1 = new Duration('P1W');
+      let d2 = new Duration('P2W');
+      expect(d1.add(d2).toString()).toEqual('P3W');
+
+      d1 = new Duration('P6D');
+      d2 = new Duration('P1D');
+      expect(d1.add(d2).toString()).toEqual('P1W');
+    });
+  });
+
+  describe('#subtract()', () => {
+    it('works with a simple duration', () => {
+      const d1 = new Duration('P1DT2H');
+      const d2 = new Duration('PT1H');
+
+      expect(d1.subtract(d2).toString()).toEqual('P1DT1H');
+    });
+
+    it('works with a less simple duration', () => {
+      const d1 = new Duration('P1D');
+      const d2 = new Duration('PT1H');
+
+      expect(d1.subtract(d2).toString()).toEqual('PT23H');
+    });
+
+    it('works with weeks', () => {
+      const d1 = new Duration('P1W');
+      const d2 = new Duration('P1D');
+
+      expect(d1.subtract(d2).toString()).toEqual('P6D');
+    });
+
+    it('throws an error if result is going to be negative', () => {
+      const d1 = new Duration('P1D');
+      const d2 = new Duration('P2D');
+
+      expect(() => d1.subtract(d2)).toThrow();
+    });
+  });
+
+  describe('#multiply()', () => {
+    it('works with a simple duration', () => {
+      const d = new Duration('P1D');
+      expect(d.multiply(5).toString()).toEqual('P5D');
+    });
+
+    it('works with a less simple duration', () => {
+      const d = new Duration('P1DT2H');
+      expect(d.multiply(2).toString()).toEqual('P2DT4H');
+    });
+
+    it('works with weeks', () => {
+      const d = new Duration('P1W');
+      expect(d.multiply(5).toString()).toEqual('P5W');
+    });
+
+    it('throws an error if result is going to be negative', () => {
+      const d = new Duration('P1D');
+      expect(() => d.multiply(-1)).toThrow('Multiplier must be positive 
non-zero');
+    });
+
+    it('gets description properly', () => {
+      const d = new Duration('P2D');
+      expect(d.multiply(2).getDescription(true)).toEqual('4 Days');
+    });
+  });
+
+  describe('#getDescription()', () => {
+    it('gives back the correct description', () => {
+      let durationStr: string;
+
+      durationStr = 'P1D';
+      expect(new Duration(durationStr).getDescription()).toEqual('day');
+
+      durationStr = 'P1DT2H';
+      expect(new Duration(durationStr).getDescription()).toEqual('1 day, 2 
hours');
+
+      durationStr = 'P3Y';
+      expect(new Duration(durationStr).getDescription()).toEqual('3 years');
+
+      durationStr = 'P2W';
+      expect(new Duration(durationStr).getDescription()).toEqual('2 weeks');
+
+      durationStr = 'PT5H';
+      expect(new Duration(durationStr).getDescription()).toEqual('5 hours');
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).getDescription()).toEqual('3 days, 15 
hours');
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).getDescription(true)).toEqual('3 Days, 
15 Hours');
+    });
+  });
+
+  describe('#getSingleSpan()', () => {
+    it('gives back the correct span', () => {
+      let durationStr: string;
+
+      durationStr = 'P1D';
+      expect(new Duration(durationStr).getSingleSpan()).toEqual('day');
+
+      durationStr = 'P3Y';
+      expect(new Duration(durationStr).getSingleSpan()).toEqual('year');
+
+      durationStr = 'P2W';
+      expect(new Duration(durationStr).getSingleSpan()).toEqual('week');
+
+      durationStr = 'PT5H';
+      expect(new Duration(durationStr).getSingleSpan()).toEqual('hour');
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).getSingleSpan()).toBeUndefined();
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).getSingleSpan()).toBeUndefined();
+    });
+  });
+
+  describe('#getSingleSpanValue()', () => {
+    it('gives back the correct span value', () => {
+      let durationStr: string;
+
+      durationStr = 'P1D';
+      expect(new Duration(durationStr).getSingleSpanValue()).toEqual(1);
+
+      durationStr = 'P3Y';
+      expect(new Duration(durationStr).getSingleSpanValue()).toEqual(3);
+
+      durationStr = 'P2W';
+      expect(new Duration(durationStr).getSingleSpanValue()).toEqual(2);
+
+      durationStr = 'PT5H';
+      expect(new Duration(durationStr).getSingleSpanValue()).toEqual(5);
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).getSingleSpanValue()).toBeUndefined();
+
+      durationStr = 'P3DT15H';
+      expect(new Duration(durationStr).getSingleSpanValue()).toBeUndefined();
+    });
+  });
+
+  describe('#limitToDays', () => {
+    it('works', () => {
+      expect(new Duration('P6D').limitToDays().toString()).toEqual('P6D');
+
+      expect(new Duration('P1M').limitToDays().toString()).toEqual('P30D');
+
+      expect(new Duration('P1Y').limitToDays().toString()).toEqual('P365D');
+
+      expect(new Duration('P1Y2M').limitToDays().toString()).toEqual('P425D');
+    });
+  });
+});
diff --git a/web-console/src/utils/duration/duration.ts 
b/web-console/src/utils/duration/duration.ts
new file mode 100755
index 00000000000..b93dab175ad
--- /dev/null
+++ b/web-console/src/utils/duration/duration.ts
@@ -0,0 +1,381 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { second, shifters } from 
'../date-floor-shift-ceil/date-floor-shift-ceil';
+import { capitalizeFirst, pluralIfNeeded } from '../general';
+
+const SPANS_WITH_WEEK = ['year', 'month', 'week', 'day', 'hour', 'minute', 
'second'];
+const SPANS_WITHOUT_WEEK = ['year', 'month', 'day', 'hour', 'minute', 
'second'];
+const SPANS_WITHOUT_WEEK_OR_MONTH = ['year', 'day', 'hour', 'minute', 
'second'];
+const SPANS_UP_TO_DAY = ['day', 'hour', 'minute', 'second'];
+
+export interface DurationValue {
+  year?: number;
+  month?: number;
+  week?: number;
+  day?: number;
+  hour?: number;
+  minute?: number;
+  second?: number;
+
+  // Indexable
+  [span: string]: number | undefined;
+}
+
+const periodWeekRegExp = /^P(\d+)W$/;
+const periodRegExp = 
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
+//                     P   (year ) (month   ) (day     )    T(hour    ) 
(minute  ) (second  )
+
+function getSpansFromString(durationStr: string): DurationValue {
+  const spans: DurationValue = {};
+  let matches: RegExpExecArray | null;
+  if ((matches = periodWeekRegExp.exec(durationStr))) {
+    spans.week = Number(matches[1]);
+    if (!spans.week) throw new Error('Duration can not have empty weeks');
+  } else if ((matches = periodRegExp.exec(durationStr))) {
+    const nums = matches.map(Number);
+    for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) {
+      const span = SPANS_WITHOUT_WEEK[i];
+      const value = nums[i + 1];
+      if (value) spans[span] = value;
+    }
+  } else {
+    throw new Error("Can not parse duration '" + durationStr + "'");
+  }
+  return spans;
+}
+
+function getSpansFromStartEnd(start: Date, end: Date, timezone: string): 
DurationValue {
+  start = second.floor(start, timezone);
+  end = second.floor(end, timezone);
+  if (end <= start) throw new Error('start must come before end');
+
+  const spans: DurationValue = {};
+  let iterator: Date = start;
+  for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) {
+    const span = SPANS_WITHOUT_WEEK[i];
+    let spanCount = 0;
+
+    // Shortcut
+    const length = end.valueOf() - iterator.valueOf();
+    const canonicalLength: number = shifters[span].canonicalLength;
+    if (length < canonicalLength / 4) continue;
+    const numberToFit = Math.min(0, Math.floor(length / canonicalLength) - 1);
+    let iteratorMove: Date;
+    if (numberToFit > 0) {
+      // try to skip by numberToFit
+      iteratorMove = shifters[span].shift(iterator, timezone, numberToFit);
+      if (iteratorMove <= end) {
+        spanCount += numberToFit;
+        iterator = iteratorMove;
+      }
+    }
+
+    while (true) {
+      iteratorMove = shifters[span].shift(iterator, timezone, 1);
+      if (iteratorMove <= end) {
+        iterator = iteratorMove;
+        spanCount++;
+      } else {
+        break;
+      }
+    }
+
+    if (spanCount) {
+      spans[span] = spanCount;
+    }
+  }
+  return spans;
+}
+
+function removeZeros(spans: DurationValue): DurationValue {
+  const newSpans: DurationValue = {};
+  for (let i = 0; i < SPANS_WITH_WEEK.length; i++) {
+    const span = SPANS_WITH_WEEK[i];
+    if (Number(spans[span]) > 0) {
+      newSpans[span] = spans[span];
+    }
+  }
+  return newSpans;
+}
+
+function fitIntoSpans(length: number, spansToCheck: string[]): Record<string, 
number> {
+  const spans: Record<string, number> = {};
+
+  let lengthLeft = length;
+  for (let i = 0; i < spansToCheck.length; i++) {
+    const span = spansToCheck[i];
+    const spanLength = shifters[span].canonicalLength;
+    const count = Math.floor(lengthLeft / spanLength);
+
+    if (count) {
+      lengthLeft -= spanLength * count;
+      spans[span] = count;
+    }
+  }
+
+  return spans;
+}
+
+/**
+ * Represents an ISO duration like P1DT3H
+ */
+export class Duration {
+  public readonly singleSpan?: string;
+  public readonly spans: Readonly<DurationValue>;
+
+  static parse(durationStr: string): Duration {
+    if (typeof durationStr !== 'string') throw new TypeError('Duration JS must 
be a string');
+    return new Duration(getSpansFromString(durationStr));
+  }
+
+  static fromCanonicalLength(length: number, skipMonths = false): Duration {
+    if (length <= 0) throw new Error('length must be positive');
+    let spans = fitIntoSpans(length, skipMonths ? SPANS_WITHOUT_WEEK_OR_MONTH 
: SPANS_WITHOUT_WEEK);
+
+    if (
+      length % shifters['week'].canonicalLength === 0 && // Weeks fits
+      (Object.keys(spans).length > 1 || // We already have a more complex span
+        spans['day']) // or... we only have days and it might be simpler to 
express as weeks
+    ) {
+      spans = { week: length / shifters['week'].canonicalLength };
+    }
+
+    return new Duration(spans);
+  }
+
+  static fromCanonicalLengthUpToDays(length: number): Duration {
+    if (length <= 0) throw new Error('length must be positive');
+    return new Duration(fitIntoSpans(length, SPANS_UP_TO_DAY));
+  }
+
+  static fromRange(start: Date, end: Date, timezone: string): Duration {
+    return new Duration(getSpansFromStartEnd(start, end, timezone));
+  }
+
+  constructor(spans: DurationValue | string) {
+    const effectiveSpans: DurationValue =
+      typeof spans === 'string' ? getSpansFromString(spans) : 
removeZeros(spans);
+
+    const usedSpans = Object.keys(effectiveSpans);
+    if (!usedSpans.length) throw new Error('Duration can not be empty');
+    if (usedSpans.length === 1) {
+      this.singleSpan = usedSpans[0];
+    } else if (effectiveSpans.week) {
+      throw new Error("Can not mix 'week' and other spans");
+    }
+    this.spans = effectiveSpans;
+  }
+
+  public toString() {
+    const strArr: string[] = ['P'];
+    const spans = this.spans;
+    if (spans.week) {
+      strArr.push(String(spans.week), 'W');
+    } else {
+      let addedT = false;
+      for (let i = 0; i < SPANS_WITHOUT_WEEK.length; i++) {
+        const span = SPANS_WITHOUT_WEEK[i];
+        const value = spans[span];
+        if (!value) continue;
+        if (!addedT && i >= 3) {
+          strArr.push('T');
+          addedT = true;
+        }
+        strArr.push(String(value), span[0].toUpperCase());
+      }
+    }
+    return strArr.join('');
+  }
+
+  public add(duration: Duration): Duration {
+    return Duration.fromCanonicalLength(this.getCanonicalLength() + 
duration.getCanonicalLength());
+  }
+
+  public subtract(duration: Duration): Duration {
+    const newCanonicalDuration = this.getCanonicalLength() - 
duration.getCanonicalLength();
+    if (newCanonicalDuration < 0) throw new Error('A duration can not be 
negative.');
+    return Duration.fromCanonicalLength(newCanonicalDuration);
+  }
+
+  public multiply(multiplier: number): Duration {
+    if (multiplier <= 0) throw new Error('Multiplier must be positive 
non-zero');
+    if (multiplier === 1) return this;
+    const newCanonicalDuration = this.getCanonicalLength() * multiplier;
+    return Duration.fromCanonicalLength(newCanonicalDuration);
+  }
+
+  public valueOf() {
+    return this.spans;
+  }
+
+  public equals(other: Duration | undefined): boolean {
+    return other instanceof Duration && this.toString() === other.toString();
+  }
+
+  public isSimple(): boolean {
+    const { singleSpan } = this;
+    if (!singleSpan) return false;
+    return this.spans[singleSpan] === 1;
+  }
+
+  public isFloorable(): boolean {
+    const { singleSpan } = this;
+    if (!singleSpan) return false;
+    const span = Number(this.spans[singleSpan]);
+    if (span === 1) return true;
+    const { siblings } = shifters[singleSpan];
+    if (!siblings) return false;
+    return siblings % span === 0;
+  }
+
+  /**
+   * Floors the date according to this duration.
+   * @param date The date to floor
+   * @param timezone The timezone within which to floor
+   */
+  public floor(date: Date, timezone: string): Date {
+    const { singleSpan } = this;
+    if (!singleSpan) throw new Error('Can not floor on a complex duration');
+    const span = this.spans[singleSpan]!;
+    const mover = shifters[singleSpan];
+    let dt = mover.floor(date, timezone);
+    if (span !== 1) {
+      if (!mover.siblings) {
+        throw new Error(`Can not floor on a ${singleSpan} duration that is not 
1`);
+      }
+      if (mover.siblings % span !== 0) {
+        throw new Error(
+          `Can not floor on a ${singleSpan} duration that does not divide into 
${mover.siblings}`,
+        );
+      }
+      dt = mover.round(dt, span, timezone);
+    }
+    return dt;
+  }
+
+  /**
+   * Moves the given date by 'step' times of the duration
+   * Negative step value will move back in time.
+   * @param date The date to move
+   * @param timezone The timezone within which to make the move
+   * @param step The number of times to step by the duration
+   */
+  public shift(date: Date, timezone: string, step = 1): Date {
+    const spans = this.spans;
+    for (const span of SPANS_WITH_WEEK) {
+      const value = spans[span];
+      if (value) date = shifters[span].shift(date, timezone, step * value);
+    }
+    return date;
+  }
+
+  public ceil(date: Date, timezone: string): Date {
+    const floored = this.floor(date, timezone);
+    if (floored.valueOf() === date.valueOf()) return date; // Just like 
ceil(3) is 3 and not 4
+    return this.shift(floored, timezone, 1);
+  }
+
+  public round(date: Date, timezone: string): Date {
+    const floorDate = this.floor(date, timezone);
+    const ceilDate = this.ceil(date, timezone);
+    const distanceToFloor = Math.abs(date.valueOf() - floorDate.valueOf());
+    const distanceToCeil = Math.abs(date.valueOf() - ceilDate.valueOf());
+    return distanceToFloor < distanceToCeil ? floorDate : ceilDate;
+  }
+
+  /**
+   * Materializes all the values of this duration form start to end
+   * @param start The date to start on
+   * @param end The date to start on
+   * @param timezone The timezone within which to materialize
+   * @param step The number of times to step by the duration
+   */
+  public materialize(start: Date, end: Date, timezone: string, step = 1): 
Date[] {
+    const values: Date[] = [];
+    let iter = this.floor(start, timezone);
+    while (iter <= end) {
+      values.push(iter);
+      iter = this.shift(iter, timezone, step);
+    }
+    return values;
+  }
+
+  /**
+   * Checks to see if date is aligned to this duration within the timezone 
(floors to itself)
+   * @param date The date to check
+   * @param timezone The timezone within which to make the check
+   */
+  public isAligned(date: Date, timezone: string): boolean {
+    return this.floor(date, timezone).valueOf() === date.valueOf();
+  }
+
+  /**
+   * Check to see if this duration can be divided by the given duration
+   * @param smaller The smaller duration to divide by
+   */
+  public dividesBy(smaller: Duration): boolean {
+    const myCanonicalLength = this.getCanonicalLength();
+    const smallerCanonicalLength = smaller.getCanonicalLength();
+    return (
+      myCanonicalLength % smallerCanonicalLength === 0 &&
+      this.isFloorable() &&
+      smaller.isFloorable()
+    );
+  }
+
+  public getCanonicalLength(): number {
+    const spans = this.spans;
+    let length = 0;
+    for (const span of SPANS_WITH_WEEK) {
+      const value = spans[span];
+      if (value) length += value * shifters[span].canonicalLength;
+    }
+    return length;
+  }
+
+  public getDescription(capitalize?: boolean): string {
+    const spans = this.spans;
+    const description: string[] = [];
+    for (const span of SPANS_WITH_WEEK) {
+      const value = spans[span];
+      const spanTitle = capitalize ? capitalizeFirst(span) : span;
+      if (value) {
+        if (value === 1 && this.singleSpan) {
+          description.push(spanTitle);
+        } else {
+          description.push(pluralIfNeeded(value, spanTitle));
+        }
+      }
+    }
+    return description.join(', ');
+  }
+
+  public getSingleSpan(): string | undefined {
+    return this.singleSpan;
+  }
+
+  public getSingleSpanValue(): number | undefined {
+    if (!this.singleSpan) return;
+    return this.spans[this.singleSpan];
+  }
+
+  public limitToDays(): Duration {
+    return Duration.fromCanonicalLengthUpToDays(this.getCanonicalLength());
+  }
+}
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index edea5ad0a52..096f0dfe063 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -19,10 +19,12 @@
 export * from './base64-url';
 export * from './column-metadata';
 export * from './date';
+export * from './date-floor-shift-ceil/date-floor-shift-ceil';
 export * from './download';
 export * from './download-query-detail-archive';
 export * from './druid-lookup';
 export * from './druid-query';
+export * from './duration/duration';
 export * from './formatter';
 export * from './general';
 export * from './local-storage-backed-visibility';
diff --git a/web-console/src/views/datasources-view/datasources-view.tsx 
b/web-console/src/views/datasources-view/datasources-view.tsx
index a2bd3692346..4477540af66 100644
--- a/web-console/src/views/datasources-view/datasources-view.tsx
+++ b/web-console/src/views/datasources-view/datasources-view.tsx
@@ -53,7 +53,13 @@ import type {
   QueryWithContext,
   Rule,
 } from '../../druid-models';
-import { formatCompactionInfo, RuleUtil, zeroCompactionStatus } from 
'../../druid-models';
+import {
+  END_OF_TIME_DATE,
+  formatCompactionInfo,
+  RuleUtil,
+  START_OF_TIME_DATE,
+  zeroCompactionStatus,
+} from '../../druid-models';
 import type { Capabilities, CapabilitiesMode } from '../../helpers';
 import { STANDARD_TABLE_PAGE_SIZE, STANDARD_TABLE_PAGE_SIZE_OPTIONS } from 
'../../react-table';
 import { Api, AppToaster } from '../../singletons';
@@ -361,7 +367,7 @@ export class DatasourcesView extends React.PureComponent<
           `COUNT(*) FILTER (WHERE is_active = 1 AND "start" LIKE 
'%T00:00:00.000Z' AND "end" LIKE '%T00:00:00.000Z') AS day_aligned_segments`,
           `COUNT(*) FILTER (WHERE is_active = 1 AND "start" LIKE 
'%-01T00:00:00.000Z' AND "end" LIKE '%-01T00:00:00.000Z') AS 
month_aligned_segments`,
           `COUNT(*) FILTER (WHERE is_active = 1 AND "start" LIKE 
'%-01-01T00:00:00.000Z' AND "end" LIKE '%-01-01T00:00:00.000Z') AS 
year_aligned_segments`,
-          `COUNT(*) FILTER (WHERE is_active = 1 AND "start" = 
'-146136543-09-08T08:23:32.096Z' AND "end" = '146140482-04-24T15:36:27.903Z') 
AS all_granularity_segments`,
+          `COUNT(*) FILTER (WHERE is_active = 1 AND "start" = 
'${START_OF_TIME_DATE}' AND "end" = '${END_OF_TIME_DATE}') AS 
all_granularity_segments`,
         ],
         visibleColumns.shown('Total rows') &&
           `SUM("num_rows") FILTER (WHERE is_active = 1) AS total_rows`,
diff --git 
a/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx 
b/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx
index e5f2fb47f9a..f5a25d2ea0a 100644
--- a/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx
+++ b/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx
@@ -26,6 +26,7 @@ import { useEffect, useMemo, useRef } from 'react';
 import { Loader } from '../../../components';
 import { useQueryManager } from '../../../hooks';
 import {
+  Duration,
   formatInteger,
   formatNumber,
   prettyFormatIsoDateTick,
@@ -35,7 +36,7 @@ import { Issue } from '../components';
 import { highlightStore } from '../highlight-store/highlight-store';
 import type { ExpressionMeta } from '../models';
 import { ModuleRepository } from '../module-repository/module-repository';
-import { DATE_FORMAT, getAutoGranularity, snapToGranularity } from '../utils';
+import { DATE_FORMAT, getAutoGranularity } from '../utils';
 
 import './record-table-module.scss';
 
@@ -223,12 +224,9 @@ 
ModuleRepository.registerModule<MultiAxisChartParameterValues>({
         // this is only used for the label and the data saved in the highlight
         // the positioning is done with the true coordinates until the user
         // releases the mouse button (in the `brushend` event)
-        const { start, end } = snapToGranularity(
-          params.areas[0].coordRange[0],
-          params.areas[0].coordRange[1],
-          timeGranularity,
-          undefined, // context.timezone,
-        );
+        const duration = new Duration(timeGranularity);
+        const start = duration.round(params.areas[0].coordRange[0], 'Etc/UTC');
+        const end = duration.round(params.areas[0].coordRange[1], 'Etc/UTC');
 
         const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[0]);
         const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[1]);
diff --git a/web-console/src/views/explore-view/modules/time-chart-module.tsx 
b/web-console/src/views/explore-view/modules/time-chart-module.tsx
index 1c19963b1ed..661e3ec4575 100644
--- a/web-console/src/views/explore-view/modules/time-chart-module.tsx
+++ b/web-console/src/views/explore-view/modules/time-chart-module.tsx
@@ -25,6 +25,7 @@ import { useEffect, useMemo, useRef } from 'react';
 import { Loader } from '../../../components';
 import { useQueryManager } from '../../../hooks';
 import {
+  Duration,
   formatInteger,
   formatNumber,
   prettyFormatIsoDateTick,
@@ -34,7 +35,7 @@ import { Issue } from '../components';
 import { highlightStore } from '../highlight-store/highlight-store';
 import type { ExpressionMeta } from '../models';
 import { ModuleRepository } from '../module-repository/module-repository';
-import { DATE_FORMAT, getAutoGranularity, snapToGranularity } from '../utils';
+import { DATE_FORMAT, getAutoGranularity } from '../utils';
 
 import './record-table-module.scss';
 
@@ -295,13 +296,13 @@ 
ModuleRepository.registerModule<TimeChartParameterValues>({
         // this is only used for the label and the data saved in the highlight
         // the positioning is done with the true coordinates until the user
         // releases the mouse button (in the `brushend` event)
-        const { start, end } = snappyHighlight
-          ? snapToGranularity(
-              params.areas[0].coordRange[0],
-              params.areas[0].coordRange[1],
-              timeGranularity,
-            )
-          : { start: params.areas[0].coordRange[0], end: 
params.areas[0].coordRange[1] };
+        let start = params.areas[0].coordRange[0];
+        let end = params.areas[0].coordRange[1];
+        if (snappyHighlight) {
+          const duration = new Duration(timeGranularity);
+          start = duration.round(start, 'Etc/UTC');
+          end = duration.round(end, 'Etc/UTC');
+        }
 
         const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[0]);
         const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[1]);
diff --git a/web-console/src/views/explore-view/utils/duration.ts 
b/web-console/src/views/explore-view/utils/duration.ts
deleted file mode 100644
index 621aa0a57a0..00000000000
--- a/web-console/src/views/explore-view/utils/duration.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you 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 { sum } from 'd3-array';
-
-import { filterMap, pluralIfNeeded } from '../../../utils';
-
-const SPANS = ['year', 'month', 'day', 'hour', 'minute', 'second'];
-
-export function formatDuration(duration: string, preferCompact = false): 
string {
-  // Regular expressions to match ISO 8601 duration parts
-  const regex = 
/P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/;
-  const matches = regex.exec(duration);
-  if (!matches) return duration;
-
-  // Extract the relevant parts
-  const counts = SPANS.map((_, i) => parseInt(matches[i + 1] || '0', 10));
-
-  // Construct the human-readable format based on the parsed values
-  let parts: string[];
-  if (preferCompact && sum(counts) === 1) {
-    parts = filterMap(SPANS, (span, i) => (counts[i] ? span : undefined));
-  } else {
-    parts = filterMap(SPANS, (span, i) =>
-      counts[i] ? pluralIfNeeded(counts[i], span) : undefined,
-    );
-  }
-
-  // Join the parts with commas, and return the result
-  return parts.length > 0 ? parts.join(', ') : '0 seconds';
-}
diff --git a/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts 
b/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts
index 6b2245dbd38..57a654fa10e 100644
--- a/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts
+++ b/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts
@@ -18,8 +18,9 @@
 
 import type { Column, FilterPattern } from '@druid-toolkit/query';
 
+import { Duration } from '../../../utils';
+
 import { DATE_FORMAT } from './date-format';
-import { formatDuration } from './duration';
 
 const TIME_RELATIVE_TYPES: Record<string, string> = {
   'maxDataTime/': 'latest',
@@ -82,10 +83,9 @@ export function formatPatternWithoutNegation(pattern: 
FilterPattern): string {
 
     case 'timeRelative': {
       const type = TIME_RELATIVE_TYPES[`${pattern.anchor}/${pattern.alignType 
|| ''}`];
-      return `${pattern.column} in ${type ? `${type} ` : ''}${formatDuration(
+      return `${pattern.column} in ${type ? `${type} ` : ''}${new Duration(
         pattern.rangeDuration,
-        true,
-      )}`;
+      ).getDescription()}`;
     }
 
     case 'numberRange':
diff --git a/web-console/src/views/explore-view/utils/get-auto-granularity.ts 
b/web-console/src/views/explore-view/utils/get-auto-granularity.ts
index f8928f3b86b..21cc2743820 100644
--- a/web-console/src/views/explore-view/utils/get-auto-granularity.ts
+++ b/web-console/src/views/explore-view/utils/get-auto-granularity.ts
@@ -18,7 +18,8 @@
 
 import type { SqlExpression } from '@druid-toolkit/query';
 import { fitFilterPattern, SqlMulti } from '@druid-toolkit/query';
-import { day, Duration, hour } from 'chronoshift';
+
+import { day, Duration, hour } from '../../../utils';
 
 function getCanonicalDuration(
   expression: SqlExpression,
@@ -32,7 +33,7 @@ function getCanonicalDuration(
       return pattern.end.valueOf() - pattern.start.valueOf();
 
     case 'timeRelative':
-      return Duration.fromJS(pattern.rangeDuration).getCanonicalLength();
+      return new Duration(pattern.rangeDuration).getCanonicalLength();
 
     case 'custom':
       if (pattern.expression instanceof SqlMulti) {
@@ -76,7 +77,5 @@ export function getAutoGranularity(where: SqlExpression, 
timeColumnName: string)
     return 'PT1M';
   }
 
-  console.debug('Unable to determine granularity from where clause', 
where.toString());
-
   return DEFAULT_GRANULARITY;
 }
diff --git a/web-console/src/views/explore-view/utils/index.ts 
b/web-console/src/views/explore-view/utils/index.ts
index 8469c83b927..57c72e2c0e7 100644
--- a/web-console/src/views/explore-view/utils/index.ts
+++ b/web-console/src/views/explore-view/utils/index.ts
@@ -17,7 +17,6 @@
  */
 
 export * from './date-format';
-export * from './duration';
 export * from './filter-pattern-helpers';
 export * from './general';
 export * from './get-auto-granularity';
@@ -25,6 +24,5 @@ export * from './known-aggregations';
 export * from './max-time-for-table';
 export * from './misc';
 export * from './query-log';
-export * from './snap-to-granularity';
 export * from './table-query';
 export * from './time-manipulation';
diff --git a/web-console/src/views/explore-view/utils/snap-to-granularity.ts 
b/web-console/src/views/explore-view/utils/snap-to-granularity.ts
deleted file mode 100644
index 15b0cce0351..00000000000
--- a/web-console/src/views/explore-view/utils/snap-to-granularity.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you 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 { Duration, Timezone } from 'chronoshift';
-
-/**
- * Will try to snap start and end to the closest available dates, given the 
granularity.
- *
- * @param start the start date
- * @param end the end date
- * @param granularity the granularity
- * @param timezone the timezone
- * @returns an object with the start and end dates snapped to the given 
granularity
- */
-export function snapToGranularity(
-  start: Date,
-  end: Date,
-  granularity: string,
-  timezone?: string,
-): { start: Date; end: Date } {
-  const tz = Timezone.fromJS(timezone || 'Etc/UTC');
-  const duration = Duration.fromJS(granularity);
-
-  // get closest to start
-  const flooredStart = duration.floor(start, tz);
-  const ceiledStart = duration.shift(flooredStart, tz, 1);
-  const distanceToFlooredStart = Math.abs(start.valueOf() - 
flooredStart.valueOf());
-  const distanceToCeiledStart = Math.abs(start.valueOf() - 
ceiledStart.valueOf());
-  const closestStart = distanceToFlooredStart < distanceToCeiledStart ? 
flooredStart : ceiledStart;
-
-  // get closest to end
-  const flooredEnd = duration.floor(end, tz);
-  const ceiledEnd = duration.shift(flooredEnd, tz, 1);
-  const distanceToFlooredEnd = Math.abs(end.valueOf() - flooredEnd.valueOf());
-  const distanceToCeiledEnd = Math.abs(end.valueOf() - ceiledEnd.valueOf());
-  const closestEnd = distanceToFlooredEnd < distanceToCeiledEnd ? flooredEnd : 
ceiledEnd;
-
-  return {
-    start: closestStart,
-    end: closestEnd,
-  };
-}
diff --git a/web-console/src/views/explore-view/utils/table-query.ts 
b/web-console/src/views/explore-view/utils/table-query.ts
index bc5b6484c9a..633f02ff15b 100644
--- a/web-console/src/views/explore-view/utils/table-query.ts
+++ b/web-console/src/views/explore-view/utils/table-query.ts
@@ -31,11 +31,10 @@ import {
 } from '@druid-toolkit/query';
 
 import type { ColumnHint } from '../../../utils';
-import { forceSignInNumberFormatter, formatNumber, formatPercent } from 
'../../../utils';
+import { Duration, forceSignInNumberFormatter, formatNumber, formatPercent } 
from '../../../utils';
 import type { ExpressionMeta } from '../models';
 import { Measure } from '../models';
 
-import { formatDuration } from './duration';
 import { addTableScope } from './general';
 import { KNOWN_AGGREGATIONS } from './known-aggregations';
 import type { Compare } from './time-manipulation';
@@ -103,7 +102,7 @@ function makeBaseColumnHints(
       ).getUnderlyingExpression(),
     };
     if (isTimestamp(splitColumn)) {
-      hint.displayName = `${splitColumn.name} (by ${formatDuration(timeBucket, 
true)})`;
+      hint.displayName = `${splitColumn.name} (by ${new 
Duration(timeBucket).getDescription()})`;
     }
     columnHints.set(splitColumn.name, hint);
   }
@@ -291,7 +290,7 @@ function makeCompareAggregatorsAndAddHints(
   prevMeasure: SqlExpression,
   columnHints: Map<string, ColumnHint>,
 ): SqlExpression[] {
-  const group = `Previous ${formatDuration(compare, true)}`;
+  const group = `Previous ${new Duration(compare).getDescription()}`;
   const diff = mainMeasure.subtract(prevMeasure);
 
   const ret: SqlExpression[] = [];
diff --git a/web-console/src/views/segments-view/segments-view.tsx 
b/web-console/src/views/segments-view/segments-view.tsx
index 18a44e66180..ded3ac5474c 100644
--- a/web-console/src/views/segments-view/segments-view.tsx
+++ b/web-console/src/views/segments-view/segments-view.tsx
@@ -44,6 +44,7 @@ import { AsyncActionDialog } from '../../dialogs';
 import { SegmentTableActionDialog } from 
'../../dialogs/segments-table-action-dialog/segment-table-action-dialog';
 import { ShowValueDialog } from 
'../../dialogs/show-value-dialog/show-value-dialog';
 import type { QueryWithContext } from '../../druid-models';
+import { computeSegmentTimeSpan } from '../../druid-models';
 import type { Capabilities, CapabilitiesMode } from '../../helpers';
 import {
   booleanCustomTableFilter,
@@ -84,7 +85,7 @@ const TABLE_COLUMNS_BY_MODE: Record<CapabilitiesMode, 
TableColumnSelectorColumn[
     'Start',
     'End',
     'Version',
-    { text: 'Time span', label: '𝑓(sys.segments)' },
+    'Time span',
     'Shard type',
     'Shard spec',
     'Partition',
@@ -106,7 +107,7 @@ const TABLE_COLUMNS_BY_MODE: Record<CapabilitiesMode, 
TableColumnSelectorColumn[
     'Start',
     'End',
     'Version',
-    { text: 'Time span', label: '𝑓(sys.segments)' },
+    'Time span',
     'Shard type',
     'Shard spec',
     'Partition',
@@ -177,7 +178,6 @@ interface SegmentQueryResultRow {
   interval: string;
   segment_id: string;
   version: string;
-  time_span: string;
   shard_spec: string;
   partition_num: number;
   size: number;
@@ -225,16 +225,6 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
       `"start"`,
       `"end"`,
       `"version"`,
-      visibleColumns.shown('Time span') &&
-        `CASE
-  WHEN "start" = '-146136543-09-08T08:23:32.096Z' AND "end" = 
'146140482-04-24T15:36:27.903Z' THEN 'All'
-  WHEN "start" LIKE '%-01-01T00:00:00.000Z' AND "end" LIKE 
'%-01-01T00:00:00.000Z' THEN 'Year'
-  WHEN "start" LIKE '%-01T00:00:00.000Z' AND "end" LIKE '%-01T00:00:00.000Z' 
THEN 'Month'
-  WHEN "start" LIKE '%T00:00:00.000Z' AND "end" LIKE '%T00:00:00.000Z' THEN 
'Day'
-  WHEN "start" LIKE '%:00:00.000Z' AND "end" LIKE '%:00:00.000Z' THEN 'Hour'
-  WHEN "start" LIKE '%:00.000Z' AND "end" LIKE '%:00.000Z' THEN 'Minute'
-  ELSE 'Sub minute'
-END AS "time_span"`,
       visibleColumns.shown('Shard type', 'Shard spec') && `"shard_spec"`,
       visibleColumns.shown('Partition') && `"partition_num"`,
       visibleColumns.shown('Size') && `"size"`,
@@ -253,30 +243,6 @@ END AS "time_span"`,
     return `WITH s AS (SELECT\n${columns.join(',\n')}\nFROM sys.segments)`;
   }
 
-  static computeTimeSpan(start: string, end: string): string {
-    if (start.endsWith('-01-01T00:00:00.000Z') && 
end.endsWith('-01-01T00:00:00.000Z')) {
-      return 'Year';
-    }
-
-    if (start.endsWith('-01T00:00:00.000Z') && 
end.endsWith('-01T00:00:00.000Z')) {
-      return 'Month';
-    }
-
-    if (start.endsWith('T00:00:00.000Z') && end.endsWith('T00:00:00.000Z')) {
-      return 'Day';
-    }
-
-    if (start.endsWith(':00:00.000Z') && end.endsWith(':00:00.000Z')) {
-      return 'Hour';
-    }
-
-    if (start.endsWith(':00.000Z') && end.endsWith(':00.000Z')) {
-      return 'Minute';
-    }
-
-    return 'Sub minute';
-  }
-
   private readonly segmentsQueryManager: QueryManager<SegmentsQuery, 
SegmentQueryResultRow[]>;
 
   constructor(props: SegmentsViewProps) {
@@ -287,7 +253,7 @@ END AS "time_span"`,
       segmentsState: QueryState.INIT,
       visibleColumns: new LocalStorageBackedVisibility(
         LocalStorageKeys.SEGMENT_TABLE_COLUMN_SELECTION,
-        ['Time span', 'Is published', 'Is overshadowed'],
+        ['Is published', 'Is overshadowed'],
       ),
       groupByInterval: false,
       showSegmentTimeline: false,
@@ -412,7 +378,6 @@ END AS "time_span"`,
                 end,
                 interval: segment.interval,
                 version: segment.version,
-                time_span: SegmentsView.computeTimeSpan(start, end),
                 shard_spec: deepGet(segment, 'shardSpec'),
                 partition_num: deepGet(segment, 'shardSpec.partitionNum') || 0,
                 size: segment.size,
@@ -615,7 +580,7 @@ END AS "time_span"`,
             show: visibleColumns.shown('Start'),
             accessor: 'start',
             headerClassName: 'enable-comparisons',
-            width: 160,
+            width: 180,
             sortable: hasSql,
             defaultSortDesc: true,
             filterable: allowGeneralFilter,
@@ -626,7 +591,7 @@ END AS "time_span"`,
             show: visibleColumns.shown('End'),
             accessor: 'end',
             headerClassName: 'enable-comparisons',
-            width: 160,
+            width: 180,
             sortable: hasSql,
             defaultSortDesc: true,
             filterable: allowGeneralFilter,
@@ -636,20 +601,21 @@ END AS "time_span"`,
             Header: 'Version',
             show: visibleColumns.shown('Version'),
             accessor: 'version',
-            width: 160,
+            width: 180,
             sortable: hasSql,
             defaultSortDesc: true,
             filterable: allowGeneralFilter,
-            Cell: this.renderFilterableCell('version'),
+            Cell: this.renderFilterableCell('version', true),
           },
           {
             Header: 'Time span',
             show: visibleColumns.shown('Time span'),
-            accessor: 'time_span',
+            id: 'time_span',
+            className: 'padded',
+            accessor: ({ start, end }) => computeSegmentTimeSpan(start, end),
             width: 100,
-            sortable: hasSql,
-            filterable: allowGeneralFilter,
-            Cell: this.renderFilterableCell('time_span'),
+            sortable: false,
+            filterable: false,
           },
           {
             Header: 'Shard type',


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to