From d90f2311636b542b17024ed08d54dd404a9cebd5 Mon Sep 17 00:00:00 2001
From: Tom Robinson <tlrobinson@gmail.com>
Date: Tue, 18 Sep 2018 15:40:03 -0700
Subject: [PATCH] Refactor date/time formatting

---
 frontend/src/metabase/lib/formatting.js       | 135 ++++++++----------
 frontend/src/metabase/lib/formatting/date.js  |  74 ++++++++++
 .../visualizations/lib/settings/column.js     |  96 ++++++++++---
 3 files changed, 207 insertions(+), 98 deletions(-)
 create mode 100644 frontend/src/metabase/lib/formatting/date.js

diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js
index c93f4fe0454..c97dcfd01ca 100644
--- a/frontend/src/metabase/lib/formatting.js
+++ b/frontend/src/metabase/lib/formatting.js
@@ -25,6 +25,13 @@ import { rangeForValue } from "metabase/lib/dataset";
 import { getFriendlyName } from "metabase/visualizations/lib/utils";
 import { decimalCount } from "metabase/visualizations/lib/numeric";
 
+import {
+  DEFAULT_DATE_STYLE,
+  getDateFormatFromStyle,
+  DEFAULT_TIME_STYLE,
+  getTimeFormatFromStyle,
+} from "metabase/lib/formatting/date";
+
 import Field from "metabase-lib/lib/metadata/Field";
 import type { Column, Value } from "metabase/meta/types/Dataset";
 import type { DatetimeUnit } from "metabase/meta/types/Query";
@@ -284,27 +291,41 @@ function formatWeek(m: Moment, options: FormattingOptions = {}) {
   return formatMajorMinor(m.format("wo"), m.format("gggg"), options);
 }
 
