Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
276677a
WS-NAL Update format Duration to use Temporal [copilot]
Isabella-Mitchell Jul 17, 2026
8d8bc54
WS-NA: Adds logic to fix issues [copilot]
Isabella-Mitchell Jul 17, 2026
846005f
WS-NA-temporal: Fixes [copilot]
Isabella-Mitchell Jul 17, 2026
e7fba4d
WS-NA: Merge in parent
Isabella-Mitchell Jul 17, 2026
97a7a6d
WS-NA-Temporal: Unblat over changes
Isabella-Mitchell Jul 17, 2026
979e1cc
Merge branch 'WS-NA-temporal-migrate-add-internal-adapter' into WS-NA…
Isabella-Mitchell Aug 14, 2026
12f731c
WS-NA-Temporal-PR-3: Refactors
Isabella-Mitchell Aug 14, 2026
d1f06ba
Merge branch 'WS-NA-temporal-migrate-add-internal-adapter' into WS-NA…
Isabella-Mitchell Aug 14, 2026
3af5ab6
WS-Temporal-PR-3: Tidy functions
Isabella-Mitchell Aug 14, 2026
a0e9350
WS-Temporal-PR-3: Import polyfill for Jest tests
Isabella-Mitchell Aug 14, 2026
c6e3012
WS-Temporal-PR-3: Defensive code for unexpected runtime path where Te…
Isabella-Mitchell Aug 14, 2026
31b28c9
Merge branch 'WS-NA-temporal-migrate-add-internal-adapter' into WS-NA…
Isabella-Mitchell Aug 14, 2026
d82e244
Merge branch 'latest' into WS-NA-temporal-migrate-duration
Isabella-Mitchell Aug 28, 2026
abd7e3b
WS-Temporal-PR-3: Tidy and add comments for temp use
Isabella-Mitchell Aug 28, 2026
15a8c69
WS-Temporal-PR-3: Remove references to arab script in favour of local…
Isabella-Mitchell Aug 28, 2026
c115b06
WS-Temporal-PR-3: Refactor formatting into seperate functions
Isabella-Mitchell Aug 28, 2026
bcdd399
WS-Temporal-PR-3: Refactors translateDigits and applyFormat for reada…
Isabella-Mitchell Aug 28, 2026
b8bb3f8
WS-Temporal-PR-3: Refine types
Isabella-Mitchell Aug 28, 2026
f134018
WS-Temporal-PR-3: Move helper functions out
Isabella-Mitchell Aug 28, 2026
ed5eac2
WS-Temporal-PR-3: Adds unit tests [copilot]
Isabella-Mitchell Aug 28, 2026
d70289e
WS-Temporal-PR-3: Refactor tests
Isabella-Mitchell Aug 28, 2026
e7b3c4b
WS-Temporal-PR-3: Imports Temporal polyfill to storybook
Isabella-Mitchell Aug 28, 2026
1b327a3
WS-Temporal-PR-3: Adds ps eastern arabic override
Isabella-Mitchell Aug 28, 2026
5b0c88c
WS-Temporal-PR-3: Updates imports
Isabella-Mitchell Aug 28, 2026
1699813
WS-Temporal-PR-3: Adds test for PS locale override [copilot]
Isabella-Mitchell Aug 28, 2026
acfc680
WS-Temporal-PR-3: First pass at Readme and comments tidy [copilot]
Isabella-Mitchell Aug 28, 2026
d4b9817
WS-Temporal-PR-3: Type format based on our current use cases
Isabella-Mitchell Aug 28, 2026
bac8daa
WS-Temporal-PR-3: Adds types to tests
Isabella-Mitchell Aug 28, 2026
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
11 changes: 6 additions & 5 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import 'temporal-polyfill/global';
import { Global } from '@emotion/react';
import isChromatic from 'chromatic/isChromatic';
import { forceVisible } from 'react-lazyload';
Expand Down Expand Up @@ -232,17 +233,17 @@ const preview: Preview = {
<AccountProvider initialConfig={context.globals.idctaConfig}>
<UserContextProvider>
<ThemeProviderSCSSModules
service={context.globals.service.service}
variant={context.globals.service.variant}
>
<ThemeProvider
service={context.globals.service.service}
variant={context.globals.service.variant}
>
<ThemeProvider
service={context.globals.service.service}
variant={context.globals.service.variant}
>
<Story />
</ThemeProvider>
</ThemeProviderSCSSModules>
</UserContextProvider>
</UserContextProvider>
</AccountProvider>
</EventTrackingContextProvider>
</RequestContextProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'temporal-polyfill/global';
import { getLocalisedDate } from '../../helpers/localise-datetime';

describe('getLocalisedDate', () => {
Expand Down
20 changes: 15 additions & 5 deletions src/app/legacy/psammead/psammead-timestamp-container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,29 @@ const WrappingContainer = () => (
```jsx
import { formatDuration } from '#psammead/psammead-timestamp-container/src/utilities';

const localisedDuration = formatDuration({ duration: 'PTM30', locale: 'my' });
const localisedDuration = formatDuration({ duration: 'PT30M', locale: 'my' });
const customFormatDuration = formatDuration({
duration: 'PTM30',
duration: 'PT30M',
format: 'mm,ss',
});
```

`formatDuration` is powered by the [`Temporal`](https://tc39.es/proposal-temporal/docs/) API rather than moment. `duration` is parsed with `Temporal.Duration.from()`; if `Temporal` isn't available in the runtime (e.g. an older browser without the [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill) loaded) or `duration` isn't a valid ISO 8601 duration string, it falls back to `0` seconds rather than throwing.

`locale` doesn't need to be a strict BCP 47 language tag (e.g. underscore-separated locales like `fa_af` are normalised) — it's used to localise digits via `Intl.NumberFormat`, e.g. `locale: 'my'` renders Burmese digits (`၀`-`၉`). If `locale` is not provided or it can't be parsed, it falls back to `en-GB`.

For Arabic-script locales (`ar`, `fa`, `ps`, `ur`), the `,` separator in `format` is rendered as the Arabic comma (`،`) instead.

Pashto (`ps`) additionally forces the Eastern Arabic-Indic numbering system explicitly, matching the equivalent override in `psammead-locales/moment/ps.js`.

Only the `h`, `mm`, `m` and `ss` tokens are recognised in `format`, joined by a single `:` or `,` separator (e.g. `'h:mm:ss'`, `'mm,ss'`, `'m'`) — this is enforced by the `DurationFormat` type, so passing anything else (e.g. a moment-style string like `'YYYY-MM-DD'`) is a TypeScript error.

<!-- prettier-ignore -->
| Argument | Type | Required | Default | Example |
|-----------|-------------|----------|---------|-----------------|
| duration | string | Yes | N/A | `PTH1M29S30` Duration string as specified by ISO 8601 standard. |
| format | string | No | `mm:ss`, or `h:mm:ss` if in hours | `mm,ss` |
| locale | string | No | `en-gb` | `fr` |
| duration | string | Yes | N/A | `PT1H29M30S` Duration string as specified by ISO 8601 standard. |
| format | `DurationFormat` | No | `mm:ss`, or `h:mm:ss` if in hours | `mm,ss` |
| locale | string | No | `en-GB` | `ar` |

**localisedMoment**

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import moment from 'moment-timezone';
import {
sanitiseDuration,
sanitiseLocale,
applyFormat,
withArabicComma,
DurationFormat,
} from './temporalHelpers';

type Locale = string;
type ISODuration = string;

// Locales that use Arabic script and require Arabic comma
const ARABIC_SCRIPT_LOCALES = new Set(['ar', 'fa', 'ps', 'ur']);

// Note that this next section is globally configuring moment.
// It is not possible to configure these on specific moment instances.
// The current requirements for rounding & thresholding are the same universally
Expand All @@ -28,15 +38,32 @@ export const formatDuration = ({
locale = 'en-gb',
}: {
duration: ISODuration;
format?: string;
format?: DurationFormat;
locale?: Locale;
}): string => {
const defaultDurationFormat = duration?.includes('H') ? 'h:mm:ss' : 'mm:ss';
const durationInMilliseconds = moment.duration(duration).asMilliseconds();
return moment
.utc(durationInMilliseconds)
.locale(locale)
.format(format || defaultDurationFormat);
const sanitisedLocale = sanitiseLocale(locale);

const totalSeconds = sanitiseDuration(duration).total({
unit: 'seconds',
});
Comment on lines +46 to +48
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);

const formattedString = applyFormat({
format,
hours,
minutes,
seconds,
sanitisedLocale,
});

// Extract language code (e.g., 'fa' from 'fa-AF', 'ar' from 'ar-EG')
const langCode = locale.split('-')[0];
if (ARABIC_SCRIPT_LOCALES.has(langCode)) {
return withArabicComma(formattedString);
}
return formattedString;
};

// if the date is invalid return false - https://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript#answer-1353711
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import {
sanitiseDuration,
sanitiseLocale,
withArabicComma,
translateDigits,
applyFormat,
DurationFormat,
} from '.';

describe('Temporal Helper functions', () => {
describe('sanitiseDuration', () => {
describe('valid duration input', () => {
it.each([
['PT0S', 0],
['PT45S', 45],
['PT30M', 1800],
['PT1H30M45S', 5445],
])('parses %s to %i total seconds', (duration, expectedSeconds) => {
expect(sanitiseDuration(duration).total({ unit: 'seconds' })).toEqual(
expectedSeconds,
);
});
});

describe('invalid duration input', () => {
it.each(['garbage', ''])(
'falls back to 0 seconds for invalid duration %p',
duration => {
expect(sanitiseDuration(duration).total({ unit: 'seconds' })).toEqual(
0,
);
},
);
});

it('falls back to 0 seconds when Temporal is unavailable', () => {
const originalTemporal = globalThis.Temporal;
try {
// @ts-expect-error - simulating an environment without Temporal support
delete globalThis.Temporal;
expect(sanitiseDuration('PT1H30M').total({ unit: 'seconds' })).toEqual(
0,
);
} finally {
globalThis.Temporal = originalTemporal;
}
});
});

describe('sanitiseLocale', () => {
describe('valid locale input', () => {
it.each([
['en-GB', 'en-GB'],
['en-gb', 'en-GB'],
['en_GB', 'en-GB'],
['ar', 'ar'],
['fa', 'fa'],
['fa_af', 'fa-AF'],
['fa-AF', 'fa-AF'],
['fa-af', 'fa-AF'],
])(
'normalises %p to %p so the output is a valid BCP 47 language tag',
(locale, expected) => {
expect(sanitiseLocale(locale)).toEqual(expected);
},
);
});

describe('invalid locale input', () => {
it('falls back to en-GB for an unparseable locale', () => {
expect(sanitiseLocale('not a locale!!')).toEqual('en-GB');
});
});
});

describe('withArabicComma', () => {
it('replaces commas with the Arabic comma', () => {
expect(withArabicComma('1,234,567')).toEqual('1،234،567');
});

it('replaces multiple adjacent commas', () => {
expect(withArabicComma('1,,2')).toEqual('1،،2');
});

it('returns the string unchanged when it has no commas', () => {
expect(withArabicComma('no commas here')).toEqual('no commas here');
});
});

describe('translateDigits', () => {
it.each([
[5, 1, '5'],
[5, 2, '05'],
[5, 3, '005'],
[12, 2, '12'],
])(
'formats %i with minDigits %i as %p for en-GB',
(value, minDigits, expected) => {
expect(translateDigits(value, minDigits, 'en-GB')).toEqual(expected);
},
);

it.each([
['my', '၀၅'],
['bn', '০৫'],
['ne', '०५'],
['fa', '۰۵'],
['fa-AF', '۰۵'],
['ps', '۰۵'],
])('translates digits into the %p numeral system', (locale, expected) => {
expect(translateDigits(5, 2, locale)).toEqual(expected);
});
});

describe('locale numbering system overrides', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('forces Eastern Arabic numerals for the `ps` locale', () => {
const numberFormatSpy = jest.spyOn(Intl, 'NumberFormat');

translateDigits(5, 2, 'ps');

expect(numberFormatSpy).toHaveBeenCalledWith(
'ps-u-nu-arabext',
expect.objectContaining({
minimumIntegerDigits: 2,
useGrouping: false,
}),
);
});

it('leaves locales without an explicit override unchanged', () => {
const numberFormatSpy = jest.spyOn(Intl, 'NumberFormat');

translateDigits(5, 2, 'fa');

expect(numberFormatSpy).toHaveBeenCalledWith(
'fa',
expect.objectContaining({
minimumIntegerDigits: 2,
useGrouping: false,
}),
);
});

it('resolves arabext unicode extension to the Eastern Arabic system', () => {
expect(
new Intl.NumberFormat('ps-u-nu-arabext').resolvedOptions()
.numberingSystem,
).toEqual('arabext');
});
});

describe('applyFormat', () => {
describe('when format is provided', () => {
it.each<[DurationFormat, string]>([
['mm,ss', '05,09'],
['h:mm:ss', '1:05:09'],
['m', '5'],
])('replaces the %p format token', (format, expected) => {
expect(
applyFormat({
format,
hours: 1,
minutes: 5,
seconds: 9,
sanitisedLocale: 'en-GB',
}),
).toEqual(expected);
});

it.each<[DurationFormat, string]>([
['mm,ss', '۰۵,۰۹'],
['h:mm:ss', '۱:۰۵:۰۹'],
['m', '۵'],
])(
'replaces the %p format token for a non en-GB locale',
(format, expected) => {
expect(
applyFormat({
format,
hours: 1,
minutes: 5,
seconds: 9,
sanitisedLocale: 'fa',
}),
).toEqual(expected);
},
);
});

describe('when format is not provided', () => {
it('uses mm:ss when hours is 0', () => {
expect(
applyFormat({
hours: 0,
minutes: 5,
seconds: 9,
sanitisedLocale: 'en-GB',
}),
).toEqual('05:09');
});

it('uses h:mm:ss when hours is greater than 0', () => {
expect(
applyFormat({
hours: 1,
minutes: 5,
seconds: 9,
sanitisedLocale: 'en-GB',
}),
).toEqual('1:05:09');
});

it('pads minutes and seconds to 2 digits at the zero boundary', () => {
expect(
applyFormat({
hours: 0,
minutes: 0,
seconds: 0,
sanitisedLocale: 'en-GB',
}),
).toEqual('00:00');
});

it.each([
['my', '၀၅:၀၉'],
['bn', '০৫:০৯'],
['ne', '०५:०९'],
['fa', '۰۵:۰۹'],
['fa-AF', '۰۵:۰۹'],
['ps', '۰۵:۰۹'],
])('translates digits for locale %p', (sanitisedLocale, expected) => {
expect(
applyFormat({ hours: 0, minutes: 5, seconds: 9, sanitisedLocale }),
).toEqual(expected);
});
});
});
});
Loading
Loading