Repository: rotki/rotki

Issue:
Add setting for display of datetime format for the UI
## Problem Definition

Currently in the application there is a hardcoded format for each different place a date is shown. It would be rather nice if a new setting is added to specify the way dates should be displayed. Also if it should be displayed in UTC or in the user system's local time. For a suggested format we could go with the python [strftime](https://docs.python.org/3.6/library/datetime.html#strftime-and-strptime-behavior) but perhaps that would be a lot of work to implement. @kelsos is there a javascript way to provide a unix timestamp and a string format and get a date string? Google searches seem to point to a `moment.js` package but it seems crazy to me to need an external package for something so simple.

## Task

- Add a new setting. `ui_date_display_format`. Default value should be `%d/%m/%Y %H:%M:%S %Z `. Depending on the format we choose
- Use that format in the front-end to display timestamps to date strings. The function where this happens is `timestamp_to_date()` seen [here](https://github.com/rotkehlchenio/rotkehlchen/blob/3bd8ac193db26da73fe48768db036c25ecd2a4ff/ui/utils.ts#L23).


Discussion:
I can't find anything specific for formatting based on the same patterns, but I guess with the available apis it should not be hard for us to build something like this, no need to include an extra library. It should be easy to build this.
nice! Do you want to handle this?
Yes of course 

Resolving patch:
diff --git a/rotkehlchen/data_handler.py b/rotkehlchen/data_handler.py
--- a/rotkehlchen/data_handler.py
+++ b/rotkehlchen/data_handler.py
@@ -75,6 +75,7 @@
     'balance_save_frequency',
     'anonymized_logs',
     'include_gas_costs',
+    'date_display_format',
 )
 
 BOOLEAN_SETTINGS = (
diff --git a/rotkehlchen/db/dbhandler.py b/rotkehlchen/db/dbhandler.py
--- a/rotkehlchen/db/dbhandler.py
+++ b/rotkehlchen/db/dbhandler.py
@@ -76,6 +76,7 @@ class DBStartupAction(Enum):
 DEFAULT_UI_FLOATING_PRECISION = 2
 DEFAULT_BALANCE_SAVE_FREQUENCY = 24
 DEFAULT_MAIN_CURRENCY = S_USD
+DEFAULT_DATE_DISPLAY_FORMAT = '%d/%m/%Y %H:%M:%S %Z'
 KDF_ITER = 64000
 
 
@@ -431,6 +432,8 @@ def get_settings(self) -> DBSettings:
             settings['anonymized_logs'] = DEFAULT_ANONYMIZED_LOGS
         if 'include_gas_costs' not in settings:
             settings['include_gas_costs'] = DEFAULT_INCLUDE_GAS_COSTS
+        if 'date_display_format' not in settings:
+            settings['date_display_format'] = DEFAULT_DATE_DISPLAY_FORMAT
 
         # populate values that are not saved in the setting but computed and returned
         # as part of the get_settings call


Test patch:
diff --git a/rotkehlchen/tests/test_db.py b/rotkehlchen/tests/test_db.py
--- a/rotkehlchen/tests/test_db.py
+++ b/rotkehlchen/tests/test_db.py
@@ -15,6 +15,7 @@
     DBINFO_FILENAME,
     DEFAULT_ANONYMIZED_LOGS,
     DEFAULT_BALANCE_SAVE_FREQUENCY,
+    DEFAULT_DATE_DISPLAY_FORMAT,
     DEFAULT_INCLUDE_CRYPTO2CRYPTO,
     DEFAULT_INCLUDE_GAS_COSTS,
     DEFAULT_MAIN_CURRENCY,
@@ -152,6 +153,7 @@ def test_writting_fetching_data(data_dir, username):
         'last_balance_save': 0,
         'main_currency': DEFAULT_MAIN_CURRENCY,
         'anonymized_logs': DEFAULT_ANONYMIZED_LOGS,
+        'date_display_format': DEFAULT_DATE_DISPLAY_FORMAT,
     }
 
     # Check setting non-existing settings. Should be ignored
@@ -317,6 +319,7 @@ def test_settings_entry_types(data_dir, username):
         'eth_rpc_port': '8545',
         'balance_save_frequency': 24,
         'anonymized_logs': True,
