Skip to content

V15: Show duration on time displays #18341

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,10 @@ export default {
skipToMenu: 'Spring til menu',
skipToContent: 'Spring til indhold',
newVersionAvailable: 'Ny version tilgængelig',
duration: (duration: string, date: Date | string, now: Date | string) => {
if (new Date(date).getTime() < new Date(now).getTime()) return `for ${duration} siden`;
return `om ${duration}`;
},
},
colors: {
blue: 'Blå',
Expand Down
4 changes: 4 additions & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,10 @@ export default {
revert: 'Revert',
validate: 'Validate',
newVersionAvailable: 'New version available',
duration: (duration: string, date: Date | string, now: Date | string) => {
if (new Date(date).getTime() < new Date(now).getTime()) return `${duration} ago`;
return `in ${duration}`;
},
},
colors: {
blue: 'Blue',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,61 @@ describe('UmbLocalizeController', () => {
});
});

describe('duration', () => {
it('should return a duration', () => {
const now = new Date('2020-01-01T00:00:00');
const inTwoDays = new Date(now.getTime());
inTwoDays.setDate(inTwoDays.getDate() + 2);
inTwoDays.setHours(11, 30, 5);

expect(controller.duration(inTwoDays, now)).to.equal('2 days, 11 hours, 30 minutes, 5 seconds');
});

it('should return a date in seconds if the date is less than a minute away', () => {
const now = new Date();
const inTenSeconds = new Date(now.getTime() + 10000);

expect(controller.duration(inTenSeconds, now)).to.equal('10 seconds');
});

it('should compare between two dates', () => {
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const inTwoDays = new Date();
inTwoDays.setDate(inTwoDays.getDate() + 2);

expect(controller.duration(inTwoDays, twoDaysAgo)).to.equal('4 days');
});

it('should return a negative duration', () => {
expect(controller.duration('2020-01-01', '2019-12-30')).to.equal('2 days');
});

it('should update the relative compounded time when the language changes', async () => {
const now = new Date();
const inTwoDays = new Date();
inTwoDays.setDate(inTwoDays.getDate() + 2);

expect(controller.duration(inTwoDays, now)).to.equal('2 days');

// Switch browser to Danish
document.documentElement.lang = danishRegional.$code;
await aTimeout(0);

expect(controller.duration(inTwoDays, now)).to.equal('2 dage');
});
});

describe('list format', () => {
it('should return a list with conjunction', () => {
expect(controller.list(['one', 'two', 'three'], { type: 'conjunction' })).to.equal('one, two, and three');
});

it('should return a list with disjunction', () => {
expect(controller.list(['one', 'two', 'three'], { type: 'disjunction' })).to.equal('one, two, or three');
});
});

