sourceappcustom-date-format.js

const englishWeekdayNames = [
	"Sunday",
	"Monday",
	"Tuesday",
	"Wednesday",
	"Thursday",
	"Friday",
	"Saturday"
];

const dateMatcherString = "GyMdE";
const dateMatcherArray = ["era", "year", "month", "day", "weekday"];

const lettersToParts = {
	G: "era",
	y: "year",
	M: "month",
	L: "standaloneMonth",
	d: "day",
	E: "weekday",
	h: "hour",
	H: "periodHour",
	m: "minute",
	s: "second",
	B: "period",
	v: "timeZone"
};

const customDateFormat = (parts, intlFallback, numHelpers) => {
	const numeric =
		numHelpers?.generic ??
		new Intl.NumberFormat(intlFallback, { useGrouping: false });
	const twoDigits =
		numHelpers?.twoDigits ??
		new Intl.NumberFormat(intlFallback, {
			minimumIntegerDigits: 2
		});
	const twoDigitsDecimal =
		numHelpers?.twoDigitsDecimal ??
		(dp =>
			new Intl.NumberFormat(intlFallback, {
				minimumIntegerDigits: 2,
				minimumFractionDigits: dp
			}));

	return class {
		constructor(options) {
			this.options = {
				hour12: options?.hour12,
				timeZone: options?.timeZone ?? "Europe/London"
			};

			if ("dateStyle" in options) {
				this.options.dateStyle = options.dateStyle;
				this.options.year = "numeric";
				this.options.month =
					options.dateStyle == "short"
						? "2-digit"
						: options.dateStyle == "medium"
						? "short"
						: "long";
				this.options.day = "2-digit";
				this.options.weekday =
					options.dateStyle == "full" ? "long" : undefined;
			}

			if ("timeStyle" in options) {
				this.options.hour = "2-digit";
				this.options.minute = "2-digit";
				this.options.second =
					options.timeStyle == "short" ? undefined : "2-digit";
				this.options.timeZoneName =
					options.timeStyle == "full"
						? "long"
						: options.timeStyle == "long"
						? "short"
						: undefined;
			}

			Object.assign(this.options, options);

			this.matchedFormat = {
				date: undefined,
				time: undefined,
				joiner: undefined
			};

			if (dateMatcherArray.some(el => this.options?.[el])) {
				this.matchedFormat.date = dateMatcherString.slice(
					dateMatcherArray.findIndex(el => this.options?.[el]),
					dateMatcherArray.findLastIndex(el => this.options?.[el]) + 1
				);
			}

			if (this.options?.hour) {
				this.matchedFormat.time = "h";
				if (this.options?.minute) {
					this.matchedFormat.time += "m";
					if (this.options?.second) this.matchedFormat.time += "s";
				}

				if (this.options?.hour12) this.matchedFormat.time += "B";
				if (this.options?.timeZoneName) this.matchedFormat.time += "v";
			}

			if (this.matchedFormat?.date && this.matchedFormat?.time) {
				this.matchedFormat.joiner =
					this.options?.dateStyle ?? this.options?.month == "long"
						? "long"
						: "short";
			}

			this.intlReferenceFormat = new Intl.DateTimeFormat("en-GB", {
				day: "numeric",
				hour: "numeric",
				minute: "numeric",
				second: "numeric",
				weekday: "long",
				month: "numeric",
				year: "numeric",
				era: "short",
				fractionalSecondDigits: this.options.fractionalSecondDigits
			});
		}

		getPart(letter, refParts) {
			let keyToSearch, value;

			switch (letter) {
				case "G":
					return (
						parts.texts.era?.[this.options?.era]?.[value] ??
						parts.texts.era.short[value]
					);
				case "B":
					return parts.texts.hour12[+(refParts.hour >= 12)];
				case "H":
					value = refParts.hour - 12 || 12;
					return this.options?.hour == "numeric"
						? numeric.format(value)
						: twoDigits.format(value);
				case "y":
					return this.options?.year == "2-digits"
						? twoDigits.format(refParts.year)
						: numeric.format(refParts.year);
				case "s":
					return this.options.fractionalSecondDigits
						? twoDigitsDecimal.format(refParts.second)
						: this.options?.second == "numeric"
						? numeric.format(refParts.second)
						: twoDigits.format(refParts.second);
				case "d":
				case "h":
				case "m":
					keyToSearch = lettersToParts[letter];
					value = refParts[keyToSearch];
					return this.options?.[keyToSearch] == "numeric"
						? numeric.format(value)
						: twoDigits.format(value);
				case "M":
				case "L":
					keyToSearch =
						letter == "L" && "standaloneMonth" in parts.texts
							? "standaloneMonth"
							: "month";
					value = refParts.month - 1;
					return this.options?.month == "numeric"
						? numeric.format(value)
						: this.options?.month == "2-digits"
						? twoDigits.format(value)
						: parts.texts[keyToSearch]?.[this.options?.month]?.[
								value
						  ] ?? parts.texts[keyToSearch].long[value];
				case "E":
					value = refParts.weekday;
					return (
						parts.texts.weekday?.[this.options?.weekday]?.[value] ??
						parts.texts.weekday.long[value]
					);
				case "v":
					return this.options.timeZone;
				default:
					break;
			}
		}

		format(date) {
			const intlFormattedParts = Object.fromEntries(
				this.intlReferenceFormat
					.formatToParts(date)
					.filter(el => el.type != "literal")
					.map(el => [
						el.type,
						el.type == "weekday"
							? englishWeekdayNames.indexOf(el.value)
							: el.type == "era"
							? !el.value.startsWith("B")
							: +el.value
					])
			);

			const dateTemplate = this.matchedFormat?.date
				? parts.date[this.matchedFormat.date]
				: null;
			const timeTemplate = this.matchedFormat?.time
				? parts.time[this.matchedFormat.time]
				: null;

			const formatTemplate = this.matchedFormat?.joiner
				? parts.joiner[this.matchedFormat.joiner]
						.replace("{1}", dateTemplate)
						.replace("{0}", timeTemplate)
				: dateTemplate || timeTemplate;

			return formatTemplate.replace(
				/\{([GyMLdEhHmsBv])\}/g,
				(_, letter) => this.getPart(letter, intlFormattedParts)
			);
		}
	};
};

export default customDateFormat;