Source: dates.js

/*!
 * Module Dates
 */

/**
 * @namespace Dates
 */

const MODULE_NAME = 'Dates';



//###[ IMPORTS ]########################################################################################################

import {
	hasValue,
	assert,
	orDefault,
	isArray,
	isDate,
	isString,
	isNumber,
	isInt,
	isNaN,
	isObject,
	isPlainObject,
	isFunction
} from './basic.js';

import {pad} from './strings.js';



//###[ DATA ]###########################################################################################################

const DATE_PART_SETTERS_AND_GETTERS = {
	local : {
		year : {
			setter : 'setFullYear',
			getter : 'getFullYear'
		},
		month : {
			setter : 'setMonth',
			getter : 'getMonth',
		},
		date : {
			setter : 'setDate',
			getter : 'getDate',
		},
		hours : {
			setter : 'setHours',
			getter : 'getHours',
		},
		minutes : {
			setter : 'setMinutes',
			getter : 'getMinutes',
		},
		seconds : {
			setter : 'setSeconds',
			getter : 'getSeconds',
		},
		milliseconds : {
			setter : 'setMilliseconds',
			getter : 'getMilliseconds',
		},
	},
	utc : {
		year : {
			setter : 'setUTCFullYear',
			getter : 'getUTCFullYear',
		},
		month : {
			setter : 'setUTCMonth',
			getter : 'getUTCMonth',
		},
		date : {
			setter : 'setUTCDate',
			getter : 'getUTCDate',
		},
		hours : {
			setter : 'setUTCHours',
			getter : 'getUTCHours',
		},
		minutes : {
			setter : 'setUTCMinutes',
			getter : 'getUTCMinutes',
		},
		seconds : {
			setter : 'setUTCSeconds',
			getter : 'getUTCSeconds',
		},
		milliseconds : {
			setter : 'setUTCMilliseconds',
			getter : 'getUTCMilliseconds',
		},
	}
};



//###[ EXPORTS ]########################################################################################################

/**
 * @namespace Dates:format
 */

/**
 * Returns a formatted string, describing the date in a verbose, non-technical way.
 *
 * Under the hood, this uses Intl.DateTimeFormat, which is widely supported and conveniently to use
 * for most widely used locales.
 *
 * "definition" may be a format shortcut for "dateStyle" (and "timeStyle" if type is "datetime") or a format string,
 * for a custom format, using these tokens:
 *
 * YY      18         two-digit year;
 * YYYY    2018       four-digit year;
 * M       1-12       the month, beginning at 1;
 * MM      01-12      the month, 2-digits;
 * D       1-31       the day of the month;
 * DD      01-31      the day of the month, 2-digits;
 * H       0-23       the hour;
 * HH      00-23      the hour, 2-digits;
 * h       1-12       the hour, 12-hour clock;
 * hh      01-12      the hour, 12-hour clock, 2-digits;
 * m       0-59       the minute;
 * mm      00-59      the minute, 2-digits;
 * s       0-59       the second;
 * ss      00-59      the second, 2-digits;
 * SSS     000-999    the millisecond, 3-digits;
 * Z       +05:00     the offset from UTC, ±HH:mm;
 * ZZ      +0500      the offset from UTC, ±HHmm;
 * A       AM PM;
 * a       am pm;
 *
 * Using these, you could create your own ISO string like this:
 * "YYYY-MM-DDTHH:mm:ss.SSSZ"
 *
 * If you use "full", "long", "medium" or "short" instead, you'll use the DateTimeFormatters built-in, preset
 * format styles for localized dates, based on the given locale(s).
 *
 * @param {Date} date - the date to format
 * @param {?String} [definition='long'] - either a preset style to quickly define a format style, by setting shortcuts for dateStyle and timeStyle (if type is "datetime"), set to "none" or nullish value to skip quick format; alternatively, define this as a format string to use a custom format
 * @param {?String|Array<String>} [locale='en-US'] - locale to use for date format and text generation, use array to define fallback; always falls back to en-US if nothing else works
 * @param {?String} [type='datetime'] - set to 'datetime', 'date' or 'time' to define which parts should be rendered
 * @param {?Object} [options=null] - options to pass to the Intl.DateTimeFormat constructor, is applied last, so should override anything predefined, if key is reset
 * @returns {String} - the formatted date/time string
 *
 * @memberof Dates:format
 * @alias format
 * @variation Dates
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#style_shortcuts
 * @example
 * format(new Date(), 'de-DE', 'long', 'datetime', {timeZone : 'UTC'})
 * => '12. Dezember 2023 um 02:00:00 UTC'
 * format(new Date(), 'YYYY-MM-DDTHH:mm:ss.SSSZ')
 * => '2023-12-12T02:00:00'
 */
