Skip to content

Commit a485194

Browse files
authored
V15: Show duration on time displays (#18341)
* feat: adds a method to output a list format * test: adds test for list format * feat: adds a method to show a compounded relative time * test: improves test to be absolute * feat: allows dates to be strings * chore: fixes lit warnings * feat: adds compounded time to document info view * feat: rename to list * feat: use the Intl.DurationFormat API to format durations * test: adds test for string dates * feat: times should always be absolute * feat: adds duration to the umb-localize-date element * feat: adds support to set your own title * revert changes * feat: adds localization to calculate duration past or future * feat: adds danish localization
1 parent a6e8b23 commit a485194

File tree

7 files changed

+207
-7
lines changed

7 files changed

+207
-7
lines changed

src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,10 @@ export default {
928928
skipToMenu: 'Spring til menu',
929929
skipToContent: 'Spring til indhold',
930930
newVersionAvailable: 'Ny version tilgængelig',
931+
duration: (duration: string, date: Date | string, now: Date | string) => {
932+
if (new Date(date).getTime() < new Date(now).getTime()) return `for ${duration} siden`;
933+
return `om ${duration}`;
934+
},
931935
},
932936
colors: {
933937
blue: 'Blå',

src/Umbraco.Web.UI.Client/src/assets/lang/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,10 @@ export default {
965965
revert: 'Revert',
966966
validate: 'Validate',
967967
newVersionAvailable: 'New version available',
968+
duration: (duration: string, date: Date | string, now: Date | string) => {
969+
if (new Date(date).getTime() < new Date(now).getTime()) return `${duration} ago`;
970+
return `in ${duration}`;
971+
},
968972
},
969973
colors: {
970974
blue: 'Blue',

src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,61 @@ describe('UmbLocalizeController', () => {
282282
});
283283
});
284284

285+
describe('duration', () => {
286+
it('should return a duration', () => {
287+
const now = new Date('2020-01-01T00:00:00');
288+
const inTwoDays = new Date(now.getTime());
289+
inTwoDays.setDate(inTwoDays.getDate() + 2);
290+
inTwoDays.setHours(11, 30, 5);
291+
292+
expect(controller.duration(inTwoDays, now)).to.equal('2 days, 11 hours, 30 minutes, 5 seconds');
293+
});
294+
295+
it('should return a date in seconds if the date is less than a minute away', () => {
296+
const now = new Date();
297+
const inTenSeconds = new Date(now.getTime() + 10000);
298+
299+
expect(controller.duration(inTenSeconds, now)).to.equal('10 seconds');
300+
});
301+
302+
it('should compare between two dates', () => {
303+
const twoDaysAgo = new Date();
304+
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
305+
const inTwoDays = new Date();
306+
inTwoDays.setDate(inTwoDays.getDate() + 2);
307+
308+
expect(controller.duration(inTwoDays, twoDaysAgo)).to.equal('4 days');
309+
});
310+
311+
it('should return a negative duration', () => {
312+
expect(controller.duration('2020-01-01', '2019-12-30')).to.equal('2 days');
313+
});
314+
315+
it('should update the relative compounded time when the language changes', async () => {
316+
const now = new Date();
317+
const inTwoDays = new Date();
318+
inTwoDays.setDate(inTwoDays.getDate() + 2);
319+
320+
expect(controller.duration(inTwoDays, now)).to.equal('2 days');
321+
322+
// Switch browser to Danish
323+
document.documentElement.lang = danishRegional.$code;
324+
await aTimeout(0);
325+
326+
expect(controller.duration(inTwoDays, now)).to.equal('2 dage');
327+
});
328+
});
329+
330+
describe('list format', () => {
331+
it('should return a list with conjunction', () => {
332+
expect(controller.list(['one', 'two', 'three'], { type: 'conjunction' })).to.equal('one, two, and three');
333+
});
334+
335+
it('should return a list with disjunction', () => {
336+
expect(controller.list(['one', 'two', 'three'], { type: 'disjunction' })).to.equal('one, two, or three');
337+
});
338+
});
339+
285340
describe('string', () => {
286341
it('should replace words prefixed with a # with translated value', async () => {
287342
const str = '#close';

src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
182182

183183
/**
184184
* Outputs a localized time in relative format.
185+
* @example "in 2 days"
185186
* @param {number} value - the value to format.
186187
* @param {Intl.RelativeTimeFormatUnit} unit - the unit of time to format.
187188
* @param {Intl.RelativeTimeFormatOptions} options - the options to use when formatting the time.
@@ -191,6 +192,60 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
191192
return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
192193
}
193194

195+
/**
196+
* Outputs a localized compounded time in a duration format.
197+
* @example "2 days, 3 hours and 5 minutes"
198+
* @param {Date} fromDate - the date to compare from.
199+
* @param {Date} toDate - the date to compare to, usually the current date (default: current date).
200+
* @param {object} options - the options to use when formatting the time.
201+
* @returns {string} - the formatted time, example: "2 days, 3 hours, 5 minutes"
202+
*/
203+
duration(fromDate: Date | string, toDate?: Date | string, options?: any): string {
204+
const d1 = new Date(fromDate);
205+
const d2 = new Date(toDate ?? Date.now());
206+
const diff = Math.abs(d1.getTime() - d2.getTime());
207+
const diffInSecs = Math.abs(Math.floor(diff / 1000));
208+
209+
if (false === 'DurationFormat' in Intl) {
210+
return `${diffInSecs} seconds`;
211+
}
212+
213+
const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24));
214+
const restDiffInHours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
215+
const restDiffInMins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
216+
const restDiffInSecs = Math.floor((diff % (1000 * 60)) / 1000);
217+
218+
const formatOptions = {
219+
style: 'long',
220+
...options,
221+
};
222+
223+
// TODO: This is a hack to get around the fact that the DurationFormat is not yet available in the TypeScript typings. [JOV]
224+
const formatter = new (Intl as any).DurationFormat(this.lang(), formatOptions);
225+
226+
if (diffInDays === 0 && restDiffInHours === 0 && restDiffInMins === 0) {
227+
return formatter.format({ seconds: diffInSecs });
228+
}
229+
230+
return formatter.format({
231+
days: diffInDays,
232+
hours: restDiffInHours,
233+
minutes: restDiffInMins,
234+
seconds: restDiffInSecs,
235+
});
236+
}
237+
238+
/**
239+
* Outputs a localized list of values in the specified format.
240+
* @example "one, two, and three"
241+
* @param {Iterable<string>} values - the values to format.
242+
* @param {Intl.ListFormatOptions} options - the options to use when formatting the list.
243+
* @returns {string} - the formatted list.
244+
*/
245+
list(values: Iterable<string>, options?: Intl.ListFormatOptions): string {
246+
return new Intl.ListFormat(this.lang(), options).format(values);
247+
}
248+
194249
// TODO: for V.16 we should set type to be string | undefined. [NL]
195250
/**
196251
* Translates a string containing one or more terms. The terms should be prefixed with a `#` character.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
2+
import { UmbLocalizeDateElement } from './localize-date.element.js';
3+
import { aTimeout, expect, fixture, html } from '@open-wc/testing';
4+
5+
const english = {
6+
type: 'localization',
7+
alias: 'test.en',
8+
name: 'Test English',
9+
meta: {
10+
culture: 'en',
11+
localizations: {
12+
general: {
13+
duration: () => {
14+
return '2 years ago'; // This is a simplified version of the actual implementation
15+
},
16+
},
17+
},
18+
},
19+
};
20+
21+
describe('umb-localize-date', () => {
22+
let date: Date;
23+
let element: UmbLocalizeDateElement;
24+
25+
beforeEach(async () => {
26+
date = new Date('2020-01-01T00:00:00');
27+
element = await fixture(html`<umb-localize-date .date=${date}>Fallback value</umb-localize-date>`);
28+
});
29+
30+
it('should be defined', () => {
31+
expect(element).to.be.instanceOf(UmbLocalizeDateElement);
32+
});
33+
34+
describe('localization', () => {
35+
umbExtensionsRegistry.register(english);
36+
37+
it('should localize a date', () => {
38+
expect(element.shadowRoot?.textContent).to.equal('1/1/2020');
39+
});
40+
41+
it('should localize a date with options', async () => {
42+
element.options = { dateStyle: 'full' };
43+
await element.updateComplete;
44+
45+
expect(element.shadowRoot?.textContent).to.equal('Wednesday, January 1, 2020');
46+
});
47+
48+
it('should set a title', async () => {
49+
await aTimeout(0);
50+
expect(element.title).to.equal('2 years ago');
51+
});
52+
53+
it('should not set a title', async () => {
54+
element.skipDuration = true;
55+
element.title = 'Another title';
56+
await element.updateComplete;
57+
expect(element.title).to.equal('Another title');
58+
});
59+
});
60+
});

src/Umbraco.Web.UI.Client/src/packages/core/localization/localize-date.element.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
1+
import { css, customElement, nothing, property } from '@umbraco-cms/backoffice/external/lit';
22
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
33

44
/**
@@ -24,13 +24,35 @@ export class UmbLocalizeDateElement extends UmbLitElement {
2424
@property({ type: Object })
2525
options?: Intl.DateTimeFormatOptions;
2626

27-
@state()
28-
protected get text(): string {
29-
return this.localize.date(this.date!, this.options);
27+
/**
28+
* Do not show the duration in the title.
29+
*/
30+
@property({ type: Boolean })
31+
skipDuration = false;
32+
33+
override updated() {
34+
this.#setTitle();
3035
}
3136

3237
override render() {
33-
return this.date ? html`${unsafeHTML(this.text)}` : html`<slot></slot>`;
38+
return this.date ? this.localize.date(this.date, this.options) : nothing;
39+
}
40+
41+
#setTitle() {
42+
if (this.skipDuration) {
43+
return;
44+
}
45+
46+
let title = '';
47+
48+
if (this.date) {
49+
const now = new Date();
50+
const d = new Date(this.date);
51+
const duration = this.localize.duration(d, now);
52+
title = this.localize.term('general_duration', duration, d, now);
53+
}
54+
55+
this.title = title;
3456
}
3557

3658
static override styles = [

src/Umbraco.Web.UI.Client/src/packages/core/localization/localize-relative-time.element.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ export class UmbLocalizeRelativeTimeElement extends UmbLitElement {
1313
* @attr
1414
* @example time=10
1515
*/
16-
@property()
16+
@property({ type: Number })
1717
time!: number;
1818

1919
/**
2020
* Formatting options
2121
* @attr
2222
* @example options={ dateStyle: 'full', timeStyle: 'long', timeZone: 'Australia/Sydney' }
2323
*/
24-
@property()
24+
@property({ type: Object })
2525
options?: Intl.RelativeTimeFormatOptions;
2626

2727
/**

0 commit comments

Comments
 (0)