+function replaceDateFormatNames(format, options) {
+  return format
+    .replace(/\bMMMM\b/g, getMonthFormat(options))
+    .replace(/\bdddd\b/g, getDayFormat(options));
+}
+
+function formatDateTimeWithFormats(value, dateFormat, timeFormat, options) {
+  let m = parseTimestamp(value, options.column && options.column.unit);
+  if (!m.isValid()) {
+    return String(value);
+  }
+
+  const format = [];
+  if (dateFormat) {
+    format.push(replaceDateFormatNames(dateFormat, options));
+  }
+  if (timeFormat && options.time_enabled) {
+    format.push(timeFormat);
+  }
+  return m.format(format.join(" "));
+}
+
 function formatDateTime(value, options) {
   let m = parseTimestamp(value, options.column && options.column.unit);
   if (!m.isValid()) {
     return String(value);
   }
 
-  if (options.date_format) {
-    const format = [];
-    if (options.date_abbreviate) {
-      format.push(
-        options.date_format
-          .replace(/\bMMMM\b/g, getMonthFormat(options))
-          .replace(/\bdddd\b/g, getDayFormat(options)),
-      );
-    } else {
-      format.push(options.date_format);
-    }
-    if (options.time_format && options.time_enabled !== false) {
-      format.push(options.time_format);
-    }
-    return m.format(format.join(" "));
+  if (options.date_format || options.time_format) {
+    formatDateTimeWithFormats(
+      value,
+      options.date_format,
+      options.time_format,
+      options,
+    );
   } else {
     if (options.show_time === false) {
       return m.format(options.date_abbreviate ? "ll" : "LL");
@@ -324,71 +345,31 @@ export function formatDateTimeWithUnit(
     return String(value);
   }
 
-  // only use custom formats for unbucketed dates for now
-  if (options.date_format) {
-    formatDateTime(value, options);
+  // expand "week" into a range in specific contexts
+  if (unit === "week") {
+    if (
+      (options.type === "tooltip" || options.type === "cell") &&
+      !options.noRange
+    ) {
+      // tooltip show range like "January 1 - 7, 2017"
+      return formatDateTimeRangeWithUnit(value, unit, options);
+    }
   }
 
-  switch (unit) {
-    case "hour": // 12 AM - January 1, 2015
-      return formatMajorMinor(
-        m.format("h A"),
-        m.format(`${getMonthFormat(options)} D, YYYY`),
-        options,
-      );
-    case "day": // January 1, 2015
-      return m.format(`${getMonthFormat(options)} D, YYYY`);
-    case "week": // 1st - 2015
-      if (options.type === "tooltip" && !options.noRange) {
-        // tooltip show range like "January 1 - 7, 2017"
-        return formatDateTimeRangeWithUnit(value, unit, options);
-      } else if (options.type === "cell" && !options.noRange) {
-        // table cells show range like "Jan 1, 2017 - Jan 7, 2017"
-        return formatDateTimeRangeWithUnit(value, unit, options);
-      } else if (options.type === "axis") {
-        // axis ticks show start of the week as "Jan 1"
-        return m
-          .clone()
-          .startOf(unit)
-          .format(`MMM D`);
-      } else {
-        return formatWeek(m, options);
-      }
-    case "month": // January 2015
-      return options.jsx ? (
-        <div>
-          <span className="text-bold">{m.format(getMonthFormat(options))}</span>{" "}
-          {m.format("YYYY")}
-        </div>
-      ) : (
-        m.format(`${getMonthFormat(options)} YYYY`)
-      );
-    case "year": // 2015
-      return m.format("YYYY");
-    case "quarter": // Q1 - 2015
-      return formatMajorMinor(m.format("[Q]Q"), m.format("YYYY"), {
-        ...options,
-        majorWidth: 0,
-      });
-    case "minute-of-hour":
-      return m.format("m");
-    case "hour-of-day": // 12 AM
-      return m.format("h A");
-    case "day-of-week": // Sunday
-      return m.format(getDayFormat(options));
-    case "day-of-month":
-      return m.format("D");
-    case "day-of-year":
-      return m.format("DDD");
-    case "week-of-year": // 1st
-      return m.format("wo");
-    case "month-of-year": // January
-      return m.format(getMonthFormat(options));
-    case "quarter-of-year": // January
-      return m.format("[Q]Q");
-    default:
-      return formatDateTime(value, options);
+  let dateFormat = options.date_format;
+  let timeFormat = options.time_format;
+
+  if (!dateFormat) {
+    const dateStyle = options.date_style || DEFAULT_DATE_STYLE;
+    dateFormat = getDateFormatFromStyle(dateStyle, unit);
   }
+
+  if (!timeFormat) {
+    const timeStyle = options.time_style || DEFAULT_TIME_STYLE;
+    timeFormat = getTimeFormatFromStyle(timeStyle, unit, options.time_enabled);
+  }
+
+  return formatDateTimeWithFormats(value, dateFormat, timeFormat, options);
 }
 
 export function formatTime(value: Value) {
diff --git a/frontend/src/metabase/lib/formatting/date.js b/frontend/src/metabase/lib/formatting/date.js
new file mode 100644
index 00000000000..ab982d776ac
--- /dev/null
+++ b/frontend/src/metabase/lib/formatting/date.js
@@ -0,0 +1,74 @@
+const DEFAULT_DATE_FORMATS = {
+  year: "YYYY",
+  quarter: "[Q]Q - YYYY",
+  "minute-of-hour": "m",
+  "hour-of-day": "h A",
+  "day-of-week": "dddd",
+  "day-of-month": "D",
+  "day-of-year": "DDD",
+  "week-of-year": "wo",
+  "month-of-year": "MMMM",
+  "quarter-of-year": "[Q]Q",
+};
+
+// a "date style" is essentially a "day" format with overrides for larger units
+const DATE_STYLE_TO_FORMAT = {
+  "M/D/YY": {
+    month: "M/YY",
+  },
+  "D/M/YY": {
+    month: "M/YY",
+  },
+  "YYYY/M/D": {
+    month: "YYYY/M",
+    quarter: "YYYY - [Q]Q",
+  },
+  "MMMM D, YYYY": {
+    month: "MMMM, YYYY",
+  },
+  "D MMMM, YYYY": {
+    month: "MMMM, YYYY",
+  },
+  "dddd, MMMM D, YYYY": {
+    week: "MMMM D, YYYY",
+    month: "MMMM, YYYY",
+  },
+};
+
+export const DEFAULT_DATE_STYLE = "MMMM D, YYYY";
+
+export function getDateFormatFromStyle(style, unit) {
+  if (DATE_STYLE_TO_FORMAT[style]) {
+    if (DATE_STYLE_TO_FORMAT[style][unit]) {
+      return DATE_STYLE_TO_FORMAT[style][unit];
+    }
+  } else {
+    console.warn("Unknown date style", style);
+  }
+  if (DEFAULT_DATE_FORMATS[unit]) {
+    return DEFAULT_DATE_FORMATS[unit];
+  }
+  return style;
+}
+
+const UNITS_WITH_HOUR = [null, "default", "second", "minute", "hour"];
+const UNITS_WITH_DAY = [...UNITS_WITH_HOUR, "day", "week"];
+
+const UNITS_WITH_HOUR_SET = new Set(UNITS_WITH_HOUR);
+const UNITS_WITH_DAY_SET = new Set(UNITS_WITH_DAY);
+
+export const hasHour = unit => UNITS_WITH_HOUR_SET.has(unit);
+export const hasDay = unit => UNITS_WITH_DAY_SET.has(unit);
+
+export const DEFAULT_TIME_STYLE = "h:mm A";
+
+export function getTimeFormatFromStyle(style, unit, timeEnabled) {
+  let format = style;
+  if (!timeEnabled || timeEnabled === "milliseconds") {
+    return format.replace(/mm/, "mm:ss.SSS");
+  } else if (timeEnabled === "seconds") {
+    return format.replace(/mm/, "mm:ss");
+  } else {
+    return format;
+  }
+}
diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js
index 72e6c484c12..bd744bee1d8 100644
--- a/frontend/src/metabase/visualizations/lib/settings/column.js
+++ b/frontend/src/metabase/visualizations/lib/settings/column.js
@@ -8,6 +8,12 @@ import { keyForColumn } from "metabase/lib/dataset";
 import { isDate, isNumber, isCoordinate } from "metabase/lib/schema_metadata";
 import { getVisualizationRaw } from "metabase/visualizations";
 import { numberFormatterForOptions } from "metabase/lib/formatting";
+import {
+  DEFAULT_DATE_STYLE,
+  getDateFormatFromStyle,
+  hasDay,
+  hasHour,
+} from "metabase/lib/formatting/date";
 
 const DEFAULT_GET_COLUMNS = (series, vizSettings) =>
   [].concat(...series.map(s => s.data.cols));
@@ -31,51 +37,99 @@ export function columnSettings({
 
 const EXAMPLE_DATE = moment("2018-01-07 17:24");
 
-function dateTimeFormatOption(format, description) {
+function getDateStyleOptionsForUnit(unit) {
+  const options = [
+    dateStyleOption("M/D/YY", unit, hasDay(unit) && "month, day, year"),
+    dateStyleOption("D/M/YY", unit, hasDay(unit) && "day, month, year"),
+    dateStyleOption("YYYY/M/D", unit, hasDay(unit) && "year, month, day"),
+    dateStyleOption("MMMM D, YYYY", unit),
+    dateStyleOption("D MMMM, YYYY", unit),
+    dateStyleOption("dddd, MMMM D, YYYY", unit),
+  ];
+  const seen = new Set();
+  return options.filter(option => {
+    const format = getDateFormatFromStyle(option.value, unit);
+    if (seen.has(format)) {
+      return false;
+    } else {
+      seen.add(format);
+      return true;
+    }
+  });
+}
+
+function dateStyleOption(style, unit, description) {
+  const format = getDateFormatFromStyle(style, unit);
+  return {
+    name:
+      EXAMPLE_DATE.format(format) + (description ? ` (${description})` : ``),
+    value: style,
+  };
+}
+
+function timeStyleOption(style, description) {
+  const format = style;
   return {
     name:
       EXAMPLE_DATE.format(format) + (description ? ` (${description})` : ``),
-    value: format,
+    value: style,
   };
 }
 
 export const DATE_COLUMN_SETTINGS = {
-  date_format: {
+  date_style: {
     title: t`Date style`,
     widget: "radio",
-    default: "dddd, MMMM D, YYYY",
-    props: {
-      options: [
-        dateTimeFormatOption("M/D/YYYY", "month, day, year"),
-        dateTimeFormatOption("D/M/YYYY", "day, month, year"),
-        dateTimeFormatOption("YYYY/M/D", "year, month, day"),
-        dateTimeFormatOption("MMMM D, YYYY"),
-        dateTimeFormatOption("D MMMM YYYY"),
-        dateTimeFormatOption("dddd, MMMM D, YYYY"),
-      ],
-    },
+    default: DEFAULT_DATE_STYLE,
+    getProps: ({ unit }) => ({
+      options: getDateStyleOptionsForUnit(unit),
+    }),
+    getHidden: ({ unit }) => getDateStyleOptionsForUnit(unit).length < 2,
   },
   date_abbreviate: {
     title: t`Abbreviate names of days and months`,
     widget: "toggle",
     default: false,
+    getHidden: ({ unit }, settings) => {
+      const format = getDateFormatFromStyle(settings["date_style"], unit);
+      return !format.match(/MMMM|dddd/);
+    },
+    readDependencies: ["date_style"],
   },
   time_enabled: {
     title: t`Show the time`,
-    widget: "toggle",
-    default: true,
+    widget: "buttonGroup",
+    getProps: ({ unit }, settings) => {
+      const options = [
+        { name: t`Off`, value: null },
+        { name: t`Minutes`, value: "minutes" },
+      ];
+      if (!unit || unit === "default" || unit === "second") {
+        options.push({ name: t`Seconds`, value: "seconds" });
+      }
+      if (!unit || unit === "default") {
+        options.push({ name: t`Milliseconds`, value: "milliseconds" });
+      }
+      if (options.length === 2) {
+        options[1].name = t`On`;
+      }
+      return { options };
+    },
+    getHidden: ({ unit }, settings) => !hasHour(unit),
+    getDefault: ({ unit }) => (hasHour(unit) ? "minutes" : null),
   },
-  time_format: {
+  time_style: {
     title: t`Time style`,
     widget: "radio",
     default: "h:mm A",
-    props: {
+    getProps: (column, settings) => ({
       options: [
-        dateTimeFormatOption("h:mm A", "12-hour clock"),
-        dateTimeFormatOption("k:mm", "24-hour clock"),
+        timeStyleOption("h:mm A", "12-hour clock"),
+        timeStyleOption("k:mm", "24-hour clock"),
       ],
-    },
+    }),
     getHidden: (column, settings) => !settings["time_enabled"],
+    readDependencies: ["time_enabled"],
   },
 };
 
-- 
GitLab