+        'date_display_format': '%d/%m/%Y %H:%M:%S %z',
     })
     assert success
     assert msg == '', f'set settings returned error: "{msg}"'
@@ -345,6 +348,8 @@ def test_settings_entry_types(data_dir, username):
     assert res['main_currency'] == 'USD'
     assert isinstance(res['anonymized_logs'], bool)
     assert res['anonymized_logs'] is True
+    assert isinstance(res['date_display_format'], str)
+    assert res['date_display_format'] == '%d/%m/%Y %H:%M:%S %z'
 
 
 def test_balance_save_frequency_check(data_dir, username):
diff --git a/ui/tests/application_launch.spec.ts b/ui/tests/application_launch.spec.ts
--- a/ui/tests/application_launch.spec.ts
+++ b/ui/tests/application_launch.spec.ts
@@ -20,11 +20,11 @@ describe('application launch', function () {
     });
 
     afterEach(async function () {
+        await captureOnFailure(app, this.currentTest);
+
         if (app && app.isRunning()) {
             await app.stop();
         }
-
-        await captureOnFailure(app, this.currentTest);
     });
 
     it('assert we got 1 window running', () => {
diff --git a/ui/tests/create_account.spec.ts b/ui/tests/create_account.spec.ts
--- a/ui/tests/create_account.spec.ts
+++ b/ui/tests/create_account.spec.ts
@@ -19,10 +19,11 @@ describe('first usage', function () {
     });
 
     afterEach(async function () {
+        await captureOnFailure(app, this.currentTest);
+
         if (app && app.isRunning()) {
             await app.stop();
         }
-        await captureOnFailure(app, this.currentTest);
     });
 
     it('create account', async () => {
diff --git a/ui/tests/dashboard.spec.ts b/ui/tests/dashboard.spec.ts
--- a/ui/tests/dashboard.spec.ts
+++ b/ui/tests/dashboard.spec.ts
@@ -53,11 +53,11 @@ describe('dashboard', function () {
     });
 
     after(async function () {
+        await captureOnFailure(app, this.currentTest);
+
         if (app && app.isRunning()) {
             await app.stop();
         }
-
-        await captureOnFailure(app, this.currentTest);
     });
 
     it('should be originally empty', async () => {
diff --git a/ui/tests/date_formatter.spec.ts b/ui/tests/date_formatter.spec.ts
new file mode 100644
--- /dev/null
+++ b/ui/tests/date_formatter.spec.ts
@@ -0,0 +1,139 @@
+import {DateFormatter} from '../date_formatter';
+import * as chai from 'chai';
+
+chai.should();
+
+describe('DateFormatter', function () {
+    const converter = new DateFormatter();
+    let date: Date;
+    let originalOffset:  () => number;
+
+    function overrideTimezoneOffset(offset: number) {
+        Date.prototype.getTimezoneOffset = () => offset;
+    }
+
+    beforeEach(function () {
+        process.env.TZ='UTC';
+        originalOffset = Date.prototype.getTimezoneOffset;
+        overrideTimezoneOffset(0);
+        date = new Date(Date.parse('03 Feb 2019 13:09:09 UTC'));
+    });
+
+    afterEach(function () {
+        Date.prototype.getTimezoneOffset = originalOffset;
+    });
+
+    it('should return the day in short form if the pattern is %a', function () {
+        converter.format(date, '%a').should.equal('Sun');
+    });
+
+    it('should return the day in long form if the pattern is %A', function () {
+        converter.format(date, '%A').should.equal('Sunday');
+    });
+
+    it('should return the day in numeric if the pattern is %w', function () {
+        converter.format(date, '%w').should.equal('0');
+    });
+
+    it('should return the year in 2-digit format if the pattern is %y', function () {
+        converter.format(date, '%y').should.equal('19');
+    });
+
+    it('should return the year in full format if the pattern is %Y', function () {
+        converter.format(date, '%Y').should.equal('2019');
+    });
+
+    it('should return the month in short format if the pattern is %b', function () {
+        converter.format(date, '%b').should.equal('Feb');
+    });
+
+    it('should return the month in full format if the pattern is %B', function () {
+        converter.format(date, '%B').should.equal('February');
+    });
+
+    it('should return the month in 2-digit format if the pattern is %m', function () {
+        converter.format(date, '%m').should.equal('02');
+    });
+
+    it('should return the month in numeric format if the pattern is %-m', function () {
+        converter.format(date, '%-m').should.equal('2');
+    });
+
+    it('should return the day in 2-digit format if the pattern is %d', function () {
+        converter.format(date, '%d').should.equal('03');
+    });
+
+    it('should return the day in numeric format if the pattern is %-d', function () {
+        converter.format(date, '%-d').should.equal('3');
+    });
+
+    it('should return the hour in 2-digit format if the pattern is %H', function () {
+        date.setHours(8);
+        converter.format(date, '%H').should.equal('08');
+    });
+
+    it('should return the hour in numeric format if the pattern is %-H', function () {
+        date.setHours(8);
+        converter.format(date, '%-H').should.equal('8');
+    });
+
+    it('should return the hours in 12-hour 2-digit format if the pattern is %I', function () {
+        converter.format(date, '%I').should.equal('01');
+    });
+
+    it('should return the hours in 12-hour numeric format if the pattern is %-I', function () {
+        converter.format(date, '%-I').should.equal('1');
+    });
+
+    it('should return the minutes in 2-digit format if the pattern is %M', function () {
+        converter.format(date, '%M').should.equal('09');
+    });
+
+    it('should return the minutes in numeric format if the pattern is %-M', function () {
+        converter.format(date, '%-M').should.equal('9');
+    });
+
+    it('should return the seconds in 2-digit format if the pattern is %S', function () {
+        converter.format(date, '%S').should.equal('09');
+    });
+
+    it('should return the seconds in numeric format if the pattern is %-S', function () {
+        converter.format(date, '%-S').should.equal('9');
+    });
+
+    it('should return the am/pm if the pattern is %p', function () {
+        converter.format(date, '%p').should.equal('PM');
+        date.setTime(8);
+        converter.format(date, '%p').should.equal('AM');
+    });
+
+    it('should return the timezone if the pattern is %z', function () {
+        converter.format(date, '%z').should.equal('+0000');
+        overrideTimezoneOffset(+120);
+        converter.format(new Date('2019-02-03T13:09:09-02:00'), '%z').should.equal('-0200');
+    });
+
+    it('should return the timezone if the pattern is %Z', function () {
+        converter.format(date, '%Z').should.be.oneOf(['UTC', 'GMT']);
+    });
+
+    it('should return the day of the year padded if the pattern is %j', function () {
+        converter.format(date, '%j').should.equal('034');
+    });
+
+    it('should return the day of the year if the pattern is %-j', function () {
+        converter.format(date, '%-j').should.equal('34');
+    });
+
+    it('should return the locale’s appropriate date and time representation if the pattern is %c', function () {
+        converter.format(date, '%c').should.equal('2/3/2019, 1:09:09 PM');
+    });
+
+    it('should return the locale’s appropriate date representation if the pattern is %x', function () {
+        converter.format(date, '%x').should.equal('2/3/2019');
+    });
+
+    it('should return the locale’s appropriate time representation if the pattern is %X', function () {
+        converter.format(date, '%X').should.equal('1:09:09 PM');
+    });
+});
diff --git a/ui/tests/settings_general.spec.ts b/ui/tests/settings_general.spec.ts
--- a/ui/tests/settings_general.spec.ts
+++ b/ui/tests/settings_general.spec.ts
@@ -100,10 +100,19 @@ describe('general settings', function () {
         await retry( async() => {
             client.clearElement('#historical_data_start');
         });
+
         await retry( async() => {
             client.addValue('#historical_data_start', '03/10/2018');
         });
 
+        await retry( async() => {
+            client.clearElement('#date_display_format');
+        });
+
+        await retry( async() => {
+            client.addValue('#date_display_format', '%d-%m-%Y %H:%M:%S %z');
+        });
+
         await client.selectByValue('#maincurrencyselector', 'JPY');
 
         await retry( async() => {
@@ -136,5 +145,6 @@ describe('general settings', function () {
         await client.getValue('#eth_rpc_port').should.eventually.equal('8545');
         await client.isSelected('#anonymized_logs_input').should.eventually.be.true;
         await client.getValue('#maincurrencyselector').should.eventually.equal('JPY');
+        await client.getValue('#date_display_format').should.eventually.equal('%d-%m-%Y %H:%M:%S %z');
     });
 });