Repository: tradingview/lightweight-charts (ts)

Issue:
Refactor date formats
https://github.com/tradingview/lightweight-charts/blob/master/src/formatters/date-format.ts

Here we have a list of available date formats, and this list might be extended every time we want to add new date format. I think we can refactor this code so it will be possible to provide any combination of `yy`/`yyyy`/`MMM`/`mm`/etc within a string and we'll format it in that way (yeah, just use regexes here...).

Pull request description:
Refactoring dateFormat
**Type of PR:** enhancement

**PR checklist:**

- [x] Addresses an existing issue: fixes #368
- [x] Includes tests
- [x] Documentation update


Patch:
diff --git a/docs/customization.md b/docs/customization.md
index 0cc78bacf..e154883b1 100644
--- a/docs/customization.md
+++ b/docs/customization.md
@@ -52,38 +52,16 @@ chart.applyOptions({
 
 #### Date Format
 
-Preferred date format can be set using the `dateFormat` property of the `localization` option. The following date formats are available:
-
-- `'yy MMM dd` - `'18 Jun 25`
-- `'yy MMMM dd` - `'18 June 25`
-- `yyyy MMM dd` - `2018 Jun 25`
-- `yyyy MMMM dd` - `2018 June 25`
-- `dd MMM 'yy` - `25 Jun '18` _(default)_
-- `dd MMMM 'yy` - `25 June '18`
-- `dd MMM yyyy` - `25 Jun 2018`
-- `dd MMMM yyyy` - `25 June 2018`
-- `MMM dd, 'yy` - `Jun 25, '18`
-- `MMMM dd, 'yy` - `June 25, '18`
-- `MMM dd, yyyy` - `Jun 25, 2018`
-- `MMMM dd, yyyy` - `June 25, 2018`
-- `yyyy-MM-dd` - `2018-06-25`
-- `yy-MM-dd` - `18-06-25`
-- `yy/MM/dd` - `18/06/25`
-- `yyyy/MM/dd` - `2018/06/25`
-- `yy.MM.dd` - `18.06.25`
-- `yyyy.MM.dd` - `2018.06.25`
-- `dd-MM-yyyy` - `25-06-2018`
-- `dd-MM-yy` - `25-06-18`
-- `dd/MM/yy` - `25/06/18`
-- `dd/MM/yyyy` - `25/06/2018`
-- `dd.MM.yy` - `25.06.18`
-- `dd.MM.yyyy` - `25.06.2018`
-- `MM-dd-yy` - `06-25-18`
-- `MM-dd-yyyy` - `06-25-2018`
-- `MM/dd/yy` - `06/25/18`
-- `MM/dd/yyyy` - `06/25/2018`
-- `MM.dd.yy` - `06.25.18`
-- `MM.dd.yyyy` - `06.25.2018`
+Preferred date format can be set using the `dateFormat` property of the `localization` option.
+
+The format string might contain "special" sequences, which will be replaced with corresponding date's value:
+
+- `yyyy` - full year value (e.g. 2020)
+- `yy` - short year value (e.g. 20)
+- `MMMM` - long month value (e.g. July)
+- `MMM` - short month value (e.g. Feb)
+- `MM` - numeric (with leading zero if needed) month value (e.g. 03)
+- `dd` - day of month (with leading zero if needed) value (e.g. 15)
 
 ```javascript
 const chart = createChart(document.body, {
diff --git a/src/formatters/date-format.ts b/src/formatters/date-format.ts
deleted file mode 100644
index f64619a79..000000000
--- a/src/formatters/date-format.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { numberToStringWithLeadingZero as numToStr } from './price-formatter';
-
-const getMonth = (date: Date) => date.getUTCMonth() + 1;
-const getDay = (date: Date) => date.getUTCDate();
-const getYear = (date: Date) => date.getUTCFullYear();
-
-const dd = (date: Date) => numToStr(getDay(date), 2);
-const MMMM = (date: Date, locale: string) => new Date(date.getUTCFullYear(), date.getUTCMonth(), 1)
-	.toLocaleString(locale, { month: 'long' });
-const MMM = (date: Date, locale: string) => new Date(date.getUTCFullYear(), date.getUTCMonth(), 1)
-	.toLocaleString(locale, { month: 'short' });
-const MM = (date: Date) => numToStr(getMonth(date), 2);
-const yy = (date: Date) => numToStr(getYear(date) % 100, 2);
-const yyyy = (date: Date) => numToStr(getYear(date), 4);
-
-export type DateFormat =
-	| '\'yy MMM dd'
-	| '\'yy MMMM dd'
-	| 'yyyy MMM dd'
-	| 'yyyy MMMM dd'
-	| 'dd MMM \'yy'
-	| 'dd MMMM \'yy'
-	| 'dd MMM yyyy'
-	| 'dd MMMM yyyy'
-	| 'MMM dd, \'yy'
-	| 'MMMM dd, \'yy'
-	| 'MMM dd, yyyy'
-	| 'MMMM dd, yyyy'
-	| 'yyyy-MM-dd'
-	| 'yy-MM-dd'
-	| 'yy/MM/dd'
-	| 'yyyy/MM/dd'
-	| 'yy.MM.dd'
-	| 'yyyy.MM.dd'
-	| 'dd-MM-yyyy'
-	| 'dd-MM-yy'
-	| 'dd/MM/yy'
-	| 'dd/MM/yyyy'
-	| 'dd.MM.yy'
-	| 'dd.MM.yyyy'
-	| 'MM-dd-yy'
-	| 'MM-dd-yyyy'
-	| 'MM/dd/yy'
-	| 'MM/dd/yyyy'
-	| 'MM.dd.yy'
-	| 'MM.dd.yyyy'
-;
-
-export type DateFormatFn = (date: Date, locale: string) => string;
-
-/*
- Map of date formatting functions
- */
-export const dateFormatFunctions: Record<DateFormat, DateFormatFn> = {
-	'\'yy MMM dd': (date: Date, locale: string) => `\'${yy(date)} ${MMM(date, locale)} ${dd(date)}`,
-	'\'yy MMMM dd': (date: Date, locale: string) => `\'${yy(date)} ${MMMM(date, locale)} ${dd(date)}`,
-	'yyyy MMM dd': (date: Date, locale: string) => `${yyyy(date)} ${MMM(date, locale)} ${dd(date)}`,
-	'yyyy MMMM dd': (date: Date, locale: string) => `${yyyy(date)} ${MMMM(date, locale)} ${dd(date)}`,
-	'dd MMM \'yy': (date: Date, locale: string) => `${dd(date)} ${MMM(date, locale)} \'${yy(date)}`,
-	'dd MMMM \'yy': (date: Date, locale: string) => `${dd(date)} ${MMMM(date, locale)} \'${yy(date)}`,
-	'dd MMM yyyy': (date: Date, locale: string) => `${dd(date)} ${MMM(date, locale)} ${yyyy(date)}`,
-	'dd MMMM yyyy': (date: Date, locale: string) => `${dd(date)} ${MMMM(date, locale)} ${yyyy(date)}`,
-	'MMM dd, \'yy': (date: Date, locale: string) => `${MMM(date, locale)} ${dd(date)}, \'${yy(date)}`,
-	'MMMM dd, \'yy': (date: Date, locale: string) => `${MMMM(date, locale)} ${dd(date)}, \'${yy(date)}`,
-	'MMM dd, yyyy': (date: Date, locale: string) => `${MMM(date, locale)} ${dd(date)}, ${yyyy(date)}`,
-	'MMMM dd, yyyy': (date: Date, locale: string) => `${MMMM(date, locale)} ${dd(date)}, ${yyyy(date)}`,
-	'yyyy-MM-dd': (date: Date, locale: string) => `${yyyy(date)}-${MM(date)}-${dd(date)}`,
-	'yy-MM-dd': (date: Date, locale: string) => `${yy(date)}-${MM(date)}-${dd(date)}`,
-	'yy/MM/dd': (date: Date, locale: string) => `${yy(date)}/${MM(date)}/${dd(date)}`,
-	'yyyy/MM/dd': (date: Date, locale: string) => `${yyyy(date)}/${MM(date)}/${dd(date)}`,
-	'yy.MM.dd': (date: Date, locale: string) => `${yy(date)}.${MM(date)}.${dd(date)}`,
-	'yyyy.MM.dd': (date: Date, locale: string) => `${yyyy(date)}.${MM(date)}.${dd(date)}`,
-	'dd-MM-yyyy': (date: Date, locale: string) => `${dd(date)}-${MM(date)}-${yyyy(date)}`,
-	'dd-MM-yy': (date: Date, locale: string) => `${dd(date)}-${MM(date)}-${yy(date)}`,
-	'dd/MM/yy': (date: Date, locale: string) => `${dd(date)}/${MM(date)}/${yy(date)}`,
-	'dd/MM/yyyy': (date: Date, locale: string) => `${dd(date)}/${MM(date)}/${yyyy(date)}`,
-	'dd.MM.yy': (date: Date, locale: string) => `${dd(date)}.${MM(date)}.${yy(date)}`,
-	'dd.MM.yyyy': (date: Date, locale: string) => `${dd(date)}.${MM(date)}.${yyyy(date)}`,
-	'MM-dd-yy': (date: Date, locale: string) => `${MM(date)}-${dd(date)}-${yy(date)}`,
-	'MM-dd-yyyy': (date: Date, locale: string) => `${MM(date)}-${dd(date)}-${yyyy(date)}`,
-	'MM/dd/yy': (date: Date, locale: string) => `${MM(date)}/${dd(date)}/${yy(date)}`,
-	'MM/dd/yyyy': (date: Date, locale: string) => `${MM(date)}/${dd(date)}/${yyyy(date)}`,
-	'MM.dd.yy': (date: Date, locale: string) => `${MM(date)}.${dd(date)}.${yy(date)}`,
-	'MM.dd.yyyy': (date: Date, locale: string) => `${MM(date)}.${dd(date)}.${yyyy(date)}`,
-};
diff --git a/src/formatters/date-formatter.ts b/src/formatters/date-formatter.ts
index 4b68a6264..ea3d4446a 100644
--- a/src/formatters/date-formatter.ts
+++ b/src/formatters/date-formatter.ts
@@ -1,16 +1,16 @@
-import { DateFormat, DateFormatFn, dateFormatFunctions } from './date-format';
+import { formatDate } from './format-date';
 import { IFormatter } from './iformatter';
 
 export class DateFormatter implements IFormatter {
 	private readonly _locale: string;
-	private readonly _dateFormatFunc: DateFormatFn;
+	private readonly _dateFormat: string;
 
-	public constructor(dateFormat: DateFormat = 'yyyy-MM-dd', locale: string = 'default') {
-		this._dateFormatFunc = dateFormatFunctions[dateFormat];
+	public constructor(dateFormat: string = 'yyyy-MM-dd', locale: string = 'default') {
+		this._dateFormat = dateFormat;
 		this._locale = locale;
 	}
 
 	public format(date: Date): string {
-		return this._dateFormatFunc(date, this._locale);
+		return formatDate(date, this._dateFormat, this._locale);
 	}
 }
diff --git a/src/formatters/date-time-formatter.ts b/src/formatters/date-time-formatter.ts
index f841a562b..f8cd31257 100644
--- a/src/formatters/date-time-formatter.ts
+++ b/src/formatters/date-time-formatter.ts
@@ -1,10 +1,9 @@
-import { DateFormat } from './date-format';
 import { DateFormatter } from './date-formatter';
 import { IFormatter } from './iformatter';
 import { TimeFormatter } from './time-formatter';
 
 export interface DateTimeFormatterParams {
-	dateFormat: DateFormat;
+	dateFormat: string;
 	timeFormat: string;
 	dateTimeSeparator: string;
 	locale: string;
diff --git a/src/formatters/format-date.ts b/src/formatters/format-date.ts
new file mode 100644
index 000000000..29b737579
--- /dev/null
+++ b/src/formatters/format-date.ts
@@ -0,0 +1,25 @@
+import { numberToStringWithLeadingZero as numToStr } from './price-formatter';
+
+const getMonth = (date: Date) => date.getUTCMonth() + 1;
+const getDay = (date: Date) => date.getUTCDate();
+const getYear = (date: Date) => date.getUTCFullYear();
+
+const dd = (date: Date) => numToStr(getDay(date), 2);
+const MMMM = (date: Date, locale: string) => new Date(date.getUTCFullYear(), date.getUTCMonth(), 1)
+	.toLocaleString(locale, { month: 'long' });
+const MMM = (date: Date, locale: string) => new Date(date.getUTCFullYear(), date.getUTCMonth(), 1)
+	.toLocaleString(locale, { month: 'short' });
+const MM = (date: Date) => numToStr(getMonth(date), 2);
+const yy = (date: Date) => numToStr(getYear(date) % 100, 2);
+const yyyy = (date: Date) => numToStr(getYear(date), 4);
+
+export function formatDate(date: Date, format: string, locale: string): string {
+	return format
+		.replace(/yyyy/g, yyyy(date))
+		.replace(/yy/g, yy(date))
+		.replace(/MMMM/g, MMMM(date, locale))
+		.replace(/MMM/g, MMM(date, locale))
+		.replace(/MM/g, MM(date))
+		.replace(/dd/g, dd(date))
+	;
+}
diff --git a/src/model/localization-options.ts b/src/model/localization-options.ts
index 6796798eb..2cb16c88e 100644
--- a/src/model/localization-options.ts
+++ b/src/model/localization-options.ts
@@ -1,5 +1,3 @@
-import { DateFormat } from '../formatters/date-format';
-
 import { PriceFormatterFn } from './price-formatter-fn';
 import { BusinessDay, UTCTimestamp } from './time-data';
 
@@ -23,7 +21,9 @@ export interface LocalizationOptions {
 	timeFormatter?: TimeFormatterFn;
 
 	/**
-	 * One of predefined options to format time. Ignored if timeFormatter has been specified.
+	 * Date formatting string.
+	 * Might contains `yyyy`, `yy`, `MMMM`, `MMM`, `MM` and `dd` literals which will be replaced with corresponding date's value.
+	 * Ignored if timeFormatter has been specified.
 	 */
-	dateFormat: DateFormat;
+	dateFormat: string;
 }


Test patch:
diff --git a/tests/e2e/graphics/test-cases/initial-options/custom-date-format.js b/tests/e2e/graphics/test-cases/initial-options/custom-date-format.js
new file mode 100644
index 000000000..31295ac8b
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/initial-options/custom-date-format.js
@@ -0,0 +1,26 @@
+function generateData() {
+	var res = [];
+	var time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (var i = 0; i < 500; ++i) {
+		res.push({
+			time: time.getTime() / 1000,
+			value: i,
+		});
+
+		time.setUTCDate(time.getUTCDate() + 1);
+	}
+	return res;
+}
+
+// eslint-disable-next-line no-unused-vars
+function runTestCase(container) {
+	var chart = LightweightCharts.createChart(container, {
+		localization: {
+			dateFormat: 'Year: yy (MM-yyyy)',
+		},
+	});
+
+	var mainSeries = chart.addAreaSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/e2e/graphics/test-cases/initial-options/date-format.js b/tests/e2e/graphics/test-cases/initial-options/date-format.js
new file mode 100644
index 000000000..5af603de3
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/initial-options/date-format.js
@@ -0,0 +1,26 @@
+function generateData() {
+	var res = [];
+	var time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (var i = 0; i < 500; ++i) {
+		res.push({
+			time: time.getTime() / 1000,
+			value: i,
+		});
+
+		time.setUTCDate(time.getUTCDate() + 1);
+	}
+	return res;
+}
+
+// eslint-disable-next-line no-unused-vars
+function runTestCase(container) {
+	var chart = LightweightCharts.createChart(container, {
+		localization: {
+			dateFormat: 'MMM dd, yyyy',
+		},
+	});
+
+	var mainSeries = chart.addAreaSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/unittests/format-date.spec.ts b/tests/unittests/format-date.spec.ts
new file mode 100644
index 000000000..6855d352e
--- /dev/null
+++ b/tests/unittests/format-date.spec.ts
@@ -0,0 +1,57 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+
+import { formatDate } from '../../src/formatters/format-date';
+
+describe('formatDate', () => {
+	const date = new Date('1990-04-24');
+	const locale = 'en-US';
+
+	it('should correct handle yyyy sequence', () => {
+		expect(formatDate(date, 'yyyy', locale)).to.be.equal('1990');
+		expect(formatDate(date, 'yyyy yyyy', locale)).to.be.equal('1990 1990');
+	});
+
+	it('should correct handle yy sequence', () => {
+		expect(formatDate(date, 'yy', locale)).to.be.equal('90');
+		expect(formatDate(date, 'yy yy', locale)).to.be.equal('90 90');
+	});
+
+	it('should correct handle yyyy and yy sequences', () => {
+		expect(formatDate(date, 'yyyy yy', locale)).to.be.equal('1990 90');
+		expect(formatDate(date, 'yy yyyy', locale)).to.be.equal('90 1990');
+
+		expect(formatDate(date, 'yy yyyy yy yyyy yyyy yy', locale)).to.be.equal('90 1990 90 1990 1990 90');
+	});
+
+	it('should correct handle MMMM sequence', () => {
+		expect(formatDate(date, 'MMMM', locale)).to.be.equal('April');
+		expect(formatDate(date, 'MMMM MMMM', locale)).to.be.equal('April April');
+	});
+
+	it('should correct handle MMM sequence', () => {
+		expect(formatDate(date, 'MMM', locale)).to.be.equal('Apr');
+		expect(formatDate(date, 'MMM MMM', locale)).to.be.equal('Apr Apr');
+	});
+
+	it('should correct handle MM sequence', () => {
+		expect(formatDate(date, 'MM', locale)).to.be.equal('04');
+		expect(formatDate(date, 'MM MM', locale)).to.be.equal('04 04');
+	});
+
+	it('should correct handle MMMM, MMM and MM sequences', () => {
+		expect(formatDate(date, 'MMMM MMM MM', locale)).to.be.equal('April Apr 04');
+		expect(formatDate(date, 'MMMM MMM MM MM MMM MMMM', locale)).to.be.equal('April Apr 04 04 Apr April');
+	});
+
+	it('should correct handle dd sequence', () => {
+		expect(formatDate(date, 'dd', locale)).to.be.equal('24');
+		expect(formatDate(date, 'dd dd', locale)).to.be.equal('24 24');
+	});
+
+	it('should ignore non-sequences', () => {
+		expect(
+			formatDate(date, 'My custom format for date is yyyy-yy-MMMM-MM-MMM dd!', locale)
+		).to.be.equal('My custom format for date is 1990-90-April-04-Apr 24!');
+	});
+});