Source: dates.js

  1. /*!
  2. * Module Dates
  3. */
  4. /**
  5. * @namespace Dates
  6. */
  7. const MODULE_NAME = 'Dates';
  8. //###[ IMPORTS ]########################################################################################################
  9. import {
  10. hasValue,
  11. assert,
  12. orDefault,
  13. isArray,
  14. isDate,
  15. isString,
  16. isNumber,
  17. isInt,
  18. isNaN,
  19. isObject,
  20. isPlainObject,
  21. isFunction
  22. } from './basic.js';
  23. import {pad} from './strings.js';
  24. //###[ DATA ]###########################################################################################################
  25. const DATE_PART_SETTERS_AND_GETTERS = {
  26. local : {
  27. year : {
  28. setter : 'setFullYear',
  29. getter : 'getFullYear'
  30. },
  31. month : {
  32. setter : 'setMonth',
  33. getter : 'getMonth',
  34. },
  35. date : {
  36. setter : 'setDate',
  37. getter : 'getDate',
  38. },
  39. hours : {
  40. setter : 'setHours',
  41. getter : 'getHours',
  42. },
  43. minutes : {
  44. setter : 'setMinutes',
  45. getter : 'getMinutes',
  46. },
  47. seconds : {
  48. setter : 'setSeconds',
  49. getter : 'getSeconds',
  50. },
  51. milliseconds : {
  52. setter : 'setMilliseconds',
  53. getter : 'getMilliseconds',
  54. },
  55. },
  56. utc : {
  57. year : {
  58. setter : 'setUTCFullYear',
  59. getter : 'getUTCFullYear',
  60. },
  61. month : {
  62. setter : 'setUTCMonth',
  63. getter : 'getUTCMonth',
  64. },
  65. date : {
  66. setter : 'setUTCDate',
  67. getter : 'getUTCDate',
  68. },
  69. hours : {
  70. setter : 'setUTCHours',
  71. getter : 'getUTCHours',
  72. },
  73. minutes : {
  74. setter : 'setUTCMinutes',
  75. getter : 'getUTCMinutes',
  76. },
  77. seconds : {
  78. setter : 'setUTCSeconds',
  79. getter : 'getUTCSeconds',
  80. },
  81. milliseconds : {
  82. setter : 'setUTCMilliseconds',
  83. getter : 'getUTCMilliseconds',
  84. },
  85. }
  86. };
  87. //###[ EXPORTS ]########################################################################################################
  88. /**
  89. * @namespace Dates:format
  90. */
  91. /**
  92. * Returns a formatted string, describing the date in a verbose, non-technical way.
  93. *
  94. * Under the hood, this uses Intl.DateTimeFormat, which is widely supported and conveniently to use
  95. * for most widely used locales.
  96. *
  97. * "definition" may be a format shortcut for "dateStyle" (and "timeStyle" if type is "datetime") or a format string,
  98. * for a custom format, using these tokens:
  99. *
  100. * YY 18 two-digit year;
  101. * YYYY 2018 four-digit year;
  102. * M 1-12 the month, beginning at 1;
  103. * MM 01-12 the month, 2-digits;
  104. * D 1-31 the day of the month;
  105. * DD 01-31 the day of the month, 2-digits;
  106. * H 0-23 the hour;
  107. * HH 00-23 the hour, 2-digits;
  108. * h 1-12 the hour, 12-hour clock;
  109. * hh 01-12 the hour, 12-hour clock, 2-digits;
  110. * m 0-59 the minute;
  111. * mm 00-59 the minute, 2-digits;
  112. * s 0-59 the second;
  113. * ss 00-59 the second, 2-digits;
  114. * SSS 000-999 the millisecond, 3-digits;
  115. * Z +05:00 the offset from UTC, ±HH:mm;
  116. * ZZ +0500 the offset from UTC, ±HHmm;
  117. * A AM PM;
  118. * a am pm;
  119. *
  120. * Using these, you could create your own ISO string like this:
  121. * "YYYY-MM-DDTHH:mm:ss.SSSZ"
  122. *
  123. * If you use "full", "long", "medium" or "short" instead, you'll use the DateTimeFormatters built-in, preset
  124. * format styles for localized dates, based on the given locale(s).
  125. *
  126. * @param {Date} date - the date to format
  127. * @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
  128. * @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
  129. * @param {?String} [type='datetime'] - set to 'datetime', 'date' or 'time' to define which parts should be rendered
  130. * @param {?Object} [options=null] - options to pass to the Intl.DateTimeFormat constructor, is applied last, so should override anything predefined, if key is reset
  131. * @returns {String} - the formatted date/time string
  132. *
  133. * @memberof Dates:format
  134. * @alias format
  135. * @variation Dates
  136. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
  137. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#style_shortcuts
  138. * @example
  139. * format(new Date(), 'de-DE', 'long', 'datetime', {timeZone : 'UTC'})
  140. * => '12. Dezember 2023 um 02:00:00 UTC'
  141. * format(new Date(), 'YYYY-MM-DDTHH:mm:ss.SSSZ')
  142. * => '2023-12-12T02:00:00'
  143. */
  144. export function format(date, definition='long', locale='en-US', type='datetime', options=null){
  145. const
  146. utc = (options?.timeZone === 'UTC'),
  147. settersAndGetters = utc
  148. ? DATE_PART_SETTERS_AND_GETTERS.utc
  149. : DATE_PART_SETTERS_AND_GETTERS.local
  150. ,
  151. predefinedStyles = ['full', 'long', 'medium', 'short', 'none']
  152. ;
  153. if( hasValue(definition) && !predefinedStyles.includes(definition) ){
  154. let timezone = '';
  155. const offset = date.getTimezoneOffset();
  156. if( !utc && (offset !== 0) ){
  157. const
  158. hours = pad(Math.floor(Math.abs(offset) / 60), '0', 2),
  159. minutes = pad(Math.abs(offset) - (hours * 60), '0', 2)
  160. ;
  161. timezone = `${(offset < 0) ? '+' : '-'}${hours}:${minutes}`;
  162. }
  163. const tokenMap = new Map();
  164. tokenMap.set('YYYY', `${date[settersAndGetters.year.getter]()}`);
  165. tokenMap.set('YY', `${date[settersAndGetters.year.getter]()}`.slice(-2));
  166. tokenMap.set('MM', pad(`${date[settersAndGetters.month.getter]() + 1}`, '0', 2));
  167. tokenMap.set('M', `${date[settersAndGetters.month.getter]() + 1}`);
  168. tokenMap.set('DD', pad(`${date[settersAndGetters.date.getter]()}`, '0', 2));
  169. tokenMap.set('D', `${date[settersAndGetters.date.getter]()}`);
  170. tokenMap.set('HH', pad(`${date[settersAndGetters.hours.getter]()}`, '0', 2));
  171. tokenMap.set('H', `${date[settersAndGetters.hours.getter]()}`);
  172. tokenMap.set('hh', pad(`${
  173. (date[settersAndGetters.hours.getter]() === 0)
  174. ? 12
  175. : (
  176. (date[settersAndGetters.hours.getter]() > 12)
  177. ? date[settersAndGetters.hours.getter]() - 12
  178. : date[settersAndGetters.hours.getter]()
  179. )
  180. }`, '0', 2));
  181. tokenMap.set('h', `${
  182. (date[settersAndGetters.hours.getter]() === 0)
  183. ? 12
  184. : (
  185. (date[settersAndGetters.hours.getter]() > 12)
  186. ? date[settersAndGetters.hours.getter]() - 12
  187. : date[settersAndGetters.hours.getter]()
  188. )
  189. }`);
  190. tokenMap.set('mm', pad(`${date[settersAndGetters.minutes.getter]()}`, '0', 2));
  191. tokenMap.set('m', `${date[settersAndGetters.minutes.getter]()}`);
  192. tokenMap.set('ss', pad(`${date[settersAndGetters.seconds.getter]()}`, '0', 2));
  193. tokenMap.set('s', `${date[settersAndGetters.seconds.getter]()}`);
  194. tokenMap.set('SSS', pad(`${date[settersAndGetters.milliseconds.getter]()}`, '0', 3));
  195. tokenMap.set('ZZ', timezone.replaceAll(':', ''));
  196. tokenMap.set('Z', timezone);
  197. tokenMap.set('A', `${(date[settersAndGetters.hours.getter]() >= 12) ? 'PM' : 'AM'}`);
  198. tokenMap.set('a', `${(date[settersAndGetters.hours.getter]() >= 12) ? 'pm' : 'am'}`);
  199. let formattedDate = definition;
  200. tokenMap.forEach((value, token) => {
  201. formattedDate = formattedDate.replaceAll(token, value);
  202. });
  203. return formattedDate;
  204. } else {
  205. let formatterOptions = {};
  206. if( predefinedStyles.includes(definition) ){
  207. if( ['datetime', 'date'].includes(type) ){
  208. formatterOptions.dateStyle = definition;
  209. }
  210. if( ['datetime', 'time'].includes(type) ){
  211. formatterOptions.timeStyle = definition;
  212. }
  213. }
  214. locale = orDefault(locale, 'en-US');
  215. if(
  216. (!isArray(locale) && (locale !== 'en-US'))
  217. || (isArray(locale) && !locale.includes('en-US'))
  218. ){
  219. locale = [].concat(locale).concat('en-US');
  220. }
  221. formatterOptions = {
  222. ...formatterOptions,
  223. ...(options ?? {})
  224. };
  225. return Intl.DateTimeFormat(locale, formatterOptions).format(date);
  226. }
  227. }
  228. /**
  229. * @namespace Dates:SaneDate
  230. **/
  231. /**
  232. * SaneDate is a reimplementation of JavaScript date objects, trying to iron out all the small fails
  233. * which make you want to pull your hair while keeping the cool stuff in a streamlined manner.
  234. *
  235. * SaneDates operate between the years 0 and 9999.
  236. * If you create a new SaneDate, it starts off in local mode, always working and returning local information, but
  237. * you may activate UTC mode by defining `.utc = true;`.
  238. *
  239. * Parsing an ISO string creates a local SaneDate if no timezone is defined, if you define "Z" or an offset, the
  240. * given string is interpreted as UTC info, so "2012-12-12T12:00:00" will set all parts as local information,
  241. * meaning, that the UTC representation may differ according to your timezone, while "2012-12-12T12:00:00Z" will
  242. * set all parts as UTC information, meaning that this is exactly what you get as the UTC representation, but your local
  243. * info will differ according to your timezone. "2012-12-12T12:00:00+02:00" on the other hand, will create UTC
  244. * information, with a negative offset of two hours, since this says: this datetime is two hours in the UTC future,
  245. * so the resulting UTC info will be at 10 o'clock, while your local info will behave according to your timezone in
  246. * regard to that info.
  247. *
  248. * The relevant date parts of a SaneDate, which are also available as attributes to get and set are:
  249. * "year", "month", "date" (not day!), "hours", "minutes", "seconds" and "milliseconds".
  250. *
  251. * Additionally, set UTC mode, using the "utc" property.
  252. *
  253. * SaneDates are very exception-happy and won't allow anything, that changes or produces a date in an unexpected
  254. * manner. All automagic behaviour of JS dates is an error here, so setting a month to 13 and expecting a year jump
  255. * will not work. Dates are very sensitive information and often used for contractual stuff, so anything coming out
  256. * differently than you defined it in the first place is very problematic. Every change to any single property triggers
  257. * a check, if any side effects occurred at all and if the change exactly results in the exact info being represented.
  258. * Any side effect or misrepresentation results in an exception, since something happened we did not expect or define.
  259. *
  260. * Months and week days are not zero based in SaneDates but begin with 1. Week days are not an attribute
  261. * (and not settable), but accessible via .getWeekDay().
  262. *
  263. * This whole implementation is heavily built around iso strings, so building a date with one and getting one
  264. * to transfer should be forgiving, easy and robust. Something like '1-2-3 4:5:6.7' is a usable iso string
  265. * for SaneDate, but getIsoString() will return correctly formatted '0001-02-03T04:05:06.700'.
  266. *
  267. * See class documentation below for details.
  268. *
  269. * @memberof Dates:SaneDate
  270. * @name SaneDate
  271. *
  272. * @see SaneDate
  273. * @example
  274. * let date = new SaneDate('1-2-3 4:5:6.7');
  275. * date = new SaneDate('2016-4-7');
  276. * date = new SaneDate('2016-04-07 13:37:00');
  277. * date = new SaneDate(2016, 4, 7);
  278. * date = new SaneDate(2016, 4, 7, 13, 37, 0, 999);
  279. * date.year = 2000;
  280. * date.forward('hours', 42);
  281. */
  282. class SaneDate {
  283. #__className__ = 'SaneDate';
  284. #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)';
  285. #paramInvalidOrOutOfRangeMessage = 'invalid or out of range';
  286. #date = null;
  287. #utc = false;
  288. /**
  289. * Creates a new SaneDate, either based on Date.now(), a given initial value or given date parts.
  290. *
  291. * @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()
  292. * @param {?Number} [month=null] - month between 1 and 12, to set in initial value
  293. * @param {?Number} [date=null] - date between 1 and 31, to set in initial value
  294. * @param {?Number} [hours=null] - hours between 0 and 23, to set in initial value
  295. * @param {?Number} [minutes=null] - minutes between 0 and 59, to set in initial value
  296. * @param {?Number} [seconds=null] - seconds between 0 and 59, to set in initial value
  297. * @param {?Number} [milliseconds=null] - milliseconds between 0 and 999, to set in initial value
  298. * @throws error if created date is invalid
  299. */
  300. constructor(initialValueOrYear=null, month=null, date=null, hours=null, minutes=null, seconds=null, milliseconds=null){
  301. const __methodName__ = 'constructor';
  302. let year = null;
  303. const definedIndividualDateParts = {year, month, date, hours, minutes, seconds, milliseconds};
  304. let hasDefinedIndividualDateParts = Object.values(definedIndividualDateParts).filter(part => isNumber(part)).length >= 1;
  305. if( initialValueOrYear instanceof SaneDate ){
  306. this.#date = initialValueOrYear.getVanillaDate();
  307. } else if( isDate(initialValueOrYear) ){
  308. this.#date = initialValueOrYear;
  309. } else if( isString(initialValueOrYear) ){
  310. this.#date = this.#parseIsoString(initialValueOrYear);
  311. } else if( isNumber(initialValueOrYear) ){
  312. if( hasDefinedIndividualDateParts ){
  313. year = parseInt(initialValueOrYear, 10);
  314. definedIndividualDateParts.year = year;
  315. } else {
  316. this.#date = new Date(initialValueOrYear);
  317. }
  318. } else if(
  319. isObject(initialValueOrYear)
  320. && (
  321. isFunction(initialValueOrYear.toISOString)
  322. || isFunction(initialValueOrYear.toIsoString)
  323. || isFunction(initialValueOrYear.getISOString)
  324. || isFunction(initialValueOrYear.getIsoString)
  325. )
  326. ){
  327. this.#date = this.#parseIsoString(
  328. initialValueOrYear.toISOString?.()
  329. ?? initialValueOrYear.toIsoString?.()
  330. ?? initialValueOrYear.getISOString?.()
  331. ?? initialValueOrYear.getIsoString?.()
  332. );
  333. }
  334. if( !isDate(this.#date) ){
  335. this.#date = hasDefinedIndividualDateParts ? new Date('1970-01-01T00:00:00.0') : new Date();
  336. }
  337. assert(
  338. !isNaN(this.#date.getTime()),
  339. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${this.#invalidDateMessage}`
  340. );
  341. this.#createDatePartGettersAndSetters();
  342. if( hasDefinedIndividualDateParts ){
  343. Object.entries(definedIndividualDateParts)
  344. .filter(([_, value]) => isNumber(value))
  345. .forEach(([part, value]) => {
  346. this[part] = value;
  347. })
  348. ;
  349. }
  350. }
  351. /**
  352. * Creates getters and setters to leisurely access and change date properties by using property assignments
  353. * instead of method calls. This method provides most of the public interface of every SaneDate object.
  354. *
  355. * @private
  356. */
  357. #createDatePartGettersAndSetters(){
  358. const propertyConfig = {
  359. enumerable : true
  360. };
  361. /**
  362. * @name SaneDate#utc
  363. * @property {Boolean} - defines if the date should behave as a UTC date instead of a local date (which is the default)
  364. */
  365. Object.defineProperty(this, 'utc', {
  366. ...propertyConfig,
  367. set(utc){
  368. this.#utc = !!utc;
  369. },
  370. get(){
  371. return this.#utc;
  372. }
  373. });
  374. /**
  375. * @name SaneDate#year
  376. * @property {Number} - the date's year in the range of 0 to 9999
  377. */
  378. Object.defineProperty(this, 'year', {
  379. ...propertyConfig,
  380. set(year){
  381. const __methodName__ = 'set year';
  382. year = parseInt(year, 10);
  383. assert(
  384. isInt(year)
  385. && (year >= 0 && year <= 9999),
  386. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | year ${this.#paramInvalidOrOutOfRangeMessage} (0...9999)`
  387. );
  388. this.#tryDatePartChange('year', year);
  389. },
  390. get(){
  391. const settersAndGetters = this.#utc
  392. ? DATE_PART_SETTERS_AND_GETTERS.utc
  393. : DATE_PART_SETTERS_AND_GETTERS.local
  394. ;
  395. return this.#date[settersAndGetters.year.getter]();
  396. }
  397. });
  398. /**
  399. * @name SaneDate#month
  400. * @property {Number} - the date's month in the range of 1 to 12
  401. */
  402. Object.defineProperty(this, 'month', {
  403. ...propertyConfig,
  404. set(month){
  405. const __methodName__ = 'set month';
  406. month = parseInt(month, 10);
  407. assert(
  408. isInt(month)
  409. && (month >= 1 && month <= 12),
  410. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | month ${this.#paramInvalidOrOutOfRangeMessage} (1...12)`
  411. );
  412. this.#tryDatePartChange('month', month - 1);
  413. },
  414. get(){
  415. const settersAndGetters = this.#utc
  416. ? DATE_PART_SETTERS_AND_GETTERS.utc
  417. : DATE_PART_SETTERS_AND_GETTERS.local
  418. ;
  419. return this.#date[settersAndGetters.month.getter]() + 1;
  420. }
  421. });
  422. /**
  423. * @name SaneDate#date
  424. * @property {Number} - the date's day of the month in the range of 1 to 31
  425. */
  426. Object.defineProperty(this, 'date', {
  427. ...propertyConfig,
  428. set(date){
  429. const __methodName__ = 'set date';
  430. date = parseInt(date, 10);
  431. assert(
  432. isInt(date)
  433. && (date >= 1 && date <= 31),
  434. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | date ${this.#paramInvalidOrOutOfRangeMessage} (1...31)`
  435. );
  436. this.#tryDatePartChange('date', date);
  437. },
  438. get(){
  439. const settersAndGetters = this.#utc
  440. ? DATE_PART_SETTERS_AND_GETTERS.utc
  441. : DATE_PART_SETTERS_AND_GETTERS.local
  442. ;
  443. return this.#date[settersAndGetters.date.getter]();
  444. }
  445. });
  446. /**
  447. * @name SaneDate#hours
  448. * @property {Number} - the date's hours in the range of 0 to 23
  449. */
  450. Object.defineProperty(this, 'hours',{
  451. set(hours){
  452. const __methodName__ = 'set hours';
  453. hours = parseInt(hours, 10);
  454. assert(
  455. isInt(hours)
  456. && (hours >= 0 && hours <= 23),
  457. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | hours ${this.#paramInvalidOrOutOfRangeMessage} (0...23)`
  458. );
  459. this.#tryDatePartChange('hours', hours);
  460. },
  461. get(){
  462. const settersAndGetters = this.#utc
  463. ? DATE_PART_SETTERS_AND_GETTERS.utc
  464. : DATE_PART_SETTERS_AND_GETTERS.local
  465. ;
  466. return this.#date[settersAndGetters.hours.getter]();
  467. }
  468. });
  469. /**
  470. * @name SaneDate#minutes
  471. * @property {Number} - the date's minutes in the range of 0 to 59
  472. */
  473. Object.defineProperty(this, 'minutes', {
  474. set(minutes){
  475. const __methodName__ = 'set hours';
  476. minutes = parseInt(minutes, 10);
  477. assert(
  478. isInt(minutes)
  479. && (minutes >= 0 && minutes <= 59),
  480. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | minutes ${this.#paramInvalidOrOutOfRangeMessage} (0...59)`
  481. );
  482. this.#tryDatePartChange('minutes', minutes);
  483. },
  484. get(){
  485. const settersAndGetters = this.#utc
  486. ? DATE_PART_SETTERS_AND_GETTERS.utc
  487. : DATE_PART_SETTERS_AND_GETTERS.local
  488. ;
  489. return this.#date[settersAndGetters.minutes.getter]();
  490. }
  491. });
  492. /**
  493. * @name SaneDate#seconds
  494. * @property {Number} - the date's seconds in the range of 0 to 59
  495. */
  496. Object.defineProperty(this, 'seconds', {
  497. set(seconds){
  498. const __methodName__ = 'set seconds';
  499. seconds = parseInt(seconds, 10);
  500. assert(
  501. isInt(seconds)
  502. && (seconds >= 0 && seconds <= 59),
  503. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | seconds ${this.#paramInvalidOrOutOfRangeMessage} (0...59)`
  504. );
  505. this.#tryDatePartChange('seconds', seconds);
  506. },
  507. get(){
  508. const settersAndGetters = this.#utc
  509. ? DATE_PART_SETTERS_AND_GETTERS.utc
  510. : DATE_PART_SETTERS_AND_GETTERS.local
  511. ;
  512. return this.#date[settersAndGetters.seconds.getter]();
  513. }
  514. });
  515. /**
  516. * @name SaneDate#milliseconds
  517. * @property {Number} - the date's milliseconds in the range of 0 to 999
  518. */
  519. Object.defineProperty(this, 'milliseconds', {
  520. set(milliseconds){
  521. const __methodName__ = 'set milliseconds';
  522. milliseconds = parseInt(milliseconds, 10);
  523. assert(
  524. isInt(milliseconds)
  525. && (milliseconds >= 0 && milliseconds <= 999),
  526. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | milliseconds ${this.#paramInvalidOrOutOfRangeMessage} (0...999)`
  527. );
  528. this.#tryDatePartChange('milliseconds', milliseconds);
  529. },
  530. get(){
  531. const settersAndGetters = this.#utc
  532. ? DATE_PART_SETTERS_AND_GETTERS.utc
  533. : DATE_PART_SETTERS_AND_GETTERS.local
  534. ;
  535. return this.#date[settersAndGetters.milliseconds.getter]();
  536. }
  537. });
  538. }
  539. /**
  540. * Returns the current day of the week as a number between 1 and 7 or an english day name.
  541. * This method counts days the European way, starting with monday, but you can change this
  542. * behaviour using the first parameter (if your week starts with sunday or friday for example).
  543. *
  544. * @param {?String} [startingWith='monday'] - set to the english day, which is the first day of the week (monday, tuesday, wednesday, thursday, friday, saturday, sunday)
  545. * @param {?Boolean} [asName=false] - set to true, if you'd like the method to return english day names instead of an index
  546. * @returns {Number|String} weekday index between 1 and 7 or english name of the day
  547. *
  548. * @example
  549. * const d = new SaneDate();
  550. * if( d.getWeekDay() == 5 ){
  551. * alert(`Thank god it's ${d.getWeekday(null, true)}!`);
  552. * }
  553. */
  554. getWeekDay(startingWith='monday', asName=false){
  555. const __methodName__ = 'getWeekDay';
  556. const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
  557. startingWith = orDefault(startingWith, weekdays[1], 'str');
  558. assert(
  559. weekdays.includes(startingWith),
  560. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | unknown weekday "${startingWith}"`
  561. );
  562. let day = this.#utc ? this.#date.getUTCDay() : this.#date.getDay();
  563. if( asName ) return weekdays[day];
  564. const offset = day - weekdays.indexOf(startingWith);
  565. if( offset < 0 ){
  566. day = 7 + offset;
  567. } else {
  568. day = offset;
  569. }
  570. return day + 1;
  571. }
  572. /**
  573. * Returns the date's current timezone, like it would occur in an ISO-string ("Z", "+06:00", "-02:30").
  574. *
  575. * If you need the raw offset, use the vanilla date's getTimezoneOffset() method.
  576. *
  577. * @returns {String} - the timezone string
  578. *
  579. * @example
  580. * const d = new SaneDate()
  581. * d.getTimezone()
  582. * => "+09:30"
  583. */
  584. getTimezone(){
  585. if( this.#utc ) return 'Z';
  586. const offset = this.#date.getTimezoneOffset();
  587. if( offset === 0 ){
  588. return 'Z';
  589. } else {
  590. const
  591. hours = this.#padWithZero(Math.floor(Math.abs(offset) / 60), 2),
  592. minutes = this.#padWithZero(Math.abs(offset) - (hours * 60), 2)
  593. ;
  594. return `${(offset < 0) ? '+' : '-'}${hours}:${minutes}`;
  595. }
  596. }
  597. /**
  598. * Returns the representation of the date's current date parts (year, month, day) as an ISO-string.
  599. *
  600. * A difference to the vanilla implementation is, that this method respects UTC mode and does not always
  601. * coerce the date to UTC automatically. So, this will return a local ISO representation if not in UTC mode
  602. * and the UTC representation in UTC mode.
  603. *
  604. * @returns {String} date ISO-string of the format "2016-04-07"
  605. *
  606. * @example
  607. * const d = new SaneDate();
  608. * thatDatePicker.setValue(d.getIsoDateString());
  609. */
  610. getIsoDateString(){
  611. const
  612. year = this.#padWithZero(this.year, 4),
  613. month = this.#padWithZero(this.month, 2),
  614. date = this.#padWithZero(this.date, 2)
  615. ;
  616. return `${year}-${month}-${date}`;
  617. }
  618. /**
  619. * Returns the representation of the date's current time parts (hours, minutes, seconds, milliseconds) as an
  620. * ISO-string.
  621. *
  622. * A difference to the vanilla implementation is, that this method respects UTC mode and does not always
  623. * coerce the date to UTC automatically. So, this will return a local ISO representation (optionally with
  624. * timezone information in relation to UTC) if not in UTC mode and the UTC representation in UTC mode.
  625. *
  626. * @param {?Boolean} [withTimezone=true] - defines if the ISO string should end with timezone information, such as "Z" or "+02:00"
  627. * @returns {String} time ISO-string of the format "12:59:00.123Z"
  628. *
  629. * @example
  630. * const d = new SaneDate();
  631. * thatDatePicker.setValue(`2023-12-12T${d.getIsoTimeString()}`);
  632. */
  633. getIsoTimeString(withTimezone=true){
  634. withTimezone = orDefault(withTimezone, true, 'bool');
  635. const
  636. hours = this.#padWithZero(this.hours, 2),
  637. minutes = this.#padWithZero(this.minutes, 2),
  638. seconds = this.#padWithZero(this.seconds, 2),
  639. milliseconds = this.#padWithZero(this.milliseconds, 3),
  640. timezone = this.getTimezone()
  641. ;
  642. return `${hours}:${minutes}:${seconds}${(milliseconds > 0) ? '.'+milliseconds : ''}${withTimezone ? timezone : ''}`;
  643. }
  644. /**
  645. * Returns the date as an ISO-string.
  646. *
  647. * A difference to the vanilla implementation is, that this method respects UTC mode and does not always
  648. * coerce the date to UTC automatically. So, this will return a local ISO representation (optionally with
  649. * timezone information in relation to UTC) if not in UTC mode and the UTC representation in UTC mode.
  650. *
  651. * @param {?Boolean} [withSeparator=true] - defines if date and time should be separated with a "T"
  652. * @param {?Boolean} [withTimezone=true] - defines if the ISO string should end with timezone information, such as "Z" or "+02:00"
  653. * @returns {String} ISO-string of the format "2016-04-07T13:37:00.222Z"
  654. *
  655. * @example
  656. * const d = new SaneDate();
  657. * thatDateTimePicker.setValue(d.getIsoString());
  658. */
  659. getIsoString(withSeparator=true, withTimezone=true){
  660. withSeparator = orDefault(withSeparator, true, 'bool');
  661. return `${this.getIsoDateString()}${withSeparator ? 'T' : ' '}${this.getIsoTimeString(withTimezone)}`;
  662. }
  663. /**
  664. * Returns a formatted string, describing the current date in a verbose, human-readable, non-technical way.
  665. *
  666. * "definition" may be a format shortcut for "dateStyle" (and "timeStyle" if type is "datetime") or a format string,
  667. * for a custom format, using these tokens:
  668. *
  669. * YY 18 two-digit year;
  670. * YYYY 2018 four-digit year;
  671. * M 1-12 the month, beginning at 1;
  672. * MM 01-12 the month, 2-digits;
  673. * D 1-31 the day of the month;
  674. * DD 01-31 the day of the month, 2-digits;
  675. * H 0-23 the hour;
  676. * HH 00-23 the hour, 2-digits;
  677. * h 1-12 the hour, 12-hour clock;
  678. * hh 01-12 the hour, 12-hour clock, 2-digits;
  679. * m 0-59 the minute;
  680. * mm 00-59 the minute, 2-digits;
  681. * s 0-59 the second;
  682. * ss 00-59 the second, 2-digits;
  683. * SSS 000-999 the millisecond, 3-digits;
  684. * Z +05:00 the offset from UTC, ±HH:mm;
  685. * ZZ +0500 the offset from UTC, ±HHmm;
  686. * A AM PM;
  687. * a am pm;
  688. *
  689. * Using these, you could create your own ISO string like this:
  690. * "YYYY-MM-DDTHH:mm:ss.SSSZ"
  691. *
  692. * If you use "full", "long", "medium" or "short" instead, you'll use the DateTimeFormatters built-in, preset
  693. * format styles for localized dates, based on the given locale(s).
  694. *
  695. * @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
  696. * @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
  697. * @param {?String} [type='datetime'] - set to 'datetime', 'date' or 'time' to define which parts should be rendered
  698. * @param {?Object} [options=null] - options to pass to the Intl.DateTimeFormat constructor, is applied last, so should override anything predefined, if key is reset
  699. * @returns {String} - the formatted date/time string
  700. *
  701. * @see format(Dates)
  702. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
  703. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#style_shortcuts
  704. * @example
  705. * const d = new SaneDate();
  706. * d.format('de-DE', 'long', 'datetime', {timeZone : 'UTC'})
  707. * => '12. Dezember 2023 um 02:00:00 UTC'
  708. * d.format('YYYY-MM-DDTHH:mm:ss.SSSZ')
  709. * => '2023-12-12T02:00:00'
  710. */
  711. format(definition='long', locale='en-US', type='datetime', options=null){
  712. options = orDefault(options, {});
  713. if( !hasValue(options.timeZone) && this.#utc ){
  714. options.timeZone = 'UTC';
  715. }
  716. return format(this.#date, definition, locale, type, options);
  717. }
  718. /**
  719. * Return the current original JavaScript date object wrapped by SaneDate.
  720. * Use this to do special things.
  721. *
  722. * @returns {Date} the original JavaScript date object
  723. *
  724. * @example
  725. * const d = new SaneDate();
  726. * const timezoneOffset = d.getVanillaDate().getTimezoneOffset();
  727. */
  728. getVanillaDate(){
  729. return this.#date;
  730. }
  731. /**
  732. * Compares the date to another date in terms of placement on the time axis.
  733. *
  734. * Returns a classical comparator value (-1/0/1), being -1 if the date is earlier than the parameter.
  735. * Normally checks date and time. Set type to "date" to only check date.
  736. *
  737. * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
  738. * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
  739. * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
  740. * @throws error if compare date is not usable
  741. * @returns {Number} -1 if this date is smaller/earlier, 0 if identical, 1 if this date is bigger/later
  742. *
  743. * @example
  744. * const d = new SaneDate();
  745. * if( d.compareTo('2016-04-07', 'date') === 0 ){
  746. * alert('congratulations, that\'s the same date!');
  747. * }
  748. */
  749. compareTo(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
  750. type = orDefault(type, 'datetime', 'string');
  751. withMilliseconds = orDefault(withMilliseconds, true, 'bool');
  752. const
  753. saneDate = new SaneDate(initialValueOrSaneDate),
  754. dateCompareGetters = [
  755. DATE_PART_SETTERS_AND_GETTERS.utc.year.getter,
  756. DATE_PART_SETTERS_AND_GETTERS.utc.month.getter,
  757. DATE_PART_SETTERS_AND_GETTERS.utc.date.getter,
  758. ],
  759. timeCompareGetters = [
  760. DATE_PART_SETTERS_AND_GETTERS.utc.hours.getter,
  761. DATE_PART_SETTERS_AND_GETTERS.utc.minutes.getter,
  762. DATE_PART_SETTERS_AND_GETTERS.utc.seconds.getter,
  763. ],
  764. millisecondsCompareGetter = DATE_PART_SETTERS_AND_GETTERS.utc.milliseconds.getter
  765. ;
  766. let compareGetters = [].concat(dateCompareGetters);
  767. if( type === 'datetime' ){
  768. compareGetters = compareGetters.concat(timeCompareGetters);
  769. if( withMilliseconds ){
  770. compareGetters = compareGetters.concat(millisecondsCompareGetter);
  771. }
  772. }
  773. let ownValue, compareValue, comparator;
  774. for( const compareGetter of compareGetters ){
  775. ownValue = this.#date[compareGetter]();
  776. compareValue = saneDate.getVanillaDate()[compareGetter]();
  777. comparator = (ownValue < compareValue)
  778. ? -1
  779. : ((ownValue > compareValue) ? 1 : 0)
  780. ;
  781. if( comparator !== 0 ){
  782. break;
  783. }
  784. }
  785. return comparator;
  786. }
  787. /**
  788. * Returns if the SaneDate is earlier on the time axis than the comparison value.
  789. *
  790. * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
  791. * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
  792. * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
  793. * @throws error if compare date is not usable
  794. * @returns {Boolean} true if SaneDate is earlier than compare value
  795. *
  796. * @example
  797. * const now = new SaneDate();
  798. * const theFuture = now.clone().forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
  799. * now.isBefore(theFuture)
  800. * => true
  801. * theFuture.isBefore(now)
  802. * => false
  803. */
  804. isBefore(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
  805. return this.compareTo(initialValueOrSaneDate, type, withMilliseconds) === -1;
  806. }
  807. /**
  808. * Returns if the SaneDate is later on the time axis than the comparison value.
  809. *
  810. * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
  811. * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
  812. * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
  813. * @throws error if compare date is not usable
  814. * @returns {Boolean} true if SaneDate is later than compare value
  815. *
  816. * @example
  817. * const now = new SaneDate();
  818. * const theFuture = now.clone().forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
  819. * now.isAfter(theFuture)
  820. * => false
  821. * theFuture.isAfter(now)
  822. * => true
  823. */
  824. isAfter(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
  825. return this.compareTo(initialValueOrSaneDate, type, withMilliseconds) === 1;
  826. }
  827. /**
  828. * Returns if the SaneDate is at the same time as comparison value.
  829. *
  830. * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
  831. * @param {?String} [type='datetime'] - either "datetime" or "date", telling the method if time should be considered
  832. * @param {?Boolean} [withMilliseconds=true] - tells the method if milliseconds should be considered if type is "datetime"
  833. * @throws error if compare date is not usable
  834. * @returns {Boolean} true if SaneDate is at the same time as compare value
  835. *
  836. * @example
  837. * const now = new SaneDate();
  838. * const theFuture = now.clone();
  839. * now.isSame(theFuture)
  840. * => true
  841. * theFuture.forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
  842. * theFuture.isSame(now)
  843. * => false
  844. */
  845. isSame(initialValueOrSaneDate, type='datetime', withMilliseconds=true){
  846. return this.compareTo(initialValueOrSaneDate, type, withMilliseconds) === 0;
  847. }
  848. /**
  849. * Move the date a defined offset to the past or the future.
  850. *
  851. * @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})
  852. * @param {?Number} [amount=0] - negative or positive integer defining the offset from the current date
  853. * @throws error on invalid part name
  854. * @returns {SaneDate} the SaneDate instance
  855. *
  856. * @example
  857. * let d = new SaneDate();
  858. * d = d.move('years', 10).move('milliseconds', -1);
  859. */
  860. move(part, amount=0){
  861. const __methodName__ = 'move;'
  862. amount = orDefault(amount, 0, 'int');
  863. const
  864. settersAndGetters = DATE_PART_SETTERS_AND_GETTERS.utc,
  865. parts = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'milliseconds']
  866. ;
  867. let partDict = {};
  868. if( !isPlainObject(part) ){
  869. partDict[part] = amount;
  870. } else {
  871. partDict = part;
  872. }
  873. Object.keys(partDict).forEach(part => {
  874. assert(
  875. parts.includes(part),
  876. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | part must be one of ${parts.join(', ')}, is "${part}"`
  877. );
  878. });
  879. Object.entries(partDict).forEach(([part, amount]) => {
  880. switch( part ){
  881. case 'years':
  882. this.#date[settersAndGetters.year.setter](this.#date[settersAndGetters.year.getter]() + amount);
  883. break;
  884. case 'months':
  885. this.#date[settersAndGetters.month.setter](this.#date[settersAndGetters.month.getter]() + amount);
  886. break;
  887. case 'days':
  888. this.#date[settersAndGetters.date.setter](this.#date[settersAndGetters.date.getter]() + amount);
  889. break;
  890. case 'hours':
  891. this.#date[settersAndGetters.hours.setter](this.#date[settersAndGetters.hours.getter]() + amount);
  892. break;
  893. case 'minutes':
  894. this.#date[settersAndGetters.minutes.setter](this.#date[settersAndGetters.minutes.getter]() + amount);
  895. break;
  896. case 'seconds':
  897. this.#date[settersAndGetters.seconds.setter](this.#date[settersAndGetters.seconds.getter]() + amount);
  898. break;
  899. case 'milliseconds':
  900. this.#date[settersAndGetters.milliseconds.setter](this.#date[settersAndGetters.milliseconds.getter]() + amount);
  901. break;
  902. }
  903. });
  904. return this;
  905. }
  906. /**
  907. * Moves the date's time forward a certain offset.
  908. *
  909. * @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})
  910. * @param {?Number} [amount=0] - integer defining the positive offset from the current date, negative value is treated as an error
  911. * @throws error on invalid part name or negative amount
  912. * @returns {SaneDate} the SaneDate instance
  913. *
  914. * @example
  915. * let d = new SaneDate();
  916. * d = d.forward('hours', 8);
  917. */
  918. forward(part, amount=0){
  919. const
  920. __methodName__ = 'forward',
  921. amountMustBePositiveMessage = 'amount must be >= 0'
  922. ;
  923. part = `${part}`;
  924. amount = orDefault(amount, 0, 'int');
  925. let partDict = {};
  926. if( !isPlainObject(part) ){
  927. assert(
  928. amount >= 0,
  929. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
  930. );
  931. partDict[part] = amount;
  932. } else {
  933. partDict = part;
  934. Object.entries(partDict).forEach(([part, amount]) => {
  935. amount = parseInt(amount, 10);
  936. assert(
  937. amount >= 0,
  938. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
  939. );
  940. partDict[part] = amount;
  941. });
  942. }
  943. return this.move(partDict);
  944. }
  945. /**
  946. * Moves the date's time backward a certain offset.
  947. *
  948. * @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})
  949. * @param {?Number} [amount=0] - integer defining the negative offset from the current date, negative value is treated as an error
  950. * @throws error on invalid part name or negative amount
  951. * @returns {SaneDate} the SaneDate instance
  952. *
  953. * @example
  954. * let d = new SaneDate();
  955. * d = d.backward('years', 1000);
  956. */
  957. backward(part, amount=0){
  958. const
  959. __methodName__ = 'backward',
  960. amountMustBePositiveMessage = 'amount must be >= 0'
  961. ;
  962. part = `${part}`;
  963. amount = orDefault(amount, 0, 'int');
  964. let partDict = {};
  965. if( !isPlainObject(part) ){
  966. assert(
  967. amount >= 0,
  968. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
  969. );
  970. partDict[part] = (amount === 0) ? 0 : -amount;
  971. } else {
  972. partDict = part;
  973. Object.entries(partDict).forEach(([part, amount]) => {
  974. assert(
  975. amount >= 0,
  976. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${amountMustBePositiveMessage}`
  977. );
  978. partDict[part] = (amount === 0) ? 0 : -amount;
  979. });
  980. }
  981. return this.move(partDict);
  982. }
  983. /**
  984. * Calculates a time delta between the SaneDate and a comparison value.
  985. *
  986. * The result is a plain object with the delta's units up to the defined "largestUnit". All values are integers.
  987. * The largest unit is days, since above neither months nor years are calculable via a fixed divisor and therefore
  988. * useless (since month vary from 28 to 31 days and years vary between 365 and 366 days, so both are not a fixed
  989. * unit).
  990. *
  991. * By default, the order does not matter and only the absolute value is used, but you can change this
  992. * through the parameter "relative", which by setting this to true, will include "-", if the comparison value
  993. * is in the future.
  994. *
  995. * @param {?Date|SaneDate|String|Number|Object|SaneDate} initialValueOrSaneDate - anything compatible to the SaneDate constructor or a SaneDate instance
  996. * @param {?String} [largestUnit='days'] - the largest time unit to differentiate in the result
  997. * @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)
  998. * @throws error on unknown largestUnit or incompatible comparison value
  999. * @returns {Object} time delta object in the format {days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5} (keys depending on largestUnit)
  1000. *
  1001. * @example
  1002. * const now = new SaneDate();
  1003. * const theFuture = now.clone().forward({days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5});
  1004. * now.getDelta(theFuture)
  1005. * => {days : 1, hours : 2, minutes : 3, seconds : 4, milliseconds : 5}
  1006. * now.getDelta(theFuture, 'hours', true)
  1007. * => {hours : -26, minutes : -3, seconds : -4, milliseconds : -5}
  1008. */
  1009. getDelta(initialValueOrSaneDate, largestUnit='days', relative=false){
  1010. const __methodName__ = 'getDelta';
  1011. const saneDate = new SaneDate(initialValueOrSaneDate);
  1012. largestUnit = orDefault(largestUnit, 'days', 'string');
  1013. assert(
  1014. ['days', 'hours', 'minutes', 'seconds', 'milliseconds'].includes(largestUnit),
  1015. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | unknown largest unit`
  1016. );
  1017. relative = orDefault(relative, false, 'bool');
  1018. const parts = {};
  1019. let delta = relative
  1020. ? (this.#date.getTime() - saneDate.#date.getTime())
  1021. : Math.abs(this.#date.getTime() - saneDate.#date.getTime())
  1022. ;
  1023. const negativeDelta = delta < 0;
  1024. delta = Math.abs(delta);
  1025. if( largestUnit === 'days' ){
  1026. parts.days = Math.floor(delta / 1000 / 60 / 60 / 24);
  1027. delta -= parts.days * 1000 * 60 * 60 * 24;
  1028. largestUnit = 'hours';
  1029. }
  1030. if( largestUnit === 'hours' ){
  1031. parts.hours = Math.floor(delta / 1000 / 60 / 60);
  1032. delta -= parts.hours * 1000 * 60 * 60;
  1033. largestUnit = 'minutes';
  1034. }
  1035. if( largestUnit === 'minutes' ){
  1036. parts.minutes = Math.floor(delta / 1000 / 60);
  1037. delta -= parts.minutes * 1000 * 60;
  1038. largestUnit = 'seconds';
  1039. }
  1040. if( largestUnit === 'seconds' ){
  1041. parts.seconds = Math.floor(delta / 1000);
  1042. delta -= parts.seconds * 1000;
  1043. largestUnit = 'milliseconds';
  1044. }
  1045. if( largestUnit === 'milliseconds' ){
  1046. parts.milliseconds = delta;
  1047. }
  1048. if( negativeDelta ){
  1049. for( const partName in parts ){
  1050. parts[partName] = (parts[partName] === 0) ? 0 : -parts[partName];
  1051. }
  1052. }
  1053. return parts;
  1054. }
  1055. /**
  1056. * Returns a copy of the current SaneDate.
  1057. * Might be very handy for creating dates based on another with an offset for example.
  1058. * Keeps UTC mode.
  1059. *
  1060. * @returns {SaneDate} copy of current SaneDate instance
  1061. *
  1062. * @example
  1063. * const d = new SaneDate();
  1064. * const theFuture = d.clone().forward('hours', 8);
  1065. **/
  1066. clone(){
  1067. const clonedSaneDate = new SaneDate(new Date(this.getVanillaDate().getTime()));
  1068. clonedSaneDate.utc = this.#utc;
  1069. return clonedSaneDate;
  1070. }
  1071. /**
  1072. * Adds leading zeroes to values, which are not yet of a defined expected length.
  1073. *
  1074. * @param {*} value - the value to pad
  1075. * @param {?Number} [digitCount=2] - the number of digits, the result has to have at least
  1076. * @returns {String} the padded value, will always be cast to a string
  1077. *
  1078. * @private
  1079. * @example
  1080. * this.#padWithZero(1, 4)
  1081. * => '0001'
  1082. */
  1083. #padWithZero(value, digitCount=2){
  1084. return pad(value, '0', digitCount);
  1085. }
  1086. /**
  1087. * Tries to parse an ISO string (or at least, something resembling an ISO string) into a date.
  1088. *
  1089. * The basic idea of this method is, that it is supposed to be fairly forgiving, as long as the info is there,
  1090. * even in a little wonky notation, this should result in a successfully created SaneDate.
  1091. *
  1092. * @param {String} isoString - something resembling an ISO string, that we can create a date from
  1093. * @throws error if isoString is not usable
  1094. * @returns {Date} the date create from the given ISO string
  1095. *
  1096. * @private
  1097. * @example
  1098. * this.#parseIsoString('2018-02-28T13:37:00')
  1099. * this.#parseIsoString('1-2-3 4:5:6.7')
  1100. */
  1101. #parseIsoString(isoString){
  1102. const
  1103. __methodName__ = '#parseIsoString',
  1104. unparsableIsoStringMessage = 'ISO string not parsable'
  1105. ;
  1106. isoString = `${isoString}`;
  1107. let
  1108. year = 1970,
  1109. month = 1,
  1110. date = 1,
  1111. hours = 0,
  1112. minutes = 0,
  1113. seconds = 0,
  1114. milliseconds = 0,
  1115. timezoneOffset = 0,
  1116. utc = false
  1117. ;
  1118. let isoStringParts = isoString.split('T');
  1119. if( isoStringParts.length === 1 ){
  1120. isoStringParts = isoStringParts[0].split(' ');
  1121. }
  1122. // date parts
  1123. const isoStringDateParts = isoStringParts[0].split('-');
  1124. year = isoStringDateParts[0];
  1125. if( isoStringDateParts.length >= 2 ){
  1126. month = isoStringDateParts[1];
  1127. }
  1128. if( isoStringDateParts.length >= 3 ){
  1129. date = isoStringDateParts[2];
  1130. }
  1131. // time parts
  1132. if( isoStringParts.length >= 2 ){
  1133. // timezone
  1134. let isoStringTimezoneParts = isoStringParts[1].split('Z');
  1135. if( isoStringTimezoneParts.length >= 2 ){
  1136. utc = true;
  1137. } else {
  1138. let offsetFactor = 0;
  1139. if( isoStringTimezoneParts[0].includes('+') ){
  1140. offsetFactor = -1;
  1141. isoStringTimezoneParts = isoStringTimezoneParts[0].split('+');
  1142. } else if( isoStringTimezoneParts[0].includes('-') ){
  1143. offsetFactor = 1;
  1144. isoStringTimezoneParts = isoStringTimezoneParts[0].split('-');
  1145. }
  1146. if( isoStringTimezoneParts.length >= 2 ){
  1147. const isoStringTimezoneTimeParts = isoStringTimezoneParts[1].split(':');
  1148. if( isoStringTimezoneTimeParts.length >= 2 ){
  1149. timezoneOffset += parseInt(isoStringTimezoneTimeParts[0], 10) * 60;
  1150. timezoneOffset += parseInt(isoStringTimezoneTimeParts[1], 10);
  1151. } else if( isoStringTimezoneTimeParts[0].length >= 3 ){
  1152. timezoneOffset += parseInt(isoStringTimezoneTimeParts[0].slice(0, 2), 10) * 60;
  1153. timezoneOffset += parseInt(isoStringTimezoneTimeParts[1].slice(2), 10);
  1154. } else {
  1155. timezoneOffset += parseInt(isoStringTimezoneTimeParts[0], 10) * 60;
  1156. }
  1157. timezoneOffset *= offsetFactor;
  1158. assert(
  1159. !isNaN(timezoneOffset),
  1160. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | invalid timezone "${isoStringTimezoneParts[1]}"`
  1161. );
  1162. }
  1163. }
  1164. // hours and minutes
  1165. const isoStringTimeParts = isoStringTimezoneParts[0].split(':');
  1166. hours = isoStringTimeParts[0];
  1167. if( isoStringTimeParts.length >= 2 ){
  1168. minutes = isoStringTimeParts[1];
  1169. }
  1170. // seconds and milliseconds
  1171. if( isoStringTimeParts.length >= 3 ){
  1172. const isoStringSecondsParts = isoStringTimeParts[2].split('.');
  1173. seconds = isoStringSecondsParts[0];
  1174. if( isoStringSecondsParts.length >= 2 ){
  1175. milliseconds = isoStringSecondsParts[1];
  1176. if( milliseconds.length > 3 ){
  1177. milliseconds = milliseconds.slice(0, 3);
  1178. } else if( milliseconds.length === 2 ){
  1179. milliseconds = `${parseInt(milliseconds, 10) * 10}`;
  1180. } else if( milliseconds.length === 1 ){
  1181. milliseconds = `${parseInt(milliseconds, 10) * 100}`;
  1182. }
  1183. }
  1184. }
  1185. }
  1186. // date construction
  1187. const saneDate = new SaneDate();
  1188. saneDate.utc = utc || (timezoneOffset !== 0);
  1189. try {
  1190. saneDate.year = year;
  1191. saneDate.month = month;
  1192. saneDate.date = date;
  1193. saneDate.hours = hours;
  1194. saneDate.minutes = minutes;
  1195. saneDate.seconds = seconds;
  1196. saneDate.milliseconds = milliseconds;
  1197. } catch(ex){
  1198. throw Error(`${MODULE_NAME}:${this.#__className__}.${__methodName__} | ${unparsableIsoStringMessage} "${isoString}"`);
  1199. }
  1200. saneDate.move('minutes', timezoneOffset);
  1201. return saneDate.getVanillaDate();
  1202. }
  1203. /**
  1204. * Tries to change a part of the date and makes sure, that this change does not trigger automagic and only
  1205. * leads to exactly the change, we wanted to do and nothing else.
  1206. *
  1207. * @param {String} part - the date part to change, one of: year, month, date, hours, minutes, seconds or milliseconds
  1208. * @param {Number} value - the new value to set
  1209. * @returns {SaneDate} the SaneDate instance
  1210. *
  1211. * @private
  1212. */
  1213. #tryDatePartChange(part, value){
  1214. const __methodName__ = '#tryDatePartChange';
  1215. const
  1216. newDate = this.clone().getVanillaDate(),
  1217. settersAndGetters = this.#utc
  1218. ? DATE_PART_SETTERS_AND_GETTERS.utc
  1219. : DATE_PART_SETTERS_AND_GETTERS.local
  1220. ,
  1221. allDatePartGetters = Object.values(settersAndGetters).map(methods => methods.getter)
  1222. ;
  1223. newDate[settersAndGetters[part].setter](value);
  1224. let sideEffect = false;
  1225. for( const datePartGetter of allDatePartGetters ){
  1226. if( datePartGetter !== settersAndGetters[part].getter){
  1227. sideEffect ||= this.#date[datePartGetter]() !== newDate[datePartGetter]();
  1228. }
  1229. if( sideEffect ){
  1230. break;
  1231. }
  1232. }
  1233. assert(
  1234. (newDate[settersAndGetters[part].getter]() === value) && !sideEffect,
  1235. `${MODULE_NAME}:${this.#__className__}.${__methodName__} | date part change "${part} = ${value}" is invalid or has side effects`
  1236. );
  1237. this.#date = newDate;
  1238. return this;
  1239. }
  1240. }
  1241. export {SaneDate};