export function format(date, definition='long', locale='en-US', type='datetime', options=null){
	const
		utc = (options?.timeZone === 'UTC'),
		settersAndGetters = utc
			? DATE_PART_SETTERS_AND_GETTERS.utc
			: DATE_PART_SETTERS_AND_GETTERS.local
		,
		predefinedStyles = ['full', 'long', 'medium', 'short', 'none']
	;

	if( hasValue(definition) && !predefinedStyles.includes(definition) ){
		let timezone = '';
		const offset = date.getTimezoneOffset();
		if( !utc && (offset !== 0) ){
			const
				hours = pad(Math.floor(Math.abs(offset) / 60), '0', 2),
				minutes = pad(Math.abs(offset) - (hours * 60), '0', 2)
			;
			timezone = `${(offset < 0) ? '+' : '-'}${hours}:${minutes}`;
		}

		const tokenMap = new Map();
		tokenMap.set('YYYY', `${date[settersAndGetters.year.getter]()}`);
		tokenMap.set('YY', `${date[settersAndGetters.year.getter]()}`.slice(-2));
		tokenMap.set('MM', pad(`${date[settersAndGetters.month.getter]() + 1}`, '0', 2));
		tokenMap.set('M', `${date[settersAndGetters.month.getter]() + 1}`);
		tokenMap.set('DD', pad(`${date[settersAndGetters.date.getter]()}`, '0', 2));
		tokenMap.set('D', `${date[settersAndGetters.date.getter]()}`);
		tokenMap.set('HH', pad(`${date[settersAndGetters.hours.getter]()}`, '0', 2));
		tokenMap.set('H', `${date[settersAndGetters.hours.getter]()}`);
		tokenMap.set('hh', pad(`${
			(date[settersAndGetters.hours.getter]() === 0)
			? 12
			: (
				(date[settersAndGetters.hours.getter]() > 12)
				? date[settersAndGetters.hours.getter]() - 12
				: date[settersAndGetters.hours.getter]()
			)
		}`, '0', 2));
		tokenMap.set('h', `${
			(date[settersAndGetters.hours.getter]() === 0)
			? 12
			: (
				(date[settersAndGetters.hours.getter]() > 12)
				? date[settersAndGetters.hours.getter]() - 12
				: date[settersAndGetters.hours.getter]()
			)
		}`);
		tokenMap.set('mm', pad(`${date[settersAndGetters.minutes.getter]()}`, '0', 2));
		tokenMap.set('m', `${date[settersAndGetters.minutes.getter]()}`);
		tokenMap.set('ss', pad(`${date[settersAndGetters.seconds.getter]()}`, '0', 2));
		tokenMap.set('s', `${date[settersAndGetters.seconds.getter]()}`);
		tokenMap.set('SSS', pad(`${date[settersAndGetters.milliseconds.getter]()}`, '0', 3));
		tokenMap.set('ZZ', timezone.replaceAll(':', ''));
		tokenMap.set('Z', timezone);
		tokenMap.set('A', `${(date[settersAndGetters.hours.getter]() >= 12) ? 'PM' : 'AM'}`);
		tokenMap.set('a', `${(date[settersAndGetters.hours.getter]() >= 12) ? 'pm' : 'am'}`);

		let formattedDate = definition;
		tokenMap.forEach((value, token) => {
			formattedDate = formattedDate.replaceAll(token, value);
		});

		return formattedDate;
	} else {
		let formatterOptions = {};

		if( predefinedStyles.includes(definition) ){
			if( ['datetime', 'date'].includes(type) ){
				formatterOptions.dateStyle = definition;
			}
			if( ['datetime', 'time'].includes(type) ){
				formatterOptions.timeStyle = definition;
			}
		}

		locale = orDefault(locale, 'en-US');
		if(
			(!isArray(locale) && (locale !== 'en-US'))
			|| (isArray(locale) && !locale.includes('en-US'))
		){
			locale = [].concat(locale).concat('en-US');
		}

		formatterOptions = {
			...formatterOptions,
			...(options ?? {})
		};

		return Intl.DateTimeFormat(locale, formatterOptions).format(date);
	}

}



/**
 * @namespace Dates:SaneDate
 **/

/**
 * SaneDate is a reimplementation of JavaScript date objects, trying to iron out all the small fails
 * which make you want to pull your hair while keeping the cool stuff in a streamlined manner.
 *
 * SaneDates operate between the years 0 and 9999.
 * If you create a new SaneDate, it starts off in local mode, always working and returning local information, but
 * you may activate UTC mode by defining `.utc = true;`.
 *
 * Parsing an ISO string creates a local SaneDate if no timezone is defined, if you define "Z" or an offset, the
 * given string is interpreted as UTC info, so "2012-12-12T12:00:00" will set all parts as local information,
 * meaning, that the UTC representation may differ according to your timezone, while "2012-12-12T12:00:00Z" will
 * set all parts as UTC information, meaning that this is exactly what you get as the UTC representation, but your local
 * info will differ according to your timezone. "2012-12-12T12:00:00+02:00" on the other hand, will create UTC
 * information, with a negative offset of two hours, since this says: this datetime is two hours in the UTC future,
 * so the resulting UTC info will be at 10 o'clock, while your local info will behave according to your timezone in
 * regard to that info.
 *
 * The relevant date parts of a SaneDate, which are also available as attributes to get and set are:
 * "year", "month", "date" (not day!), "hours", "minutes", "seconds" and "milliseconds".
 *
 * Additionally, set UTC mode, using the "utc" property.
 *
 * SaneDates are very exception-happy and won't allow anything, that changes or produces a date in an unexpected
 * manner. All automagic behaviour of JS dates is an error here, so setting a month to 13 and expecting a year jump
 * will not work. Dates are very sensitive information and often used for contractual stuff, so anything coming out
 * differently than you defined it in the first place is very problematic. Every change to any single property triggers
 * a check, if any side effects occurred at all and if the change exactly results in the exact info being represented.
 * Any side effect or misrepresentation results in an exception, since something happened we did not expect or define.
 *
 * Months and week days are not zero based in SaneDates but begin with 1. Week days are not an attribute
 * (and not settable), but accessible via .getWeekDay().
 *
 * This whole implementation is heavily built around iso strings, so building a date with one and getting one
 * to transfer should be forgiving, easy and robust. Something like '1-2-3 4:5:6.7' is a usable iso string
 * for SaneDate, but getIsoString() will return correctly formatted '0001-02-03T04:05:06.700'.
 *
 * See class documentation below for details.
 *
 * @memberof Dates:SaneDate
 * @name SaneDate
 *
 * @see SaneDate
 * @example
 * let date = new SaneDate('1-2-3 4:5:6.7');
 * date = new SaneDate('2016-4-7');
 * date = new SaneDate('2016-04-07 13:37:00');
 * date = new SaneDate(2016, 4, 7);
 * date = new SaneDate(2016, 4, 7, 13, 37, 0, 999);
 * date.year = 2000;
 * date.forward('hours', 42);
 */
class SaneDate {

	#__className__ = 'SaneDate';
	#invalidDateMessage = 'invalid date, please check parameters - SaneDate only accepts values that result in a valid date, where the given value is reflected exactly (e.g.: setting hours to 25 will not work)';
	#paramInvalidOrOutOfRangeMessage = 'invalid or out of range';
	#date = null;
	#utc = false;