describe('string', () => {
it('should replace words prefixed with a # with translated value', async () => {
const str = '#close';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@

/**
* Outputs a localized time in relative format.
* @example "in 2 days"
* @param {number} value - the value to format.
* @param {Intl.RelativeTimeFormatUnit} unit - the unit of time to format.
* @param {Intl.RelativeTimeFormatOptions} options - the options to use when formatting the time.
Expand All @@ -191,6 +192,60 @@
return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
}

/**
* Outputs a localized compounded time in a duration format.
* @example "2 days, 3 hours and 5 minutes"
* @param {Date} fromDate - the date to compare from.
* @param {Date} toDate - the date to compare to, usually the current date (default: current date).
* @param {object} options - the options to use when formatting the time.
* @returns {string} - the formatted time, example: "2 days, 3 hours, 5 minutes"
*/
duration(fromDate: Date | string, toDate?: Date | string, options?: any): string {
const d1 = new Date(fromDate);
const d2 = new Date(toDate ?? Date.now());
const diff = Math.abs(d1.getTime() - d2.getTime());
const diffInSecs = Math.abs(Math.floor(diff / 1000));

if (false === 'DurationFormat' in Intl) {
return `${diffInSecs} seconds`;
}

const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24));
const restDiffInHours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const restDiffInMins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const restDiffInSecs = Math.floor((diff % (1000 * 60)) / 1000);

const formatOptions = {
style: 'long',
...options,
};

// TODO: This is a hack to get around the fact that the DurationFormat is not yet available in the TypeScript typings. [JOV]
const formatter = new (Intl as any).DurationFormat(this.lang(), formatOptions);

if (diffInDays === 0 && restDiffInHours === 0 && restDiffInMins === 0) {

Check warning on line 226 in src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (v15/dev)

❌ New issue: Complex Conditional

UmbLocalizationController.duration has 1 complex conditionals with 2 branches, threshold = 2. A complex conditional is an expression inside a branch (e.g. if, for, while) which consists of multiple, logical operators such as AND/OR. The more logical operators in an expression, the more severe the code smell.
return formatter.format({ seconds: diffInSecs });
}

return formatter.format({
days: diffInDays,
hours: restDiffInHours,
minutes: restDiffInMins,
seconds: restDiffInSecs,
});
}

/**
* Outputs a localized list of values in the specified format.
* @example "one, two, and three"
* @param {Iterable<string>} values - the values to format.
* @param {Intl.ListFormatOptions} options - the options to use when formatting the list.
* @returns {string} - the formatted list.
*/
list(values: Iterable<string>, options?: Intl.ListFormatOptions): string {
return new Intl.ListFormat(this.lang(), options).format(values);
}

// TODO: for V.16 we should set type to be string | undefined. [NL]
/**
* Translates a string containing one or more terms. The terms should be prefixed with a `#` character.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbLocalizeDateElement } from './localize-date.element.js';
import { aTimeout, expect, fixture, html } from '@open-wc/testing';

const english = {
type: 'localization',
alias: 'test.en',
name: 'Test English',
meta: {
culture: 'en',
localizations: {
general: {
duration: () => {
return '2 years ago'; // This is a simplified version of the actual implementation
},
},
},
},
};

describe('umb-localize-date', () => {
let date: Date;
let element: UmbLocalizeDateElement;

beforeEach(async () => {
date = new Date('2020-01-01T00:00:00');
element = await fixture(html`<umb-localize-date .date=${date}>Fallback value</umb-localize-date>`);
});

it('should be defined', () => {
expect(element).to.be.instanceOf(UmbLocalizeDateElement);
});

describe('localization', () => {
umbExtensionsRegistry.register(english);

it('should localize a date', () => {
expect(element.shadowRoot?.textContent).to.equal('1/1/2020');
});

it('should localize a date with options', async () => {
element.options = { dateStyle: 'full' };
await element.updateComplete;

expect(element.shadowRoot?.textContent).to.equal('Wednesday, January 1, 2020');
});

it('should set a title', async () => {
await aTimeout(0);
expect(element.title).to.equal('2 years ago');
});

it('should not set a title', async () => {
element.skipDuration = true;
element.title = 'Another title';
await element.updateComplete;
expect(element.title).to.equal('Another title');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, nothing, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

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

@state()
protected get text(): string {
return this.localize.date(this.date!, this.options);
/**
* Do not show the duration in the title.
*/
@property({ type: Boolean })
skipDuration = false;

override updated() {
this.#setTitle();
}

override render() {
return this.date ? html`${unsafeHTML(this.text)}` : html`<slot></slot>`;
return this.date ? this.localize.date(this.date, this.options) : nothing;
}

#setTitle() {
if (this.skipDuration) {
return;
}

let title = '';

if (this.date) {
const now = new Date();
const d = new Date(this.date);
const duration = this.localize.duration(d, now);
title = this.localize.term('general_duration', duration, d, now);
}

this.title = title;
}

static override styles = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ export class UmbLocalizeRelativeTimeElement extends UmbLitElement {
* @attr
* @example time=10
*/
@property()
@property({ type: Number })
time!: number;

/**
* Formatting options
* @attr
* @example options={ dateStyle: 'full', timeStyle: 'long', timeZone: 'Australia/Sydney' }
*/
@property()
@property({ type: Object })
options?: Intl.RelativeTimeFormatOptions;

/**
Expand Down
Loading