From ee39df37bec3aa0461ea2e71cc474bfdc006db17 Mon Sep 17 00:00:00 2001 From: 4eb0da <4eb0da@yandex-team.com> Date: Tue, 21 May 2024 12:38:56 +0300 Subject: [PATCH] Additional datetime funcs b96996dc12c52fdefb6da21a52aa9ad23b81066a --- .mapping.json | 2 + client/web/divkit/README.md | 6 + client/web/divkit/package-lock.json | 20 +- client/web/divkit/package.json | 2 +- client/web/divkit/src/client-devtool.ts | 1 + client/web/divkit/src/client.ts | 1 + client/web/divkit/src/components/Root.svelte | 9 +- client/web/divkit/src/dev.ts | 136 +- client/web/divkit/src/expressions/eval.ts | 8 +- .../divkit/src/expressions/funcs/datetime.ts | 39 +- client/web/divkit/src/expressions/json.ts | 50 +- client/web/divkit/src/server.ts | 1 + client/web/divkit/src/utils/formatDate.ts | 368 ++++ .../tests/expressions/expressions.test.ts | 4 +- .../web/divkit/tests/utils/formatDate.test.ts | 1503 +++++++++++++++++ .../web/divkit/tests/utils/variables.test.ts | 12 +- client/web/divkit/tsconfig.json | 2 +- client/web/divkit/typings/client-devtool.d.ts | 3 + .../web/divkit/typings/client-hydratable.d.ts | 1 + client/web/divkit/typings/client.d.ts | 1 + client/web/divkit/typings/server.d.ts | 1 + .../function_signatures_datetime.json | 15 +- .../functions_datetime_format.json | 45 +- 23 files changed, 2166 insertions(+), 64 deletions(-) create mode 100644 client/web/divkit/src/utils/formatDate.ts create mode 100644 client/web/divkit/tests/utils/formatDate.test.ts diff --git a/.mapping.json b/.mapping.json index ee004d88f..84b8ea592 100644 --- a/.mapping.json +++ b/.mapping.json @@ -12839,6 +12839,7 @@ "client/web/divkit/src/utils/flattenAnimation.ts":"divkit/public/client/web/divkit/src/utils/flattenAnimation.ts", "client/web/divkit/src/utils/flattenTransition.ts":"divkit/public/client/web/divkit/src/utils/flattenTransition.ts", "client/web/divkit/src/utils/fontWeightToCss.ts":"divkit/public/client/web/divkit/src/utils/fontWeightToCss.ts", + "client/web/divkit/src/utils/formatDate.ts":"divkit/public/client/web/divkit/src/utils/formatDate.ts", "client/web/divkit/src/utils/genClassName.ts":"divkit/public/client/web/divkit/src/utils/genClassName.ts", "client/web/divkit/src/utils/getMarginBox.ts":"divkit/public/client/web/divkit/src/utils/getMarginBox.ts", "client/web/divkit/src/utils/getTouchCoords.ts":"divkit/public/client/web/divkit/src/utils/getTouchCoords.ts", @@ -15094,6 +15095,7 @@ "client/web/divkit/tests/utils/flattenAnimation.test.ts":"divkit/public/client/web/divkit/tests/utils/flattenAnimation.test.ts", "client/web/divkit/tests/utils/flattenTransition.test.ts":"divkit/public/client/web/divkit/tests/utils/flattenTransition.test.ts", "client/web/divkit/tests/utils/fontWeightToCss.test.ts":"divkit/public/client/web/divkit/tests/utils/fontWeightToCss.test.ts", + "client/web/divkit/tests/utils/formatDate.test.ts":"divkit/public/client/web/divkit/tests/utils/formatDate.test.ts", "client/web/divkit/tests/utils/genClassName.test.ts":"divkit/public/client/web/divkit/tests/utils/genClassName.test.ts", "client/web/divkit/tests/utils/gridCalcTemplates.test.ts":"divkit/public/client/web/divkit/tests/utils/gridCalcTemplates.test.ts", "client/web/divkit/tests/utils/htmlFilter.test.ts":"divkit/public/client/web/divkit/tests/utils/htmlFilter.test.ts", diff --git a/client/web/divkit/README.md b/client/web/divkit/README.md index 888e358c5..9da9f73ad 100644 --- a/client/web/divkit/README.md +++ b/client/web/divkit/README.md @@ -371,6 +371,12 @@ Additional external storage. The `getValue` and `setValue` methods should be imp Please note that these methods must be synchronous! DivKit does not have asynchronous render methods, so any `getValue` function cannot be asynchronous. It is possible to process `setValue` asynchronously, but if the value from `getValue` is critical for you and cannot be read synchronously, you must read it before calling `render`. +#### weekStartDay + +Number, optional. 0 by default. + +Affects the formatting of dates. + #### theme (DEPRECATED) `system` | `light` | `dark` diff --git a/client/web/divkit/package-lock.json b/client/web/divkit/package-lock.json index d61d843d2..57c61a6df 100644 --- a/client/web/divkit/package-lock.json +++ b/client/web/divkit/package-lock.json @@ -1,12 +1,12 @@ { "name": "@divkitframework/divkit", - "version": "29.11.0", + "version": "30.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@divkitframework/divkit", - "version": "29.11.0", + "version": "30.1.0", "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.21.4", @@ -39,7 +39,7 @@ "svelte-check": "3.5.0", "svelte-loader": "3.1.9", "svelte-preprocess": "5.0.4", - "typescript": "5.0.4", + "typescript": "5.4.5", "webpack": "5.91.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4" @@ -13639,16 +13639,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { @@ -24505,9 +24505,9 @@ } }, "typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true }, "unbox-primitive": { diff --git a/client/web/divkit/package.json b/client/web/divkit/package.json index ae4b93fe2..a3b290bc1 100644 --- a/client/web/divkit/package.json +++ b/client/web/divkit/package.json @@ -121,7 +121,7 @@ "svelte-check": "3.5.0", "svelte-loader": "3.1.9", "svelte-preprocess": "5.0.4", - "typescript": "5.0.4", + "typescript": "5.4.5", "webpack": "5.91.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4" diff --git a/client/web/divkit/src/client-devtool.ts b/client/web/divkit/src/client-devtool.ts index b2aa4ce11..6631aac1b 100644 --- a/client/web/divkit/src/client-devtool.ts +++ b/client/web/divkit/src/client-devtool.ts @@ -31,6 +31,7 @@ export function render(opts: { tooltipRoot?: HTMLElement; customComponents?: Map | undefined; store?: Store; + weekStartDay?: number; }): DivkitDebugInstance { const { target, hydrate, ...rest } = opts; diff --git a/client/web/divkit/src/client.ts b/client/web/divkit/src/client.ts index c7732575f..5438554a1 100644 --- a/client/web/divkit/src/client.ts +++ b/client/web/divkit/src/client.ts @@ -40,6 +40,7 @@ export function render(opts: { customComponents?: Map | undefined; direction?: Direction; store?: Store; + weekStartDay?: number; }): DivkitInstance { const { target, hydrate, ...rest } = opts; diff --git a/client/web/divkit/src/components/Root.svelte b/client/web/divkit/src/components/Root.svelte index 9523cc145..aa71b2704 100644 --- a/client/web/divkit/src/components/Root.svelte +++ b/client/web/divkit/src/components/Root.svelte @@ -103,6 +103,7 @@ export let customComponents: Map | undefined = undefined; export let direction: Direction = 'ltr'; export let store: Store | undefined = undefined; + export let weekStartDay = 0; let isDesktop = writable(platform === 'desktop'); if (platform === 'auto' && typeof matchMedia !== 'undefined') { @@ -260,7 +261,7 @@ const vars = mergeVars(variables, additionalVars); - const prepared = prepareVars(jsonProp, logError, store); + const prepared = prepareVars(jsonProp, logError, store, weekStartDay); if (!prepared.vars.length) { if (prepared.hasExpression) { return constStore(prepared.applyVars(vars)); @@ -280,7 +281,7 @@ additionalVars?: Map, keepComplex = false ): MaybeMissing { - const prepared = prepareVars(jsonProp, logError, store); + const prepared = prepareVars(jsonProp, logError, store, weekStartDay); if (!prepared.hasExpression) { return jsonProp; @@ -1589,7 +1590,9 @@ const stores = exprVars.map(name => variables.get(name) || awaitVariableChanges(name)); derived(stores, () => { - const res = evalExpression(variables, store, ast); + const res = evalExpression(variables, store, ast, { + weekStartDay + }); res.warnings.forEach(logError); diff --git a/client/web/divkit/src/dev.ts b/client/web/divkit/src/dev.ts index c72513942..d1f5cc8c2 100644 --- a/client/web/divkit/src/dev.ts +++ b/client/web/divkit/src/dev.ts @@ -6,15 +6,143 @@ import Lottie from 'lottie-web/build/player/lottie'; import { initComponents } from './devCustomComponents'; const json = { - "templates": {}, + "templates": { + "tutorialCard": { + "type": "container", + "border": { + "corner_radius": 16, + "stroke": { + "color": "#aaaaaa", + "width": 1 + } + }, + "items": [ + { + "type": "text", + "font_size": 21, + "font_weight": "bold", + "margins": { + "bottom": 8 + }, + "$text": "title" + }, + { + "type": "text", + "font_size": 16, + "margins": { + "bottom": 4 + }, + "$text": "body" + }, + { + "type": "container", + "$items": "links" + } + ], + "margins": { + "bottom": 6 + }, + "orientation": "vertical", + "paddings": { + "top": 12, + "bottom": 12, + "left": 12, + "right": 12 + } + }, + "link": { + "type": "text", + "action": { + "$url": "link", + "$log_id": "log" + }, + "font_size": 14, + "margins": { + "bottom": 2 + }, + "text_color": "#0000ff", + "underline": "single", + "$text": "link_text" + } + }, "card": { - "log_id": "snapshot_test_card", + "log_id": "div2_sample_card", "states": [ { "state_id": 0, "div": { - "type": "text", - "text": "Hello world" + "type": "container", + "items": [ + { + "type": "tutorialCard", + "title": "@{getHours(parseUnixTimeAsLocal(1715855877))}", + "body": "Server side driven ui Яндекса\nПолезные ссылки:", + "links": [ + { + "type": "link", + "link_text": "Официальная документация", + "link": "https://doc.yandex-team.ru/divkit/overview/", + "log": "docs" + }, + { + "type": "link", + "link_text": "Примеры карточек", + "link": "https://a.yandex-team.ru/arcadia/divkit/public/test_data/samples", + "log": "samples" + }, + { + "type": "link", + "link_text": "Клуб в Этушке", + "link": "https://clubs.at.yandex-team.ru/divkit/", + "log": "atushka" + }, + { + "type": "link", + "link_text": "Канал в Мессенджере", + "link": "https://q.yandex-team.ru/#/join/b7263a0a-c2ef-4c02-9669-e8a1f4cf3762", + "log": "slack" + } + ] + }, + { + "type": "tutorialCard", + "title": "Горячие клавиши редактора", + "body": "ctrl+space - вызвать автодополнение\nctrl+f2 - заменить всё\nctrl+shift+i - отформатировать json\nctrl+shift+a закомментировать блок\nСхолпнуть json-блоки можно с помощью стрелочек, что появляются при наведении на блок с номерами строк слева\nf1 - вызвать меню со всеми доступными командами и горячими клавишами", + "links": [ + { + "type": "separator", + "delimiter_style": { + "color": "#00000000" + } + } + ] + }, + { + "type": "tutorialCard", + "title": "Интерактивная верстка на ваших устройствах", + "body": "Для того, чтобы посмотреть вашу верстку на android:\n1) Скачайте demo apk по ссылке ниже\n2) Установите apk на устройство и перейдите во вкладку \"Div2\"\n3) Нажмите на share на этой страничке и отсканируйте QR-код, нажав соответствующую кнопку в приложении, либо вставьте урл из поля \"Link to json\"\n4) Для каждого подключенного устройства в редакторе появится вкладка с тем, что отображается сейчас на экране", + "links": [ + { + "type": "link", + "link_text": "Android демо-приложение", + "link": "https://beta.m.soft.yandex.ru/description?app=divkit&platform_shortcut=android&branch=unknown", + "log": "android" + }, + { + "type": "link", + "link_text": "iOS демо-приложение", + "link": "https://teamcity.yandex-team.ru/buildConfiguration/MobileNew_Monorepo_DivKit_iOS_trunk?branch=&mode=builds", + "log": "ios" + } + ] + } + ], + "margins": { + "top": 8, + "bottom": 8, + "left": 8, + "right": 8 + } } } ] diff --git a/client/web/divkit/src/expressions/eval.ts b/client/web/divkit/src/expressions/eval.ts index 520dc9ef3..dab81f46c 100644 --- a/client/web/divkit/src/expressions/eval.ts +++ b/client/web/divkit/src/expressions/eval.ts @@ -100,6 +100,7 @@ export interface EvalContext { warnings: WrappedError[]; safeIntegerOverflow: boolean; store?: Store; + weekStartDay: number; } register(); @@ -560,7 +561,9 @@ export function evalAny(ctx: EvalContext, expr: Node): EvalValue { throw new Error('Unsupported expression'); } -export function evalExpression(vars: VariablesMap, store: Store | undefined, expr: Node): { +export function evalExpression(vars: VariablesMap, store: Store | undefined, expr: Node, opts?: { + weekStartDay?: number; +}): { result: EvalResult; warnings: WrappedError[]; } { @@ -569,7 +572,8 @@ export function evalExpression(vars: VariablesMap, store: Store | undefined, exp variables: vars, warnings: [], safeIntegerOverflow: false, - store + store, + weekStartDay: opts?.weekStartDay || 0 }; const result = evalAny(ctx, expr); diff --git a/client/web/divkit/src/expressions/funcs/datetime.ts b/client/web/divkit/src/expressions/funcs/datetime.ts index 3d34caa29..c195aa09a 100644 --- a/client/web/divkit/src/expressions/funcs/datetime.ts +++ b/client/web/divkit/src/expressions/funcs/datetime.ts @@ -1,8 +1,10 @@ -import type { DatetimeValue, EvalContext, EvalValue, IntegerValue } from '../eval'; +/* eslint-disable no-nested-ternary */ +import type { DatetimeValue, EvalContext, EvalValue, IntegerValue, StringValue } from '../eval'; import { registerFunc } from './funcs'; -import { DATETIME, INTEGER } from '../const'; +import { DATETIME, INTEGER, STRING } from '../const'; import { valToString } from '../utils'; import { toBigInt } from '../bigint'; +import { formatDate } from '../../utils/formatDate'; function getMaxDate(date: Date): number { const copy = new Date(date); @@ -20,6 +22,17 @@ function parseUnixTime(_ctx: EvalContext, arg: IntegerValue): EvalValue { }; } +function parseUnixTimeAsLocal(_ctx: EvalContext, arg: IntegerValue): EvalValue { + const date = new Date(Number(arg.value) * 1000); + const offset = date.getTimezoneOffset(); + date.setMinutes(date.getMinutes() - offset); + + return { + type: DATETIME, + value: date + }; +} + function nowLocal(): EvalValue { return { type: DATETIME, @@ -164,6 +177,19 @@ const getter = ( }; }; +function makeFormat(isUTC: boolean) { + return (ctx: EvalContext, datetime: DatetimeValue, format: StringValue, locale?: StringValue): EvalValue => { + return { + type: STRING, + value: formatDate(datetime.value, format.value, { + locale: locale?.value, + isUTC, + weekStartDay: ctx.weekStartDay + }) + }; + }; +} + const getYear = getter('getUTCFullYear'); const getMonth = getter('getUTCMonth'); const getDay = getter('getUTCDate'); @@ -173,8 +199,12 @@ const getMinutes = getter('getUTCMinutes'); const getSeconds = getter('getUTCSeconds'); const getMillis = getter('getUTCMilliseconds'); +const formatAsLocal = makeFormat(false); +const formatAsUTC = makeFormat(true); + export function registerDatetime(): void { registerFunc('parseUnixTime', [INTEGER], parseUnixTime); + registerFunc('parseUnixTimeAsLocal', [INTEGER], parseUnixTimeAsLocal); registerFunc('nowLocal', [], nowLocal); registerFunc('addMillis', [DATETIME, INTEGER], addMillis); @@ -194,4 +224,9 @@ export function registerDatetime(): void { registerFunc('getMinutes', [DATETIME], getMinutes); registerFunc('getSeconds', [DATETIME], getSeconds); registerFunc('getMillis', [DATETIME], getMillis); + + registerFunc('formatDateAsLocal', [DATETIME, STRING], formatAsLocal); + registerFunc('formatDateAsUTC', [DATETIME, STRING], formatAsUTC); + registerFunc('formatDateAsLocalWithLocale', [DATETIME, STRING, STRING], formatAsLocal); + registerFunc('formatDateAsUTCWithLocale', [DATETIME, STRING, STRING], formatAsUTC); } diff --git a/client/web/divkit/src/expressions/json.ts b/client/web/divkit/src/expressions/json.ts index f9eaa88f1..5690e4a7e 100644 --- a/client/web/divkit/src/expressions/json.ts +++ b/client/web/divkit/src/expressions/json.ts @@ -23,13 +23,24 @@ class ExpressionBinding { * @param logError */ apply( - variables: VariablesMap, - logError: LogError, - store: Store | undefined, - keepComplex = false + { + variables, + logError, + store, + weekStartDay, + keepComplex + }: { + variables: VariablesMap; + logError: LogError; + store: Store | undefined; + weekStartDay: number; + keepComplex?: boolean; + } ): VariableValue | string | undefined { try { - const res = evalExpression(variables, store, this.ast); + const res = evalExpression(variables, store, this.ast, { + weekStartDay + }); res.warnings.forEach(logError); const result = res.result; @@ -180,28 +191,31 @@ function prepareVarsObj( function applyVars( jsonProp: unknown, - variables: VariablesMap, - logError: LogError, - store: Store | undefined, - keepComplex = false + opts: { + variables: VariablesMap; + logError: LogError; + store: Store | undefined; + weekStartDay: number; + keepComplex?: boolean; + } ): unknown { if (jsonProp) { if ( (process.env.ENABLE_EXPRESSIONS || process.env.ENABLE_EXPRESSIONS === undefined) && jsonProp instanceof ExpressionBinding ) { - return jsonProp.apply(variables, logError, store, keepComplex); + return jsonProp.apply(opts); } else if ( (!process.env.ENABLE_EXPRESSIONS && process.env.ENABLE_EXPRESSIONS !== undefined) && jsonProp instanceof VariableBinding ) { - return jsonProp.apply(variables, logError); + return jsonProp.apply(opts.variables, opts.logError); } else if (Array.isArray(jsonProp)) { - return jsonProp.map(it => applyVars(it, variables, logError, store)); + return jsonProp.map(it => applyVars(it, opts)); } else if (typeof jsonProp === 'object') { const res: Record = {}; for (const key in jsonProp) { - res[key] = applyVars(jsonProp[key as keyof typeof jsonProp], variables, logError, store); + res[key] = applyVars(jsonProp[key as keyof typeof jsonProp], opts); } return res; } @@ -209,7 +223,7 @@ function applyVars( return jsonProp; } -export function prepareVars(jsonProp: T, logError: LogError, store: Store | undefined): { +export function prepareVars(jsonProp: T, logError: LogError, store: Store | undefined, weekStartDay: number): { vars: string[]; hasExpression: boolean; applyVars: (variables: VariablesMap, keepComplex?: boolean) => MaybeMissing; @@ -229,7 +243,13 @@ export function prepareVars(jsonProp: T, logError: LogError, store: Store | u vars, hasExpression: result.hasExpression, applyVars(variables: VariablesMap, keepComplex?: boolean) { - return applyVars(root, variables, logError, store, keepComplex) as MaybeMissing; + return applyVars(root, { + variables, + logError, + store, + weekStartDay, + keepComplex + }) as MaybeMissing; } }; } diff --git a/client/web/divkit/src/server.ts b/client/web/divkit/src/server.ts index e58ab1d97..055e34482 100644 --- a/client/web/divkit/src/server.ts +++ b/client/web/divkit/src/server.ts @@ -17,6 +17,7 @@ export function render(opts: { customComponents?: Map | undefined; direction?: Direction; store?: Store; + weekStartDay?: number; }) { // Root has client-side typings, not a server one return (Root as any).render(opts).html; diff --git a/client/web/divkit/src/utils/formatDate.ts b/client/web/divkit/src/utils/formatDate.ts new file mode 100644 index 000000000..6c1385576 --- /dev/null +++ b/client/web/divkit/src/utils/formatDate.ts @@ -0,0 +1,368 @@ +// Some code based on the https://github.com/date-fns (MIT License) + +type FormatGetter = (opts: Intl.DateTimeFormatOptions & { + date?: Date; +}, field: Intl.DateTimeFormatPartTypes | 'week' | 'weekyear' | 'extendedyear' | 'weekofmonth' | 'dayofyear' | 'dayofweekinmonth' | 'weekdaynumeric') => string | undefined; + +function formatNumber(num: string | undefined, len: number | undefined): string | undefined { + if (!num || !len) { + return num; + } + + return num.padStart(len, '0'); +} + +const formatters: Record string | undefined> = { + G(token, getter) { + let era: Intl.DateTimeFormatOptions['era']; + if (token < 4) { + era = 'short'; + } else if (token === 5) { + era = 'narrow'; + } else { + era = 'long'; + } + return getter({ + era + }, 'era'); + }, + d(token, getter) { + return formatNumber(getter({ + day: 'numeric' + }, 'day'), token > 1 ? token : 0); + }, + D(token, getter) { + return formatNumber(getter({}, 'dayofyear'), token > 1 ? token : 0); + }, + F(token, getter) { + return formatNumber(getter({}, 'dayofweekinmonth'), token > 1 ? token : 0); + }, + M(token, getter) { + let month: Intl.DateTimeFormatOptions['month']; + if (token === 1) { + month = 'numeric'; + } else if (token === 2) { + month = '2-digit'; + } else if (token === 3) { + month = 'short'; + } else if (token === 5) { + month = 'narrow'; + } else { + month = 'long'; + } + return getter({ + month, + // to get a genitive case of month + day: 'numeric' + }, 'month'); + }, + y(token, getter) { + return formatNumber(getter({ + year: token === 2 ? '2-digit' : 'numeric' + }, 'year'), token > 2 ? token : undefined); + }, + Y(token, getter) { + return formatNumber(getter({ + year: token === 2 ? '2-digit' : 'numeric' + }, 'weekyear'), token > 2 ? token : undefined); + }, + u(token, getter) { + return formatNumber(getter({ + year: 'numeric' + }, 'extendedyear'), token > 1 ? token : undefined); + }, + E(token, getter) { + let weekday: Intl.DateTimeFormatOptions['weekday']; + if (token <= 3) { + weekday = 'short'; + } else if (token === 5) { + weekday = 'narrow'; + } else { + weekday = 'long'; + } + return getter({ + weekday + }, 'weekday'); + }, + e(token, getter) { + if (token > 2) { + return formatters.E(token, getter); + } + return formatNumber(getter({}, 'weekdaynumeric'), token > 1 ? token : undefined); + }, + w(token, getter) { + return formatNumber(getter({}, 'week'), token > 1 ? token : undefined); + }, + W(token, getter) { + return formatNumber(getter({}, 'weekofmonth'), token > 1 ? token : undefined); + }, + H(token, getter) { + const hours = getter({ + hour: 'numeric', + hour12: false, + hourCycle: 'h23', + }, 'hour'); + + if (!hours) { + return; + } + + // v8 may return '24' with 'h23' style + const fixed = String(Number(hours) % 24); + return formatNumber(fixed, token > 1 ? token : undefined); + }, + h(token, getter) { + return formatNumber(getter({ + hour: 'numeric', + hour12: true, + hourCycle: 'h12', + }, 'hour'), token > 1 ? token : undefined); + }, + K(token, getter) { + const hours = getter({ + hour: 'numeric', + hour12: true, + hourCycle: 'h11', + }, 'hour'); + + if (!hours) { + return; + } + + const fixed = String(Number(hours) % 12); + return formatNumber(fixed, token > 1 ? token : undefined); + }, + k(token, getter) { + return formatNumber(getter({ + hour: 'numeric', + hour12: false, + hourCycle: 'h24', + }, 'hour'), token > 2 ? token : undefined); + }, + a(_token, getter) { + return getter({ + hour: 'numeric', + hour12: true, + hourCycle: 'h11', + dayPeriod: undefined + }, 'dayPeriod'); + }, + m(token, getter) { + return formatNumber(getter({ + minute: 'numeric' + }, 'minute'), token > 1 ? token : undefined); + }, + s(token, getter) { + return formatNumber(getter({ + second: 'numeric' + }, 'second'), token > 1 ? token : undefined); + }, + S(token, getter) { + const res = getter({ + fractionalSecondDigits: Math.min(3, token) as 1 | 2 | 3 + }, 'fractionalSecond'); + + if (res && token > 3) { + return res.padEnd(token, '0'); + } + return res; + }, +}; + +const formattingTokensRegExp = + /(\w)\1*|''|'(''|[^'])+('|$)|./g; + +const escapedStringRegExp = /^'([^]*?)'?$/; +const doubleQuoteRegExp = /''/g; +const unescapedLatinCharacterRegExp = /[a-zA-Z]/; + +const msInDay = 1000 * 60 * 60 * 24; + +function cleanEscapedString(input: string): string { + const matched = input.match(escapedStringRegExp); + + if (!matched) { + return input; + } + + return matched[1].replace(doubleQuoteRegExp, "'"); +} + +function getWeekFirstDay(date: Date, isUTC: boolean, weekStartDay: number): Date { + const weekDay = date[isUTC ? 'getUTCDay' : 'getDay'](); + + // Monday logic + const offset = (weekDay < weekStartDay) ? + weekStartDay - weekDay - 7 : + weekStartDay - weekDay; + + return new Date(date.getTime() + msInDay * offset); +} + +function getFirstWeekFirstDay(date: Date, isUTC: boolean, weekStartDay: number): Date { + const firstDay = new Date(date); + firstDay[isUTC ? 'setUTCDate' : 'setDate'](1); + firstDay[isUTC ? 'setUTCMonth' : 'setMonth'](0); + + return getWeekFirstDay(firstDay, isUTC, weekStartDay); +} + +function dayDiff(date0: Date, date1: Date): number { + return Math.round((date0.getTime() - date1.getTime()) / msInDay); +} + +function getIOSWeekYear(date: Date, isUTC: boolean, weekStartDay: number): { + week: number; + year: number; +} { + let week = 0; + + const firstWeekFirstDay = getFirstWeekFirstDay(date, isUTC || false, weekStartDay); + const nextYearDate = new Date(date); + nextYearDate[isUTC ? 'setUTCFullYear' : 'setFullYear'](date[isUTC ? 'getUTCFullYear' : 'getFullYear']() + 1); + const nextYearFirstWeekFirstDay = getFirstWeekFirstDay(nextYearDate, isUTC || false, weekStartDay); + + const isBeforeFirstWeek = date.getTime() < firstWeekFirstDay.getTime(); + const isAfterLastWeek = date.getTime() >= nextYearFirstWeekFirstDay.getTime(); + + let year = date[isUTC ? 'getUTCFullYear' : 'getFullYear'](); + if (isBeforeFirstWeek) { + --year; + firstWeekFirstDay[isUTC ? 'setUTCFullYear' : 'setFullYear'](firstWeekFirstDay[isUTC ? 'getUTCFullYear' : 'getFullYear']() - 1); + const dayOfYear = dayDiff(getWeekFirstDay(date, isUTC, weekStartDay), firstWeekFirstDay); + week = Math.round(dayOfYear / 7) + 1; + } else if (isAfterLastWeek) { + ++year; + week = 1; + } else { + const dayOfYear = dayDiff(getWeekFirstDay(date, isUTC, weekStartDay), firstWeekFirstDay); + week = Math.round(dayOfYear / 7) + 1; + } + + return { + week, + year + }; +} + +export function formatDate(date: Date, format: string, { + locale, + isUTC, + weekStartDay = 0 +}: { + locale?: string; + isUTC?: boolean; + weekStartDay?: number; +} = {}): string { + const getter: FormatGetter = (opts, field): string | undefined => { + if (field === 'week') { + const { week } = getIOSWeekYear(date, isUTC || false, weekStartDay); + + return String(week); + } + + if (field === 'weekofmonth') { + const weekday = date[isUTC ? 'getUTCDay' : 'getDay'](); + const firstDay = new Date(date); + firstDay[isUTC ? 'setUTCDate' : 'setDate'](1); + const firstWeekday = firstDay[isUTC ? 'getUTCDay' : 'getDay'](); + const dayOfMonth = date[isUTC ? 'getUTCDate' : 'getDate'](); + + return String(Math.ceil(dayOfMonth / 7) + (weekday < firstWeekday ? 1 : 0)); + } + + if (field === 'dayofweekinmonth') { + const dayOfMonth = date[isUTC ? 'getUTCDate' : 'getDate'](); + + return String(Math.ceil(dayOfMonth / 7)); + } + + if (field === 'weekdaynumeric') { + let weekday = date[isUTC ? 'getUTCDay' : 'getDay'](); + + if (weekday < weekStartDay) { + weekday += 7; + } + + return String(weekday - weekStartDay + 1); + } + + if (field === 'dayofyear') { + const firstDay = new Date(date); + firstDay[isUTC ? 'setUTCMonth' : 'setMonth'](0); + firstDay[isUTC ? 'setUTCDate' : 'setDate'](1); + firstDay[isUTC ? 'setUTCHours' : 'setHours'](1); + firstDay[isUTC ? 'setUTCMinutes' : 'setMinutes'](1); + firstDay[isUTC ? 'setUTCSeconds' : 'setSeconds'](1); + + const dayOfYear = Math.ceil((date.getTime() - firstDay.getTime()) / msInDay); + + return String(dayOfYear); + } + + if (field === 'weekyear') { + let { year } = getIOSWeekYear(date, isUTC || false, weekStartDay); + + if (year < 1) { + year = 1 - year; + } + + if (opts.year === '2-digit') { + return String(year % 100); + } + return String(year); + } + + if (field === 'extendedyear') { + const year = date[isUTC ? 'getUTCFullYear' : 'getFullYear'](); + + if (opts.year === '2-digit') { + return String(year % 100); + } + return String(year); + } + + if (isUTC) { + opts.timeZone = 'UTC'; + } + + const formatter = new Intl.DateTimeFormat(locale, opts); + const parts = formatter.formatToParts(date); + + for (let i = 0; i < parts.length; ++i) { + if (parts[i].type === field) { + return parts[i].value; + } + } + }; + + return (format + .match(formattingTokensRegExp) || []) + .map(substring => { + if (substring === "''") { + return "'"; + } + + const firstCharacter = substring[0]; + if (firstCharacter === "'") { + return cleanEscapedString(substring); + } + + if (firstCharacter === 'z' || firstCharacter === 'Z') { + throw new Error(`z/Z not supported in [${format}].`); + } + + if (formatters[firstCharacter]) { + return formatters[firstCharacter](substring.length, getter); + } + + if (firstCharacter.match(unescapedLatinCharacterRegExp)) { + throw new Error( + `Format string contains an unescaped latin alphabet character "${firstCharacter}"` + ); + } + + return substring; + }) + .join(''); +} diff --git a/client/web/divkit/tests/expressions/expressions.test.ts b/client/web/divkit/tests/expressions/expressions.test.ts index a9708b783..21671ec08 100644 --- a/client/web/divkit/tests/expressions/expressions.test.ts +++ b/client/web/divkit/tests/expressions/expressions.test.ts @@ -55,7 +55,9 @@ function runCase(item: any) { } return; } - const res = evalExpression(vars, undefined, ast); + const res = evalExpression(vars, undefined, ast, { + weekStartDay: item.platform_specific?.web?.weekStartDay || 0 + }); if (item.expected.value !== '' || res.result.type !== 'error') { if (res.result.type === 'number' && item.expected.type === 'number') { expect(res.result.value).toBeCloseTo(item.expected.value); diff --git a/client/web/divkit/tests/utils/formatDate.test.ts b/client/web/divkit/tests/utils/formatDate.test.ts new file mode 100644 index 000000000..a73772a3a --- /dev/null +++ b/client/web/divkit/tests/utils/formatDate.test.ts @@ -0,0 +1,1503 @@ +/* eslint-disable max-len */ +import { formatDate } from '../../src/utils/formatDate'; + +const testDate = new Date(0); +const testDate2 = new Date(1970, 1, 15, 17); +const testDate3 = new Date(1970, 1, 15, 12, 30, 30, 123); +const testDate4 = new Date(1970, 1, 15, 0, 30); +const year1 = new Date(1, 5, 1); +const year0 = new Date(1, 5, 1); +const yearMinus1 = new Date(1, 5, 1); +year1.setFullYear(1); +year0.setFullYear(0); +yearMinus1.setFullYear(-1); + +describe('formatDate', () => { + describe('fields', () => { + test('G', () => { + expect(formatDate(testDate, 'G', { + locale: 'en-US' + })).toEqual('AD'); + expect(formatDate(testDate, 'GG', { + locale: 'en-US' + })).toEqual('AD'); + expect(formatDate(testDate, 'GGG', { + locale: 'en-US' + })).toEqual('AD'); + expect(formatDate(testDate, 'GGGG', { + locale: 'en-US' + })).toEqual('Anno Domini'); + expect(formatDate(testDate, 'GGGGG', { + locale: 'en-US' + })).toEqual('A'); + }); + + test('y', () => { + expect(formatDate(testDate, 'y', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'yy', { + locale: 'en-US' + })).toEqual('70'); + expect(formatDate(testDate, 'yyy', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'yyyy', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'yyyyy', { + locale: 'en-US' + })).toEqual('01970'); + + expect(formatDate(year1, 'y G', { + locale: 'en-US' + })).toEqual('1 AD'); + expect(formatDate(year0, 'y G', { + locale: 'en-US' + })).toEqual('1 BC'); + expect(formatDate(yearMinus1, 'y G', { + locale: 'en-US' + })).toEqual('2 BC'); + + expect(formatDate(new Date(2008, 11, 29), 'y', { + locale: 'en-US' + })).toEqual('2008'); + + expect(formatDate(new Date(2010, 0, 3), 'y', { + locale: 'en-US' + })).toEqual('2010'); + }); + + test('Y', () => { + expect(formatDate(testDate, 'Y', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'YY', { + locale: 'en-US' + })).toEqual('70'); + expect(formatDate(testDate, 'YYY', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'YYYY', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'YYYYY', { + locale: 'en-US' + })).toEqual('01970'); + + expect(formatDate(year1, 'Y G', { + locale: 'en-US' + })).toEqual('1 AD'); + expect(formatDate(year0, 'Y G', { + locale: 'en-US' + })).toEqual('1 BC'); + expect(formatDate(yearMinus1, 'Y G', { + locale: 'en-US' + })).toEqual('2 BC'); + + expect(formatDate(new Date(2008, 11, 29), 'Y', { + locale: 'en-US' + })).toEqual('2009'); + + expect(formatDate(new Date(2010, 0, 3), 'Y', { + locale: 'en-US' + })).toEqual('2010'); + }); + + test('u', () => { + expect(formatDate(testDate, 'u', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'uu', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'uuu', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'uuuu', { + locale: 'en-US' + })).toEqual('1970'); + expect(formatDate(testDate, 'uuuuu', { + locale: 'en-US' + })).toEqual('01970'); + + expect(formatDate(year1, 'u G', { + locale: 'en-US' + })).toEqual('1 AD'); + expect(formatDate(year0, 'u G', { + locale: 'en-US' + })).toEqual('0 BC'); + expect(formatDate(yearMinus1, 'u G', { + locale: 'en-US' + })).toEqual('-1 BC'); + + expect(formatDate(new Date(2008, 11, 29), 'u', { + locale: 'en-US' + })).toEqual('2008'); + + expect(formatDate(new Date(2010, 0, 3), 'u', { + locale: 'en-US' + })).toEqual('2010'); + }); + + test('M', () => { + expect(formatDate(testDate, 'M', { + locale: 'en-US' + })).toEqual('1'); + expect(formatDate(testDate, 'MM', { + locale: 'en-US' + })).toEqual('01'); + expect(formatDate(testDate, 'MMM', { + locale: 'en-US' + })).toEqual('Jan'); + expect(formatDate(testDate, 'MMMM', { + locale: 'en-US' + })).toEqual('January'); + expect(formatDate(testDate, 'MMMMM', { + locale: 'en-US' + })).toEqual('J'); + }); + + test('w', () => { + expect(formatDate(testDate2, 'w', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('8'); + expect(formatDate(testDate2, 'ww', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('08'); + expect(formatDate(testDate2, 'www', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('008'); + + expect(formatDate(testDate2, 'w', { + locale: 'en-US', + weekStartDay: 1 + })).toEqual('7'); + + expect(formatDate(new Date(2005, 0 /* Jan */, 2), 'w', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('2'); + expect(formatDate(new Date(2008, 11 /* Dec */, 29), 'w', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('1'); + expect(formatDate(new Date(2008, 11 /* Dec */, 29), 'w', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('1'); + expect(formatDate(new Date(1970, 1 /* Feb */, 15), 'w', { + locale: 'en-US', + weekStartDay: 0 + })).toEqual('8'); + expect(formatDate(new Date(1970, 1 /* Feb */, 15), 'w', { + locale: 'en-US', + weekStartDay: 1 + })).toEqual('7'); + }); + + test('W', () => { + expect(formatDate(testDate2, 'W', { + locale: 'en-US' + })).toEqual('3'); + expect(formatDate(testDate2, 'WW', { + locale: 'en-US' + })).toEqual('03'); + expect(formatDate(testDate2, 'WWW', { + locale: 'en-US' + })).toEqual('003'); + }); + + test('d', () => { + expect(formatDate(testDate2, 'd', { + locale: 'en-US' + })).toEqual('15'); + expect(formatDate(testDate2, 'dd', { + locale: 'en-US' + })).toEqual('15'); + expect(formatDate(testDate2, 'ddd', { + locale: 'en-US' + })).toEqual('015'); + }); + + test('D', () => { + expect(formatDate(testDate2, 'D', { + locale: 'en-US' + })).toEqual('46'); + expect(formatDate(testDate2, 'DD', { + locale: 'en-US' + })).toEqual('46'); + expect(formatDate(testDate2, 'DDD', { + locale: 'en-US' + })).toEqual('046'); + }); + + test('F', () => { + expect(formatDate(testDate2, 'F', { + locale: 'en-US' + })).toEqual('3'); + expect(formatDate(testDate2, 'FF', { + locale: 'en-US' + })).toEqual('03'); + expect(formatDate(testDate2, 'FFF', { + locale: 'en-US' + })).toEqual('003'); + }); + + test('E', () => { + expect(formatDate(testDate, 'E', { + locale: 'en-US' + })).toEqual('Thu'); + expect(formatDate(testDate, 'EE', { + locale: 'en-US' + })).toEqual('Thu'); + expect(formatDate(testDate, 'EEE', { + locale: 'en-US' + })).toEqual('Thu'); + expect(formatDate(testDate, 'EEEE', { + locale: 'en-US' + })).toEqual('Thursday'); + expect(formatDate(testDate, 'EEEEE', { + locale: 'en-US' + })).toEqual('T'); + }); + + test('e', () => { + expect(formatDate(testDate, 'e', { + locale: 'en-US' + })).toEqual('5'); + expect(formatDate(testDate, 'ee', { + locale: 'en-US' + })).toEqual('05'); + expect(formatDate(testDate, 'eee', { + locale: 'en-US' + })).toEqual('Thu'); + expect(formatDate(testDate, 'eeee', { + locale: 'en-US' + })).toEqual('Thursday'); + expect(formatDate(testDate, 'eeeee', { + locale: 'en-US' + })).toEqual('T'); + }); + + test('a', () => { + expect(formatDate(testDate, 'a', { + locale: 'en-US' + })).toEqual('AM'); + expect(formatDate(testDate, 'aa', { + locale: 'en-US' + })).toEqual('AM'); + expect(formatDate(testDate, 'aaa', { + locale: 'en-US' + })).toEqual('AM'); + expect(formatDate(testDate, 'aaaa', { + locale: 'en-US' + })).toEqual('AM'); + }); + + test('h', () => { + expect(formatDate(testDate2, 'h', { + locale: 'en-US' + })).toEqual('5'); + expect(formatDate(testDate2, 'hh', { + locale: 'en-US' + })).toEqual('05'); + expect(formatDate(testDate2, 'hhh', { + locale: 'en-US' + })).toEqual('005'); + + expect(formatDate(testDate3, 'h', { + locale: 'en-US' + })).toEqual('12'); + expect(formatDate(testDate3, 'hh', { + locale: 'en-US' + })).toEqual('12'); + expect(formatDate(testDate3, 'hhh', { + locale: 'en-US' + })).toEqual('012'); + }); + + test('H', () => { + expect(formatDate(testDate2, 'H', { + locale: 'en-US' + })).toEqual('17'); + expect(formatDate(testDate2, 'HH', { + locale: 'en-US' + })).toEqual('17'); + expect(formatDate(testDate2, 'HHH', { + locale: 'en-US' + })).toEqual('017'); + + expect(formatDate(testDate4, 'H', { + locale: 'en-US' + })).toEqual('0'); + expect(formatDate(testDate4, 'HH', { + locale: 'en-US' + })).toEqual('00'); + expect(formatDate(testDate4, 'HHH', { + locale: 'en-US' + })).toEqual('000'); + }); + + test('K', () => { + expect(formatDate(testDate2, 'K', { + locale: 'en-US' + })).toEqual('5'); + expect(formatDate(testDate2, 'KK', { + locale: 'en-US' + })).toEqual('05'); + expect(formatDate(testDate2, 'KKK', { + locale: 'en-US' + })).toEqual('005'); + + expect(formatDate(testDate3, 'K', { + locale: 'en-US' + })).toEqual('0'); + expect(formatDate(testDate3, 'KK', { + locale: 'en-US' + })).toEqual('00'); + expect(formatDate(testDate3, 'KKK', { + locale: 'en-US' + })).toEqual('000'); + }); + + test('k', () => { + expect(formatDate(testDate2, 'k', { + locale: 'en-US' + })).toEqual('17'); + expect(formatDate(testDate2, 'kk', { + locale: 'en-US' + })).toEqual('17'); + expect(formatDate(testDate2, 'kkk', { + locale: 'en-US' + })).toEqual('017'); + + expect(formatDate(testDate4, 'k', { + locale: 'en-US' + })).toEqual('24'); + expect(formatDate(testDate4, 'kk', { + locale: 'en-US' + })).toEqual('24'); + expect(formatDate(testDate4, 'kkk', { + locale: 'en-US' + })).toEqual('024'); + }); + + test('m', () => { + expect(formatDate(testDate2, 'm', { + locale: 'en-US' + })).toEqual('0'); + expect(formatDate(testDate2, 'mm', { + locale: 'en-US' + })).toEqual('00'); + expect(formatDate(testDate2, 'mmm', { + locale: 'en-US' + })).toEqual('000'); + + expect(formatDate(testDate3, 'm', { + locale: 'en-US' + })).toEqual('30'); + expect(formatDate(testDate3, 'mm', { + locale: 'en-US' + })).toEqual('30'); + expect(formatDate(testDate3, 'mmm', { + locale: 'en-US' + })).toEqual('030'); + }); + + test('s', () => { + expect(formatDate(testDate2, 's', { + locale: 'en-US' + })).toEqual('0'); + expect(formatDate(testDate2, 'ss', { + locale: 'en-US' + })).toEqual('00'); + expect(formatDate(testDate2, 'sss', { + locale: 'en-US' + })).toEqual('000'); + + expect(formatDate(testDate3, 's', { + locale: 'en-US' + })).toEqual('30'); + expect(formatDate(testDate3, 'ss', { + locale: 'en-US' + })).toEqual('30'); + expect(formatDate(testDate3, 'sss', { + locale: 'en-US' + })).toEqual('030'); + }); + + test('S', () => { + expect(formatDate(testDate2, 'S', { + locale: 'en-US' + })).toEqual('0'); + expect(formatDate(testDate2, 'SS', { + locale: 'en-US' + })).toEqual('00'); + expect(formatDate(testDate2, 'SSS', { + locale: 'en-US' + })).toEqual('000'); + + expect(formatDate(testDate3, 'S', { + locale: 'en-US' + })).toEqual('1'); + expect(formatDate(testDate3, 'SS', { + locale: 'en-US' + })).toEqual('12'); + expect(formatDate(testDate3, 'SSS', { + locale: 'en-US' + })).toEqual('123'); + }); + }); + + // Based on date-fns tests https://github.com/date-fns/date-fns/blob/main/src/format/test.ts (MIT License) + describe('complex', () => { + const date = new Date(1986, 3 /* Apr */, 4, 10, 32, 55, 123); + + /* const offset = date.getTimezoneOffset(); + const absoluteOffset = Math.abs(offset); + const hours = Math.trunc(absoluteOffset / 60); + const hoursLeadingZero = hours < 10 ? '0' : ''; + const minutes = absoluteOffset % 60; + const minutesLeadingZero = minutes < 10 ? '0' : ''; + const sign = offset > 0 ? '-' : '+'; */ + + /* const timezone = + sign + hoursLeadingZero + hours + ':' + minutesLeadingZero + minutes; + const timezoneShort = timezone.replace(':', ''); + const timezoneWithOptionalMinutesShort = + minutes === 0 ? sign + hoursLeadingZero + hours : timezoneShort; */ + + /* const timezoneWithZ = offset === 0 ? 'Z' : timezone; + const timezoneWithZShort = offset === 0 ? 'Z' : timezoneShort; + const timezoneWithOptionalMinutesAndZShort = + offset === 0 ? 'Z' : timezoneWithOptionalMinutesShort; + + const timezoneGMTShort = + minutes === 0 ? + 'GMT' + sign + hours : + 'GMT' + sign + hours + ':' + minutesLeadingZero + minutes; + const timezoneGMT = 'GMT' + timezone; + + const timestamp = date.getTime().toString(); + const secondsTimestamp = Math.trunc(date.getTime() / 1000).toString(); */ + + it('escapes characters between the single quote characters', () => { + const result = formatDate(date, "'yyyy-'MM-dd'THH:mm:ss.SSSX' yyyy-'MM-dd'"); + expect(result).toBe('yyyy-04-04THH:mm:ss.SSSX 1986-MM-dd'); + }); + + it('two single quote characters are transformed into a "real" single quote', () => { + const date = new Date(2014, 3, 4, 5); + expect(formatDate(date, "''h 'o''clock'''")).toBe("'5 o'clock'"); + }); + + it('accepts new line charactor', () => { + const date = new Date(2014, 3, 4, 5); + expect(formatDate(date, "yyyy-MM-dd'\n'HH:mm:ss")).toBe('2014-04-04\n05:00:00'); + }); + + it('alias formatDate has same behavior as format', () => { + const date = new Date(2014, 3, 4, 5); + expect(formatDate(date, "yyyy-MM-dd'\n'HH:mm:ss")).toBe(formatDate(date, "yyyy-MM-dd'\n'HH:mm:ss")); + }); + + /* xdescribe('ordinal numbers', () => { + it('ordinal day of an ordinal month', () => { + const result = formatDate(date, "do 'day of the' Mo 'month of' yyyy"); + expect(result).toBe('4th day of the 4th month of 1986'); + }); + + it('should return a correct ordinal number', () => { + const result = []; + for (let i = 1; i <= 31; i++) { + result.push(formatDate(new Date(2015, 0, i), 'do')); + } + const expected = [ + '1st', + '2nd', + '3rd', + '4th', + '5th', + '6th', + '7th', + '8th', + '9th', + '10th', + '11th', + '12th', + '13th', + '14th', + '15th', + '16th', + '17th', + '18th', + '19th', + '20th', + '21st', + '22nd', + '23rd', + '24th', + '25th', + '26th', + '27th', + '28th', + '29th', + '30th', + '31st', + ]; + expect(result).toEqual(expected); + }); + }); */ + + it('era', () => { + const result = formatDate(date, 'G GG GGG GGGG GGGGG'); + expect(result).toBe('AD AD AD Anno Domini A'); + + const bcDate = new Date(); + bcDate.setFullYear(-1, 0 /* Jan */, 1); + const bcResult = formatDate(bcDate, 'G GG GGG GGGG GGGGG'); + expect(bcResult).toBe('BC BC BC Before Christ B'); + }); + + describe('year', () => { + describe('regular year', () => { + /* it('works as expected', () => { + const result = formatDate(date, 'y yo yy yyy yyyy yyyyy'); + expect(result).toBe('1986 1986th 86 1986 1986 01986'); + }); */ + it('works as expected', () => { + const result = formatDate(date, 'y yy yyy yyyy yyyyy'); + expect(result).toBe('1986 86 1986 1986 01986'); + }); + + it('1 BC formats as 1', () => { + const date = new Date(0); + date.setFullYear(0, 0 /* Jan */, 1); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'y'); + expect(result).toBe('1'); + }); + + it('2 BC formats as 2', () => { + const date = new Date(0); + date.setFullYear(-1, 0 /* Jan */, 1); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'y'); + expect(result).toBe('2'); + }); + + /* xit('2 BC formats as 2nd', () => { + const date = new Date(); + date.setFullYear(-1, 0 /* Jan *\/, 1); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'yo'); + expect(result).toBe('2nd'); + }); */ + it('2 BC formats as 2nd', () => { + const date = new Date(); + date.setFullYear(-1, 0 /* Jan */, 1); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'y'); + expect(result).toBe('2'); + }); + }); + + describe('local week-numbering year', () => { + /* xit('works as expected', () => { + const result = formatDate(date, 'Y Yo YY YYY YYYY YYYYY', { + useAdditionalWeekYearTokens: true, + }); + expect(result).toBe('1986 1986th 86 1986 1986 01986'); + }); */ + it('works as expected', () => { + const result = formatDate(date, 'Y YY YYY YYYY YYYYY'); + expect(result).toBe('1986 86 1986 1986 01986'); + }); + + it('the first week of the next year', () => { + const result = formatDate(new Date(2013, 11 /* Dec */, 29), 'YYYY'); + expect(result).toBe('2014'); + }); + + /* xit('allows to specify `weekStartsOn` and `firstWeekContainsDate` in options', () => { + const result = formatDate(new Date(2013, 11 /* Dec *\/, 29), 'YYYY', { + weekStartsOn: 1, + firstWeekContainsDate: 4, + useAdditionalWeekYearTokens: true, + }); + expect(result).toBe('2013'); + }); */ + + it('the first week of year', () => { + const result = formatDate(new Date(2016, 0 /* Jan */, 1), 'YYYY'); + expect(result).toBe('2016'); + }); + + it('1 BC formats as 1', () => { + const date = new Date(0); + date.setFullYear(0, 6 /* Jul */, 2); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'Y'); + expect(result).toBe('1'); + }); + + it('2 BC formats as 2', () => { + const date = new Date(0); + date.setFullYear(-1, 6 /* Jul */, 2); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'Y'); + expect(result).toBe('2'); + }); + }); + + /* xdescribe('ISO week-numbering year', () => { + it('works as expected', () => { + const result = formatDate(date, 'R RR RRR RRRR RRRRR'); + expect(result).toBe('1986 1986 1986 1986 01986'); + }); + + it('the first week of the next year', () => { + const result = formatDate(new Date(2013, 11 /* Dec *\/, 30), 'RRRR'); + expect(result).toBe('2014'); + }); + + it('the last week of the previous year', () => { + const result = formatDate(new Date(2016, 0 /* Jan *\/, 1), 'RRRR'); + expect(result).toBe('2015'); + }); + + it('1 BC formats as 0', () => { + const date = new Date(0); + date.setFullYear(0, 6 /* Jul *\/, 2); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'R'); + expect(result).toBe('0'); + }); + + it('2 BC formats as -1', () => { + const date = new Date(0); + date.setFullYear(-1, 6 /* Jul *\/, 2); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'R'); + expect(result).toBe('-1'); + }); + }); */ + + describe('extended year', () => { + it('works as expected', () => { + const result = formatDate(date, 'u uu uuu uuuu uuuuu'); + expect(result).toBe('1986 1986 1986 1986 01986'); + }); + + it('1 BC formats as 0', () => { + const date = new Date(0); + date.setFullYear(0, 0, 1); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'u'); + expect(result).toBe('0'); + }); + + it('2 BC formats as -1', () => { + const date = new Date(0); + date.setFullYear(-1, 0, 1); + date.setHours(0, 0, 0, 0); + const result = formatDate(date, 'u'); + expect(result).toBe('-1'); + }); + }); + }); + + /* xdescribe('quarter', () => { + it('formatting quarter', () => { + const result = formatDate(date, 'Q Qo QQ QQQ QQQQ QQQQQ'); + expect(result).toBe('2 2nd 02 Q2 2nd quarter 2'); + }); + + it('stand-alone quarter', () => { + const result = formatDate(date, 'q qo qq qqq qqqq qqqqq'); + expect(result).toBe('2 2nd 02 Q2 2nd quarter 2'); + }); + + it('returns a correct quarter for each month', () => { + const result = []; + for (let i = 0; i <= 11; i++) { + result.push(formatDate(new Date(1986, i, 1), 'Q')); + } + const expected = [ + '1', + '1', + '1', + '2', + '2', + '2', + '3', + '3', + '3', + '4', + '4', + '4', + ]; + expect(result).toEqual(expected); + }); + }); */ + + describe('month', () => { + /* it('formatting month', () => { + const result = formatDate(date, 'M Mo MM MMM MMMM MMMMM'); + expect(result).toBe('4 4th 04 Apr April A'); + }); */ + it('formatting month', () => { + const result = formatDate(date, 'M MM MMM MMMM MMMMM'); + expect(result).toBe('4 04 Apr April A'); + }); + + /* it('stand-alone month', () => { + const result = formatDate(date, 'L Lo LL LLL LLLL LLLLL'); + expect(result).toBe('4 4th 04 Apr April A'); + }); */ + }); + + describe('week', () => { + describe('local week of year', () => { + /* it('works as expected', () => { + const date = new Date(1986, 3 /* Apr *\/, 6); + const result = formatDate(date, 'w wo ww'); + expect(result).toBe('15 15th 15'); + }); */ + it('works as expected', () => { + const date = new Date(1986, 3 /* Apr */, 6); + const result = formatDate(date, 'w ww'); + expect(result).toBe('15 15'); + }); + + /* it('allows to specify `weekStartsOn` and `firstWeekContainsDate` in options', () => { + const date = new Date(1986, 3 /* Apr *\/, 6); + const result = formatDate(date, 'w wo ww', { + weekStartsOn: 1, + firstWeekContainsDate: 4, + }); + expect(result).toBe('14 14th 14'); + }); */ + }); + + /* xit('ISO week of year', () => { + const date = new Date(1986, 3 /* Apr *\/, 6); + const result = formatDate(date, 'I Io II'); + expect(result).toBe('14 14th 14'); + }); */ + }); + + describe('day', () => { + /* xit('date', () => { + const result = formatDate(date, 'd do dd'); + expect(result).toBe('4 4th 04'); + }); */ + it('date', () => { + const result = formatDate(date, 'd dd'); + expect(result).toBe('4 04'); + }); + + describe('day of year', () => { + /* xit('works as expected', () => { + const result = formatDate(date, 'D Do DD DDD DDDDD', { + useAdditionalDayOfYearTokens: true, + }); + expect(result).toBe('94 94th 94 094 00094'); + }); */ + it('works as expected', () => { + const result = formatDate(date, 'D DD DDD DDDDD'); + expect(result).toBe('94 94 094 00094'); + }); + + it('returns a correct day number for the last day of a leap year', () => { + const result = formatDate( + new Date(1992, 11 /* Dec */, 31, 23, 59, 59, 999), + 'D' + ); + expect(result).toBe('366'); + }); + }); + }); + + describe('week day', () => { + describe('day of week', () => { + /* it('works as expected', () => { + const result = formatDate(date, 'E EE EEE EEEE EEEEE EEEEEE'); + expect(result).toBe('Fri Fri Fri Friday F Fr'); + }); */ + it('works as expected', () => { + const result = formatDate(date, 'E EE EEE EEEE EEEEE'); + expect(result).toBe('Fri Fri Fri Friday F'); + }); + }); + + /* xdescribe('ISO day of week', () => { + it('works as expected', () => { + const result = formatDate(date, 'i io ii iii iiii iiiii iiiiii'); + expect(result).toBe('5 5th 05 Fri Friday F Fr'); + }); + + it('returns a correct day of an ISO week', () => { + const result = []; + for (let i = 1; i <= 7; i++) { + result.push(formatDate(new Date(1986, 8 /* Sep *\/, i), 'i')); + } + const expected = ['1', '2', '3', '4', '5', '6', '7']; + expect(result).toEqual(expected); + }); + }); */ + + describe('formatting day of week', () => { + /* xit('works as expected', () => { + const result = formatDate(date, 'e eo ee eee eeee eeeee eeeeee'); + expect(result).toBe('6 6th 06 Fri Friday F Fr'); + }); */ + it('works as expected', () => { + const result = formatDate(date, 'e ee eee eeee eeeee'); + expect(result).toBe('6 06 Fri Friday F'); + }); + + it('by default, 1 is Sunday, 2 is Monday, ...', () => { + const result = []; + for (let i = 7; i <= 13; i++) { + result.push(formatDate(new Date(1986, 8 /* Sep */, i), 'e')); + } + const expected = ['1', '2', '3', '4', '5', '6', '7']; + expect(result).toEqual(expected); + }); + + it('allows to specify which day is the first day of the week', () => { + const result = []; + for (let i = 1; i <= 7; i++) { + result.push( + formatDate(new Date(1986, 8 /* Sep */, i), 'e', { weekStartDay: 1 }), + ); + } + const expected = ['1', '2', '3', '4', '5', '6', '7']; + expect(result).toEqual(expected); + }); + }); + + /* xdescribe('stand-alone day of week', () => { + it('works as expected', () => { + const result = formatDate(date, 'c co cc ccc cccc ccccc cccccc'); + expect(result).toBe('6 6th 06 Fri Friday F Fr'); + }); + + it('by default, 1 is Sunday, 2 is Monday, ...', () => { + const result = []; + for (let i = 7; i <= 13; i++) { + result.push(formatDate(new Date(1986, 8 /* Sep *\/, i), 'c')); + } + const expected = ['1', '2', '3', '4', '5', '6', '7']; + expect(result).toEqual(expected); + }); + + it('allows to specify which day is the first day of the week', () => { + const result = []; + for (let i = 1; i <= 7; i++) { + result.push( + formatDate(new Date(1986, 8 /* Sep *\/, i), 'c', { weekStartsOn: 1 }), + ); + } + const expected = ['1', '2', '3', '4', '5', '6', '7']; + expect(result).toEqual(expected); + }); + }); */ + }); + + describe('day period and hour', () => { + /* xit('hour [1-12]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan *\/, 1, 0, 0, 0, 0), + 'h ho hh', + ); + expect(result).toBe('12 12th 12'); + }); */ + it('hour [1-12]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan */, 1, 0, 0, 0, 0), + 'h hh', + ); + expect(result).toBe('12 12'); + }); + + /* xit('hour [0-23]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan *\/, 1, 0, 0, 0, 0), + 'H Ho HH', + ); + expect(result).toBe('0 0th 00'); + }); */ + it('hour [0-23]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan */, 1, 0, 0, 0, 0), + 'H HH', + ); + expect(result).toBe('0 00'); + }); + + /* xit('hour [0-11]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan *\/, 1, 0, 0, 0, 0), + 'K Ko KK', + ); + expect(result).toBe('0 0th 00'); + }); */ + it('hour [0-11]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan */, 1, 0, 0, 0, 0), + 'K KK', + ); + expect(result).toBe('0 00'); + }); + + /* xit('hour [1-24]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan *\/, 1, 0, 0, 0, 0), + 'k ko kk', + ); + expect(result).toBe('24 24th 24'); + }); */ + it('hour [1-24]', () => { + const result = formatDate( + new Date(2018, 0 /* Jan */, 1, 0, 0, 0, 0), + 'k kk', + ); + expect(result).toBe('24 24'); + }); + + describe('AM, PM', () => { + /* xit('works as expected', () => { + const result = formatDate( + new Date(2018, 0 /* Jan *\/, 1, 0, 0, 0, 0), + 'a aa aaa aaaa aaaaa', + ); + expect(result).toBe('AM AM am a.m. a'); + }); */ + it('works as expected', () => { + const result = formatDate( + new Date(2018, 0 /* Jan */, 1, 0, 0, 0, 0), + 'a aa aaa aaaa aaaaa', + ); + expect(result).toBe('AM AM AM AM AM'); + }); + + it('12 PM', () => { + const date = new Date(1986, 3 /* Apr */, 4, 12, 0, 0, 900); + expect(formatDate(date, 'h H K k a')).toBe('12 12 0 12 PM'); + }); + + it('12 AM', () => { + const date = new Date(1986, 3 /* Apr */, 6, 0, 0, 0, 900); + expect(formatDate(date, 'h H K k a')).toBe('12 0 0 24 AM'); + }); + }); + + /* xdescribe('AM, PM, noon, midnight', () => { + it('works as expected', () => { + const result = formatDate( + new Date(1986, 3 /* Apr *\/, 6, 2, 0, 0, 900), + 'b bb bbb bbbb bbbbb', + ); + expect(result).toBe('AM AM am a.m. a'); + + const pmResult = formatDate( + new Date(1986, 3 /* Apr *\/, 6, 13, 0, 0, 900), + 'b bb bbb bbbb bbbbb', + ); + expect(pmResult).toBe('PM PM pm p.m. p'); + }); + + it('12 PM', () => { + const date = new Date(1986, 3 /* Apr *\/, 4, 12, 0, 0, 900); + expect(formatDate(date, 'b bb bbb bbbb bbbbb')).toBe('noon noon noon noon n'); + }); + + it('12 AM', () => { + const date = new Date(1986, 3 /* Apr *\/, 6, 0, 0, 0, 900); + expect(formatDate(date, 'b bb bbb bbbb bbbbb')).toBe('midnight midnight midnight midnight mi'); + }); + }); */ + + /* xdescribe('flexible day periods', () => { + it('works as expected', () => { + const result = formatDate(date, 'B, BB, BBB, BBBB, BBBBB'); + expect(result).toBe( + 'in the morning, in the morning, in the morning, in the morning, in the morning' + ); + }); + + it('12 PM', () => { + const date = new Date(1986, 3 /* Apr *\/, 4, 12, 0, 0, 900); + expect(formatDate(date, 'h B')).toBe('12 in the afternoon'); + }); + + it('5 PM', () => { + const date = new Date(1986, 3 /* Apr *\/, 6, 17, 0, 0, 900); + expect(formatDate(date, 'h B')).toBe('5 in the evening'); + }); + + it('12 AM', () => { + const date = new Date(1986, 3 /* Apr *\/, 6, 0, 0, 0, 900); + expect(formatDate(date, 'h B')).toBe('12 at night'); + }); + + it('4 AM', () => { + const date = new Date(1986, 3 /* Apr *\/, 6, 4, 0, 0, 900); + expect(formatDate(date, 'h B')).toBe('4 in the morning'); + }); + }); */ + }); + + /* xit('minute', () => { + const result = formatDate(date, 'm mo mm'); + expect(result).toBe('32 32nd 32'); + }); */ + it('minute', () => { + const result = formatDate(date, 'm mm'); + expect(result).toBe('32 32'); + }); + + describe('second', () => { + /* xit('second', () => { + const result = formatDate(date, 's so ss'); + expect(result).toBe('55 55th 55'); + }); */ + it('second', () => { + const result = formatDate(date, 's ss'); + expect(result).toBe('55 55'); + }); + + it('fractional seconds', () => { + const result = formatDate(date, 'S SS SSS SSSS'); + expect(result).toBe('1 12 123 1230'); + }); + }); + + /* xdescribe('time zone', () => { + it('ISO-8601 with Z', () => { + const result = formatDate(date, 'X XX XXX XXXX XXXXX'); + const expectedResult = [ + timezoneWithOptionalMinutesAndZShort, + timezoneWithZShort, + timezoneWithZ, + timezoneWithZShort, + timezoneWithZ, + ].join(' '); + expect(result).toBe(expectedResult); + + const getTimezoneOffsetStub = sinon.stub( + Date.prototype, + 'getTimezoneOffset', + ); + getTimezoneOffsetStub.returns(0); + const resultZeroOffset = formatDate(date, 'X XX XXX XXXX XXXXX'); + expect(resultZeroOffset).toBe('Z Z Z Z Z'); + + getTimezoneOffsetStub.returns(480); + const resultNegativeOffset = formatDate(date, 'X XX XXX XXXX XXXXX'); + expect(resultNegativeOffset).toBe('-08 -0800 -08:00 -0800 -08:00'); + + getTimezoneOffsetStub.returns(450); + const resultNegative30Offset = formatDate(date, 'X XX XXX XXXX XXXXX'); + expect(resultNegative30Offset).toBe('-0730 -0730 -07:30 -0730 -07:30'); + + getTimezoneOffsetStub.restore(); + }); + + it('ISO-8601 without Z', () => { + const result = formatDate(date, 'x xx xxx xxxx xxxxx'); + const expectedResult = [ + timezoneWithOptionalMinutesShort, + timezoneShort, + timezone, + timezoneShort, + timezone, + ].join(' '); + expect(result).toBe(expectedResult); + }); + + it('GMT', () => { + const result = formatDate(date, 'O OO OOO OOOO'); + const expectedResult = [ + timezoneGMTShort, + timezoneGMTShort, + timezoneGMTShort, + timezoneGMT, + ].join(' '); + expect(result).toBe(expectedResult); + + const getTimezoneOffsetStub = sinon.stub( + Date.prototype, + 'getTimezoneOffset', + ); + getTimezoneOffsetStub.returns(480); + const resultNegativeOffset = formatDate(date, 'O OO OOO OOOO'); + expect(resultNegativeOffset).toBe('GMT-8 GMT-8 GMT-8 GMT-08:00'); + + getTimezoneOffsetStub.returns(450); + const resultNegative30Offset = formatDate(date, 'O OO OOO OOOO'); + expect(resultNegative30Offset).toBe('GMT-7:30 GMT-7:30 GMT-7:30 GMT-07:30'); + + getTimezoneOffsetStub.restore(); + }); + + it('Specific non-location', () => { + const result = formatDate(date, 'z zz zzz zzzz'); + const expectedResult = [ + timezoneGMTShort, + timezoneGMTShort, + timezoneGMTShort, + timezoneGMT, + ].join(' '); + expect(result).toBe(expectedResult); + }); + }); */ + + /* xdescribe('timestamp', () => { + it('seconds timestamp', () => { + const result = formatDate(date, 't'); + expect(result).toBe(secondsTimestamp); + }); + + it('milliseconds timestamp', () => { + const result = formatDate(date, 'T'); + expect(result).toBe(timestamp); + }); + + it('seconds timestamp handles negative numbers', () => { + expect(formatDate(new Date(1001), 't')).toBe('1'); + expect(formatDate(new Date(-1001), 't')).toBe('-1'); + }); + }); */ + + /* xdescribe('long format', () => { + it('short date', () => { + const result = formatDate(date, 'P'); + expect(result).toBe('04/04/1986'); + }); + + it('medium date', () => { + const result = formatDate(date, 'PP'); + expect(result).toBe('Apr 4, 1986'); + }); + + it('long date', () => { + const result = formatDate(date, 'PPP'); + expect(result).toBe('April 4th, 1986'); + }); + + it('full date', () => { + const result = formatDate(date, 'PPPP'); + expect(result).toBe('Friday, April 4th, 1986'); + }); + + it('short time', () => { + const result = formatDate(date, 'p'); + expect(result).toBe('10:32 AM'); + }); + + it('medium time', () => { + const result = formatDate(date, 'pp'); + expect(result).toBe('10:32:55 AM'); + }); + + it('long time', () => { + const result = formatDate(date, 'ppp'); + expect(result).toBe('10:32:55 AM ' + timezoneGMTShort); + }); + + it('full time', () => { + const result = formatDate(date, 'pppp'); + expect(result).toBe('10:32:55 AM ' + timezoneGMT); + }); + + it('short date + time', () => { + const result = formatDate(date, 'Pp'); + expect(result).toBe('04/04/1986, 10:32 AM'); + }); + + it('medium date + time', () => { + const result = formatDate(date, 'PPpp'); + expect(result).toBe('Apr 4, 1986, 10:32:55 AM'); + }); + + it('long date + time', () => { + const result = formatDate(date, 'PPPppp'); + expect(result).toBe('April 4th, 1986 at 10:32:55 AM ' + timezoneGMTShort); + }); + + it('full date + time', () => { + const result = formatDate(date, 'PPPPpppp'); + expect(result).toBe('Friday, April 4th, 1986 at 10:32:55 AM ' + timezoneGMT); + }); + + it('allows arbitrary combination of date and time', () => { + const result = formatDate(date, 'Ppppp'); + expect(result).toBe('04/04/1986, 10:32:55 AM ' + timezoneGMT); + }); + }); */ + + /* xdescribe('edge cases', () => { + it('throws RangeError if the time value is invalid', () => { + expect(format.bind(null, new Date(NaN), 'MMMM d, yyyy')).toThrow(RangeError); + }); + + it('handles dates before 100 AD', () => { + const initialDate = new Date(0); + initialDate.setFullYear(7, 11 /* Dec *\/, 31); + initialDate.setHours(0, 0, 0, 0); + expect(formatDate(initialDate, 'Y ww i')).toBe('8 01 1'); + }); + }); */ + + /* xdescribe('locale features', () => { + it('allows to pass a custom locale', () => { + const customLocale = { + localize: { + month: () => { + return 'works'; + }, + }, + formatLong: { + date: () => { + return "'It' MMMM!"; + }, + }, + }; + const result = formatDate(date, 'PPPP', { + // @ts-expect-error - It's ok to have incomplete locale + locale: customLocale, + }); + expect(result).toBe('It works!'); + }); + + it('allows a localize preprocessor', () => { + const customLocale = { + localize: { + month: (v: number) => ['janvier'][v], + ordinalNumber: (v: number) => String(v) + 'er', + + preprocessor: (date: Date, parts: FormatPart[]) => { + // replace `do` tokens to `d` if not the first of the month + if (date.getDate() === 1) return parts; + + return parts.map(part => + part.isToken && part.value === 'do' ? + { isToken: true, value: 'd' } : + part, + ); + }, + }, + }; + + let result = formatDate(new Date(2024, 0, 1), 'do MMMM', { + // @ts-expect-error - It's ok to have incomplete locale + locale: customLocale, + }); + expect(result).toEqual('1er janvier'); + + result = formatDate(new Date(2024, 0, 2), 'do MMMM', { + // @ts-expect-error - It's ok to have incomplete locale + locale: customLocale, + }); + expect(result).toEqual('2 janvier'); + }); + }); */ + + /* xit('throws RangeError exception if the format string contains an unescaped latin alphabet character', () => { + expect(format.bind(null, date, 'yyyy-MM-dd-nnnn')).toThrow(RangeError); + }); */ + + describe('useAdditionalWeekYearTokens and useAdditionalDayOfYearTokens options', () => { + /* xit('throws an error if D token is used', () => { + expect(() => formatDate(date, 'yyyy-MM-D')).toThrow('Use `d` instead of `D`'); + }); */ + + it('allows D token if useAdditionalDayOfYearTokens is set to true', () => { + const result = formatDate(date, 'yyyy-MM-D'); + expect(result).toEqual('1986-04-94'); + }); + + /* xit('throws an error if DD token is used', () => { + expect(() => formatDate(date, 'yyyy-MM-DD')).toThrow( + 'Use `dd` instead of `DD`', + ); + }); */ + + it('allows DD token if useAdditionalDayOfYearTokens is set to true', () => { + const result = formatDate(date, 'yyyy-MM-DD'); + expect(result).toEqual('1986-04-94'); + }); + + /* xit('throws an error if YY token is used', () => { + expect(() => formatDate(date, 'YY-MM-dd')).toThrow( + 'Use `yy` instead of `YY`', + ); + }); */ + + it('allows YY token if useAdditionalWeekYearTokens is set to true', () => { + const result = formatDate(date, 'YY-MM-dd'); + expect(result).toEqual('86-04-04'); + }); + + /* xit('throws an error if YYYY token is used', () => { + expect(() => formatDate(date, 'YYYY-MM-dd')).toThrow( + 'Use `yyyy` instead of `YYYY`', + ); + }); */ + + it('allows YYYY token if useAdditionalWeekYearTokens is set to true', () => { + const result = formatDate(date, 'YYYY-MM-dd'); + expect(result).toEqual('1986-04-04'); + }); + + /* xdescribe('console.warn', () => { + let warn: SpyInstance; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('warns if "D" token is used', () => { + try { + formatDate(date, 'yyyy-MM-D'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `d` instead of `D` (in `yyyy-MM-D`) for formatting days of the month to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "DD" token is used', () => { + try { + formatDate(date, 'yyyy-MM-DD'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `dd` instead of `DD` (in `yyyy-MM-DD`) for formatting days of the month to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "DDD" token is used', () => { + try { + formatDate(date, 'yyyy-MM-DDD'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `ddd` instead of `DDD` (in `yyyy-MM-DDD`) for formatting days of the month to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "DDDD" token is used', () => { + try { + formatDate(date, 'yyyy-MM-DDDD'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `dddd` instead of `DDDD` (in `yyyy-MM-DDDD`) for formatting days of the month to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "DDDDD" token is used', () => { + try { + formatDate(date, 'yyyy-MM-DDDDD'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `ddddd` instead of `DDDDD` (in `yyyy-MM-DDDDD`) for formatting days of the month to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "Y" token is used', () => { + try { + formatDate(date, 'Y-MM-dd'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `y` instead of `Y` (in `Y-MM-dd`) for formatting years to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "YY" token is used', () => { + try { + formatDate(date, 'YY-MM-dd'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `yy` instead of `YY` (in `YY-MM-dd`) for formatting years to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "YYY" token is used', () => { + try { + formatDate(date, 'YYY-MM-dd'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `yyy` instead of `YYY` (in `YYY-MM-dd`) for formatting years to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "YYYY" token is used', () => { + try { + formatDate(date, 'YYYY-MM-dd'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `yyyy` instead of `YYYY` (in `YYYY-MM-dd`) for formatting years to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + + it('warns if "YYYYY" token is used', () => { + try { + formatDate(date, 'YYYYY-MM-dd'); + } catch (_) { + // Ignore the error + } + expect(warn).toBeCalledWith( + 'Use `yyyyy` instead of `YYYYY` (in `YYYYY-MM-dd`) for formatting years to the input `' + + date + + '`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md', + ); + }); + }); */ + }); + }); +}); diff --git a/client/web/divkit/tests/utils/variables.test.ts b/client/web/divkit/tests/utils/variables.test.ts index b3f2b06a4..b1525d745 100644 --- a/client/web/divkit/tests/utils/variables.test.ts +++ b/client/web/divkit/tests/utils/variables.test.ts @@ -14,7 +14,7 @@ describe('correctPositiveNumber', () => { test('no vars', () => { const prepared = prepareVars({ prop: 123 - }, logError, undefined); + }, logError, undefined, 0); expect(prepared.vars).toStrictEqual([]); expect(prepared.applyVars(new Map())).toStrictEqual({ prop: 123 @@ -24,7 +24,7 @@ describe('correctPositiveNumber', () => { test('simple', () => { const prepared = prepareVars({ prop: '@{var}' - }, logError, undefined); + }, logError, undefined, 0); expect(prepared.vars).toStrictEqual(['var']); const map = new Map(); const variable = new IntegerVariable('var', 123); @@ -38,7 +38,7 @@ describe('correctPositiveNumber', () => { test('simple no val', () => { const prepared = prepareVars({ prop: '@{var}' - }, noError, undefined); + }, noError, undefined, 0); expect(prepared.vars).toStrictEqual(['var']); const map = new Map(); expect(prepared.applyVars(map)).toStrictEqual({ @@ -53,7 +53,7 @@ describe('correctPositiveNumber', () => { 'smth', 456 ] - }, logError, undefined); + }, logError, undefined, 0); expect(prepared.vars).toStrictEqual(['var']); const map = new Map(); const variable = new IntegerVariable('var', 123); @@ -70,7 +70,7 @@ describe('correctPositiveNumber', () => { test('interpolation', () => { const prepared = prepareVars({ prop: 'Hello, @{var}' - }, logError, undefined); + }, logError, undefined, 0); expect(prepared.vars).toStrictEqual(['var']); const map = new Map(); const variable = new IntegerVariable('var', 123); @@ -83,7 +83,7 @@ describe('correctPositiveNumber', () => { test('multiple vars', () => { const prepared = prepareVars({ prop: 'Hello, @{var}. Good bye, @{var2}' - }, logError, undefined); + }, logError, undefined, 0); expect(prepared.vars).toStrictEqual(['var', 'var2']); const map = new Map(); const variable = new IntegerVariable('var', 123); diff --git a/client/web/divkit/tsconfig.json b/client/web/divkit/tsconfig.json index 4c566a0c2..d0412dcb6 100644 --- a/client/web/divkit/tsconfig.json +++ b/client/web/divkit/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2021", "strict": true, "sourceMap": true, "allowSyntheticDefaultImports": true, diff --git a/client/web/divkit/typings/client-devtool.d.ts b/client/web/divkit/typings/client-devtool.d.ts index 0b1499eb4..3f897c8bb 100644 --- a/client/web/divkit/typings/client-devtool.d.ts +++ b/client/web/divkit/typings/client-devtool.d.ts @@ -15,6 +15,7 @@ import type { import type { GlobalVariablesController, Variable } from './variables'; import type { WrappedError } from './common'; import type { CustomComponentDescription } from './custom'; +import type { Store } from './store'; export interface DivkitDebugInstance extends DivkitInstance { getDebugVariables(): Map; @@ -42,6 +43,8 @@ export function render(opts: { fetchInit?: FetchInit; tooltipRoot?: HTMLElement; customComponents?: Map | undefined; + store?: Store; + weekStartDay?: number; }): DivkitDebugInstance; export { createVariable, createGlobalVariablesController } from './variables'; diff --git a/client/web/divkit/typings/client-hydratable.d.ts b/client/web/divkit/typings/client-hydratable.d.ts index a3ce7a1ab..ab5795efb 100644 --- a/client/web/divkit/typings/client-hydratable.d.ts +++ b/client/web/divkit/typings/client-hydratable.d.ts @@ -36,6 +36,7 @@ export function render(opts: { tooltipRoot?: HTMLElement; customComponents?: Map | undefined; store?: Store; + weekStartDay?: number; }): DivkitInstance; export { createVariable, createGlobalVariablesController } from './variables'; diff --git a/client/web/divkit/typings/client.d.ts b/client/web/divkit/typings/client.d.ts index 8bb329e8a..d5b72f1b6 100644 --- a/client/web/divkit/typings/client.d.ts +++ b/client/web/divkit/typings/client.d.ts @@ -35,6 +35,7 @@ export function render(opts: { tooltipRoot?: HTMLElement; customComponents?: Map | undefined; store?: Store; + weekStartDay?: number; }): DivkitInstance; export { createVariable, createGlobalVariablesController } from './variables'; diff --git a/client/web/divkit/typings/server.d.ts b/client/web/divkit/typings/server.d.ts index fc544b40b..8ae5d957f 100644 --- a/client/web/divkit/typings/server.d.ts +++ b/client/web/divkit/typings/server.d.ts @@ -19,4 +19,5 @@ export function render(opts: { typefaceProvider?: TypefaceProvider; customComponents?: Map | undefined; store?: Store; + weekStartDay?: number; }): string; diff --git a/test_data/expression_test_data/function_signatures_datetime.json b/test_data/expression_test_data/function_signatures_datetime.json index c1c9c93df..691d4d0a4 100644 --- a/test_data/expression_test_data/function_signatures_datetime.json +++ b/test_data/expression_test_data/function_signatures_datetime.json @@ -30,7 +30,8 @@ "result_type": "datetime", "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -366,7 +367,8 @@ "result_type": "string", "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -386,7 +388,8 @@ "result_type": "string", "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -410,7 +413,8 @@ "result_type": "string", "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -434,7 +438,8 @@ "result_type": "string", "platforms": [ "android", - "ios" + "ios", + "web" ] } ] diff --git a/test_data/expression_test_data/functions_datetime_format.json b/test_data/expression_test_data/functions_datetime_format.json index 6b14f5985..8ff1e5793 100644 --- a/test_data/expression_test_data/functions_datetime_format.json +++ b/test_data/expression_test_data/functions_datetime_format.json @@ -9,7 +9,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -21,7 +22,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -33,7 +35,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -45,10 +48,11 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, - { + { "name": "formatDateAsUTCWithLocale(parseUnixTime(0), en-US) => 0:00 AM", "expression": "@{formatDateAsUTCWithLocale(parseUnixTime(0), 'K:mm a', 'en-US')}", "expected": { @@ -57,10 +61,11 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, - { + { "name": "formatDateAsUTCWithLocale(setDay(parseUnixTime(0), 13), en-US) => 1970-01-01T00:00:00.000", "expression": "@{formatDateAsUTCWithLocale(setDay(parseUnixTime(0), 13), 'yyyy-MM-dd\\'T\\'HH:mm:ss.SSS', 'en-US')}", "expected": { @@ -69,7 +74,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -81,19 +87,26 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { "name": "formatDateAsUTCWithLocale(parseUnixTime(3927600), 'ru-RU') => 1970-02-03, week of year 7", "expression": "@{formatDateAsUTCWithLocale(parseUnixTime(3927600), 'yyyy-MM-dd\\', week of year \\'w', 'ru-RU')}", + "platform_specific": { + "web": { + "weekStartDay": 1 + } + }, "expected": { "type": "string", "value": "1970-02-15, week of year 7" }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -105,7 +118,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -116,7 +130,8 @@ "value": "Year 1970, extended year 1970" }, "platforms": [ - "ios" + "ios", + "web" ] }, { @@ -128,7 +143,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] }, { @@ -140,7 +156,8 @@ }, "platforms": [ "android", - "ios" + "ios", + "web" ] } ]