	/**
	 * Creates a new SaneDate, either based on Date.now(), a given initial value or given date parts.
	 *
	 * @param {?Date|SaneDate|String|Number|Object} [initialValueOrYear=null] - something, that can be used to construct an initial value, this may be a vanilla Date, a SaneDate, a parsable string, a unix timestamp or an object implementing a method toISOString/toIsoString/getISOString/getIsoString; if this is a number, it will be treated as the year, if any other parameter is set as well; if nullish and all other parameters are not set either, the initial value is Date.now()
	 * @param {?Number} [month=null] - month between 1 and 12, to set in initial value
	 * @param {?Number} [date=null] - date between 1 and 31, to set in initial value
	 * @param {?Number} [hours=null] - hours between 0 and 23, to set in initial value
	 * @param {?Number} [minutes=null] - minutes between 0 and 59, to set in initial value
	 * @param {?Number} [seconds=null] - seconds between 0 and 59, to set in initial value
	 * @param {?Number} [milliseconds=null] - milliseconds between 0 and 999, to set in initial value
	 * @throws error if created date is invalid
	 */
	constructor(initialValueOrYear=null, month=null, date=null, hours=null, minutes=null, seconds=null, milliseconds=null){
		const __methodName__ = 'constructor';

		let year = null;
		const definedIndividualDateParts = {year, month, date, hours, minutes, seconds, milliseconds};
		let hasDefinedIndividualDateParts = Object.values(definedIndividualDateParts).filter(part => isNumber(part)).length >= 1;

		if( initialValueOrYear instanceof SaneDate ){
			this.#date = initialValueOrYear.getVanillaDate();
		} else if( isDate(initialValueOrYear) ){
			this.#date = initialValueOrYear;
		} else if( isString(initialValueOrYear) ){
			this.#date = this.#parseIsoString(initialValueOrYear);
		} else if( isNumber(initialValueOrYear) ){
			if( hasDefinedIndividualDateParts ){
				year = parseInt(initialValueOrYear, 10);
				definedIndividualDateParts.year = year;
			} else {
				this.#date = new Date(initialValueOrYear);
			}
		} else if(
			isObject(initialValueOrYear)
			&& (
				isFunction(initialValueOrYear.toISOString)
				|| isFunction(initialValueOrYear.toIsoString)
				|| isFunction(initialValueOrYear.getISOString)
				|| isFunction(initialValueOrYear.getIsoString)
			)
		){
			this.#date = this.#parseIsoString(
				initialValueOrYear.toISOString?.()
				?? initialValueOrYear.toIsoString?.()
				?? initialValueOrYear.getISOString?.()
				?? initialValueOrYear.getIsoString?.()
			);
		}

