diff --git a/CHANGELOG.json b/CHANGELOG.json index a9e3389..a5f4056 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@microsoft/globe", "entries": [ + { + "date": "Fri, 06 Mar 2026 10:25:10 GMT", + "version": "4.6.0", + "tag": "@microsoft/globe_v4.6.0", + "comments": { + "minor": [ + { + "author": "ligia.e.popescu@gmail.com", + "package": "@microsoft/globe", + "commit": "e9adec04f83b0099883e83f24259f07092805e02", + "comment": "Add omitYear year-removal support" + } + ] + } + }, { "date": "Tue, 02 Dec 2025 18:44:44 GMT", "version": "4.5.0", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce0ea7..c85307d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ # Change Log - @microsoft/globe - + +## 4.6.0 + +Fri, 06 Mar 2026 10:25:10 GMT + +### Minor changes + +- Add omitYear year-removal support (ligia.e.popescu@gmail.com) + ## 4.5.0 Tue, 02 Dec 2025 18:44:44 GMT diff --git a/README.md b/README.md index 7abef61..1b2a617 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Respects the OS date and time format configuration. ## Usage ```typescript -import { TimeStringFormat, DateTimeFormatOptions, DateTimeFormatter } from 'globe'; +import { TimeStringFormat, DateTimeFormatOptions, DateTimeFormatter, DateTimeFormattingBehaviorOptions } from 'globe'; // Instantiate the formatter const dateTimeFormatter = new DateTimeFormatter(locale: string | ILocaleInfo); @@ -39,13 +39,20 @@ To format a date and time value: * Localize the date/time * @param date The date/time to localize * @param format The format to be used for the localization + * @param options Optional formatting options * @returns The localized date/time string */ -function formatDateTime(date: number | Date, format: DateTimeFormatOptions) { - return dateTimeFormatter.formatDateTime(date, format); +function formatDateTime( + date: number | Date, + format: DateTimeFormatOptions, + options?: DateTimeFormattingBehaviorOptions +) { + return dateTimeFormatter.formatDateTime(date, format, options); } ``` +Set `options.omitYear` to `true` to remove the year portion while preserving locale-specific date formatting behavior. + **The function throws** in case an unexpected OS date and time format string is provided! Most likely this will happen if you feed it the OS strings verbatim and the OS is configured with a custom date/time format string. If you don't desire diff --git a/package.json b/package.json index dd44a0c..12dd648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/globe", - "version": "4.5.0", + "version": "4.6.0", "description": "Globalization Service", "author": "Microsoft", "license": "MIT", diff --git a/src/date-time-formatter.test.ts b/src/date-time-formatter.test.ts index eeda599..6057428 100644 --- a/src/date-time-formatter.test.ts +++ b/src/date-time-formatter.test.ts @@ -602,4 +602,170 @@ describe("date-time-format-options", () => { expect(result).toBe("6/28/2020 3:40 PM"); }); }); + + describe("omitYear", () => { + const date = new Date(2026, 2, 6, 15, 40, 25); + + const createWindowsLocaleInfo = ( + regionalFormat: string, + shortDate: string + ): ILocaleInfo => ({ + platform: "windows", + regionalFormat, + shortDate, + longDate: shortDate, + shortTime: "h:mm tt", + longTime: "h:mm:ss tt", + }); + + const formatShortDate = ( + localeInfo: ILocaleInfo, + format = SHORT_DATE + ) => { + const dateTimeFormatter = new DateTimeFormatter(localeInfo); + return { + withYear: dateTimeFormatter.formatDateTime(date, format), + withoutYear: dateTimeFormatter.formatDateTime(date, format, { omitYear: true }), + }; + }; + + it("omits year for en-US short date", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("en-US", "M/d/yyyy") + ); + expect(formatted.withYear).toBe("3/6/2026"); + expect(formatted.withoutYear).toBe("3/6"); + }); + + it("omits year for de-DE numeric date while preserving trailing dot", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("de-DE", "d.M.yyyy") + ); + expect(formatted.withYear).toBe("6.3.2026"); + expect(formatted.withoutYear).toBe("6.3."); + }); + + it("omits year for de-DE long month date", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("de-DE", "d. MMMM yyyy") + ); + expect(formatted.withYear).toBe("6. März 2026"); + expect(formatted.withoutYear).toBe("6. März"); + }); + + it("omits year for ja-JP dates with 年 suffix", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("ja-JP", "yyyy年M月d日") + ); + expect(formatted.withYear).toBe("2026年3月6日"); + expect(formatted.withoutYear).toBe("3月6日"); + }); + + it("omits year for ko-KR dates with 년 suffix", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("ko-KR", "yyyy년 M월 d일") + ); + expect(formatted.withYear).toBe("2026년 3월 6일"); + expect(formatted.withoutYear).toBe("3월 6일"); + }); + + it("omits year and г. suffix for ru-RU", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("ru-RU", "d MMMM yyyy 'г.'") + ); + expect(formatted.withYear).toBe("6 марта 2026 г."); + expect(formatted.withoutYear).toBe("6 марта"); + }); + + it("omits year and р. suffix for uk-UA", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("uk-UA", "d MMMM yyyy 'р.'") + ); + expect(formatted.withYear).toBe("6 березня 2026 р."); + expect(formatted.withoutYear).toBe("6 березня"); + }); + + it("omits year and gada token for lv-LV", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("lv-LV", "yyyy. 'gada' d. MMMM") + ); + expect(formatted.withYear).toBe("2026. gada 6. marts"); + expect(formatted.withoutYear).toBe("6. marts"); + }); + + it("omits year when lt-LT uses 'm' token", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("lt-LT", "M/d/yyyy 'm'") + ); + expect(formatted.withYear).toBe("03/06/2026 m"); + expect(formatted.withoutYear).toBe("03/6"); + }); + + it("omits year when eu-ES uses ('e')'ko' pattern", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("eu-ES", "yyyy('e')'ko' M/d") + ); + expect(formatted.withYear).toBe("2026(e)ko 3/6"); + expect(formatted.withoutYear).toBe("3/6"); + }); + + it("omits year for es-ES de-construction", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("es-ES", "d 'de' MMMM 'de' yyyy") + ); + expect(formatted.withYear).toBe("6 de marzo de 2026"); + expect(formatted.withoutYear).toBe("6 de marzo"); + }); + + it("omits year for fr-CH while preserving trailing dot", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("fr-CH", "d.M.yyyy") + ); + expect(formatted.withYear).toBe("06.03.2026"); + expect(formatted.withoutYear).toBe("06.03."); + }); + + it("does not omit year for WITH_YEAR OS formats", () => { + const formatted = formatShortDate( + createWindowsLocaleInfo("en-US", "M/d/yyyy"), + SHORT_DATE_WITH_YEAR + ); + expect(formatted.withYear).toBe("3/6/2026"); + expect(formatted.withoutYear).toBe("3/6/2026"); + }); + + it("omits year in Intl path and matches no-year format", () => { + const dateTimeFormatter = new DateTimeFormatter("en-US"); + const result = dateTimeFormatter.formatDateTime( + date, + SHORT_DATE_WITH_YEAR, + { omitYear: true } + ); + expect(result).toBe(dateTimeFormatter.formatDateTime(date, SHORT_DATE)); + }); + + it("keeps time-only formats unchanged when omitYear is true", () => { + const dateTimeFormatter = new DateTimeFormatter( + createWindowsLocaleInfo("en-US", "M/d/yyyy") + ); + const withOmitYear = dateTimeFormatter.formatDateTime( + date, + SHORT_TIME, + { omitYear: true } + ); + const withoutOmitYear = dateTimeFormatter.formatDateTime(date, SHORT_TIME); + expect(withOmitYear).toBe(withoutOmitYear); + }); + + it("keeps no-year Intl formats unchanged when omitYear is true", () => { + const dateTimeFormatter = new DateTimeFormatter("en-US"); + const withOmitYear = dateTimeFormatter.formatDateTime( + date, + SHORT_DATE, + { omitYear: true } + ); + const withoutOmitYear = dateTimeFormatter.formatDateTime(date, SHORT_DATE); + expect(withOmitYear).toBe(withoutOmitYear); + }); + }); }); diff --git a/src/date-time-formatter.ts b/src/date-time-formatter.ts index 0f3ed1c..7acee46 100644 --- a/src/date-time-formatter.ts +++ b/src/date-time-formatter.ts @@ -42,6 +42,11 @@ import { } from "./date-time-format-options"; import { ILocaleInfo } from "./ILocaleInfo"; import { OsDateTimeFormatter } from "./os-date-time-formatter"; +import { removeYearFromMask } from "./year-removal"; + +export type DateTimeDisplayOptions = Readonly<{ + omitYear?: boolean; +}>; export class DateTimeFormatter { // We're keying this using JSON.stringify because with a WeakMap we've have a key pair @@ -69,21 +74,31 @@ export class DateTimeFormatter { * Localizes the date/time value * @param date The date/time to localize * @param format The format to be used for the localization + * @param options The options to apply during formatting * @returns The localized date/time string */ - public formatDateTime(date: number | Date, format: DateTimeFormatOptions) { + public formatDateTime( + date: number | Date, + format: DateTimeFormatOptions, + options?: DateTimeDisplayOptions + ) { + const effectiveIntlOptions = this.getFormatOptions( + format, + options?.omitYear === true + ); if (typeof this.locale === "string") { - const dtf = this.cachedDateTimeFormat.get(this.locale, format); + const dtf = this.cachedDateTimeFormat.get(this.locale, effectiveIntlOptions); return dtf.format(date); } - return this.formatOsDateTime(date, format, this.locale); + return this.formatOsDateTime(date, format, this.locale, options); } public formatOsDateTime( date: number | Date, format: DateTimeFormatOptions, - localeInfo: ILocaleInfo + localeInfo: ILocaleInfo, + options?: DateTimeDisplayOptions ): string { if (!localeInfo) { throw new Error( @@ -106,6 +121,8 @@ export class DateTimeFormatter { loc = this.locale.regionalFormat; } + const omitYear = options?.omitYear === true; + switch (format) { case SHORT_TIME: { if (!localeInfo.shortTime) { @@ -114,7 +131,16 @@ export class DateTimeFormatter { return this.formatter.timeToString(date, localeInfo.shortTime); } - case SHORT_DATE: + case SHORT_DATE: { + if (!localeInfo.shortDate) { + throw new Error(`localeInfo.shortDate was not provided!`); + } + + return this.formatter.dateToString( + date, + this.getDateMask(localeInfo.shortDate, loc, omitYear) + ); + } case SHORT_DATE_WITH_SHORT_YEAR: case SHORT_DATE_WITH_YEAR: { if (!localeInfo.shortDate) { @@ -130,12 +156,14 @@ export class DateTimeFormatter { ); } - const d = this.formatter.dateToString(date, localeInfo.shortDate); + const d = this.formatter.dateToString( + date, + this.getDateMask(localeInfo.shortDate, loc, omitYear) + ); const t = this.formatter.timeToString(date, localeInfo.longTime); return this.combineDateAndTime(d, t); } case SHORT: - case SHORT_WITH_YEAR: case SHORT_DATE_TIME: { if (!localeInfo.shortDate || !localeInfo.shortTime) { throw new Error( @@ -143,6 +171,20 @@ export class DateTimeFormatter { ); } + const d = this.formatter.dateToString( + date, + this.getDateMask(localeInfo.shortDate, loc, omitYear) + ); + const t = this.formatter.timeToString(date, localeInfo.shortTime); + return this.combineDateAndTime(d, t); + } + case SHORT_WITH_YEAR: { + if (!localeInfo.shortDate || !localeInfo.shortTime) { + throw new Error( + `localeInfo.shortDate or localeInfo.shortTime was not provided!` + ); + } + const d = this.formatter.dateToString(date, localeInfo.shortDate); const t = this.formatter.timeToString(date, localeInfo.shortTime); return this.combineDateAndTime(d, t); @@ -186,7 +228,20 @@ export class DateTimeFormatter { ) : time; } - case MEDIUM: + case MEDIUM: { + if (!localeInfo.longDate || !localeInfo.longTime) { + throw new Error( + `localeInfo.longDate or localeInfo.longTime was not provided!` + ); + } + + const d = this.formatter.dateToString( + date, + this.getDateMask(localeInfo.longDate, loc, omitYear) + ); + const t = this.formatter.timeToString(date, localeInfo.longTime); + return this.combineDateAndTime(d, t); + } case MEDIUM_WITH_YEAR: { if (!localeInfo.longDate || !localeInfo.longTime) { throw new Error( @@ -226,7 +281,32 @@ export class DateTimeFormatter { return `${weekday}, ${d}, ${timeWithTimeZone}`; } - case FULL: + case FULL: { + let dateFormat = localeInfo.longDate; + if (localeInfo.fullDate && localeInfo.fullDate !== "UNKNOWN") { + dateFormat = localeInfo.fullDate; + } + + if (!dateFormat || !localeInfo.longTime) { + throw new Error( + `localeInfo.longDate or localeInfo.longTime was not provided!` + ); + } + + const d = this.formatter.dateToString( + date, + this.getDateMask(dateFormat, loc, omitYear) + ); + const t = this.formatter.timeToString(date, localeInfo.longTime); + const timeWithTimeZone = this.ensureTimeZone( + t, + date, + localeInfo.longTime, + format, + localeInfo + ); + return this.combineDateAndTime(d, timeWithTimeZone); + } case FULL_WITH_YEAR: { let dateFormat = localeInfo.longDate; if (localeInfo.fullDate && localeInfo.fullDate !== "UNKNOWN") { @@ -289,7 +369,16 @@ export class DateTimeFormatter { )}`; } case MEDIUM_DATE: - case LONG_DATE: + case LONG_DATE: { + if (!localeInfo.longDate) { + throw new Error(`localeInfo.longDate was not provided!`); + } + + return this.formatter.dateToString( + date, + this.getDateMask(localeInfo.longDate, loc, omitYear) + ); + } case MEDIUM_DATE_WITH_YEAR: case LONG_DATE_WITH_YEAR: { if (!localeInfo.longDate) { @@ -299,7 +388,23 @@ export class DateTimeFormatter { return this.formatter.dateToString(date, localeInfo.longDate); } - case FULL_DATE: + case FULL_DATE: { + if (localeInfo.fullDate && localeInfo.fullDate !== "UNKNOWN") { + return this.formatter.dateToString( + date, + this.getDateMask(localeInfo.fullDate, loc, omitYear) + ); + } + + if (!localeInfo.longDate) { + throw new Error(`localeInfo.longDate was not provided!`); + } + + return this.formatter.dateToString( + date, + this.getDateMask(localeInfo.longDate, loc, omitYear) + ); + } case FULL_DATE_WITH_YEAR: { if (localeInfo.fullDate && localeInfo.fullDate !== "UNKNOWN") { return this.formatter.dateToString(date, localeInfo.fullDate); @@ -312,7 +417,27 @@ export class DateTimeFormatter { return this.formatter.dateToString(date, localeInfo.longDate); } - case LONG_WITH_TIMEZONE: + case LONG_WITH_TIMEZONE: { + if (!localeInfo.longDate || !localeInfo.longTime) { + throw new Error( + `localeInfo.longDate or localeInfo.longTime was not provided!` + ); + } + + const d = this.formatter.dateToString( + date, + this.getDateMask(localeInfo.longDate, loc, omitYear) + ); + const t = this.formatter.timeToString(date, localeInfo.longTime); + const timeWithTimeZone = this.ensureTimeZone( + t, + date, + localeInfo.longTime, + format, + localeInfo + ); + return this.combineDateAndTime(d, timeWithTimeZone); + } case LONG_WITH_YEAR_TIMEZONE: { if (!localeInfo.longDate || !localeInfo.longTime) { throw new Error( @@ -349,6 +474,23 @@ export class DateTimeFormatter { return `${date} ${time}`; } + private getDateMask(mask: string, localeCode: string, omitYear: boolean): string { + return omitYear ? removeYearFromMask(mask, localeCode) : mask; + } + + private getFormatOptions( + format: DateTimeFormatOptions, + omitYear: boolean + ): Intl.DateTimeFormatOptions { + if (!omitYear || !Object.prototype.hasOwnProperty.call(format, "year")) { + return format; + } + + const formatWithoutYear: Intl.DateTimeFormatOptions = { ...format }; + delete formatWithoutYear.year; + return formatWithoutYear; + } + private ensureTimeZone( time: string, date: number | Date, diff --git a/src/index.ts b/src/index.ts index 07da55d..250d58d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,10 @@ export { export { IElectronDateTimePart } from "./os-date-time-formatter"; -export { DateTimeFormatter } from "./date-time-formatter"; +export { + DateTimeFormatter, + DateTimeFormattingBehaviorOptions, +} from "./date-time-formatter"; export { SafeDateTimeFormat } from "./safe-datetimeformat"; diff --git a/src/year-removal.test.ts b/src/year-removal.test.ts new file mode 100644 index 0000000..27d5686 --- /dev/null +++ b/src/year-removal.test.ts @@ -0,0 +1,63 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { describe, expect, it } from "vitest"; + +import { removeYearFromMask } from "./year-removal"; + +describe("year-removal", () => { + it("removes year for en-US mask", () => { + expect(removeYearFromMask("M/d/yyyy", "en-US")).toBe("M/d"); + }); + + it("preserves trailing dot for dot-preserving locales", () => { + expect(removeYearFromMask("d.M.yyyy", "de-DE")).toBe("d.M."); + expect(removeYearFromMask("d.M.yyyy", "fr-CH")).toBe("d.M."); + }); + + it("removes trailing dot for non dot-preserving locales", () => { + expect(removeYearFromMask("d.M.yyyy", "en-US")).toBe("d.M"); + }); + + it("handles long month de-DE mask", () => { + expect(removeYearFromMask("d. MMMM yyyy", "de-DE")).toBe("d. MMMM"); + }); + + it("handles Japanese year marker", () => { + expect(removeYearFromMask("yyyy年M月d日", "ja-JP")).toBe("M月d日"); + }); + + it("handles Korean year marker", () => { + expect(removeYearFromMask("yyyy년 M월 d일", "ko-KR")).toBe("M월 d일"); + }); + + it("handles Russian year suffix", () => { + expect(removeYearFromMask("d MMMM yyyy 'г.'", "ru-RU")).toBe("d MMMM"); + }); + + it("handles Ukrainian year suffix", () => { + expect(removeYearFromMask("d MMMM yyyy 'р.'", "uk-UA")).toBe("d MMMM"); + }); + + it("handles Latvian gada suffix", () => { + expect(removeYearFromMask("yyyy. 'gada' d. MMMM", "lv-LV")).toBe("d. MMMM"); + }); + + it("handles Lithuanian m token", () => { + expect(removeYearFromMask("M/d/yyyy 'm'", "lt-LT")).toBe("M/d"); + }); + + it("handles Basque ('e')'ko' pattern", () => { + expect(removeYearFromMask("yyyy('e')'ko' M/d", "eu-ES")).toBe("M/d"); + }); + + it("handles Spanish de-construction", () => { + expect(removeYearFromMask("d 'de' MMMM 'de' yyyy", "es-ES")).toBe("d 'de' MMMM"); + }); + + it("normalizes locale codes with underscores and case", () => { + expect(removeYearFromMask("d.M.yyyy", "FR_CH")).toBe("d.M."); + }); +}); diff --git a/src/year-removal.ts b/src/year-removal.ts new file mode 100644 index 0000000..02eb2f1 --- /dev/null +++ b/src/year-removal.ts @@ -0,0 +1,73 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +// Match optional separators around a year token but preserve dots used by dot-separated locales. +const NO_YEAR_PREFIX = "(?:[\\/\\-,]\\s?|\\s'de'\\s|'\\sde\\s')*"; +// Same as NO_YEAR_PREFIX, but allows removing a leading dot for locales that do not preserve dot-after-month. +const NO_YEAR_PREFIX_REMOVE_DOT_AFTER_MONTH = "(?:[\\.\\/\\-,]\\s?|\\s'de'\\s|'\\sde\\s')*"; +// Match year tokens and locale-specific year affixes (e.g. 年, 년, г., р., gada, 'm', ('e')'ko'). +const NO_YEAR_SEGMENT = "(?:y|'m'|(?:\\('e'\\)'ko')|(?:'?(?:年|년|г\\.?|\\s?р\\.?|gada)'?))+"; +const NO_YEAR_SUFFIX = "[\\.\\/\\-]*"; + +const NO_YEAR_REGEX = new RegExp(`${NO_YEAR_PREFIX}${NO_YEAR_SEGMENT}${NO_YEAR_SUFFIX}`, "g"); +const NO_YEAR_REGEX_REMOVE_DOT_AFTER_MONTH = new RegExp( + `${NO_YEAR_PREFIX_REMOVE_DOT_AFTER_MONTH}${NO_YEAR_SEGMENT}${NO_YEAR_SUFFIX}`, + "g" +); + +// Locale language codes that require preserving the dot separator after month/day when removing year. +const localesRequiringDotAfterMonth = new Set([ + "bs", + "cs", + "de", + "dsb", + "fi", + "gsw", + "hr", + "hsb", + "hu", + "is", + "ko", + "lb", + "lv", + "nb", + "nds", + "nn", + "sk", + "sl", + "sr", +]); + +// Full locale codes that require preserving the dot separator. +const localesWithCountryCodeRequiringDotAfterMonth = new Set(["fr-ch"]); + +function getLocaleCodeWithoutCountrySuffix(localeCode: string): string { + return localeCode.split("-")[0]; +} + +function normalizeLocaleCode(localeCode: string): string { + return localeCode.toLowerCase().replace(/_/g, "-"); +} + +function doesLocaleRequireDotAfterMonth(localeCode: string): boolean { + const normalizedLocaleCode = normalizeLocaleCode(localeCode); + const localeCodeWithoutCountrySuffix = getLocaleCodeWithoutCountrySuffix(normalizedLocaleCode); + return ( + localesWithCountryCodeRequiringDotAfterMonth.has(normalizedLocaleCode) || + localesRequiringDotAfterMonth.has(localeCodeWithoutCountrySuffix) + ); +} + +function normalizeMaskWhitespace(mask: string): string { + return mask.replace(/\s+/g, " ").trim(); +} + +export function removeYearFromMask(mask: string, localeCode: string): string { + // Locales requiring dot-after-month use a regex variant that does not strip leading dots. + const regex = doesLocaleRequireDotAfterMonth(localeCode) + ? NO_YEAR_REGEX + : NO_YEAR_REGEX_REMOVE_DOT_AFTER_MONTH; + return normalizeMaskWhitespace(mask.replace(regex, "")); +}