		if( !isDate(this.#date) ){
			this.#date = hasDefinedIndividualDateParts ? new Date('1970-01-01T00:00:00.0') : new Date();
		}

		assert(
			!isNaN(this.#date.getTime()),
			`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${this.#invalidDateMessage}`
		);

		this.#createDatePartGettersAndSetters();

		if( hasDefinedIndividualDateParts ){
			Object.entries(definedIndividualDateParts)
				.filter(([_, value]) => isNumber(value))
				.forEach(([part, value]) => {
					this[part] = value;
				})
			;
		}
	}



	/**
	 * Creates getters and setters to leisurely access and change date properties by using property assignments
	 * instead of method calls. This method provides most of the public interface of every SaneDate object.
	 *
	 * @private
	 */
	#createDatePartGettersAndSetters(){
		const propertyConfig = {
			enumerable : true
		};

		/**
		 * @name SaneDate#utc
		 * @property {Boolean} - defines if the date should behave as a UTC date instead of a local date (which is the default)
		 */
		Object.defineProperty(this, 'utc', {
			...propertyConfig,
			set(utc){
				this.#utc = !!utc;
			},
			get(){
				return this.#utc;
			}
		});

		/**
		 * @name SaneDate#year
		 * @property {Number} - the date's year in the range of 0 to 9999
		 */
		Object.defineProperty(this, 'year', {
			...propertyConfig,
			set(year){
				const __methodName__ = 'set year';

				year = parseInt(year, 10);
				assert(
					isInt(year)
					&& (year >= 0 && year <= 9999),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | year ${this.#paramInvalidOrOutOfRangeMessage} (0...9999)`
				);

				this.#tryDatePartChange('year', year);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.year.getter]();
			}
		});

		/**
		 * @name SaneDate#month
		 * @property {Number} - the date's month in the range of 1 to 12
		 */
		Object.defineProperty(this, 'month', {
			...propertyConfig,
			set(month){
				const __methodName__ = 'set month';

				month = parseInt(month, 10);
				assert(
					isInt(month)
					&& (month >= 1 && month <= 12),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | month ${this.#paramInvalidOrOutOfRangeMessage} (1...12)`
				);

				this.#tryDatePartChange('month', month - 1);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.month.getter]() + 1;
			}
		});

		/**
		 * @name SaneDate#date
		 * @property {Number} - the date's day of the month in the range of 1 to 31
		 */
		Object.defineProperty(this, 'date', {
			...propertyConfig,
			set(date){
				const __methodName__ = 'set date';

				date = parseInt(date, 10);
				assert(
					isInt(date)
					&& (date >= 1 && date <= 31),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | date ${this.#paramInvalidOrOutOfRangeMessage} (1...31)`
				);

				this.#tryDatePartChange('date', date);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.date.getter]();
			}
		});

		/**
		 * @name SaneDate#hours
		 * @property {Number} - the date's hours in the range of 0 to 23
		 */
		Object.defineProperty(this, 'hours',{
			set(hours){
				const __methodName__ = 'set hours';

				hours = parseInt(hours, 10);
				assert(
					isInt(hours)
					&& (hours >= 0 && hours <= 23),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | hours ${this.#paramInvalidOrOutOfRangeMessage} (0...23)`
				);

				this.#tryDatePartChange('hours', hours);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.hours.getter]();
			}
		});

		/**
		 * @name SaneDate#minutes
		 * @property {Number} - the date's minutes in the range of 0 to 59
		 */
		Object.defineProperty(this, 'minutes', {
			set(minutes){
				const __methodName__ = 'set hours';

				minutes = parseInt(minutes, 10);
				assert(
					isInt(minutes)
					&& (minutes >= 0 && minutes <= 59),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | minutes ${this.#paramInvalidOrOutOfRangeMessage} (0...59)`
				);

				this.#tryDatePartChange('minutes', minutes);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.minutes.getter]();
			}
		});

		/**
		 * @name SaneDate#seconds
		 * @property {Number} - the date's seconds in the range of 0 to 59
		 */
		Object.defineProperty(this, 'seconds', {
			set(seconds){
				const __methodName__ = 'set seconds';

				seconds = parseInt(seconds, 10);
				assert(
					isInt(seconds)
					&& (seconds >= 0 && seconds <= 59),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | seconds ${this.#paramInvalidOrOutOfRangeMessage} (0...59)`
				);

				this.#tryDatePartChange('seconds', seconds);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.seconds.getter]();
			}
		});

		/**
		 * @name SaneDate#milliseconds
		 * @property {Number} - the date's milliseconds in the range of 0 to 999
		 */
		Object.defineProperty(this, 'milliseconds', {
			set(milliseconds){
				const __methodName__ = 'set milliseconds';

				milliseconds = parseInt(milliseconds, 10);
				assert(
					isInt(milliseconds)
					&& (milliseconds >= 0 && milliseconds <= 999),
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | milliseconds ${this.#paramInvalidOrOutOfRangeMessage} (0...999)`
				);

				this.#tryDatePartChange('milliseconds', milliseconds);
			},
			get(){
				const settersAndGetters = this.#utc
					? DATE_PART_SETTERS_AND_GETTERS.utc
					: DATE_PART_SETTERS_AND_GETTERS.local
				;
				return this.#date[settersAndGetters.milliseconds.getter]();
			}
		});
	}



	/**
	 * Returns the current day of the week as a number between 1 and 7 or an english day name.
	 * This method counts days the European way, starting with monday, but you can change this
	 * behaviour using the first parameter (if your week starts with sunday or friday for example).
	 *
	 * @param {?String} [startingWith='monday'] - set to the english day, which is the first day of the week (monday, tuesday, wednesday, thursday, friday, saturday, sunday)
	 * @param {?Boolean} [asName=false] - set to true, if you'd like the method to return english day names instead of an index
	 * @returns {Number|String} weekday index between 1 and 7 or english name of the day
	 *
	 * @example
	 * const d = new SaneDate();
	 * if( d.getWeekDay() == 5 ){
	 *   alert(`Thank god it's ${d.getWeekday(null, true)}!`);
	 * }
	 */
	getWeekDay(startingWith='monday', asName=false){
		const __methodName__ = 'getWeekDay';

		const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
		startingWith = orDefault(startingWith, weekdays[1], 'str');
		assert(
			weekdays.includes(startingWith),
			`${MODULE_NAME}:${this.#__className__}.${__methodName__} | unknown weekday "${startingWith}"`
		);

		let day = this.#utc ? this.#date.getUTCDay() : this.#date.getDay();
		if( asName ) return weekdays[day];

		const offset = day - weekdays.indexOf(startingWith);
		if( offset < 0 ){
			day = 7 + offset;
		} else {
			day = offset;
		}

		return day + 1;
	}



	/**
	 * Returns the date's current timezone, like it would occur in an ISO-string ("Z", "+06:00", "-02:30").
	 *
	 * If you need the raw offset, use the vanilla date's getTimezoneOffset() method.
	 *
	 * @returns {String} - the timezone string
	 *
	 * @example
	 * const d = new SaneDate()
	 * d.getTimezone()
	 * => "+09:30"
	 */
	getTimezone(){
		if( this.#utc ) return 'Z';

		const offset = this.#date.getTimezoneOffset();

		if( offset === 0 ){
			return 'Z';
		} else {
			const
				hours = this.#padWithZero(Math.floor(Math.abs(offset) / 60), 2),
				minutes = this.#padWithZero(Math.abs(offset) - (hours * 60), 2)
			;
			return `${(offset < 0) ? '+' : '-'}${hours}:${minutes}`;
		}
	}



	/**
	 * Returns the representation of the date's current date parts (year, month, day) as an ISO-string.
	 *
	 * A difference to the vanilla implementation is, that this method respects UTC mode and does not always
	 * coerce the date to UTC automatically. So, this will return a local ISO representation if not in UTC mode
	 * and the UTC representation in UTC mode.
	 *
	 * @returns {String} date ISO-string of the format "2016-04-07"
	 *
	 * @example
	 * const d = new SaneDate();
	 * thatDatePicker.setValue(d.getIsoDateString());
	 */
	getIsoDateString(){
		const
			year = this.#padWithZero(this.year, 4),
			month = this.#padWithZero(this.month, 2),
			date = this.#padWithZero(this.date, 2)
		;

		return `${year}-${month}-${date}`;
	}



	/**
	 * Returns the representation of the date's current time parts (hours, minutes, seconds, milliseconds) as an
	 * ISO-string.
	 *
	 * A difference to the vanilla implementation is, that this method respects UTC mode and does not always
	 * coerce the date to UTC automatically. So, this will return a local ISO representation (optionally with
	 * timezone information in relation to UTC) if not in UTC mode and the UTC representation in UTC mode.
	 *
	 * @param {?Boolean} [withTimezone=true] - defines if the ISO string should end with timezone information, such as "Z" or "+02:00"
	 * @returns {String} time ISO-string of the format "12:59:00.123Z"
	 *
	 * @example
	 * const d = new SaneDate();
	 * thatDatePicker.setValue(`2023-12-12T${d.getIsoTimeString()}`);
	 */
	getIsoTimeString(withTimezone=true){
		withTimezone = orDefault(withTimezone, true, 'bool');

		const
			hours = this.#padWithZero(this.hours, 2),
			minutes = this.#padWithZero(this.minutes, 2),
			seconds = this.#padWithZero(this.seconds, 2),
			milliseconds = this.#padWithZero(this.milliseconds, 3),
			timezone = this.getTimezone()
		;

		return `${hours}:${minutes}:${seconds}${(milliseconds > 0) ? '.'+milliseconds : ''}${withTimezone ? timezone : ''}`;
	}



	/**
	 * Returns the date as an ISO-string.
	 *
	 * A difference to the vanilla implementation is, that this method respects UTC mode and does not always
	 * coerce the date to UTC automatically. So, this will return a local ISO representation (optionally with
	 * timezone information in relation to UTC) if not in UTC mode and the UTC representation in UTC mode.
	 *
	 * @param {?Boolean} [withSeparator=true] - defines if date and time should be separated with a "T"
	 * @param {?Boolean} [withTimezone=true] - defines if the ISO string should end with timezone information, such as "Z" or "+02:00"
	 * @returns {String} ISO-string of the format "2016-04-07T13:37:00.222Z"
	 *
	 * @example
	 * const d = new SaneDate();
	 * thatDateTimePicker.setValue(d.getIsoString());
	 */
	getIsoString(withSeparator=true, withTimezone=true){
		withSeparator = orDefault(withSeparator, true, 'bool');

		return `${this.getIsoDateString()}${withSeparator ? 'T' : ' '}${this.getIsoTimeString(withTimezone)}`;
	}



	/**
	 * Returns a formatted string, describing the current date in a verbose, human-readable, non-technical way.
	 *
	 * "definition" may be a format shortcut for "dateStyle" (and "timeStyle" if type is "datetime") or a format string,
	 * for a custom format, using these tokens:
	 *
	 * YY      18         two-digit year;
	 * YYYY    2018       four-digit year;
	 * M       1-12       the month, beginning at 1;
	 * MM      01-12      the month, 2-digits;
	 * D       1-31       the day of the month;
	 * DD      01-31      the day of the month, 2-digits;
	 * H       0-23       the hour;
	 * HH      00-23      the hour, 2-digits;
	 * h       1-12       the hour, 12-hour clock;
	 * hh      01-12      the hour, 12-hour clock, 2-digits;
	 * m       0-59       the minute;
	 * mm      00-59      the minute, 2-digits;
	 * s       0-59       the second;
	 * ss      00-59      the second, 2-digits;
	 * SSS     000-999    the millisecond, 3-digits;
	 * Z       +05:00     the offset from UTC, ±HH:mm;
	 * ZZ      +0500      the offset from UTC, ±HHmm;
	 * A       AM PM;
	 * a       am pm;
	 *
	 * Using these, you could create your own ISO string like this:
	 * "YYYY-MM-DDTHH:mm:ss.SSSZ"
	 *
	 * If you use "full", "long", "medium" or "short" instead, you'll use the DateTimeFormatters built-in, preset
	 * format styles for localized dates, based on the given locale(s).
	 *
	 * @param {?String} [definition='long'] - either a preset style to quickly define a format style, by setting shortcuts for dateStyle and timeStyle (if type is "datetime"), set to "none" or nullish value to skip quick format; alternatively, define this as a format string to use a custom format
	 * @param {?String|Array<String>} [locale='en-US'] - locale to use for date format and text generation, use array to define fallback; always falls back to en-US if nothing else works
	 * @param {?String} [type='datetime'] - set to 'datetime', 'date' or 'time' to define which parts should be rendered
	 * @param {?Object} [options=null] - options to pass to the Intl.DateTimeFormat constructor, is applied last, so should override anything predefined, if key is reset
	 * @returns {String} - the formatted date/time string
	 *
	 * @see format(Dates)
	 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
	 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#style_shortcuts
	 * @example
	 * const d = new SaneDate();
	 * d.format('de-DE', 'long', 'datetime', {timeZone : 'UTC'})
	 * => '12. Dezember 2023 um 02:00:00 UTC'
	 * d.format('YYYY-MM-DDTHH:mm:ss.SSSZ')
	 * => '2023-12-12T02:00:00'
	 */
	format(definition='long', locale='en-US', type='datetime', options=null){
		options = orDefault(options, {});

		if( !hasValue(options.timeZone) && this.#utc ){
			options.timeZone = 'UTC';
		}

		return format(this.#date, definition, locale, type, options);
	}



	/**
	 * Return the current original JavaScript date object wrapped by SaneDate.
	 * Use this to do special things.
	 *
	 * @returns {Date} the original JavaScript date object
	 *
	 * @example
	 * const d = new SaneDate();
	 * const timezoneOffset = d.getVanillaDate().getTimezoneOffset();
	 */
	getVanillaDate(){
		return this.#date;
	}



	/**
	 * Compares the date to another date in terms of placement on the time axis.
	 *
	 * Returns a classical comparator value (-1/0/1), being -1 if the date is earlier than the parameter.
	 * Normally checks date and time. Set type to "date" to only check date.
	 *
	 * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
	 * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
	 * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
	 * @throws error if compare date is not usable
	 * @returns {Number} -1 if this date is smaller/earlier, 0 if identical, 1 if this date is bigger/later
	 *
	 * @example
	 * const d = new SaneDate();
	 * if( d.compareTo('2016-04-07', 'date') === 0 ){
	 *   alert('congratulations, that\'s the same date!');
	 * }
	 */
	compareTo(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
		type = orDefault(type, 'datetime', 'string');
		withMilliseconds = orDefault(withMilliseconds, true, 'bool');

		const
			saneDate = new SaneDate(initialValueOrSaneDate),
			dateCompareGetters = [
				DATE_PART_SETTERS_AND_GETTERS.utc.year.getter,
				DATE_PART_SETTERS_AND_GETTERS.utc.month.getter,
				DATE_PART_SETTERS_AND_GETTERS.utc.date.getter,
			],
			timeCompareGetters = [
				DATE_PART_SETTERS_AND_GETTERS.utc.hours.getter,
				DATE_PART_SETTERS_AND_GETTERS.utc.minutes.getter,
				DATE_PART_SETTERS_AND_GETTERS.utc.seconds.getter,
			],
			millisecondsCompareGetter = DATE_PART_SETTERS_AND_GETTERS.utc.milliseconds.getter
		;

		let compareGetters = [].concat(dateCompareGetters);
		if( type === 'datetime' ){
			compareGetters = compareGetters.concat(timeCompareGetters);
			if( withMilliseconds ){
				compareGetters = compareGetters.concat(millisecondsCompareGetter);
			}
		}

		let ownValue, compareValue, comparator;
		for( const compareGetter of compareGetters ){
			ownValue = this.#date[compareGetter]();
			compareValue = saneDate.getVanillaDate()[compareGetter]();
			comparator = (ownValue < compareValue)
				? -1
				: ((ownValue > compareValue) ? 1 : 0)
			;

			if( comparator !== 0 ){
				break;
			}
		}

		return comparator;
	}



	/**
	 * Returns if the SaneDate is earlier on the time axis than the comparison value.
	 *
	 * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
	 * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
	 * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
	 * @throws error if compare date is not usable
	 * @returns {Boolean} true if SaneDate is earlier than compare value
	 *
	 * @example
	 * const now = new SaneDate();
	 * const theFuture = now.clone().forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
	 * now.isBefore(theFuture)
	 * => true
	 * theFuture.isBefore(now)
	 * => false
	 */
	isBefore(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
		return this.compareTo(initialValueOrSaneDate, type, withMilliseconds) === -1;
	}



	/**
	 * Returns if the SaneDate is later on the time axis than the comparison value.
	 *
	 * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
	 * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
	 * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
	 * @throws error if compare date is not usable
	 * @returns {Boolean} true if SaneDate is later than compare value
	 *
	 * @example
	 * const now = new SaneDate();
	 * const theFuture = now.clone().forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
	 * now.isAfter(theFuture)
	 * => false
	 * theFuture.isAfter(now)
	 * => true
	 */
	isAfter(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
		return this.compareTo(initialValueOrSaneDate, type, withMilliseconds) === 1;
	}



	/**
	 * Returns if the SaneDate is at the same time as comparison value.
	 *
	 * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
	 * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
	 * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
	 * @throws error if compare date is not usable
	 * @returns {Boolean} true if SaneDate is at the same time as compare value
	 *
	 * @example
	 * const now = new SaneDate();
	 * const theFuture = now.clone();
	 * now.isSame(theFuture)
	 * => true
	 * theFuture.forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
	 * theFuture.isSame(now)
	 * => false
	 */
	isSame(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
		return this.compareTo(initialValueOrSaneDate, type, withMilliseconds) === 0;
	}



	/**
	 * Move the date a defined offset to the past or the future.
	 *
	 * @param {String|Object} part - the name of the date part to change, one of "years", "months", "days", "hours", "minutes", "seconds" and "milliseconds" or a dictionary of part/amount pairs ({hours : -1, seconds : 30})
	 * @param {?Number} [amount=0] - negative or positive integer defining the offset from the current date
	 * @throws error on invalid part name
	 * @returns {SaneDate} the SaneDate instance
	 *
	 * @example
	 * let d = new SaneDate();
	 * d = d.move('years', 10).move('milliseconds', -1);
	 */
	move(part, amount=0){
		const __methodName__ = 'move;'

		amount = orDefault(amount, 0, 'int');

		const
			settersAndGetters = DATE_PART_SETTERS_AND_GETTERS.utc,
			parts = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'milliseconds']
		;
		let partDict = {};

		if( !isPlainObject(part) ){
			partDict[part] = amount;
		} else {
			partDict = part;
		}

		Object.keys(partDict).forEach(part => {
			assert(
				parts.includes(part),
				`${MODULE_NAME}:${this.#__className__}.${__methodName__} | part must be one of ${parts.join(', ')}, is "${part}"`
			);
		});

		Object.entries(partDict).forEach(([part, amount]) => {
			switch( part ){
				case 'years':
					this.#date[settersAndGetters.year.setter](this.#date[settersAndGetters.year.getter]() + amount);
				break;

				case 'months':
					this.#date[settersAndGetters.month.setter](this.#date[settersAndGetters.month.getter]() + amount);
				break;

				case 'days':
					this.#date[settersAndGetters.date.setter](this.#date[settersAndGetters.date.getter]() + amount);
				break;

				case 'hours':
					this.#date[settersAndGetters.hours.setter](this.#date[settersAndGetters.hours.getter]() + amount);
				break;

				case 'minutes':
					this.#date[settersAndGetters.minutes.setter](this.#date[settersAndGetters.minutes.getter]() + amount);
				break;

				case 'seconds':
					this.#date[settersAndGetters.seconds.setter](this.#date[settersAndGetters.seconds.getter]() + amount);
				break;

				case 'milliseconds':
					this.#date[settersAndGetters.milliseconds.setter](this.#date[settersAndGetters.milliseconds.getter]() + amount);
				break;
			}
		});

		return this;
	}



	/**
	 * Moves the date's time forward a certain offset.
	 *
	 * @param {String|Object} part - the name of the date part to change, one of "years", "months", "days", "hours", "minutes", "seconds" and "milliseconds" or a dictionary of part/amount pairs ({hours : 1, seconds : 30})
	 * @param {?Number} [amount=0] - integer defining the positive offset from the current date, negative value is treated as an error
	 * @throws error on invalid part name or negative amount
	 * @returns {SaneDate} the SaneDate instance
	 *
	 * @example
	 * let d = new SaneDate();
	 * d = d.forward('hours', 8);
	 */
	forward(part, amount=0){
		const
			__methodName__ = 'forward',
			amountMustBePositiveMessage = 'amount must be >= 0'
		;

		part = `${part}`;
		amount = orDefault(amount, 0, 'int');

		let partDict = {};
		if( !isPlainObject(part) ){
			assert(
				amount >= 0,
				`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
			);
			partDict[part] = amount;
		} else {
			partDict = part;
			Object.entries(partDict).forEach(([part, amount]) => {
				amount = parseInt(amount, 10);
				assert(
					amount >= 0,
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
				);
				partDict[part] = amount;
			});
		}

		return this.move(partDict);
	}



	/**
	 * Moves the date's time backward a certain offset.
	 *
	 * @param {String|Object} part - the name of the date part to change, one of "years", "months", "days", "hours", "minutes", "seconds" and "milliseconds" or a dictionary of part/amount pairs ({hours : 1, seconds : 30})
	 * @param {?Number} [amount=0] - integer defining the negative offset from the current date, negative value is treated as an error
	 * @throws error on invalid part name or negative amount
	 * @returns {SaneDate} the SaneDate instance
	 *
	 * @example
	 * let d = new SaneDate();
	 * d = d.backward('years', 1000);
	 */
	backward(part, amount=0){
		const
			__methodName__ = 'backward',
			amountMustBePositiveMessage = 'amount must be >= 0'
		;

		part = `${part}`;
		amount = orDefault(amount, 0, 'int');

		let partDict = {};
		if( !isPlainObject(part) ){
			assert(
				amount >= 0,
				`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
			);
			partDict[part] = (amount === 0) ? 0 : -amount;
		} else {
			partDict = part;
			Object.entries(partDict).forEach(([part, amount]) => {
				assert(
					amount >= 0,
					`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
				);
				partDict[part] = (amount === 0) ? 0 : -amount;
			});
		}

		return this.move(partDict);
	}



	/**
	 * Calculates a time delta between the SaneDate and a comparison value.
	 *
	 * The result is a plain object with the delta's units up to the defined "largestUnit". All values are integers.
	 * The largest unit is days, since above neither months nor years are calculable via a fixed divisor and therefore
	 * useless (since month vary from 28 to 31 days and years vary between 365 and 366 days, so both are not a fixed
	 * unit).
	 *
	 * By default, the order does not matter and only the absolute value is used, but you can change this
	 * through the parameter "relative", which by setting this to true, will include "-", if the comparison value
	 * is in the future.
	 *
	 * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
	 * @param {?String} [largestUnit='days'] - the largest time unit to differentiate in the result
	 * @param {?Boolean} [relative=false] - if true, returns negative values if first parameter is later than this date (this adheres to the order defined by compareTo)
	 * @throws error on unknown largestUnit or incompatible comparison value
	 * @returns {Object} time delta object in the format {days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5} (keys depending on largestUnit)
	 *
	 * @example
	 * const now = new SaneDate();
	 * const theFuture = now.clone().forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
	 * now.getDelta(theFuture)
	 * => {days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5}
	 * now.getDelta(theFuture, 'hours', true)
	 * => {hours : -26, minutes : -3, seconds : -4, milliseconds : -5}
	 */
	getDelta(initialValueOrSaneDate, largestUnit='days', relative=false){
		const __methodName__ = 'getDelta';

		const saneDate = new SaneDate(initialValueOrSaneDate);
		largestUnit = orDefault(largestUnit, 'days', 'string');
		assert(
			['days', 'hours', 'minutes', 'seconds', 'milliseconds'].includes(largestUnit),
			`${MODULE_NAME}:${this.#__className__}.${__methodName__} | unknown largest unit`
		);
		relative = orDefault(relative, false, 'bool');

		const parts = {};
		let delta = relative
			? (this.#date.getTime() - saneDate.#date.getTime())
			: Math.abs(this.#date.getTime() - saneDate.#date.getTime())
		;
		const negativeDelta = delta < 0;
		delta = Math.abs(delta);

		if( largestUnit === 'days' ){
			parts.days = Math.floor(delta / 1000 / 60 / 60 / 24);
			delta -= parts.days * 1000 * 60 * 60 * 24;
			largestUnit = 'hours';
		}

		if( largestUnit === 'hours' ){
			parts.hours = Math.floor(delta / 1000 / 60 / 60);
			delta -= parts.hours * 1000 * 60 * 60;
			largestUnit = 'minutes';
		}

		if( largestUnit === 'minutes' ){
			parts.minutes = Math.floor(delta / 1000 / 60);
			delta -= parts.minutes * 1000 * 60;
			largestUnit = 'seconds';
		}

		if( largestUnit === 'seconds' ){
			parts.seconds = Math.floor(delta / 1000);
			delta -= parts.seconds * 1000;
			largestUnit = 'milliseconds';
		}

		if( largestUnit === 'milliseconds' ){
			parts.milliseconds = delta;
		}

		if( negativeDelta ){
			for( const partName in parts ){
				parts[partName] = (parts[partName] === 0) ? 0 : -parts[partName];
			}
		}

		return parts;
	}



	/**
	 * Returns a copy of the current SaneDate.
	 * Might be very handy for creating dates based on another with an offset for example.
	 * Keeps UTC mode.
	 *
	 * @returns {SaneDate} copy of current SaneDate instance
	 *
	 * @example
	 * const d = new SaneDate();
	 * const theFuture = d.clone().forward('hours', 8);
	 **/
	clone(){
		const clonedSaneDate = new SaneDate(new Date(this.getVanillaDate().getTime()));
		clonedSaneDate.utc = this.#utc;
		return clonedSaneDate;
	}



	/**
	 * Adds leading zeroes to values, which are not yet of a defined expected length.
	 *
	 * @param {*} value - the value to pad
	 * @param {?Number} [digitCount=2] - the number of digits, the result has to have at least
	 * @returns {String} the padded value, will always be cast to a string
	 *
	 * @private
	 * @example
	 * this.#padWithZero(1, 4)
	 * => '0001'
	 */
	#padWithZero(value, digitCount=2){
		return pad(value, '0', digitCount);
	}



	/**
	 * Tries to parse an ISO string (or at least, something resembling an ISO string) into a date.
	 *
	 * The basic idea of this method is, that it is supposed to be fairly forgiving, as long as the info is there,
	 * even in a little wonky notation, this should result in a successfully created SaneDate.
	 *
	 * @param {String} isoString - something resembling an ISO string, that we can create a date from
	 * @throws error if isoString is not usable
	 * @returns {Date} the date create from the given ISO string
	 *
	 * @private
	 * @example
	 * this.#parseIsoString('2018-02-28T13:37:00')
	 * this.#parseIsoString('1-2-3 4:5:6.7')
	 */
	#parseIsoString(isoString){
		const
			__methodName__ = '#parseIsoString',
			unparsableIsoStringMessage = 'ISO string not parsable'
		;

		isoString = `${isoString}`;

		let
			year = 1970,
			month = 1,
			date = 1,
			hours = 0,
			minutes = 0,
			seconds = 0,
			milliseconds = 0,
			timezoneOffset = 0,
			utc = false
		;

		let isoStringParts = isoString.split('T');
		if( isoStringParts.length === 1 ){
			isoStringParts = isoStringParts[0].split(' ');
		}


		// date parts

		const isoStringDateParts = isoStringParts[0].split('-');
		year = isoStringDateParts[0];
		if( isoStringDateParts.length >= 2 ){
			month = isoStringDateParts[1];
		}
		if( isoStringDateParts.length >= 3 ){
			date = isoStringDateParts[2];
		}


		// time parts

		if( isoStringParts.length >= 2 ){
			// timezone

			let	isoStringTimezoneParts = isoStringParts[1].split('Z');
			if( isoStringTimezoneParts.length >= 2 ){
				utc = true;
			} else {
				let offsetFactor = 0;
				if( isoStringTimezoneParts[0].includes('+') ){
					offsetFactor = -1;
					isoStringTimezoneParts = isoStringTimezoneParts[0].split('+');
				} else if( isoStringTimezoneParts[0].includes('-') ){
					offsetFactor = 1;
					isoStringTimezoneParts = isoStringTimezoneParts[0].split('-');
				}

				if( isoStringTimezoneParts.length >= 2 ){
					const isoStringTimezoneTimeParts = isoStringTimezoneParts[1].split(':');

					if( isoStringTimezoneTimeParts.length >= 2 ){
						timezoneOffset += parseInt(isoStringTimezoneTimeParts[0], 10) * 60;
						timezoneOffset += parseInt(isoStringTimezoneTimeParts[1], 10);
					} else if( isoStringTimezoneTimeParts[0].length >= 3 ){
						timezoneOffset += parseInt(isoStringTimezoneTimeParts[0].slice(0, 2), 10) * 60;
						timezoneOffset += parseInt(isoStringTimezoneTimeParts[1].slice(2), 10);
					} else {
						timezoneOffset += parseInt(isoStringTimezoneTimeParts[0], 10) * 60;
					}
					timezoneOffset *= offsetFactor;

					assert(
						!isNaN(timezoneOffset),
						`${MODULE_NAME}:${this.#__className__}.${__methodName__} | invalid timezone "${isoStringTimezoneParts[1]}"`
					);
				}
			}


			// hours and minutes
			const isoStringTimeParts = isoStringTimezoneParts[0].split(':');
			hours = isoStringTimeParts[0];
			if( isoStringTimeParts.length >= 2 ){
				minutes = isoStringTimeParts[1];
			}


			// seconds and milliseconds

			if( isoStringTimeParts.length >= 3 ){
				const isoStringSecondsParts = isoStringTimeParts[2].split('.');

				seconds = isoStringSecondsParts[0];

				if( isoStringSecondsParts.length >= 2 ){
					milliseconds = isoStringSecondsParts[1];

					if( milliseconds.length > 3 ){
						milliseconds = milliseconds.slice(0, 3);
					} else if( milliseconds.length === 2 ){
						milliseconds = `${parseInt(milliseconds, 10) * 10}`;
					} else if( milliseconds.length === 1 ){
						milliseconds = `${parseInt(milliseconds, 10) * 100}`;
					}
				}
			}
		}


		// date construction

		const saneDate = new SaneDate();
		saneDate.utc = utc || (timezoneOffset !== 0);
		try {
			saneDate.year = year;
			saneDate.month = month;
			saneDate.date = date;
			saneDate.hours = hours;
			saneDate.minutes = minutes;
			saneDate.seconds = seconds;
			saneDate.milliseconds = milliseconds;
		} catch(ex){
			throw Error(`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${unparsableIsoStringMessage} "${isoString}"`);
		}
		saneDate.move('minutes', timezoneOffset);

		return saneDate.getVanillaDate();
	}



	/**
	 * Tries to change a part of the date and makes sure, that this change does not trigger automagic and only
	 * leads to exactly the change, we wanted to do and nothing else.
	 *
	 * @param {String} part - the date part to change, one of: year, month, date, hours, minutes, seconds or milliseconds
	 * @param {Number} value - the new value to set
	 * @returns {SaneDate} the SaneDate instance
	 *
	 * @private
	 */
	#tryDatePartChange(part, value){
		const __methodName__ = '#tryDatePartChange';

		const
			newDate = this.clone().getVanillaDate(),
			settersAndGetters = this.#utc
				? DATE_PART_SETTERS_AND_GETTERS.utc
				: DATE_PART_SETTERS_AND_GETTERS.local
			,
			allDatePartGetters = Object.values(settersAndGetters).map(methods => methods.getter)
		;

		newDate[settersAndGetters[part].setter](value);

		let sideEffect = false;
		for( const datePartGetter of allDatePartGetters ){
			if( datePartGetter !== settersAndGetters[part].getter){
				sideEffect ||= this.#date[datePartGetter]() !== newDate[datePartGetter]();
			}

			if( sideEffect ){
				break;
			}
		}

		assert(
			(newDate[settersAndGetters[part].getter]() === value) && !sideEffect,
			`${MODULE_NAME}:${this.#__className__}.${__methodName__} | date part change "${part} = ${value}" is invalid or has side effects`
		);

		this.#date = newDate;

		return this;
	}

}

export {SaneDate};